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,52 @@ +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) + ->whereDate('users.created_at', \now()->subMonthNoOverflow()) + ->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 hour + 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/TrialEnd.php b/src/app/Mail/TrialEnd.php new file mode 100644 --- /dev/null +++ b/src/app/Mail/TrialEnd.php @@ -0,0 +1,75 @@ +account = $account; + } + + /** + * Build the message. + * + * @return $this + */ + 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'); + + $subject = \trans('mail.trialend-subject', ['site' => $appName]); + + $this->view('emails.html.trial_end') + ->text('emails.plain.trial_end') + ->subject($subject) + ->with([ + 'site' => $appName, + 'subject' => $subject, + 'username' => $this->account->name(true), + 'paymentUrl' => $paymentUrl, + 'supportUrl' => $supportUrl, + ]); + + 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 + { + $user = new User(); + + $mail = new self($user); + + return Helper::render($mail, $type); + } +} 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,18 @@ '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.", + 'trialend-body1' => "Starting from today your subscription will be active. Your first subscription fee is due in 4 weeks." + . " You can read about how to pay the subscription fee in this knowledge base article:", + 'trialend-body2' => "You can leave :site at any time, there is no contractual minimum period" + . " and your account will in any case be working the next 4 weeks to remove it at no cost." + . " 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-body3' => "THIS OPERATION IS IRREVERSIBLE!", + 'trialend-body4' => "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.", + 'trialend-footer' => "Please 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]) }}

+

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

+

{{ $paymentUrl }}

+

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

+

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

+

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

+

{{ __('mail.trialend-footer', ['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,21 @@ +{!! __('mail.header', ['name' => $username]) !!} + +{!! __('mail.trialend-intro', ['site' => $site]) !!} + +{!! __('mail.trialend-body1', ['site' => $site]) !!} + +{!! $paymentUrl !!} + +{!! __('mail.trialend-body2', ['site' => $site]) !!} + +{!! __('mail.trialend-body3', ['site' => $site]) !!} + +{!! __('mail.trialend-body4', ['site' => $site]) !!} + +{!! __('mail.trialend-footer', ['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,90 @@ +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('wallets')->update(['created_at' => \now()]); + + // 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); + } +}