Page MenuHomePhorge

D2371.1775186092.diff
No OneTemporary

Authored By
Unknown
Size
95 KB
Referenced Files
None
Subscribers
None

D2371.1775186092.diff

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
@@ -709,11 +709,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 = "";
@@ -773,12 +773,18 @@
$entry['nsroledn'][] = "cn=2fa-user,{$hostedRootDN}";
}
- if (in_array("activesync", $roles)) {
- $entry['nsroledn'][] = "cn=activesync-user,{$hostedRootDN}";
- }
- if (!in_array("groupware", $roles)) {
- $entry['nsroledn'][] = "cn=imap-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}";
+ }
}
}
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 @@
+<?php
+
+namespace App\Console\Commands\User;
+
+use App\Console\Command;
+
+class DegradeCommand extends Command
+{
+ /**
+ * The name and signature of the console command.
+ *
+ * @var string
+ */
+ protected $signature = 'user:degrade {user}';
+
+ /**
+ * The console command description.
+ *
+ * @var string
+ */
+ protected $description = 'Degrade a user';
+
+ /**
+ * Execute the console command.
+ *
+ * @return mixed
+ */
+ public function handle()
+ {
+ $user = $this->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 @@
+<?php
+
+namespace App\Console\Commands\User;
+
+use App\Console\Command;
+
+class UndegradeCommand extends Command
+{
+ /**
+ * The name and signature of the console command.
+ *
+ * @var string
+ */
+ protected $signature = 'user:undegrade {user}';
+
+ /**
+ * The console command description.
+ *
+ * @var string
+ */
+ protected $description = 'Un-degrade a user';
+
+ /**
+ * Execute the console command.
+ *
+ * @return mixed
+ */
+ public function handle()
+ {
+ $user = $this->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
@@ -582,6 +582,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()
{
@@ -143,6 +167,25 @@
$this->wallet->setSetting('balance_warning_reminder', $now);
}
+ /**
+ * 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)
*/
@@ -194,6 +237,59 @@
$this->wallet->setSetting('balance_warning_before_delete', $now);
}
+ /**
+ * 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
*/
@@ -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 @@
+<?php
+
+namespace App\Mail;
+
+use App\Tenant;
+use App\User;
+use App\Utils;
+use App\Wallet;
+use Illuminate\Bus\Queueable;
+use Illuminate\Mail\Mailable;
+use Illuminate\Queue\SerializesModels;
+
+class DegradedAccountReminder extends Mailable
+{
+ use Queueable;
+ use SerializesModels;
+
+ /** @var \App\Wallet A wallet with a negative balance */
+ protected $wallet;
+
+ /** @var \App\User A wallet controller to whom the email is being sent */
+ protected $user;
+
+
+ /**
+ * Create a new message instance.
+ *
+ * @param \App\Wallet $wallet A wallet
+ * @param \App\User $user An email recipient
+ *
+ * @return void
+ */
+ public function __construct(Wallet $wallet, User $user)
+ {
+ $this->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 @@
+<?php
+
+namespace App\Mail;
+
+use App\Jobs\WalletCheck;
+use App\Tenant;
+use App\User;
+use App\Utils;
+use App\Wallet;
+use Illuminate\Bus\Queueable;
+use Illuminate\Mail\Mailable;
+use Illuminate\Queue\SerializesModels;
+
+class NegativeBalanceDegraded extends Mailable
+{
+ use Queueable;
+ use SerializesModels;
+
+ /** @var \App\Wallet A wallet with a negative balance */
+ protected $wallet;
+
+ /** @var \App\User A wallet controller to whom the email is being sent */
+ protected $user;
+
+
+ /**
+ * Create a new message instance.
+ *
+ * @param \App\Wallet $wallet A wallet
+ * @param \App\User $user An email recipient
+ *
+ * @return void
+ */
+ public function __construct(Wallet $wallet, User $user)
+ {
+ $this->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 @@
+<?php
+
+namespace App\Mail;
+
+use App\Jobs\WalletCheck;
+use App\Tenant;
+use App\User;
+use App\Utils;
+use App\Wallet;
+use Illuminate\Bus\Queueable;
+use Illuminate\Mail\Mailable;
+use Illuminate\Queue\SerializesModels;
+
+class NegativeBalanceReminderDegrade extends Mailable
+{
+ use Queueable;
+ use SerializesModels;
+
+ /** @var \App\Wallet A wallet with a negative balance */
+ protected $wallet;
+
+ /** @var \App\User A wallet controller to whom the email is being sent */
+ protected $user;
+
+
+ /**
+ * Create a new message instance.
+ *
+ * @param \App\Wallet $wallet A wallet
+ * @param \App\User $user An email recipient
+ *
+ * @return void
+ */
+ public function __construct(Wallet $wallet, User $user)
+ {
+ $this->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
@@ -108,6 +108,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
@@ -335,14 +335,50 @@
}
/**
- * Handle the "updating" event.
+ * Handle the "updated" event.
*
* @param 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);
+ });
+ }
}
}
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
@@ -52,6 +52,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.
@@ -293,6 +295,21 @@
return $this->canDelete($object);
}
+ /**
+ * 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.
*
@@ -448,7 +465,7 @@
}
/**
- * Returns whether this domain is active.
+ * Returns whether this user is active.
*
* @return bool
*/
@@ -458,7 +475,28 @@
}
/**
- * 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) {
+ $owner = $this->wallet()->owner;
+ return $owner && $owner->isDegraded();
+ }
+
+ return false;
+ }
+
+ /**
+ * Returns whether this user is deleted.
*
* @return bool
*/
@@ -468,8 +506,7 @@
}
/**
- * Returns whether this (external) domain has been verified
- * to exist in DNS.
+ * Returns whether this user is registered in IMAP.
*
* @return bool
*/
@@ -499,7 +536,7 @@
}
/**
- * Returns whether this domain is suspended.
+ * Returns whether this user is suspended.
*
* @return bool
*/
@@ -602,7 +639,17 @@
}
/**
- * Suspend this domain.
+ * Any (additional) properties of this user.
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\HasMany
+ */
+ public function settings()
+ {
+ return $this->hasMany('App\UserSetting', 'user_id');
+ }
+
+ /**
+ * Suspend this user.
*
* @return void
*/
@@ -617,7 +664,32 @@
}
/**
- * Unsuspend this domain.
+ * The tenant for this user account.
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
+ */
+ public function tenant()
+ {
+ return $this->belongsTo('App\Tenant', 'tenant_id', 'id');
+ }
+
+ /**
+ * 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
*/
@@ -719,6 +791,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
@@ -339,6 +339,9 @@
return this.$t('status.active')
},
+ isDegraded() {
+ return store.state.authInfo.isAccountDegraded
+ },
pageName(path) {
let page = this.$route.path
@@ -375,7 +378,7 @@
return 'text-muted'
}
- if (user.isSuspended) {
+ if (user.isDegraded || user.isAccountDegraded || user.isSuspended) {
return 'text-warning'
}
@@ -390,6 +393,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 <a href=\":href\">here</a> 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
@@ -312,6 +312,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",
@@ -349,6 +350,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 <a href=\":href\">ici</a> 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 @@
+<!DOCTYPE html>
+<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
+ <head>
+ <meta charset="utf-8">
+ </head>
+ <body>
+ <p>{{ __('mail.header', ['name' => $username]) }}</p>
+
+ <p>{{ __('mail.degradedaccountreminder-body1', ['site' => $site]) }}</p>
+ <p>{{ __('mail.degradedaccountreminder-body2', ['site' => $site]) }}</p>
+ <p>{{ __('mail.degradedaccountreminder-body3', ['site' => $site]) }}</p>
+ <p><a href="{{ $dashboardUrl }}">{{ $dashboardUrl }}</a></p>
+ <p>{{ __('mail.degradedaccountreminder-body4', ['site' => $site]) }}</p>
+ <p>{{ __('mail.degradedaccountreminder-body5', ['site' => $site]) }}</p>
+
+ <p>{{ __('mail.footer1') }}</p>
+ <p>{{ __('mail.footer2', ['site' => $site]) }}</p>
+ </body>
+</html>
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 @@
+<!DOCTYPE html>
+<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
+ <head>
+ <meta charset="utf-8">
+ </head>
+ <body>
+ <p>{{ __('mail.header', ['name' => $username]) }}</p>
+
+ <p>{{ __('mail.negativebalancedegraded-body', ['site' => $site]) }}</p>
+ <p>{{ __('mail.negativebalancedegraded-body-ext', ['site' => $site]) }}</p>
+ <p><a href="{{ $walletUrl }}">{{ $walletUrl }}</a></p>
+
+@if ($supportUrl)
+ <p>{{ __('mail.support', ['site' => $site]) }}</p>
+ <p><a href="{{ $supportUrl }}">{{ $supportUrl }}</a></p>
+@endif
+
+ <p>{{ __('mail.footer1') }}</p>
+ <p>{{ __('mail.footer2', ['site' => $site]) }}</p>
+ </body>
+</html>
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 @@
+<!DOCTYPE html>
+<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
+ <head>
+ <meta charset="utf-8">
+ </head>
+ <body>
+ <p>{{ __('mail.header', ['name' => $username]) }}</p>
+
+ <p>{{ __('mail.negativebalancereminder-body', ['site' => $site]) }}</p>
+ <p>{{ __('mail.negativebalancereminder-body-ext', ['site' => $site]) }}</p>
+ <p><a href="{{ $walletUrl }}">{{ $walletUrl }}</a></p>
+ <p><b>{{ __('mail.negativebalancereminderdegrade-body-warning', ['site' => $site, 'date' => $date]) }}</b></p>
+
+@if ($supportUrl)
+ <p>{{ __('mail.support', ['site' => $site]) }}</p>
+ <p><a href="{{ $supportUrl }}">{{ $supportUrl }}</a></p>
+@endif
+
+ <p>{{ __('mail.footer1') }}</p>
+ <p>{{ __('mail.footer2', ['site' => $site]) }}</p>
+ </body>
+</html>
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 = `<div id="status-degraded" class="d-flex justify-content-center">`
+ + `<p class="alert alert-danger">${message}</p></div>`
+
+ $('#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
@@ -19,7 +19,7 @@
<svg-icon icon="wallet"></svg-icon><span class="name">{{ $t('dashboard.wallet') }}</span>
<span v-if="balance < 0" class="badge bg-danger">{{ $root.price(balance, currency) }}</span>
</router-link>
- <router-link v-if="$root.hasSKU('meet')" class="card link-chat" :to="{ name: 'rooms' }">
+ <router-link v-if="$root.hasSKU('meet') && !$root.isDegraded()" class="card link-chat" :to="{ name: 'rooms' }">
<svg-icon icon="comments"></svg-icon><span class="name">{{ $t('dashboard.chat') }}</span>
<span class="badge bg-primary">{{ $t('dashboard.beta') }}</span>
</router-link>
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 @@
<div class="card-body">
<div class="card-title">
{{ $tc('distlist.list-title', 2) }}
- <router-link class="btn btn-success float-end create-list" :to="{ path: 'distlist/new' }" tag="button">
+ <router-link v-if="!$root.isDegraded()" class="btn btn-success float-end create-list" :to="{ path: 'distlist/new' }" tag="button">
<svg-icon icon="users"></svg-icon> {{ $t('distlist.create') }}
</router-link>
</div>
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 @@
<div class="card-body">
<div class="card-title">
{{ $t('user.domains') }}
- <router-link class="btn btn-success float-end create-domain" :to="{ path: 'domain/new' }" tag="button">
+ <router-link v-if="!$root.isDegraded()" class="btn btn-success float-end create-domain" :to="{ path: 'domain/new' }" tag="button">
<svg-icon icon="globe"></svg-icon> {{ $t('domain.create') }}
</router-link>
</div>
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/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 @@
<div class="card-text">
<div class="mb-2 d-flex">
<list-search :placeholder="$t('user.search')" :on-search="searchUsers"></list-search>
- <div>
+ <div v-if="!$root.isDegraded()">
<router-link class="btn btn-success ms-1 create-user" :to="{ path: 'user/new' }" tag="button">
<svg-icon icon="user"></svg-icon> {{ $t('user.create') }}
</router-link>
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,111 @@
+<?php
+
+namespace Tests\Browser;
+
+use App\User;
+use Tests\Browser;
+use Tests\Browser\Pages\Dashboard;
+use Tests\Browser\Pages\DistlistList;
+use Tests\Browser\Pages\DomainList;
+use Tests\Browser\Pages\Home;
+use Tests\Browser\Pages\UserList;
+use Tests\TestCaseDusk;
+
+class DegradedAccountTest extends TestCaseDusk
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ $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();
+ }
+
+ /**
+ * {@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, 'distlist');
+
+ $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');
+
+ // 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/Browser/DistlistTest.php b/src/tests/Browser/DistlistTest.php
--- a/src/tests/Browser/DistlistTest.php
+++ b/src/tests/Browser/DistlistTest.php
@@ -81,7 +81,7 @@
// Create a single group, add beta+distlist entitlements
$john = $this->getTestUser('john@kolab.org');
- $this->addDistlistEntitlement($john);
+ $this->addBetaEntitlement($john, 'distlist');
$group = $this->getTestGroup('group-test@kolab.org', ['name' => 'Test Group']);
$group->assignToWallet($john->wallets->first());
@@ -119,7 +119,7 @@
// Add beta+distlist entitlements
$john = $this->getTestUser('john@kolab.org');
- $this->addDistlistEntitlement($john);
+ $this->addBetaEntitlement($john, 'distlist');
$this->browse(function (Browser $browser) {
// Create a group
@@ -233,7 +233,7 @@
public function testStatus(): void
{
$john = $this->getTestUser('john@kolab.org');
- $this->addDistlistEntitlement($john);
+ $this->addBetaEntitlement($john, 'distlist');
$group = $this->getTestGroup('group-test@kolab.org');
$group->assignToWallet($john->wallets->first());
$group->status = Group::STATUS_NEW | Group::STATUS_ACTIVE;
@@ -270,7 +270,7 @@
public function testSettings(): void
{
$john = $this->getTestUser('john@kolab.org');
- $this->addDistlistEntitlement($john);
+ $this->addBetaEntitlement($john, 'distlist');
$group = $this->getTestGroup('group-test@kolab.org');
$group->assignToWallet($john->wallets->first());
$group->status = Group::STATUS_NEW | Group::STATUS_ACTIVE;
@@ -311,16 +311,4 @@
});
});
}
-
- /**
- * Register the beta + distlist entitlements for the user
- */
- private function addDistlistEntitlement($user): void
- {
- // Add beta+distlist entitlements
- $beta_sku = Sku::where('title', 'beta')->first();
- $distlist_sku = Sku::where('title', 'distlist')->first();
- $user->assignSku($beta_sku);
- $user->assignSku($distlist_sku);
- }
}
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
@@ -312,6 +312,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 @@
+<?php
+
+namespace Tests\Feature\Console\User;
+
+use Illuminate\Support\Facades\Queue;
+use Tests\TestCase;
+
+class DegradeTest extends TestCase
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ $this->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 @@
+<?php
+
+namespace Tests\Feature\Console\User;
+
+use Illuminate\Support\Facades\Queue;
+use Tests\TestCase;
+
+class UndegradeTest extends TestCase
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ $this->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
@@ -132,7 +132,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;
@@ -376,36 +377,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);
}
/**
@@ -671,6 +735,19 @@
$this->markTestIncomplete();
}
+ /**
+ * 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()
*/
@@ -1005,6 +1082,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
@@ -4,6 +4,7 @@
use App\Domain;
use App\Group;
+use App\Sku;
use App\Transaction;
use App\User;
use Carbon\Carbon;
@@ -89,6 +90,18 @@
*/
protected $userPassword;
+ /**
+ * Register the beta entitlement for a user
+ */
+ protected function addBetaEntitlement($user, $title): 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);
+ }
+
/**
* Assert that the entitlements for the user match the expected list of entitlements.
*
@@ -109,7 +122,7 @@
Assert::assertSame($expected, $skus);
}
- protected function backdateEntitlements($entitlements, $targetDate)
+ protected function backdateEntitlements($entitlements, $targetDate, $targetCreatedDate = null)
{
$wallets = [];
$ids = [];
@@ -120,7 +133,7 @@
}
\App\Entitlement::whereIn('id', $ids)->update([
- 'created_at' => $targetDate,
+ 'created_at' => $targetCreatedDate ?: $targetDate,
'updated_at' => $targetDate,
]);
@@ -128,7 +141,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
+ ]);
}
}
@@ -142,7 +157,7 @@
'App\Handlers\Distlist',
];
- $betas = \App\Sku::whereIn('handler_class', $beta_handlers)->pluck('id')->all();
+ $betas = Sku::whereIn('handler_class', $beta_handlers)->pluck('id')->all();
\App\Entitlement::whereIn('sku_id', $betas)->delete();
}
@@ -424,7 +439,7 @@
$this->domainUsers[] = $this->domainOwner;
// assign second factor to joe
- $this->joe->assignSku(\App\Sku::where('title', '2fa')->first());
+ $this->joe->assignSku(Sku::where('title', '2fa')->first());
\App\Auth\SecondFactor::seed($this->joe->email);
usort(
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 @@
+<?php
+
+namespace Tests\Unit\Mail;
+
+use App\Mail\DegradedAccountReminder;
+use App\User;
+use App\Wallet;
+use Tests\MailInterceptTrait;
+use Tests\TestCase;
+
+class DegradedAccountReminderTest extends TestCase
+{
+ use MailInterceptTrait;
+
+ /**
+ * Test email content
+ */
+ public function testBuild(): void
+ {
+ $user = $this->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('<a href="%s">%s</a>', $dashboardUrl, $dashboardUrl);
+ $appName = $user->tenant->title;
+
+ $this->assertMailSubject("$appName Reminder: Your account is free", $mail['message']);
+
+ $this->assertStringStartsWith('<!DOCTYPE html>', $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 @@
+<?php
+
+namespace Tests\Unit\Mail;
+
+use App\Jobs\WalletCheck;
+use App\Mail\NegativeBalanceDegraded;
+use App\User;
+use App\Wallet;
+use Tests\MailInterceptTrait;
+use Tests\TestCase;
+
+class NegativeBalanceDegradedTest extends TestCase
+{
+ use MailInterceptTrait;
+
+ /**
+ * Test email content
+ */
+ public function testBuild(): void
+ {
+ $user = $this->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('<a href="%s">%s</a>', $walletUrl, $walletUrl);
+ $supportUrl = \config('app.support_url');
+ $supportLink = sprintf('<a href="%s">%s</a>', $supportUrl, $supportUrl);
+ $appName = $user->tenant->title;
+
+ $this->assertMailSubject("$appName Account Degraded", $mail['message']);
+
+ $this->assertStringStartsWith('<!DOCTYPE html>', $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 @@
+<?php
+
+namespace Tests\Unit\Mail;
+
+use App\Jobs\WalletCheck;
+use App\Mail\NegativeBalanceReminderDegrade;
+use App\User;
+use App\Wallet;
+use Tests\MailInterceptTrait;
+use Tests\TestCase;
+
+class NegativeBalanceReminderDegradeTest extends TestCase
+{
+ use MailInterceptTrait;
+
+ /**
+ * Test email content
+ */
+ public function testBuild(): void
+ {
+ $user = $this->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('<a href="%s">%s</a>', $walletUrl, $walletUrl);
+ $supportUrl = \config('app.support_url');
+ $supportLink = sprintf('<a href="%s">%s</a>', $supportUrl, $supportUrl);
+ $appName = $user->tenant->title;
+
+ $this->assertMailSubject("$appName Payment Reminder", $mail['message']);
+
+ $this->assertStringStartsWith('<!DOCTYPE html>', $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));
}
}

File Metadata

Mime Type
text/plain
Expires
Fri, Apr 3, 3:14 AM (6 h, 6 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18822360
Default Alt Text
D2371.1775186092.diff (95 KB)

Event Timeline