diff --git a/src/app/Backends/LDAP.php b/src/app/Backends/LDAP.php --- a/src/app/Backends/LDAP.php +++ b/src/app/Backends/LDAP.php @@ -1036,11 +1036,11 @@ */ private static function setUserAttributes(User $user, array &$entry) { + $isDegraded = $user->isDegraded(true); $settings = $user->getSettings(['first_name', 'last_name', 'organization']); $firstName = $settings['first_name']; $lastName = $settings['last_name']; - $cn = "unknown"; $displayname = ""; @@ -1100,12 +1100,17 @@ $entry['nsroledn'][] = "cn=2fa-user,{$hostedRootDN}"; } - if (in_array("activesync", $roles)) { - $entry['nsroledn'][] = "cn=activesync-user,{$hostedRootDN}"; - } + if ($isDegraded) { + $entry['nsroledn'][] = "cn=degraded-user,{$hostedRootDN}"; + $entry['mailquota'] = \config('app.storage.min_qty') * 1048576; + } else { + if (in_array("activesync", $roles)) { + $entry['nsroledn'][] = "cn=activesync-user,{$hostedRootDN}"; + } - if (!in_array("groupware", $roles)) { - $entry['nsroledn'][] = "cn=imap-user,{$hostedRootDN}"; + if (!in_array("groupware", $roles)) { + $entry['nsroledn'][] = "cn=imap-user,{$hostedRootDN}"; + } } } diff --git a/src/app/Console/Commands/User/DegradeCommand.php b/src/app/Console/Commands/User/DegradeCommand.php new file mode 100644 --- /dev/null +++ b/src/app/Console/Commands/User/DegradeCommand.php @@ -0,0 +1,39 @@ +getUser($this->argument('user')); + + if (!$user) { + $this->error('User not found.'); + return 1; + } + + $user->degrade(); + } +} diff --git a/src/app/Console/Commands/User/StatusCommand.php b/src/app/Console/Commands/User/StatusCommand.php --- a/src/app/Console/Commands/User/StatusCommand.php +++ b/src/app/Console/Commands/User/StatusCommand.php @@ -42,6 +42,7 @@ 'deleted' => User::STATUS_DELETED, 'ldapReady' => User::STATUS_LDAP_READY, 'imapReady' => User::STATUS_IMAP_READY, + 'degraded' => User::STATUS_DEGRADED, ]; $user_state = []; diff --git a/src/app/Console/Commands/User/UndegradeCommand.php b/src/app/Console/Commands/User/UndegradeCommand.php new file mode 100644 --- /dev/null +++ b/src/app/Console/Commands/User/UndegradeCommand.php @@ -0,0 +1,39 @@ +getUser($this->argument('user')); + + if (!$user) { + $this->error('User not found.'); + return 1; + } + + $user->undegrade(); + } +} diff --git a/src/app/Console/Commands/Wallet/ChargeCommand.php b/src/app/Console/Commands/Wallet/ChargeCommand.php --- a/src/app/Console/Commands/Wallet/ChargeCommand.php +++ b/src/app/Console/Commands/Wallet/ChargeCommand.php @@ -64,7 +64,8 @@ } if ($wallet->balance < 0) { - // Check the account balance, send notifications, suspend, delete + // Check the account balance, send notifications, (suspend, delete,) degrade + // Also sends reminders to the degraded account owners \App\Jobs\WalletCheck::dispatch($wallet); } } diff --git a/src/app/Http/Controllers/API/V4/OpenViduController.php b/src/app/Http/Controllers/API/V4/OpenViduController.php --- a/src/app/Http/Controllers/API/V4/OpenViduController.php +++ b/src/app/Http/Controllers/API/V4/OpenViduController.php @@ -213,7 +213,7 @@ $room = Room::where('name', $id)->first(); // Room does not exist, or the owner is deleted - if (!$room || !$room->owner) { + if (!$room || !$room->owner || $room->owner->isDegraded(true)) { return $this->errorResponse(404, \trans('meet.room-not-found')); } @@ -349,7 +349,7 @@ $room = Room::where('name', $id)->first(); // Room does not exist, or the owner is deleted - if (!$room || !$room->owner) { + if (!$room || !$room->owner || $room->owner->isDegraded(true)) { return $this->errorResponse(404); } diff --git a/src/app/Http/Controllers/API/V4/UsersController.php b/src/app/Http/Controllers/API/V4/UsersController.php --- a/src/app/Http/Controllers/API/V4/UsersController.php +++ b/src/app/Http/Controllers/API/V4/UsersController.php @@ -433,6 +433,8 @@ 'isSuspended' => $user->isSuspended(), 'isActive' => $user->isActive(), 'isDeleted' => $user->isDeleted() || $user->trashed(), + 'isDegraded' => $user->isDegraded(), + 'isAccountDegraded' => $user->isDegraded(true), ]; } diff --git a/src/app/Jobs/WalletCheck.php b/src/app/Jobs/WalletCheck.php --- a/src/app/Jobs/WalletCheck.php +++ b/src/app/Jobs/WalletCheck.php @@ -18,6 +18,9 @@ use Queueable; use SerializesModels; + public const THRESHOLD_DEGRADE = 'degrade'; + public const THRESHOLD_DEGRADE_REMINDER = 'degrade-reminder'; + public const THRESHOLD_BEFORE_DEGRADE = 'before_degrade'; public const THRESHOLD_DELETE = 'delete'; public const THRESHOLD_BEFORE_DELETE = 'before_delete'; public const THRESHOLD_SUSPEND = 'suspend'; @@ -63,62 +66,83 @@ } $now = Carbon::now(); - - // Delete the account - if (self::threshold($this->wallet, self::THRESHOLD_DELETE) < $now) { - $this->deleteAccount(); - return self::THRESHOLD_DELETE; - } - - // Warn about the upcomming account deletion - if (self::threshold($this->wallet, self::THRESHOLD_BEFORE_DELETE) < $now) { - $this->warnBeforeDelete(); - return self::THRESHOLD_BEFORE_DELETE; +/* + // Steps for old "first suspend then delete" approach + $steps = [ + // Send the initial reminder + self::THRESHOLD_INITIAL => 'initialReminder', + // Try to top-up the wallet before the second reminder + self::THRESHOLD_BEFORE_REMINDER => 'topUpWallet', + // Send the second reminder + self::THRESHOLD_REMINDER => 'secondReminder', + // Try to top-up the wallet before suspending the account + self::THRESHOLD_BEFORE_SUSPEND => 'topUpWallet', + // Suspend the account + self::THRESHOLD_SUSPEND => 'suspendAccount', + // Warn about the upcomming account deletion + self::THRESHOLD_BEFORE_DELETE => 'warnBeforeDelete', + // Delete the account + self::THRESHOLD_DELETE => 'deleteAccount', + ]; +*/ + // Steps for "demote instead of suspend+delete" approach + $steps = [ + // Send the initial reminder + self::THRESHOLD_INITIAL => 'initialReminderForDegrade', + // Try to top-up the wallet before the second reminder + self::THRESHOLD_BEFORE_REMINDER => 'topUpWallet', + // Send the second reminder + self::THRESHOLD_REMINDER => 'secondReminderForDegrade', + // Try to top-up the wallet before the account degradation + self::THRESHOLD_BEFORE_DEGRADE => 'topUpWallet', + // Degrade the account + self::THRESHOLD_DEGRADE => 'degradeAccount', + ]; + + if ($this->wallet->owner && $this->wallet->owner->isDegraded()) { + $this->degradedReminder(); + return self::THRESHOLD_DEGRADE_REMINDER; } - // Suspend the account - if (self::threshold($this->wallet, self::THRESHOLD_SUSPEND) < $now) { - $this->suspendAccount(); - return self::THRESHOLD_SUSPEND; + foreach (array_reverse($steps, true) as $type => $method) { + if (self::threshold($this->wallet, $type) < $now) { + $this->{$method}(); + return $type; + } } - // Try to top-up the wallet before suspending the account - if (self::threshold($this->wallet, self::THRESHOLD_BEFORE_SUSPEND) < $now) { - PaymentsController::topUpWallet($this->wallet); - return self::THRESHOLD_BEFORE_SUSPEND; - } + return null; + } - // Send the second reminder - if (self::threshold($this->wallet, self::THRESHOLD_REMINDER) < $now) { - $this->secondReminder(); - return self::THRESHOLD_REMINDER; + /** + * Send the initial reminder (for the suspend+delete process) + */ + protected function initialReminder() + { + if ($this->wallet->getSetting('balance_warning_initial')) { + return; } - // Try to top-up the wallet before the second reminder - if (self::threshold($this->wallet, self::THRESHOLD_BEFORE_REMINDER) < $now) { - PaymentsController::topUpWallet($this->wallet); - return self::THRESHOLD_BEFORE_REMINDER; - } + // TODO: Should we check if the account is already suspended? - // Send the initial reminder - if (self::threshold($this->wallet, self::THRESHOLD_INITIAL) < $now) { - $this->initialReminder(); - return self::THRESHOLD_INITIAL; - } + $this->sendMail(\App\Mail\NegativeBalance::class, false); - return null; + $now = \Carbon\Carbon::now()->toDateTimeString(); + $this->wallet->setSetting('balance_warning_initial', $now); } /** - * Send the initial reminder + * Send the initial reminder (for the process of degrading a account) */ - protected function initialReminder() + protected function initialReminderForDegrade() { if ($this->wallet->getSetting('balance_warning_initial')) { return; } - // TODO: Should we check if the account is already suspended? + if (!$this->wallet->owner || $this->wallet->owner->isDegraded()) { + return; + } $this->sendMail(\App\Mail\NegativeBalance::class, false); @@ -127,7 +151,7 @@ } /** - * Send the second reminder + * Send the second reminder (for the suspend+delete process) */ protected function secondReminder() { @@ -144,6 +168,25 @@ } /** + * Send the second reminder (for the process of degrading a account) + */ + protected function secondReminderForDegrade() + { + if ($this->wallet->getSetting('balance_warning_reminder')) { + return; + } + + if (!$this->wallet->owner || $this->wallet->owner->isDegraded()) { + return; + } + + $this->sendMail(\App\Mail\NegativeBalanceReminderDegrade::class, true); + + $now = \Carbon\Carbon::now()->toDateTimeString(); + $this->wallet->setSetting('balance_warning_reminder', $now); + } + + /** * Suspend the account (and send the warning) */ protected function suspendAccount() @@ -195,6 +238,59 @@ } /** + * Send the periodic reminder to the degraded account owners + */ + protected function degradedReminder() + { + // Sanity check + if (!$this->wallet->owner || !$this->wallet->owner->isDegraded()) { + return; + } + + $now = \Carbon\Carbon::now(); + $last = $this->wallet->getSetting('degraded_last_reminder'); + + if ($last) { + $last = new Carbon($last); + $period = 14; + + if ($last->addDays($period) > $now) { + return; + } + + $this->sendMail(\App\Mail\DegradedAccountReminder::class, true); + } + + $this->wallet->setSetting('degraded_last_reminder', $now->toDateTimeString()); + } + + /** + * Degrade the account + */ + protected function degradeAccount() + { + // The account may be already deleted, or degraded + if (!$this->wallet->owner || $this->wallet->owner->isDegraded()) { + return; + } + + $email = $this->wallet->owner->email; + + // The dirty work will be done by UserObserver + $this->wallet->owner->degrade(); + + \Log::info( + sprintf( + "[WalletCheck] Account degraded %s (%s)", + $this->wallet->id, + $email + ) + ); + + $this->sendMail(\App\Mail\NegativeBalanceDegraded::class, true); + } + + /** * Delete the account */ protected function deleteAccount() @@ -265,47 +361,45 @@ $negative_since = new Carbon($negative_since); } - $remind = 7; // remind after first X days - $suspend = 14; // suspend after next X days - $delete = 21; // delete after next X days - $warn = 3; // warn about delete on X days before delete - - // Acount deletion - if ($type == self::THRESHOLD_DELETE) { - return $negative_since->addDays($delete + $suspend + $remind); - } - - // Warning about the upcomming account deletion - if ($type == self::THRESHOLD_BEFORE_DELETE) { - return $negative_since->addDays($delete + $suspend + $remind - $warn); - } - - // Account suspension - if ($type == self::THRESHOLD_SUSPEND) { - return $negative_since->addDays($suspend + $remind); - } - - // A day before account suspension - if ($type == self::THRESHOLD_BEFORE_SUSPEND) { - return $negative_since->addDays($suspend + $remind - 1); - } - - // Second notification - if ($type == self::THRESHOLD_REMINDER) { - return $negative_since->addDays($remind); - } - - // A day before the second reminder - if ($type == self::THRESHOLD_BEFORE_REMINDER) { - return $negative_since->addDays($remind - 1); - } - // Initial notification // Give it an hour so the async recurring payment has a chance to be finished if ($type == self::THRESHOLD_INITIAL) { return $negative_since->addHours(1); } + $thresholds = [ + // A day before the second reminder + self::THRESHOLD_BEFORE_REMINDER => 7 - 1, + // Second notification + self::THRESHOLD_REMINDER => 7, + + // A day before account suspension + self::THRESHOLD_BEFORE_SUSPEND => 14 + 7 - 1, + // Account suspension + self::THRESHOLD_SUSPEND => 14 + 7, + // Warning about the upcomming account deletion + self::THRESHOLD_BEFORE_DELETE => 21 + 14 + 7 - 3, + // Acount deletion + self::THRESHOLD_DELETE => 21 + 14 + 7, + + // Last chance to top-up the wallet + self::THRESHOLD_BEFORE_DEGRADE => 13, + // Account degradation + self::THRESHOLD_DEGRADE => 14, + ]; + + if (!empty($thresholds[$type])) { + return $negative_since->addDays($thresholds[$type]); + } + return null; } + + /** + * Try to automatically top-up the wallet + */ + protected function topUpWallet(): void + { + PaymentsController::topUpWallet($this->wallet); + } } diff --git a/src/app/Mail/DegradedAccountReminder.php b/src/app/Mail/DegradedAccountReminder.php new file mode 100644 --- /dev/null +++ b/src/app/Mail/DegradedAccountReminder.php @@ -0,0 +1,82 @@ +wallet = $wallet; + $this->user = $user; + } + + /** + * Build the message. + * + * @return $this + */ + public function build() + { + $appName = Tenant::getConfig($this->user->tenant_id, 'app.name'); + $supportUrl = Tenant::getConfig($this->user->tenant_id, 'app.support_url'); + + $subject = \trans('mail.degradedaccountreminder-subject', ['site' => $appName]); + + $this->view('emails.html.degraded_account_reminder') + ->text('emails.plain.degraded_account_reminder') + ->subject($subject) + ->with([ + 'site' => $appName, + 'subject' => $subject, + 'username' => $this->user->name(true), + 'supportUrl' => $supportUrl, + 'walletUrl' => Utils::serviceUrl('/wallet', $this->user->tenant_id), + 'dashboardUrl' => Utils::serviceUrl('/dashboard', $this->user->tenant_id), + ]); + + return $this; + } + + /** + * Render the mail template with fake data + * + * @param string $type Output format ('html' or 'text') + * + * @return string HTML or Plain Text output + */ + public static function fakeRender(string $type = 'html'): string + { + $wallet = new Wallet(); + $user = new User(); + + $mail = new self($wallet, $user); + + return Helper::render($mail, $type); + } +} diff --git a/src/app/Mail/NegativeBalanceDegraded.php b/src/app/Mail/NegativeBalanceDegraded.php new file mode 100644 --- /dev/null +++ b/src/app/Mail/NegativeBalanceDegraded.php @@ -0,0 +1,82 @@ +wallet = $wallet; + $this->user = $user; + } + + /** + * Build the message. + * + * @return $this + */ + public function build() + { + $appName = Tenant::getConfig($this->user->tenant_id, 'app.name'); + $supportUrl = Tenant::getConfig($this->user->tenant_id, 'app.support_url'); + + $subject = \trans('mail.negativebalancedegraded-subject', ['site' => $appName]); + + $this->view('emails.html.negative_balance_degraded') + ->text('emails.plain.negative_balance_degraded') + ->subject($subject) + ->with([ + 'site' => $appName, + 'subject' => $subject, + 'username' => $this->user->name(true), + 'supportUrl' => $supportUrl, + 'walletUrl' => Utils::serviceUrl('/wallet', $this->user->tenant_id), + ]); + + return $this; + } + + /** + * Render the mail template with fake data + * + * @param string $type Output format ('html' or 'text') + * + * @return string HTML or Plain Text output + */ + public static function fakeRender(string $type = 'html'): string + { + $wallet = new Wallet(); + $user = new User(); + + $mail = new self($wallet, $user); + + return Helper::render($mail, $type); + } +} diff --git a/src/app/Mail/NegativeBalanceReminderDegrade.php b/src/app/Mail/NegativeBalanceReminderDegrade.php new file mode 100644 --- /dev/null +++ b/src/app/Mail/NegativeBalanceReminderDegrade.php @@ -0,0 +1,84 @@ +wallet = $wallet; + $this->user = $user; + } + + /** + * Build the message. + * + * @return $this + */ + public function build() + { + $appName = Tenant::getConfig($this->user->tenant_id, 'app.name'); + $supportUrl = Tenant::getConfig($this->user->tenant_id, 'app.support_url'); + $threshold = WalletCheck::threshold($this->wallet, WalletCheck::THRESHOLD_DEGRADE); + + $subject = \trans('mail.negativebalancereminder-subject', ['site' => $appName]); + + $this->view('emails.html.negative_balance_reminder_degrade') + ->text('emails.plain.negative_balance_reminder_degrade') + ->subject($subject) + ->with([ + 'site' => $appName, + 'subject' => $subject, + 'username' => $this->user->name(true), + 'supportUrl' => $supportUrl, + 'walletUrl' => Utils::serviceUrl('/wallet', $this->user->tenant_id), + 'date' => $threshold->toDateString(), + ]); + + return $this; + } + + /** + * Render the mail template with fake data + * + * @param string $type Output format ('html' or 'text') + * + * @return string HTML or Plain Text output + */ + public static function fakeRender(string $type = 'html'): string + { + $wallet = new Wallet(); + $user = new User(); + + $mail = new self($wallet, $user); + + return Helper::render($mail, $type); + } +} diff --git a/src/app/Observers/EntitlementObserver.php b/src/app/Observers/EntitlementObserver.php --- a/src/app/Observers/EntitlementObserver.php +++ b/src/app/Observers/EntitlementObserver.php @@ -79,7 +79,7 @@ $sf->removeFactors(); } - if ($entitlement->entitleable && !$entitlement->entitleable->trashed()) { + if (!$entitlement->entitleable->trashed()) { $entitlement->entitleable->updated_at = Carbon::now(); $entitlement->entitleable->save(); @@ -110,6 +110,10 @@ $owner = $entitlement->wallet->owner; + if ($owner->isDegraded()) { + return; + } + // Determine if we're still within the free first month $freeMonthEnds = $owner->created_at->copy()->addMonthsWithoutOverflow(1); diff --git a/src/app/Observers/UserObserver.php b/src/app/Observers/UserObserver.php --- a/src/app/Observers/UserObserver.php +++ b/src/app/Observers/UserObserver.php @@ -189,15 +189,51 @@ } /** - * Handle the "updating" event. + * Handle the "updated" event. * - * @param User $user The user that is being updated. + * @param \App\User $user The user that is being updated. * * @return void */ - public function updating(User $user) + public function updated(User $user) { \App\Jobs\User\UpdateJob::dispatch($user->id); + + $oldStatus = $user->getOriginal('status'); + $newStatus = $user->status; + + if (($oldStatus & User::STATUS_DEGRADED) !== ($newStatus & User::STATUS_DEGRADED)) { + $wallets = []; + $isDegraded = $user->isDegraded(); + + // Charge all entitlements as if they were being deleted, + // but don't delete them. Just debit the wallet and update + // entitlements' updated_at timestamp. On un-degrade we still + // update updated_at, but with no debit (the cost is 0 on a degraded account). + foreach ($user->wallets as $wallet) { + $wallet->updateEntitlements($isDegraded); + + // Remember time of the degradation for sending periodic reminders + // and reset it on un-degradation + $val = $isDegraded ? \Carbon\Carbon::now()->toDateTimeString() : null; + $wallet->setSetting('degraded_last_reminder', $val); + + $wallets[] = $wallet->id; + } + + // (Un-)degrade users by invoking an update job. + // LDAP backend will read the wallet owner's degraded status and + // set LDAP attributes accordingly. + // We do not change their status as their wallets have its own state + \App\Entitlement::whereIn('wallet_id', $wallets) + ->where('entitleable_id', '!=', $user->id) + ->where('entitleable_type', User::class) + ->pluck('entitleable_id') + ->unique() + ->each(function ($user_id) { + \App\Jobs\User\UpdateJob::dispatch($user_id); + }); + } } /** @@ -222,12 +258,10 @@ } // Objects need to be deleted one by one to make sure observers can do the proper cleanup - if ($entitlement->entitleable) { - if ($force) { - $entitlement->entitleable->forceDelete(); - } elseif (!$entitlement->entitleable->trashed()) { - $entitlement->entitleable->delete(); - } + if ($force) { + $entitlement->entitleable->forceDelete(); + } elseif (!$entitlement->entitleable->trashed()) { + $entitlement->entitleable->delete(); } }); diff --git a/src/app/Observers/WalletObserver.php b/src/app/Observers/WalletObserver.php --- a/src/app/Observers/WalletObserver.php +++ b/src/app/Observers/WalletObserver.php @@ -89,10 +89,15 @@ 'balance_warning_before_delete' => null, ]); - // Unsuspend the account/domains/users + // FIXME: Since we use account degradation, should we leave suspended state untouched? + + // Un-suspend and un-degrade the account owner if ($wallet->owner) { $wallet->owner->unsuspend(); + $wallet->owner->undegrade(); } + + // Un-suspend domains/users foreach ($wallet->entitlements as $entitlement) { if ( $entitlement->entitleable_type == \App\Domain::class diff --git a/src/app/User.php b/src/app/User.php --- a/src/app/User.php +++ b/src/app/User.php @@ -51,6 +51,8 @@ public const STATUS_LDAP_READY = 1 << 4; // user mailbox has been created in IMAP public const STATUS_IMAP_READY = 1 << 5; + // user in "limited feature-set" state + public const STATUS_DEGRADED = 1 << 6; /** * The attributes that are mass assignable. @@ -244,6 +246,21 @@ } /** + * Degrade the user + * + * @return void + */ + public function degrade(): void + { + if ($this->isDegraded()) { + return; + } + + $this->status |= User::STATUS_DEGRADED; + $this->save(); + } + + /** * Return the \App\Domain for this user. * * @return \App\Domain|null @@ -391,7 +408,7 @@ } /** - * Returns whether this domain is active. + * Returns whether this user is active. * * @return bool */ @@ -401,7 +418,27 @@ } /** - * Returns whether this domain is deleted. + * Returns whether this user (or its wallet owner) is degraded. + * + * @param bool $owner Check also the wallet owner instead just the user himself + * + * @return bool + */ + public function isDegraded(bool $owner = false): bool + { + if ($this->status & self::STATUS_DEGRADED) { + return true; + } + + if ($owner && ($wallet = $this->wallet())) { + return $wallet->owner && $wallet->owner->isDegraded(); + } + + return false; + } + + /** + * Returns whether this user is deleted. * * @return bool */ @@ -411,8 +448,7 @@ } /** - * Returns whether this (external) domain has been verified - * to exist in DNS. + * Returns whether this user is registered in IMAP. * * @return bool */ @@ -442,7 +478,7 @@ } /** - * Returns whether this domain is suspended. + * Returns whether this user is suspended. * * @return bool */ @@ -538,7 +574,7 @@ } /** - * Suspend this domain. + * Suspend this user. * * @return void */ @@ -553,7 +589,22 @@ } /** - * Unsuspend this domain. + * Un-degrade this user. + * + * @return void + */ + public function undegrade(): void + { + if (!$this->isDegraded()) { + return; + } + + $this->status ^= User::STATUS_DEGRADED; + $this->save(); + } + + /** + * Unsuspend this user. * * @return void */ @@ -645,6 +696,7 @@ self::STATUS_DELETED, self::STATUS_LDAP_READY, self::STATUS_IMAP_READY, + self::STATUS_DEGRADED, ]; foreach ($allowed_values as $value) { diff --git a/src/app/Wallet.php b/src/app/Wallet.php --- a/src/app/Wallet.php +++ b/src/app/Wallet.php @@ -82,7 +82,14 @@ } } - public function chargeEntitlements($apply = true) + /** + * Charge entitlements in the wallet + * + * @param bool $apply Set to false for a dry-run mode + * + * @return int Charged amount in cents + */ + public function chargeEntitlements($apply = true): int { // This wallet has been created less than a month ago, this is the trial period if ($this->owner->created_at >= Carbon::now()->subMonthsWithoutOverflow(1)) { @@ -103,13 +110,16 @@ $profit = 0; $charges = 0; $discount = $this->getDiscountRate(); + $isDegraded = $this->owner->isDegraded(); - DB::beginTransaction(); + if ($apply) { + DB::beginTransaction(); + } // used to parent individual entitlement billings to the wallet debit. $entitlementTransactions = []; - foreach ($this->entitlements()->get()->fresh() as $entitlement) { + foreach ($this->entitlements()->get() as $entitlement) { // This entitlement has been created less than or equal to 14 days ago (this is at // maximum the fourteenth 24-hour period). if ($entitlement->created_at > Carbon::now()->subDays(14)) { @@ -128,6 +138,10 @@ $cost = (int) ($entitlement->cost * $discount * $diff); $fee = (int) ($entitlement->fee * $diff); + if ($isDegraded) { + $cost = 0; + } + $charges += $cost; $profit += $cost - $fee; @@ -164,9 +178,9 @@ $wallet->{$method}(abs($profit), $desc); } } - } - DB::commit(); + DB::commit(); + } return $charges; } @@ -423,4 +437,72 @@ ] ); } + + /** + * Force-update entitlements' updated_at, charge if needed. + * + * @param bool $withCost When enabled the cost will be charged + * + * @return int Charged amount in cents + */ + public function updateEntitlements($withCost = true): int + { + $charges = 0; + $discount = $this->getDiscountRate(); + $now = Carbon::now(); + + DB::beginTransaction(); + + // used to parent individual entitlement billings to the wallet debit. + $entitlementTransactions = []; + + foreach ($this->entitlements()->get() as $entitlement) { + $cost = 0; + $diffInDays = $entitlement->updated_at->diffInDays($now); + + // This entitlement has been created less than or equal to 14 days ago (this is at + // maximum the fourteenth 24-hour period). + if ($entitlement->created_at > Carbon::now()->subDays(14)) { + // $cost=0 + } elseif ($withCost && $diffInDays > 0) { + // The price per day is based on the number of days in the last month + // or the current month if the period does not overlap with the previous month + // FIXME: This really should be simplified to constant $daysInMonth=30 + if ($now->day >= $diffInDays && $now->month == $entitlement->updated_at->month) { + $daysInMonth = $now->daysInMonth; + } else { + $daysInMonth = \App\Utils::daysInLastMonth(); + } + + $pricePerDay = $entitlement->cost / $daysInMonth; + + $cost = (int) (round($pricePerDay * $discount * $diffInDays, 0)); + } + + if ($diffInDays > 0) { + $entitlement->updated_at = $entitlement->updated_at->setDateFrom($now); + $entitlement->save(); + } + + if ($cost == 0) { + continue; + } + + $charges += $cost; + + // FIXME: Shouldn't we store also cost=0 transactions (to have the full history)? + $entitlementTransactions[] = $entitlement->createTransaction( + \App\Transaction::ENTITLEMENT_BILLED, + $cost + ); + } + + if ($charges > 0) { + $this->debit($charges, '', $entitlementTransactions); + } + + DB::commit(); + + return $charges; + } } diff --git a/src/config/app.php b/src/config/app.php --- a/src/config/app.php +++ b/src/config/app.php @@ -271,6 +271,10 @@ 'footer' => env('COMPANY_FOOTER', env('COMPANY_DETAILS')), ], + 'storage' => [ + 'min_qty' => (int) env('STORAGE_MIN_QTY', 5), // in GB + ], + 'vat' => [ 'countries' => env('VAT_COUNTRIES'), 'rate' => (float) env('VAT_RATE'), diff --git a/src/resources/js/app.js b/src/resources/js/app.js --- a/src/resources/js/app.js +++ b/src/resources/js/app.js @@ -357,6 +357,9 @@ folderStatusText(folder) { return this.userStatusText(folder) }, + isDegraded() { + return store.state.authInfo.isAccountDegraded + }, pageName(path) { let page = this.$route.path @@ -399,7 +402,7 @@ return 'text-muted' } - if (user.isSuspended) { + if (user.isDegraded || user.isAccountDegraded || user.isSuspended) { return 'text-warning' } @@ -414,6 +417,10 @@ return this.$t('status.deleted') } + if (user.isDegraded || user.isAccountDegraded) { + return this.$t('status.degraded') + } + if (user.isSuspended) { return this.$t('status.suspended') } diff --git a/src/resources/lang/en/mail.php b/src/resources/lang/en/mail.php --- a/src/resources/lang/en/mail.php +++ b/src/resources/lang/en/mail.php @@ -17,6 +17,16 @@ 'more-info-html' => "See here for more information.", 'more-info-text' => "See :href for more information.", + 'degradedaccountreminder-subject' => ":site Reminder: Your account is free", + 'degradedaccountreminder-body1' => "Thanks for sticking around, we remind you your account is a free " + . "account and restricted to receiving email, and use of the web client and cockpit only.", + 'degradedaccountreminder-body2' => "This leaves you with an ideal account to use for account registration with third parties " + . "and password resets, notifications or even just subscriptions to newsletters and the like.", + 'degradedaccountreminder-body3' => "To regain functionality such as sending email, calendars, address books, phone synchronization " + . "and voice & video conferencing, log on to the cockpit and make sure you have a positive account balance.", + 'degradedaccountreminder-body4' => "You can also delete your account there, making sure your data disappears from our systems.", + 'degradedaccountreminder-body5' => "Thank you for your consideration!", + 'negativebalance-subject' => ":site Payment Required", 'negativebalance-body' => "This is a notification to let you know that your :site account balance has run into the negative and requires your attention. " . "Consider setting up an automatic payment to avoid messages like this in the future.", @@ -29,6 +39,14 @@ 'negativebalancereminder-body-warning' => "Please, be aware that your account will be suspended " . "if your account balance is not settled by :date.", + 'negativebalancereminderdegrade-body-warning' => "Please, be aware that your account will be degraded " + . "if your account balance is not settled by :date.", + + 'negativebalancedegraded-subject' => ":site Account Degraded", + 'negativebalancedegraded-body' => "Your :site account has been degraded for having a negative balance for too long. " + . "Consider setting up an automatic payment to avoid messages like this in the future.", + 'negativebalancedegraded-body-ext' => "Settle up now to undegrade your account:", + 'negativebalancesuspended-subject' => ":site Account Suspended", 'negativebalancesuspended-body' => "Your :site account has been suspended for having a negative balance for too long. " . "Consider setting up an automatic payment to avoid messages like this in the future.", diff --git a/src/resources/lang/en/ui.php b/src/resources/lang/en/ui.php --- a/src/resources/lang/en/ui.php +++ b/src/resources/lang/en/ui.php @@ -356,6 +356,7 @@ 'ready-user' => "The user account is almost ready.", 'verify' => "Verify your domain to finish the setup process.", 'verify-domain' => "Verify domain", + 'degraded' => "Degraded", 'deleted' => "Deleted", 'suspended' => "Suspended", 'notready' => "Not Ready", @@ -393,6 +394,8 @@ 'country' => "Country", 'create' => "Create user", 'custno' => "Customer No.", + 'degraded-warning' => "The account is degraded. Some features have been disabled.", + 'degraded-hint' => "Please, make a payment.", 'delete' => "Delete user", 'delete-account' => "Delete this account?", 'delete-email' => "Delete {email}", diff --git a/src/resources/lang/fr/mail.php b/src/resources/lang/fr/mail.php --- a/src/resources/lang/fr/mail.php +++ b/src/resources/lang/fr/mail.php @@ -17,6 +17,16 @@ 'more-info-html' => "Cliquez ici pour plus d'information.", 'more-info-text' => "Cliquez :href pour plus d'information.", + 'degradedaccountreminder-subject' => "Rappel du :site: Votre compte est gratuit", + 'degradedaccountreminder-body1' => "Merci de ne pas quitter le site, nous vous rappelons que votre compte est gratuit." + . " et limité à la réception d'emails, et à l'utilisation du client web et du cockpit uniquement.", + 'degradedaccountreminder-body2' => "Vous disposez ainsi d'un compte idéal à employer pour l'enregistrement de comptes auprès de tiers" + . " et les réinitialisations de mot de passe, les notifications ou même simplement les souscriptions aux newsletters et autres.", + 'degradedaccountreminder-body3' => "Pour récupérer les fonctionnalités telles que l'envoi de e-mail, les calendriers, les carnets d'adresses et la synchronisation des téléphones" + . " et les voix et vidéoconférences, connectez-vous au cockpit et assurez-vous que le solde de votre compte est positif.", + 'degradedaccountreminder-body4' => "Vous pouvez également y supprimer votre compte, afin que vos données disparaissent de nos systèmes.", + 'degradedaccountreminder-body5' => "Nous apprécions votre collaboration!", + 'negativebalance-subject' => ":site Paiement Requis", 'negativebalance-body' => "C'est une notification pour vous informer que votre :site le solde du compte est en négatif et nécessite votre attention." . " Veillez à mettre en place un auto-paiement pour éviter de tel avertissement comme celui-ci dans le future.", @@ -41,6 +51,14 @@ . " votre compte et toutes ses données seront supprimés si le solde de votre compte nest pas régler avant le :date.", 'negativebalancebeforedelete-body-ext' => "Régler votre compte immédiatement:", + 'negativebalancereminderdegrade-body-warning' => "Veuillez noter que votre compte sera dégradé" + . " si le solde de votre compte n'est pas réglé avant le :date.", + + 'negativebalancedegraded-subject' => ":site de Compte Dégradé", + 'negativebalancedegraded-body' => "Votre compte :site a été dégradé pour avoir un solde négatif depuis trop longtemps." + . " Envisagez de mettre en place un paiement automatique pour éviter des messages comme celui-ci à l'avenir.", + 'negativebalancedegraded-body-ext' => "Réglez maintenant pour rétablir votre compte:", + 'passwordreset-subject' => ":site Réinitialisation du mot de passe", 'passwordreset-body1' => "Quelqu'un a récemment demandé de changer votre :site mot de passe.", 'passwordreset-body2' => "Si vous êtes dans ce cas, veuillez utiliser ce code de vérification pour terminer le processus:", diff --git a/src/resources/lang/fr/ui.php b/src/resources/lang/fr/ui.php --- a/src/resources/lang/fr/ui.php +++ b/src/resources/lang/fr/ui.php @@ -312,6 +312,7 @@ 'ready-user' => "Le compte d'utilisateur est presque prêt.", 'verify' => "Veuillez vérifier votre domaine pour terminer le processus de configuration.", 'verify-domain' => "Vérifier domaine", + 'degraded' => "Dégradé", 'deleted' => "Supprimé", 'suspended' => "Suspendu", 'notready' => "Pas Prêt", @@ -349,6 +350,8 @@ 'country' => "Pays", 'create' => "Créer un utilisateur", 'custno' => "No. de Client.", + 'degraded-warning' => "Le compte est dégradé. Certaines fonctionnalités ont été désactivées.", + 'degraded-hint' => "Veuillez effectuer un paiement.", 'delete' => "Supprimer Utilisateur", 'delete-email' => "Supprimer {email}", 'delete-text' => "Voulez-vous vraiment supprimer cet utilisateur de façon permanente?" diff --git a/src/resources/views/emails/html/degraded_account_reminder.blade.php b/src/resources/views/emails/html/degraded_account_reminder.blade.php new file mode 100644 --- /dev/null +++ b/src/resources/views/emails/html/degraded_account_reminder.blade.php @@ -0,0 +1,19 @@ + + + + + + +

{{ __('mail.header', ['name' => $username]) }}

+ +

{{ __('mail.degradedaccountreminder-body1', ['site' => $site]) }}

+

{{ __('mail.degradedaccountreminder-body2', ['site' => $site]) }}

+

{{ __('mail.degradedaccountreminder-body3', ['site' => $site]) }}

+

{{ $dashboardUrl }}

+

{{ __('mail.degradedaccountreminder-body4', ['site' => $site]) }}

+

{{ __('mail.degradedaccountreminder-body5', ['site' => $site]) }}

+ +

{{ __('mail.footer1') }}

+

{{ __('mail.footer2', ['site' => $site]) }}

+ + diff --git a/src/resources/views/emails/html/negative_balance_degraded.blade.php b/src/resources/views/emails/html/negative_balance_degraded.blade.php new file mode 100644 --- /dev/null +++ b/src/resources/views/emails/html/negative_balance_degraded.blade.php @@ -0,0 +1,21 @@ + + + + + + +

{{ __('mail.header', ['name' => $username]) }}

+ +

{{ __('mail.negativebalancedegraded-body', ['site' => $site]) }}

+

{{ __('mail.negativebalancedegraded-body-ext', ['site' => $site]) }}

+

{{ $walletUrl }}

+ +@if ($supportUrl) +

{{ __('mail.support', ['site' => $site]) }}

+

{{ $supportUrl }}

+@endif + +

{{ __('mail.footer1') }}

+

{{ __('mail.footer2', ['site' => $site]) }}

+ + diff --git a/src/resources/views/emails/html/negative_balance_reminder_degrade.blade.php b/src/resources/views/emails/html/negative_balance_reminder_degrade.blade.php new file mode 100644 --- /dev/null +++ b/src/resources/views/emails/html/negative_balance_reminder_degrade.blade.php @@ -0,0 +1,22 @@ + + + + + + +

{{ __('mail.header', ['name' => $username]) }}

+ +

{{ __('mail.negativebalancereminder-body', ['site' => $site]) }}

+

{{ __('mail.negativebalancereminder-body-ext', ['site' => $site]) }}

+

{{ $walletUrl }}

+

{{ __('mail.negativebalancereminderdegrade-body-warning', ['site' => $site, 'date' => $date]) }}

+ +@if ($supportUrl) +

{{ __('mail.support', ['site' => $site]) }}

+

{{ $supportUrl }}

+@endif + +

{{ __('mail.footer1') }}

+

{{ __('mail.footer2', ['site' => $site]) }}

+ + diff --git a/src/resources/views/emails/plain/degraded_account_reminder.blade.php b/src/resources/views/emails/plain/degraded_account_reminder.blade.php new file mode 100644 --- /dev/null +++ b/src/resources/views/emails/plain/degraded_account_reminder.blade.php @@ -0,0 +1,17 @@ +{!! __('mail.header', ['name' => $username]) !!} + +{!! __('mail.degradedaccountreminder-body1', ['site' => $site]) !!} + +{!! __('mail.degradedaccountreminder-body2', ['site' => $site]) !!} + +{!! __('mail.degradedaccountreminder-body3', ['site' => $site]) !!} + +{!! $dashboardUrl !!} + +{!! __('mail.degradedaccountreminder-body4', ['site' => $site]) !!} + +{!! __('mail.degradedaccountreminder-body5', ['site' => $site]) !!} + +-- +{!! __('mail.footer1') !!} +{!! __('mail.footer2', ['site' => $site]) !!} diff --git a/src/resources/views/emails/plain/negative_balance_degraded.blade.php b/src/resources/views/emails/plain/negative_balance_degraded.blade.php new file mode 100644 --- /dev/null +++ b/src/resources/views/emails/plain/negative_balance_degraded.blade.php @@ -0,0 +1,17 @@ +{!! __('mail.header', ['name' => $username]) !!} + +{!! __('mail.negativebalancedegraded-body', ['site' => $site]) !!} + +{!! __('mail.negativebalancedegraded-body-ext', ['site' => $site]) !!} + +{!! $walletUrl !!} + +@if ($supportUrl) +{!! __('mail.support', ['site' => $site]) !!} + +{!! $supportUrl !!} +@endif + +-- +{!! __('mail.footer1') !!} +{!! __('mail.footer2', ['site' => $site]) !!} diff --git a/src/resources/views/emails/plain/negative_balance_reminder_degrade.blade.php b/src/resources/views/emails/plain/negative_balance_reminder_degrade.blade.php new file mode 100644 --- /dev/null +++ b/src/resources/views/emails/plain/negative_balance_reminder_degrade.blade.php @@ -0,0 +1,19 @@ +{!! __('mail.header', ['name' => $username]) !!} + +{!! __('mail.negativebalancereminder-body', ['site' => $site]) !!} + +{!! __('mail.negativebalancereminder-body-ext', ['site' => $site]) !!} + +{!! $walletUrl !!} + +{!! __('mail.negativebalancereminderdegrade-body-warning', ['site' => $site, 'date' => $date]) !!} + +@if ($supportUrl) +{!! __('mail.support', ['site' => $site]) !!} + +{!! $supportUrl !!} +@endif + +-- +{!! __('mail.footer1') !!} +{!! __('mail.footer2', ['site' => $site]) !!} diff --git a/src/resources/vue/App.vue b/src/resources/vue/App.vue --- a/src/resources/vue/App.vue +++ b/src/resources/vue/App.vue @@ -63,6 +63,22 @@ childMounted() { this.$root.updateBodyClass() this.getFAQ() + this.degradedWarning() + }, + degradedWarning() { + // Display "Account Degraded" warning on all pages + if (this.$root.isDegraded()) { + let message = this.$t('user.degraded-warning') + + if (this.$store.state.authInfo.isDegraded) { + message += ' ' + this.$t('user.degraded-hint') + } + + const html = `
` + + `

${message}

` + + $('#app > div.container').prepend(html) + } }, getFAQ() { let page = this.$route.path diff --git a/src/resources/vue/Dashboard.vue b/src/resources/vue/Dashboard.vue --- a/src/resources/vue/Dashboard.vue +++ b/src/resources/vue/Dashboard.vue @@ -25,7 +25,7 @@ {{ $t('dashboard.wallet') }} {{ $root.price(balance, currency) }} - + {{ $t('dashboard.chat') }} {{ $t('dashboard.beta') }} diff --git a/src/resources/vue/Distlist/List.vue b/src/resources/vue/Distlist/List.vue --- a/src/resources/vue/Distlist/List.vue +++ b/src/resources/vue/Distlist/List.vue @@ -4,7 +4,7 @@
{{ $tc('distlist.list-title', 2) }} - + {{ $t('distlist.create') }}
diff --git a/src/resources/vue/Domain/List.vue b/src/resources/vue/Domain/List.vue --- a/src/resources/vue/Domain/List.vue +++ b/src/resources/vue/Domain/List.vue @@ -4,7 +4,7 @@
{{ $t('user.domains') }} - + {{ $t('domain.create') }}
diff --git a/src/resources/vue/Resource/List.vue b/src/resources/vue/Resource/List.vue --- a/src/resources/vue/Resource/List.vue +++ b/src/resources/vue/Resource/List.vue @@ -4,7 +4,7 @@
{{ $tc('resource.list-title', 2) }} - + {{ $t('resource.create') }}
diff --git a/src/resources/vue/Rooms.vue b/src/resources/vue/Rooms.vue --- a/src/resources/vue/Rooms.vue +++ b/src/resources/vue/Rooms.vue @@ -41,7 +41,7 @@ } }, mounted() { - if (!this.$root.hasSKU('meet')) { + if (!this.$root.hasSKU('meet') || this.$root.isDegraded()) { this.$root.errorPage(403) return } diff --git a/src/resources/vue/SharedFolder/List.vue b/src/resources/vue/SharedFolder/List.vue --- a/src/resources/vue/SharedFolder/List.vue +++ b/src/resources/vue/SharedFolder/List.vue @@ -4,7 +4,7 @@
{{ $tc('shf.list-title', 2) }} - + {{ $t('shf.create') }}
diff --git a/src/resources/vue/User/List.vue b/src/resources/vue/User/List.vue --- a/src/resources/vue/User/List.vue +++ b/src/resources/vue/User/List.vue @@ -8,7 +8,7 @@
-
+
{{ $t('user.create') }} diff --git a/src/tests/Browser/DegradedAccountTest.php b/src/tests/Browser/DegradedAccountTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Browser/DegradedAccountTest.php @@ -0,0 +1,125 @@ +getTestUser('john@kolab.org'); + + if (!$john->isDegraded()) { + $john->status |= User::STATUS_DEGRADED; + User::where('id', $john->id)->update(['status' => $john->status]); + } + + $this->clearBetaEntitlements(); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $john = $this->getTestUser('john@kolab.org'); + + if ($john->isDegraded()) { + $john->status ^= User::STATUS_DEGRADED; + User::where('id', $john->id)->update(['status' => $john->status]); + } + + $this->clearBetaEntitlements(); + + parent::tearDown(); + } + + /** + * Test acting as an owner of a degraded account + */ + public function testDegradedAccountOwner(): void + { + // Add beta+distlist entitlements + $john = $this->getTestUser('john@kolab.org'); + $this->addBetaEntitlement($john, ['beta-distlists', 'beta-resources', 'beta-shared-folders']); + + $this->browse(function (Browser $browser) { + $browser->visit(new Home()) + ->submitLogon('john@kolab.org', 'simple123', true) + ->on(new Dashboard()) + ->assertSeeIn('#status-degraded p.alert', 'The account is degraded') + ->assertSeeIn('#status-degraded p.alert', 'Please, make a payment'); + + // Goto /users and assert that the warning is also displayed there + $browser->visit(new UserList()) + ->assertSeeIn('#status-degraded p.alert', 'The account is degraded') + ->assertSeeIn('#status-degraded p.alert', 'Please, make a payment') + ->whenAvailable('@table', function (Browser $browser) { + $browser->waitFor('tbody tr') + ->assertVisible('tbody tr:nth-child(1) td:first-child svg.text-warning') // Jack + ->assertText('tbody tr:nth-child(2) td:first-child svg.text-warning title', 'Degraded') + ->assertVisible('tbody tr:nth-child(3) td:first-child svg.text-warning') // John + ->assertText('tbody tr:nth-child(3) td:first-child svg.text-warning title', 'Degraded'); + }) + ->assertMissing('button.create-user'); + + // Goto /domains and assert that the warning is also displayed there + $browser->visit(new DomainList()) + ->assertSeeIn('#status-degraded p.alert', 'The account is degraded') + ->assertSeeIn('#status-degraded p.alert', 'Please, make a payment') + ->assertMissing('button.create-domain'); + + // Goto /distlists and assert that the warning is also displayed there + $browser->visit(new DistlistList()) + ->assertSeeIn('#status-degraded p.alert', 'The account is degraded') + ->assertSeeIn('#status-degraded p.alert', 'Please, make a payment') + ->assertMissing('button.create-list'); + + // Goto /resources and assert that the warning is also displayed there + $browser->visit(new ResourceList()) + ->assertSeeIn('#status-degraded p.alert', 'The account is degraded') + ->assertSeeIn('#status-degraded p.alert', 'Please, make a payment') + ->assertMissing('button.create-resource'); + + // Goto /shared-folders and assert that the warning is also displayed there + $browser->visit(new SharedFolderList()) + ->assertSeeIn('#status-degraded p.alert', 'The account is degraded') + ->assertSeeIn('#status-degraded p.alert', 'Please, make a payment') + ->assertMissing('button.create-resource'); + + // Test that /rooms is not accessible + $browser->visit('/rooms') + ->waitFor('#app > #error-page') + ->assertSeeIn('#error-page .code', '403'); + }); + } + + /** + * Test acting as non-owner of a degraded account + */ + public function testDegradedAccountUser(): void + { + $this->browse(function (Browser $browser) { + $browser->visit(new Home()) + ->submitLogon('jack@kolab.org', 'simple123', true) + ->on(new Dashboard()) + ->assertSeeIn('#status-degraded p.alert', 'The account is degraded') + ->assertDontSeeIn('#status-degraded p.alert', 'Please, make a payment'); + }); + } +} diff --git a/src/tests/Feature/Backends/LDAPTest.php b/src/tests/Feature/Backends/LDAPTest.php --- a/src/tests/Feature/Backends/LDAPTest.php +++ b/src/tests/Feature/Backends/LDAPTest.php @@ -469,6 +469,32 @@ $this->assertSame($expected_roles, $ldap_roles); + // Test degraded user + + $sku_storage = \App\Sku::withEnvTenantContext()->where('title', 'storage')->first(); + $sku_2fa = \App\Sku::withEnvTenantContext()->where('title', '2fa')->first(); + $user->status |= User::STATUS_DEGRADED; + $user->update(['status' => $user->status]); + $user->assignSku($sku_storage, 2); + $user->assignSku($sku_2fa, 1); + + LDAP::updateUser($user->fresh()); + + $expected['inetuserstatus'] = $user->status; + $expected['mailquota'] = \config('app.storage.min_qty') * 1048576; + $expected['nsroledn'] = [ + 'cn=2fa-user,' . \config('ldap.hosted.root_dn'), + 'cn=degraded-user,' . \config('ldap.hosted.root_dn') + ]; + + $ldap_user = LDAP::getUser($user->email); + + foreach ($expected as $attr => $value) { + $this->assertEquals($value, isset($ldap_user[$attr]) ? $ldap_user[$attr] : null); + } + + // TODO: Test user who's owner is degraded + // Delete the user LDAP::deleteUser($user); diff --git a/src/tests/Feature/Console/User/DegradeTest.php b/src/tests/Feature/Console/User/DegradeTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Feature/Console/User/DegradeTest.php @@ -0,0 +1,56 @@ +deleteTestUser('user-degrade-test@kolabnow.com'); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $this->deleteTestUser('user-degrade-test@kolabnow.com'); + + parent::tearDown(); + } + + /** + * Test the command + */ + public function testHandle(): void + { + Queue::fake(); + + // Non-existing user + $code = \Artisan::call("user:degrade unknown@unknown.org"); + $output = trim(\Artisan::output()); + + $this->assertSame(1, $code); + $this->assertSame("User not found.", $output); + + // Create a user account for degrade + $user = $this->getTestUser('user-degrade-test@kolabnow.com'); + + $code = \Artisan::call("user:degrade {$user->email}"); + $output = trim(\Artisan::output()); + + $user->refresh(); + + $this->assertTrue($user->isDegraded()); + $this->assertSame('', $output); + $this->assertSame(0, $code); + } +} diff --git a/src/tests/Feature/Console/User/UndegradeTest.php b/src/tests/Feature/Console/User/UndegradeTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Feature/Console/User/UndegradeTest.php @@ -0,0 +1,58 @@ +deleteTestUser('user-degrade-test@kolabnow.com'); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $this->deleteTestUser('user-degrade-test@kolabnow.com'); + + parent::tearDown(); + } + + /** + * Test the command + */ + public function testHandle(): void + { + Queue::fake(); + + // Non-existing user + $code = \Artisan::call("user:undegrade unknown@unknown.org"); + $output = trim(\Artisan::output()); + + $this->assertSame(1, $code); + $this->assertSame("User not found.", $output); + + // Create a user account for degrade/undegrade + $user = $this->getTestUser('user-degrade-test@kolabnow.com', ['status' => \App\User::STATUS_DEGRADED]); + + $this->assertTrue($user->isDegraded()); + + $code = \Artisan::call("user:undegrade {$user->email}"); + $output = trim(\Artisan::output()); + + $user->refresh(); + + $this->assertFalse($user->isDegraded()); + $this->assertSame('', $output); + $this->assertSame(0, $code); + } +} diff --git a/src/tests/Feature/Controller/UsersTest.php b/src/tests/Feature/Controller/UsersTest.php --- a/src/tests/Feature/Controller/UsersTest.php +++ b/src/tests/Feature/Controller/UsersTest.php @@ -214,6 +214,8 @@ $this->assertSame($ned->email, $json['list'][3]['email']); // Values below are tested by Unit tests $this->assertArrayHasKey('isDeleted', $json['list'][0]); + $this->assertArrayHasKey('isDegraded', $json['list'][0]); + $this->assertArrayHasKey('isAccountDegraded', $json['list'][0]); $this->assertArrayHasKey('isSuspended', $json['list'][0]); $this->assertArrayHasKey('isActive', $json['list'][0]); $this->assertArrayHasKey('isLdapReady', $json['list'][0]); @@ -290,6 +292,8 @@ $this->assertSame([], $json['skus']); // Values below are tested by Unit tests $this->assertArrayHasKey('isDeleted', $json); + $this->assertArrayHasKey('isDegraded', $json); + $this->assertArrayHasKey('isAccountDegraded', $json); $this->assertArrayHasKey('isSuspended', $json); $this->assertArrayHasKey('isActive', $json); $this->assertArrayHasKey('isLdapReady', $json); diff --git a/src/tests/Feature/EntitlementTest.php b/src/tests/Feature/EntitlementTest.php --- a/src/tests/Feature/EntitlementTest.php +++ b/src/tests/Feature/EntitlementTest.php @@ -106,7 +106,72 @@ } /** - * Test Entitlement::entitleableTitle() + * @todo This really should be in User or Wallet tests file + */ + public function testBillDeletedEntitlement(): void + { + $user = $this->getTestUser('entitlement-test@kolabnow.com'); + $package = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first(); + $storage = \App\Sku::withEnvTenantContext()->where('title', 'storage')->first(); + + $user->assignPackage($package); + // some additional SKUs so we have something to delete. + $user->assignSku($storage, 4); + + // the mailbox, the groupware, the 5 original storage and the additional 4 + $this->assertCount(11, $user->fresh()->entitlements); + + $wallet = $user->wallets()->first(); + + $backdate = Carbon::now()->subWeeks(7); + $this->backdateEntitlements($user->entitlements, $backdate); + + $charge = $wallet->chargeEntitlements(); + + $this->assertSame(-1090, $wallet->balance); + + $balance = $wallet->balance; + $discount = \App\Discount::withEnvTenantContext()->where('discount', 30)->first(); + $wallet->discount()->associate($discount); + $wallet->save(); + + $user->removeSku($storage, 4); + + // we expect the wallet to have been charged for ~3 weeks of use of + // 4 deleted storage entitlements, it should also take discount into account + $backdate->addMonthsWithoutOverflow(1); + $diffInDays = $backdate->diffInDays(Carbon::now()); + + // entitlements-num * cost * discount * days-in-month + $max = intval(4 * 25 * 0.7 * $diffInDays / 28); + $min = intval(4 * 25 * 0.7 * $diffInDays / 31); + + $wallet->refresh(); + $this->assertTrue($wallet->balance >= $balance - $max); + $this->assertTrue($wallet->balance <= $balance - $min); + + $transactions = \App\Transaction::where('object_id', $wallet->id) + ->where('object_type', \App\Wallet::class)->get(); + + // one round of the monthly invoicing, four sku deletions getting invoiced + $this->assertCount(5, $transactions); + + // Test that deleting an entitlement on a degraded account costs nothing + $balance = $wallet->balance; + User::where('id', $user->id)->update(['status' => $user->status | User::STATUS_DEGRADED]); + + $backdate = Carbon::now()->subWeeks(7); + $this->backdateEntitlements($user->entitlements()->get(), $backdate); + + $groupware = \App\Sku::withEnvTenantContext()->where('title', 'groupware')->first(); + $entitlement = $wallet->entitlements()->where('sku_id', $groupware->id)->first(); + $entitlement->delete(); + + $this->assertSame($wallet->refresh()->balance, $balance); + } + + /** + * Test Entitlement::entitlementTitle() */ public function testEntitleableTitle(): void { diff --git a/src/tests/Feature/Jobs/WalletCheckTest.php b/src/tests/Feature/Jobs/WalletCheckTest.php --- a/src/tests/Feature/Jobs/WalletCheckTest.php +++ b/src/tests/Feature/Jobs/WalletCheckTest.php @@ -4,6 +4,7 @@ use App\Jobs\WalletCheck; use App\User; +use App\Wallet; use Carbon\Carbon; use Illuminate\Support\Facades\Mail; use Tests\TestCase; @@ -17,12 +18,6 @@ { parent::setUp(); - $ned = $this->getTestUser('ned@kolab.org'); - if ($ned->isSuspended()) { - $ned->status -= User::STATUS_SUSPENDED; - $ned->save(); - } - $this->deleteTestUser('wallet-check@kolabnow.com'); } @@ -31,12 +26,6 @@ */ public function tearDown(): void { - $ned = $this->getTestUser('ned@kolab.org'); - if ($ned->isSuspended()) { - $ned->status -= User::STATUS_SUSPENDED; - $ned->save(); - } - $this->deleteTestUser('wallet-check@kolabnow.com'); parent::tearDown(); @@ -49,14 +38,9 @@ { Mail::fake(); - $user = $this->getTestUser('ned@kolab.org'); - $user->setSetting('external_email', 'external@test.com'); - $wallet = $user->wallets()->first(); + $user = $this->prepareTestUser($wallet); $now = Carbon::now(); - // Balance is not negative, double-update+save for proper resetting of the state - $wallet->balance = -100; - $wallet->save(); $wallet->balance = 0; $wallet->save(); @@ -123,8 +107,7 @@ { Mail::fake(); - $user = $this->getTestUser('ned@kolab.org'); - $wallet = $user->wallets()->first(); + $user = $this->prepareTestUser($wallet); $now = Carbon::now(); // Balance turned negative 7-1 days ago @@ -149,9 +132,7 @@ { Mail::fake(); - $user = $this->getTestUser('ned@kolab.org'); - $user->setSetting('external_email', 'external@test.com'); - $wallet = $user->wallets()->first(); + $user = $this->prepareTestUser($wallet); $now = Carbon::now(); // Balance turned negative 7+1 days ago, expect mail sent @@ -160,10 +141,10 @@ $job = new WalletCheck($wallet); $job->handle(); - // Assert the mail was sent to the user's email, but not to his external email - Mail::assertSent(\App\Mail\NegativeBalanceReminder::class, 1); - Mail::assertSent(\App\Mail\NegativeBalanceReminder::class, function ($mail) use ($user) { - return $mail->hasTo($user->email) && !$mail->hasCc('external@test.com'); + // Assert the mail was sent to the user's email and to his external email + Mail::assertSent(\App\Mail\NegativeBalanceReminderDegrade::class, 1); + Mail::assertSent(\App\Mail\NegativeBalanceReminderDegrade::class, function ($mail) use ($user) { + return $mail->hasTo($user->email) && $mail->hasCc('external@test.com'); }); // Run the job again to make sure the notification is not sent again @@ -179,12 +160,12 @@ * * @depends testHandleReminder */ +/* public function testHandleBeforeSuspended(): void { Mail::fake(); - $user = $this->getTestUser('ned@kolab.org'); - $wallet = $user->wallets()->first(); + $user = $this->prepareTestUser($wallet); $now = Carbon::now(); // Balance turned negative 7+14-1 days ago @@ -200,19 +181,18 @@ $this->assertSame(WalletCheck::THRESHOLD_BEFORE_SUSPEND, $res); $this->assertFalse($user->fresh()->isSuspended()); } - +*/ /** * Test job handle, account suspending * * @depends testHandleBeforeSuspended */ +/* public function testHandleSuspended(): void { Mail::fake(); - $user = $this->getTestUser('ned@kolab.org'); - $user->setSetting('external_email', 'external@test.com'); - $wallet = $user->wallets()->first(); + $user = $this->prepareTestUser($wallet); $now = Carbon::now(); // Balance turned negative 7+14+1 days ago, expect mail sent @@ -232,16 +212,6 @@ $this->assertTrue($user->fresh()->isSuspended()); // TODO: Test that group account members/domain are also being suspended - /* - foreach ($wallet->entitlements()->fresh()->get() as $entitlement) { - if ( - $entitlement->entitleable_type == \App\Domain::class - || $entitlement->entitleable_type == \App\User::class - ) { - $this->assertTrue($entitlement->entitleable->isSuspended()); - } - } - */ // Run the job again to make sure the notification is not sent again Mail::fake(); @@ -250,19 +220,18 @@ Mail::assertNothingSent(); } - +*/ /** * Test job handle, final warning before delete * * @depends testHandleSuspended */ +/* public function testHandleBeforeDelete(): void { Mail::fake(); - $user = $this->getTestUser('ned@kolab.org'); - $user->setSetting('external_email', 'external@test.com'); - $wallet = $user->wallets()->first(); + $user = $this->prepareTestUser($wallet); $now = Carbon::now(); // Balance turned negative 7+14+21-3+1 days ago, expect mail sent @@ -288,25 +257,20 @@ Mail::assertNothingSent(); } - +*/ /** * Test job handle, account delete * * @depends testHandleBeforeDelete */ +/* public function testHandleDelete(): void { Mail::fake(); - $user = $this->getTestUser('wallet-check@kolabnow.com'); - $wallet = $user->wallets()->first(); - $wallet->balance = -100; - $wallet->save(); + $user = $this->prepareTestUser($wallet); $now = Carbon::now(); - $package = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first(); - $user->assignPackage($package); - $this->assertFalse($user->isDeleted()); $this->assertCount(7, $user->entitlements()->get()); @@ -325,4 +289,111 @@ // TODO: Test it deletes all members of the group account } +*/ + + /** + * Test job handle, account degrade + * + * @depends testHandleReminder + */ + public function testHandleDegrade(): void + { + Mail::fake(); + + $user = $this->prepareTestUser($wallet); + $now = Carbon::now(); + + $this->assertFalse($user->isDegraded()); + + // Balance turned negative 7+7+1 days ago, expect mail sent + $days = 7 + 7 + 1; + $wallet->setSetting('balance_negative_since', $now->subDays($days)->toDateTimeString()); + + $job = new WalletCheck($wallet); + $job->handle(); + + // Assert the mail was sent to the user's email, and his external email + Mail::assertSent(\App\Mail\NegativeBalanceDegraded::class, 1); + Mail::assertSent(\App\Mail\NegativeBalanceDegraded::class, function ($mail) use ($user) { + return $mail->hasTo($user->email) && $mail->hasCc('external@test.com'); + }); + + // Check that it has been degraded + $this->assertTrue($user->fresh()->isDegraded()); + } + + /** + * Test job handle, periodic reminder to a degraded account + * + * @depends testHandleDegrade + */ + public function testHandleDegradeReminder(): void + { + Mail::fake(); + + $user = $this->prepareTestUser($wallet); + $user->update(['status' => $user->status | User::STATUS_DEGRADED]); + $now = Carbon::now(); + + $this->assertTrue($user->isDegraded()); + + // Test degraded_last_reminder not set + $wallet->setSetting('degraded_last_reminder', null); + + $job = new WalletCheck($wallet); + $res = $job->handle(); + + Mail::assertNothingSent(); + + $_last = Wallet::find($wallet->id)->getSetting('degraded_last_reminder'); + $this->assertSame(Carbon::now()->toDateTimeString(), $_last); + $this->assertSame(WalletCheck::THRESHOLD_DEGRADE_REMINDER, $res); + + // Test degraded_last_reminder set, but 14 days didn't pass yet + $last = $now->copy()->subDays(10); + $wallet->setSetting('degraded_last_reminder', $last->toDateTimeString()); + + $job = new WalletCheck($wallet); + $res = $job->handle(); + + Mail::assertNothingSent(); + + $_last = $wallet->fresh()->getSetting('degraded_last_reminder'); + $this->assertSame(WalletCheck::THRESHOLD_DEGRADE_REMINDER, $res); + $this->assertSame($last->toDateTimeString(), $_last); + + // Test degraded_last_reminder set, and 14 days passed + $wallet->setSetting('degraded_last_reminder', $now->copy()->subDays(14)->setSeconds(0)); + + $job = new WalletCheck($wallet); + $res = $job->handle(); + + // Assert the mail was sent to the user's email, and his external email + Mail::assertSent(\App\Mail\DegradedAccountReminder::class, 1); + Mail::assertSent(\App\Mail\DegradedAccountReminder::class, function ($mail) use ($user) { + return $mail->hasTo($user->email) && $mail->hasCc('external@test.com'); + }); + + $_last = $wallet->fresh()->getSetting('degraded_last_reminder'); + $this->assertSame(Carbon::now()->toDateTimeString(), $_last); + $this->assertSame(WalletCheck::THRESHOLD_DEGRADE_REMINDER, $res); + } + + /** + * A helper to prepare a user for tests + */ + private function prepareTestUser(&$wallet) + { + $user = $this->getTestUser('wallet-check@kolabnow.com'); + $user->setSetting('external_email', 'external@test.com'); + $wallet = $user->wallets()->first(); + + $package = \App\Package::withObjectTenantContext($user)->where('title', 'kolab')->first(); + $user->assignPackage($package); + + $wallet->balance = -100; + $wallet->save(); + + return $user; + } } diff --git a/src/tests/Feature/UserTest.php b/src/tests/Feature/UserTest.php --- a/src/tests/Feature/UserTest.php +++ b/src/tests/Feature/UserTest.php @@ -5,6 +5,7 @@ use App\Domain; use App\Group; use App\User; +use Carbon\Carbon; use Illuminate\Support\Facades\Queue; use Tests\TestCase; @@ -397,36 +398,99 @@ } /** - * Test User::hasSku() method + * Test user account degradation and un-degradation */ - public function testHasSku(): void + public function testDegradeAndUndegrade(): void { - $john = $this->getTestUser('john@kolab.org'); + Queue::fake(); - $this->assertTrue($john->hasSku('mailbox')); - $this->assertTrue($john->hasSku('storage')); - $this->assertFalse($john->hasSku('beta')); - $this->assertFalse($john->hasSku('unknown')); - } + // Test an account with users, domain + $userA = $this->getTestUser('UserAccountA@UserAccount.com'); + $userB = $this->getTestUser('UserAccountB@UserAccount.com'); + $package_kolab = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first(); + $package_domain = \App\Package::withEnvTenantContext()->where('title', 'domain-hosting')->first(); + $domain = $this->getTestDomain('UserAccount.com', [ + 'status' => Domain::STATUS_NEW, + 'type' => Domain::TYPE_HOSTED, + ]); + $userA->assignPackage($package_kolab); + $domain->assignPackage($package_domain, $userA); + $userA->assignPackage($package_kolab, $userB); - public function testUserQuota(): void - { - // TODO: This test does not test much, probably could be removed - // or moved to somewhere else, or extended with - // other entitlements() related cases. + $entitlementsA = \App\Entitlement::where('entitleable_id', $userA->id); + $entitlementsB = \App\Entitlement::where('entitleable_id', $userB->id); + $entitlementsDomain = \App\Entitlement::where('entitleable_id', $domain->id); - $user = $this->getTestUser('john@kolab.org'); - $storage_sku = \App\Sku::withEnvTenantContext()->where('title', 'storage')->first(); + $yesterday = Carbon::now()->subDays(1); - $count = 0; + $this->backdateEntitlements($entitlementsA->get(), $yesterday, Carbon::now()->subMonthsWithoutOverflow(1)); + $this->backdateEntitlements($entitlementsB->get(), $yesterday, Carbon::now()->subMonthsWithoutOverflow(1)); - foreach ($user->entitlements()->get() as $entitlement) { - if ($entitlement->sku_id == $storage_sku->id) { - $count += 1; - } - } + $wallet = $userA->wallets->first(); + + $this->assertSame(7, $entitlementsA->count()); + $this->assertSame(7, $entitlementsB->count()); + $this->assertSame(7, $entitlementsA->whereDate('updated_at', $yesterday->toDateString())->count()); + $this->assertSame(7, $entitlementsB->whereDate('updated_at', $yesterday->toDateString())->count()); + $this->assertSame(0, $wallet->balance); + + Queue::fake(); // reset queue state - $this->assertTrue($count == 5); + // Degrade the account/wallet owner + $userA->degrade(); + + $entitlementsA = \App\Entitlement::where('entitleable_id', $userA->id); + $entitlementsB = \App\Entitlement::where('entitleable_id', $userB->id); + + $this->assertTrue($userA->fresh()->isDegraded()); + $this->assertTrue($userA->fresh()->isDegraded(true)); + $this->assertFalse($userB->fresh()->isDegraded()); + $this->assertTrue($userB->fresh()->isDegraded(true)); + + $balance = $wallet->fresh()->balance; + $this->assertTrue($balance <= -64); + $this->assertSame(7, $entitlementsA->whereDate('updated_at', Carbon::now()->toDateString())->count()); + $this->assertSame(7, $entitlementsB->whereDate('updated_at', Carbon::now()->toDateString())->count()); + + // Expect one update job for every user + // @phpstan-ignore-next-line + $userIds = Queue::pushed(\App\Jobs\User\UpdateJob::class)->map(function ($job) { + return TestCase::getObjectProperty($job, 'userId'); + })->all(); + + $this->assertSame([$userA->id, $userB->id], $userIds); + + // Un-Degrade the account/wallet owner + + $entitlementsA = \App\Entitlement::where('entitleable_id', $userA->id); + $entitlementsB = \App\Entitlement::where('entitleable_id', $userB->id); + + $yesterday = Carbon::now()->subDays(1); + + $this->backdateEntitlements($entitlementsA->get(), $yesterday, Carbon::now()->subMonthsWithoutOverflow(1)); + $this->backdateEntitlements($entitlementsB->get(), $yesterday, Carbon::now()->subMonthsWithoutOverflow(1)); + + Queue::fake(); // reset queue state + + $userA->undegrade(); + + $this->assertFalse($userA->fresh()->isDegraded()); + $this->assertFalse($userA->fresh()->isDegraded(true)); + $this->assertFalse($userB->fresh()->isDegraded()); + $this->assertFalse($userB->fresh()->isDegraded(true)); + + // Expect no balance change, degraded account entitlements are free + $this->assertSame($balance, $wallet->fresh()->balance); + $this->assertSame(7, $entitlementsA->whereDate('updated_at', Carbon::now()->toDateString())->count()); + $this->assertSame(7, $entitlementsB->whereDate('updated_at', Carbon::now()->toDateString())->count()); + + // Expect one update job for every user + // @phpstan-ignore-next-line + $userIds = Queue::pushed(\App\Jobs\User\UpdateJob::class)->map(function ($job) { + return TestCase::getObjectProperty($job, 'userId'); + })->all(); + + $this->assertSame([$userA->id, $userB->id], $userIds); } /** @@ -718,6 +782,19 @@ } /** + * Test User::hasSku() method + */ + public function testHasSku(): void + { + $john = $this->getTestUser('john@kolab.org'); + + $this->assertTrue($john->hasSku('mailbox')); + $this->assertTrue($john->hasSku('storage')); + $this->assertFalse($john->hasSku('beta')); + $this->assertFalse($john->hasSku('unknown')); + } + + /** * Test User::name() */ public function testName(): void @@ -1099,6 +1176,9 @@ $this->assertCount(4, $users); } + /** + * Tests for User::wallets() + */ public function testWallets(): void { $this->markTestIncomplete(); diff --git a/src/tests/Feature/WalletTest.php b/src/tests/Feature/WalletTest.php --- a/src/tests/Feature/WalletTest.php +++ b/src/tests/Feature/WalletTest.php @@ -9,6 +9,7 @@ use App\Wallet; use Carbon\Carbon; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Queue; use Tests\TestCase; class WalletTest extends TestCase @@ -48,27 +49,34 @@ /** * Test that turning wallet balance from negative to positive - * unsuspends the account + * unsuspends and undegrades the account */ - public function testBalancePositiveUnsuspend(): void + public function testBalanceTurnsPositive(): void { + Queue::fake(); + $user = $this->getTestUser('UserWallet1@UserWallet.com'); $user->suspend(); + $user->degrade(); $wallet = $user->wallets()->first(); $wallet->balance = -100; $wallet->save(); $this->assertTrue($user->isSuspended()); + $this->assertTrue($user->isDegraded()); $this->assertNotNull($wallet->getSetting('balance_negative_since')); $wallet->balance = 100; $wallet->save(); - $this->assertFalse($user->fresh()->isSuspended()); + $user->refresh(); + + $this->assertFalse($user->isSuspended()); + $this->assertFalse($user->isDegraded()); $this->assertNull($wallet->getSetting('balance_negative_since')); - // TODO: Test group account and unsuspending domain/members + // TODO: Test group account and unsuspending domain/members/groups } /** @@ -412,4 +420,12 @@ // TODO: Test entitlement transaction records } + + /** + * Tests for updateEntitlements() + */ + public function testUpdateEntitlements(): void + { + $this->markTestIncomplete(); + } } diff --git a/src/tests/TestCaseTrait.php b/src/tests/TestCaseTrait.php --- a/src/tests/TestCaseTrait.php +++ b/src/tests/TestCaseTrait.php @@ -96,13 +96,18 @@ /** * Register the beta entitlement for a user */ - protected function addBetaEntitlement($user, $title): void + protected function addBetaEntitlement($user, $titles = []): void { // Add beta + $title entitlements $beta_sku = Sku::withEnvTenantContext()->where('title', 'beta')->first(); - $sku = Sku::withEnvTenantContext()->where('title', $title)->first(); $user->assignSku($beta_sku); - $user->assignSku($sku); + + if (!empty($titles)) { + Sku::withEnvTenantContext()->whereIn('title', (array) $titles)->get() + ->each(function ($sku) use ($user) { + $user->assignSku($sku); + }); + } } /** @@ -125,7 +130,7 @@ Assert::assertSame($expected, $skus); } - protected function backdateEntitlements($entitlements, $targetDate) + protected function backdateEntitlements($entitlements, $targetDate, $targetCreatedDate = null) { $wallets = []; $ids = []; @@ -136,7 +141,7 @@ } \App\Entitlement::whereIn('id', $ids)->update([ - 'created_at' => $targetDate, + 'created_at' => $targetCreatedDate ?: $targetDate, 'updated_at' => $targetDate, ]); @@ -144,7 +149,9 @@ $wallets = array_unique($wallets); $owners = \App\Wallet::whereIn('id', $wallets)->pluck('user_id')->all(); - \App\User::whereIn('id', $owners)->update(['created_at' => $targetDate]); + \App\User::whereIn('id', $owners)->update([ + 'created_at' => $targetCreatedDate ?: $targetDate + ]); } } diff --git a/src/tests/Unit/Mail/DegradedAccountReminderTest.php b/src/tests/Unit/Mail/DegradedAccountReminderTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Unit/Mail/DegradedAccountReminderTest.php @@ -0,0 +1,45 @@ +getTestUser('ned@kolab.org'); + $wallet = $user->wallets->first(); + + $mail = $this->fakeMail(new DegradedAccountReminder($wallet, $user)); + + $html = $mail['html']; + $plain = $mail['plain']; + + $dashboardUrl = \App\Utils::serviceUrl('/dashboard'); + $dashboardLink = sprintf('%s', $dashboardUrl, $dashboardUrl); + $appName = $user->tenant->title; + + $this->assertMailSubject("$appName Reminder: Your account is free", $mail['message']); + + $this->assertStringStartsWith('', $html); + $this->assertTrue(strpos($html, $user->name(true)) > 0); + $this->assertTrue(strpos($html, $dashboardLink) > 0); + $this->assertTrue(strpos($html, "your account is a free account") > 0); + $this->assertTrue(strpos($html, "$appName Team") > 0); + + $this->assertStringStartsWith('Dear ' . $user->name(true), $plain); + $this->assertTrue(strpos($plain, $dashboardUrl) > 0); + $this->assertTrue(strpos($plain, "your account is a free account") > 0); + $this->assertTrue(strpos($plain, "$appName Team") > 0); + } +} diff --git a/src/tests/Unit/Mail/NegativeBalanceDegradedTest.php b/src/tests/Unit/Mail/NegativeBalanceDegradedTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Unit/Mail/NegativeBalanceDegradedTest.php @@ -0,0 +1,58 @@ +getTestUser('ned@kolab.org'); + $wallet = $user->wallets->first(); + $wallet->balance = -100; + $wallet->save(); + + \config([ + 'app.support_url' => 'https://kolab.org/support', + ]); + + $mail = $this->fakeMail(new NegativeBalanceDegraded($wallet, $user)); + + $html = $mail['html']; + $plain = $mail['plain']; + + $walletUrl = \App\Utils::serviceUrl('/wallet'); + $walletLink = sprintf('%s', $walletUrl, $walletUrl); + $supportUrl = \config('app.support_url'); + $supportLink = sprintf('%s', $supportUrl, $supportUrl); + $appName = $user->tenant->title; + + $this->assertMailSubject("$appName Account Degraded", $mail['message']); + + $this->assertStringStartsWith('', $html); + $this->assertTrue(strpos($html, $user->name(true)) > 0); + $this->assertTrue(strpos($html, $walletLink) > 0); + $this->assertTrue(strpos($html, $supportLink) > 0); + $this->assertTrue(strpos($html, "Your $appName account has been degraded") > 0); + $this->assertTrue(strpos($html, "$appName Support") > 0); + $this->assertTrue(strpos($html, "$appName Team") > 0); + + $this->assertStringStartsWith('Dear ' . $user->name(true), $plain); + $this->assertTrue(strpos($plain, $walletUrl) > 0); + $this->assertTrue(strpos($plain, $supportUrl) > 0); + $this->assertTrue(strpos($plain, "Your $appName account has been degraded") > 0); + $this->assertTrue(strpos($plain, "$appName Support") > 0); + $this->assertTrue(strpos($plain, "$appName Team") > 0); + } +} diff --git a/src/tests/Unit/Mail/NegativeBalanceReminderDegradeTest.php b/src/tests/Unit/Mail/NegativeBalanceReminderDegradeTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Unit/Mail/NegativeBalanceReminderDegradeTest.php @@ -0,0 +1,64 @@ +getTestUser('ned@kolab.org'); + $wallet = $user->wallets->first(); + $wallet->balance = -100; + $wallet->save(); + + $threshold = WalletCheck::threshold($wallet, WalletCheck::THRESHOLD_DEGRADE); + + \config([ + 'app.support_url' => 'https://kolab.org/support', + ]); + + $mail = $this->fakeMail(new NegativeBalanceReminderDegrade($wallet, $user)); + + $html = $mail['html']; + $plain = $mail['plain']; + + $walletUrl = \App\Utils::serviceUrl('/wallet'); + $walletLink = sprintf('%s', $walletUrl, $walletUrl); + $supportUrl = \config('app.support_url'); + $supportLink = sprintf('%s', $supportUrl, $supportUrl); + $appName = $user->tenant->title; + + $this->assertMailSubject("$appName Payment Reminder", $mail['message']); + + $this->assertStringStartsWith('', $html); + $this->assertTrue(strpos($html, $user->name(true)) > 0); + $this->assertTrue(strpos($html, $walletLink) > 0); + $this->assertTrue(strpos($html, $supportLink) > 0); + $this->assertTrue(strpos($html, "you are behind on paying for your $appName account") > 0); + $this->assertTrue(strpos($html, "your account will be degraded") > 0); + $this->assertTrue(strpos($html, $threshold->toDateString()) > 0); + $this->assertTrue(strpos($html, "$appName Support") > 0); + $this->assertTrue(strpos($html, "$appName Team") > 0); + + $this->assertStringStartsWith('Dear ' . $user->name(true), $plain); + $this->assertTrue(strpos($plain, $walletUrl) > 0); + $this->assertTrue(strpos($plain, $supportUrl) > 0); + $this->assertTrue(strpos($plain, "you are behind on paying for your $appName account") > 0); + $this->assertTrue(strpos($plain, "your account will be degraded") > 0); + $this->assertTrue(strpos($plain, $threshold->toDateString()) > 0); + $this->assertTrue(strpos($plain, "$appName Support") > 0); + $this->assertTrue(strpos($plain, "$appName Team") > 0); + } +} diff --git a/src/tests/Unit/UserTest.php b/src/tests/Unit/UserTest.php --- a/src/tests/Unit/UserTest.php +++ b/src/tests/Unit/UserTest.php @@ -3,6 +3,7 @@ namespace Tests\Unit; use App\User; +use App\Wallet; use Tests\TestCase; class UserTest extends TestCase @@ -76,6 +77,7 @@ User::STATUS_DELETED, User::STATUS_IMAP_READY, User::STATUS_LDAP_READY, + User::STATUS_DEGRADED, ]; $users = \App\Utils::powerSet($statuses); @@ -94,6 +96,7 @@ $this->assertTrue($user->isDeleted() === in_array(User::STATUS_DELETED, $user_statuses)); $this->assertTrue($user->isLdapReady() === in_array(User::STATUS_LDAP_READY, $user_statuses)); $this->assertTrue($user->isImapReady() === in_array(User::STATUS_IMAP_READY, $user_statuses)); + $this->assertTrue($user->isDegraded() === in_array(User::STATUS_DEGRADED, $user_statuses)); } }