diff --git a/src/.env.example b/src/.env.example --- a/src/.env.example +++ b/src/.env.example @@ -172,3 +172,4 @@ KB_ACCOUNT_DELETE= KB_ACCOUNT_SUSPENDED= +KB_PAYMENT_SYSTEM= diff --git a/src/app/Console/Commands/Wallet/TrialEndCommand.php b/src/app/Console/Commands/Wallet/TrialEndCommand.php new file mode 100644 --- /dev/null +++ b/src/app/Console/Commands/Wallet/TrialEndCommand.php @@ -0,0 +1,53 @@ +join('users', 'users.id', '=', 'wallets.user_id') + ->leftJoin('wallet_settings', function ($join) { + $join->on('wallet_settings.wallet_id', '=', 'wallets.id') + ->where('wallet_settings.key', 'trial_end_notice'); + }) + ->withEnvTenantContext('users') + ->whereNull('users.deleted_at') + ->where('users.status', '&', \App\User::STATUS_IMAP_READY) + ->where('users.created_at', '>', \now()->subMonthsNoOverflow(2)) + ->whereNull('wallet_settings.value') + ->cursor(); + + foreach ($wallets as $wallet) { + // Send the email asynchronously + \App\Jobs\TrialEndEmail::dispatch($wallet->owner); + + // Store the timestamp + $wallet->setSetting('trial_end_notice', (string) \now()); + } + } +} diff --git a/src/app/Console/Kernel.php b/src/app/Console/Kernel.php --- a/src/app/Console/Kernel.php +++ b/src/app/Console/Kernel.php @@ -16,25 +16,20 @@ */ protected function schedule(Schedule $schedule) { - // This command imports countries and the current set of IPv4 and IPv6 networks allocated to countries. + // This imports countries and the current set of IPv4 and IPv6 networks allocated to countries. $schedule->command('data:import')->dailyAt('05:00'); // This notifies users about coming password expiration $schedule->command('password:retention')->dailyAt('06:00'); - // These apply wallet charges - $schedule->command('wallet:charge')->dailyAt('00:00'); - $schedule->command('wallet:charge')->dailyAt('04:00'); - $schedule->command('wallet:charge')->dailyAt('08:00'); - $schedule->command('wallet:charge')->dailyAt('12:00'); - $schedule->command('wallet:charge')->dailyAt('16:00'); - $schedule->command('wallet:charge')->dailyAt('20:00'); + // This applies wallet charges + $schedule->command('wallet:charge')->everyFourHours(); - // this is a laravel 8-ism - //$schedule->command('wallet:charge')->everyFourHours(); - - // This command removes deleted storage files/file chunks from the filesystem + // This removes deleted storage files/file chunks from the filesystem $schedule->command('fs:expunge')->hourly(); + + // This notifies users about an end of the trial period + $schedule->command('wallet:trial-end')->dailyAt('07:00'); } /** diff --git a/src/app/Jobs/TrialEndEmail.php b/src/app/Jobs/TrialEndEmail.php new file mode 100644 --- /dev/null +++ b/src/app/Jobs/TrialEndEmail.php @@ -0,0 +1,65 @@ +account = $account; + } + + /** + * Determine the time at which the job should timeout. + * + * @return \DateTime + */ + public function retryUntil() + { + // FIXME: I think it does not make sense to continue trying after 24 hours + return now()->addHours(24); + } + + /** + * Execute the job. + * + * @return void + */ + public function handle() + { + \App\Mail\Helper::sendMail( + new TrialEnd($this->account), + $this->account->tenant_id, + ['to' => $this->account->email] + ); + } +} diff --git a/src/app/Mail/DegradedAccountReminder.php b/src/app/Mail/DegradedAccountReminder.php --- a/src/app/Mail/DegradedAccountReminder.php +++ b/src/app/Mail/DegradedAccountReminder.php @@ -55,7 +55,7 @@ 'site' => $appName, 'subject' => $subject, 'username' => $this->user->name(true), - 'supportUrl' => $supportUrl, + 'supportUrl' => Utils::serviceUrl($supportUrl, $this->user->tenant_id), 'walletUrl' => Utils::serviceUrl('/wallet', $this->user->tenant_id), 'dashboardUrl' => Utils::serviceUrl('/dashboard', $this->user->tenant_id), ]); diff --git a/src/app/Mail/NegativeBalance.php b/src/app/Mail/NegativeBalance.php --- a/src/app/Mail/NegativeBalance.php +++ b/src/app/Mail/NegativeBalance.php @@ -55,7 +55,7 @@ 'site' => $appName, 'subject' => $subject, 'username' => $this->user->name(true), - 'supportUrl' => $supportUrl, + 'supportUrl' => Utils::serviceUrl($supportUrl, $this->user->tenant_id), 'walletUrl' => Utils::serviceUrl('/wallet', $this->user->tenant_id), ]); diff --git a/src/app/Mail/NegativeBalanceBeforeDelete.php b/src/app/Mail/NegativeBalanceBeforeDelete.php --- a/src/app/Mail/NegativeBalanceBeforeDelete.php +++ b/src/app/Mail/NegativeBalanceBeforeDelete.php @@ -57,7 +57,7 @@ 'site' => $appName, 'subject' => $subject, 'username' => $this->user->name(true), - 'supportUrl' => $supportUrl, + 'supportUrl' => Utils::serviceUrl($supportUrl, $this->user->tenant_id), 'walletUrl' => Utils::serviceUrl('/wallet', $this->user->tenant_id), 'date' => $threshold->toDateString(), ]); diff --git a/src/app/Mail/NegativeBalanceDegraded.php b/src/app/Mail/NegativeBalanceDegraded.php --- a/src/app/Mail/NegativeBalanceDegraded.php +++ b/src/app/Mail/NegativeBalanceDegraded.php @@ -56,7 +56,7 @@ 'site' => $appName, 'subject' => $subject, 'username' => $this->user->name(true), - 'supportUrl' => $supportUrl, + 'supportUrl' => Utils::serviceUrl($supportUrl, $this->user->tenant_id), 'walletUrl' => Utils::serviceUrl('/wallet', $this->user->tenant_id), ]); diff --git a/src/app/Mail/NegativeBalanceReminder.php b/src/app/Mail/NegativeBalanceReminder.php --- a/src/app/Mail/NegativeBalanceReminder.php +++ b/src/app/Mail/NegativeBalanceReminder.php @@ -57,7 +57,7 @@ 'site' => $appName, 'subject' => $subject, 'username' => $this->user->name(true), - 'supportUrl' => $supportUrl, + 'supportUrl' => Utils::serviceUrl($supportUrl, $this->user->tenant_id), 'walletUrl' => Utils::serviceUrl('/wallet', $this->user->tenant_id), 'date' => $threshold->toDateString(), ]); diff --git a/src/app/Mail/NegativeBalanceReminderDegrade.php b/src/app/Mail/NegativeBalanceReminderDegrade.php --- a/src/app/Mail/NegativeBalanceReminderDegrade.php +++ b/src/app/Mail/NegativeBalanceReminderDegrade.php @@ -57,7 +57,7 @@ 'site' => $appName, 'subject' => $subject, 'username' => $this->user->name(true), - 'supportUrl' => $supportUrl, + 'supportUrl' => Utils::serviceUrl($supportUrl, $this->user->tenant_id), 'walletUrl' => Utils::serviceUrl('/wallet', $this->user->tenant_id), 'date' => $threshold->toDateString(), ]); diff --git a/src/app/Mail/NegativeBalanceSuspended.php b/src/app/Mail/NegativeBalanceSuspended.php --- a/src/app/Mail/NegativeBalanceSuspended.php +++ b/src/app/Mail/NegativeBalanceSuspended.php @@ -57,7 +57,7 @@ 'site' => $appName, 'subject' => $subject, 'username' => $this->user->name(true), - 'supportUrl' => $supportUrl, + 'supportUrl' => Utils::serviceUrl($supportUrl, $this->user->tenant_id), 'walletUrl' => Utils::serviceUrl('/wallet', $this->user->tenant_id), 'date' => $threshold->toDateString(), ]); diff --git a/src/app/Mail/PasswordExpirationReminder.php b/src/app/Mail/PasswordExpirationReminder.php --- a/src/app/Mail/PasswordExpirationReminder.php +++ b/src/app/Mail/PasswordExpirationReminder.php @@ -44,7 +44,6 @@ public function build() { $appName = Tenant::getConfig($this->user->tenant_id, 'app.name'); - $supportUrl = Tenant::getConfig($this->user->tenant_id, 'app.support_url'); $href = Utils::serviceUrl('profile', $this->user->tenant_id); $params = [ diff --git a/src/app/Mail/PasswordReset.php b/src/app/Mail/PasswordReset.php --- a/src/app/Mail/PasswordReset.php +++ b/src/app/Mail/PasswordReset.php @@ -40,7 +40,6 @@ public function build() { $appName = Tenant::getConfig($this->code->user->tenant_id, 'app.name'); - $supportUrl = Tenant::getConfig($this->code->user->tenant_id, 'app.support_url'); $href = Utils::serviceUrl( sprintf('/password-reset/%s-%s', $this->code->short_code, $this->code->code), diff --git a/src/app/Mail/PaymentFailure.php b/src/app/Mail/PaymentFailure.php --- a/src/app/Mail/PaymentFailure.php +++ b/src/app/Mail/PaymentFailure.php @@ -56,7 +56,7 @@ 'subject' => $subject, 'username' => $this->user->name(true), 'walletUrl' => Utils::serviceUrl('/wallet', $this->user->tenant_id), - 'supportUrl' => $supportUrl, + 'supportUrl' => Utils::serviceUrl($supportUrl, $this->user->tenant_id), ]); return $this; diff --git a/src/app/Mail/PaymentMandateDisabled.php b/src/app/Mail/PaymentMandateDisabled.php --- a/src/app/Mail/PaymentMandateDisabled.php +++ b/src/app/Mail/PaymentMandateDisabled.php @@ -56,7 +56,7 @@ 'subject' => $subject, 'username' => $this->user->name(true), 'walletUrl' => Utils::serviceUrl('/wallet', $this->user->tenant_id), - 'supportUrl' => $supportUrl, + 'supportUrl' => Utils::serviceUrl($supportUrl, $this->user->tenant_id), ]); return $this; diff --git a/src/app/Mail/PaymentSuccess.php b/src/app/Mail/PaymentSuccess.php --- a/src/app/Mail/PaymentSuccess.php +++ b/src/app/Mail/PaymentSuccess.php @@ -56,7 +56,7 @@ 'subject' => $subject, 'username' => $this->user->name(true), 'walletUrl' => Utils::serviceUrl('/wallet', $this->user->tenant_id), - 'supportUrl' => $supportUrl, + 'supportUrl' => Utils::serviceUrl($supportUrl, $this->user->tenant_id), ]); return $this; diff --git a/src/app/Mail/SuspendedDebtor.php b/src/app/Mail/SuspendedDebtor.php --- a/src/app/Mail/SuspendedDebtor.php +++ b/src/app/Mail/SuspendedDebtor.php @@ -58,7 +58,7 @@ 'subject' => $subject, 'username' => $this->account->name(true), 'cancelUrl' => $cancelUrl, - 'supportUrl' => $supportUrl, + 'supportUrl' => Utils::serviceUrl($supportUrl, $this->account->tenant_id), 'walletUrl' => Utils::serviceUrl('/wallet', $this->account->tenant_id), 'moreInfoHtml' => $moreInfoHtml, 'moreInfoText' => $moreInfoText, diff --git a/src/app/Mail/SuspendedDebtor.php b/src/app/Mail/TrialEnd.php copy from src/app/Mail/SuspendedDebtor.php copy to src/app/Mail/TrialEnd.php --- a/src/app/Mail/SuspendedDebtor.php +++ b/src/app/Mail/TrialEnd.php @@ -9,19 +9,19 @@ use Illuminate\Mail\Mailable; use Illuminate\Queue\SerializesModels; -class SuspendedDebtor extends Mailable +class TrialEnd extends Mailable { use Queueable; use SerializesModels; - /** @var \App\User A suspended user (account) */ + /** @var \App\User An account owner (account) */ protected $account; /** * Create a new message instance. * - * @param \App\User $account A suspended user (account) + * @param \App\User $account An account owner (account) * * @return void */ @@ -38,31 +38,20 @@ public function build() { $appName = Tenant::getConfig($this->account->tenant_id, 'app.name'); + $paymentUrl = Tenant::getConfig($this->account->tenant_id, 'app.kb.payment_system'); $supportUrl = Tenant::getConfig($this->account->tenant_id, 'app.support_url'); - $cancelUrl = Tenant::getConfig($this->account->tenant_id, 'app.kb.account_delete'); - $subject = \trans('mail.suspendeddebtor-subject', ['site' => $appName]); + $subject = \trans('mail.trialend-subject', ['site' => $appName]); - $moreInfoHtml = null; - $moreInfoText = null; - if ($moreInfoUrl = Tenant::getConfig($this->account->tenant_id, 'app.kb.account_suspended')) { - $moreInfoHtml = \trans('mail.more-info-html', ['href' => $moreInfoUrl]); - $moreInfoText = \trans('mail.more-info-text', ['href' => $moreInfoUrl]); - } - - $this->view('emails.html.suspended_debtor') - ->text('emails.plain.suspended_debtor') + $this->view('emails.html.trial_end') + ->text('emails.plain.trial_end') ->subject($subject) ->with([ 'site' => $appName, 'subject' => $subject, 'username' => $this->account->name(true), - 'cancelUrl' => $cancelUrl, - 'supportUrl' => $supportUrl, - 'walletUrl' => Utils::serviceUrl('/wallet', $this->account->tenant_id), - 'moreInfoHtml' => $moreInfoHtml, - 'moreInfoText' => $moreInfoText, - 'days' => 14 // TODO: Configurable + 'paymentUrl' => $paymentUrl, + 'supportUrl' => Utils::serviceUrl($supportUrl, $this->account->tenant_id), ]); return $this; diff --git a/src/app/Utils.php b/src/app/Utils.php --- a/src/app/Utils.php +++ b/src/app/Utils.php @@ -460,7 +460,7 @@ /** * Create self URL * - * @param string $route Route/Path + * @param string $route Route/Path/URL * @param int|null $tenantId Current tenant * * @todo Move this to App\Http\Controllers\Controller @@ -469,6 +469,10 @@ */ public static function serviceUrl(string $route, $tenantId = null): string { + if (preg_match('|^https?://|i', $route)) { + return $route; + } + $url = \App\Tenant::getConfig($tenantId, 'app.public_url'); if (!$url) { diff --git a/src/config/app.php b/src/config/app.php --- a/src/config/app.php +++ b/src/config/app.php @@ -230,6 +230,8 @@ 'account_suspended' => env('KB_ACCOUNT_SUSPENDED'), // An article about a way to delete an owned account 'account_delete' => env('KB_ACCOUNT_DELETE'), + // An article about the payment system + 'payment_system' => env('KB_PAYMENT_SYSTEM'), ], 'company' => [ 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 @@ -107,4 +107,17 @@ 'suspendeddebtor-middle' => "Settle up now to reactivate your account.", 'suspendeddebtor-cancel' => "Don't want to be our customer anymore? " . "Here is how you can cancel your account:", + + 'trialend-subject' => ":site: Your trial phase has ended", + 'trialend-intro' => "We hope you enjoyed the 30 days of free :site trial." + . " Your subscriptions become active after the first month of use and the fee is due after another month.", + 'trialend-kb' => "You can read about how to pay the subscription fee in this knowledge base article:", + 'trialend-body1' => "You can leave :site at any time, there is no contractual minimum period" + . " and your account will NOT be deleted automatically." + . " You can delete your account via the red [Delete account] button in your profile." + . " This will end your subscription and delete all relevant data.", + 'trialend-body2' => "THIS OPERATION IS IRREVERSIBLE!", + 'trialend-body3' => "When data is deleted it can not be recovered." + . " Please make sure that you have saved all data that you need before pressing the red button." + . " Do not hesitate to contact Support with any questions or concerns.", ]; diff --git a/src/resources/views/emails/html/trial_end.blade.php b/src/resources/views/emails/html/trial_end.blade.php new file mode 100644 --- /dev/null +++ b/src/resources/views/emails/html/trial_end.blade.php @@ -0,0 +1,21 @@ + + + + + + +

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

+

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

+@if ($paymentUrl) +

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

+

{{ $paymentUrl }}

+@endif +

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

+

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

+

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

+

{{ $supportUrl }}

+ +

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

+

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

+ + diff --git a/src/resources/views/emails/plain/trial_end.blade.php b/src/resources/views/emails/plain/trial_end.blade.php new file mode 100644 --- /dev/null +++ b/src/resources/views/emails/plain/trial_end.blade.php @@ -0,0 +1,19 @@ +{!! __('mail.header', ['name' => $username]) !!} + +{!! __('mail.trialend-intro', ['site' => $site]) !!} +@if ($paymentUrl) + +{!! __('mail.trialend-kb', ['site' => $site]) !!} {!! $paymentUrl !!} +@endif + +{!! __('mail.trialend-body1', ['site' => $site]) !!} + +{!! __('mail.trialend-body2', ['site' => $site]) !!} + +{!! __('mail.trialend-body3', ['site' => $site]) !!} + +{!! $supportUrl !!} + +-- +{!! __('mail.footer1') !!} +{!! __('mail.footer2', ['site' => $site]) !!} diff --git a/src/tests/Feature/Console/Wallet/TrialEndTest.php b/src/tests/Feature/Console/Wallet/TrialEndTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Feature/Console/Wallet/TrialEndTest.php @@ -0,0 +1,91 @@ +deleteTestUser('wallets-controller@kolabnow.com'); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $this->deleteTestUser('wallets-controller@kolabnow.com'); + + parent::tearDown(); + } + + /** + * Test command run + */ + public function testHandle(): void + { + Queue::fake(); + + $user = $this->getTestUser('wallets-controller@kolabnow.com', [ + 'status' => User::STATUS_IMAP_READY | User::STATUS_LDAP_READY | User::STATUS_ACTIVE, + ]); + $wallet = $user->wallets()->first(); + + DB::table('users')->update(['created_at' => \now()->clone()->subMonthsNoOverflow(2)->subHours(1)]); + + // Expect no wallets in after-trial state + Queue::fake(); + $code = \Artisan::call("wallet:trial-end"); + Queue::assertNothingPushed(); + + // Test an email sent + $user->created_at = \now()->clone()->subMonthNoOverflow(); + $user->save(); + + Queue::fake(); + $code = \Artisan::call("wallet:trial-end"); + Queue::assertPushed(\App\Jobs\TrialEndEmail::class, 1); + Queue::assertPushed(\App\Jobs\TrialEndEmail::class, function ($job) use ($user) { + $job_user = TestCase::getObjectProperty($job, 'account'); + return $job_user->id === $user->id; + }); + + $dt = $wallet->getSetting('trial_end_notice'); + $this->assertMatchesRegularExpression('/^' . date('Y-m-d') . ' [0-9]{2}:[0-9]{2}:[0-9]{2}$/', $dt); + + // Test no duplicate email sent for the same wallet + Queue::fake(); + $code = \Artisan::call("wallet:trial-end"); + Queue::assertNothingPushed(); + + // Test not imap ready user - no email sent + $wallet->setSetting('trial_end_notice', null); + $user->status = User::STATUS_NEW | User::STATUS_LDAP_READY | User::STATUS_ACTIVE; + $user->save(); + + Queue::fake(); + $code = \Artisan::call("wallet:trial-end"); + Queue::assertNothingPushed(); + + // Test deleted user - no email sent + $user->status = User::STATUS_NEW | User::STATUS_LDAP_READY | User::STATUS_ACTIVE | User::STATUS_IMAP_READY; + $user->save(); + $user->delete(); + + Queue::fake(); + $code = \Artisan::call("wallet:trial-end"); + Queue::assertNothingPushed(); + + $this->assertNull($wallet->getSetting('trial_end_notice')); + } +} diff --git a/src/tests/Feature/Jobs/TrialEndEmailTest.php b/src/tests/Feature/Jobs/TrialEndEmailTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Feature/Jobs/TrialEndEmailTest.php @@ -0,0 +1,62 @@ +deleteTestUser('PaymentEmail@UserAccount.com'); + } + + /** + * {@inheritDoc} + * + * @return void + */ + public function tearDown(): void + { + $this->deleteTestUser('PaymentEmail@UserAccount.com'); + + parent::tearDown(); + } + + /** + * Test job handle + * + * @return void + */ + public function testHandle() + { + $user = $this->getTestUser('PaymentEmail@UserAccount.com'); + $user->setSetting('external_email', 'ext@email.tld'); + + Mail::fake(); + + // Assert that no jobs were pushed... + Mail::assertNothingSent(); + + $job = new TrialEndEmail($user); + $job->handle(); + + // Assert the email sending job was pushed once + Mail::assertSent(TrialEnd::class, 1); + + // Assert the mail was sent to the user's email + Mail::assertSent(TrialEnd::class, function ($mail) { + return $mail->hasTo('paymentemail@useraccount.com') && !$mail->hasCc('ext@email.tld'); + }); + } +} diff --git a/src/tests/Unit/Mail/TrialEndTest.php b/src/tests/Unit/Mail/TrialEndTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Unit/Mail/TrialEndTest.php @@ -0,0 +1,49 @@ + 'https://kolab.org/support', + 'app.kb.payment_system' => 'https://kb.kolab.org/payment-system', + ]); + + $mail = $this->renderMail(new TrialEnd($user)); + + $html = $mail['html']; + $plain = $mail['plain']; + + $supportUrl = \config('app.support_url'); + $supportLink = sprintf('%s', $supportUrl, $supportUrl); + $paymentUrl = \config('app.kb.payment_system'); + $paymentLink = sprintf('%s', $paymentUrl, $paymentUrl); + $appName = \config('app.name'); + + $this->assertSame("$appName: Your trial phase has ended", $mail['subject']); + + $this->assertStringStartsWith('', $html); + $this->assertTrue(strpos($html, $user->name(true)) > 0); + $this->assertTrue(strpos($html, $supportLink) > 0); + $this->assertTrue(strpos($html, $paymentLink) > 0); + $this->assertTrue(strpos($html, "30 days of free $appName trial") > 0); + $this->assertTrue(strpos($html, "$appName Team") > 0); + + $this->assertStringStartsWith('Dear ' . $user->name(true), $plain); + $this->assertTrue(strpos($plain, $supportUrl) > 0); + $this->assertTrue(strpos($plain, $paymentUrl) > 0); + $this->assertTrue(strpos($plain, "30 days of free $appName trial") > 0); + $this->assertTrue(strpos($plain, "$appName Team") > 0); + } +}