Adjust email quotas - Hosted plan. (#3663)

* Fixes for invitations not being created in RandomDataSeeder

* Resend failed/quota exceeded emails

* Queue email tests

* Refund a client for a ninja account

* Adjust email quotas - hosted plan
This commit is contained in:
David Bomba 2020-04-30 21:45:47 +10:00 committed by GitHub
parent a70f42b31e
commit c503d58505
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 446 additions and 29 deletions

View File

@ -12,7 +12,9 @@
namespace App\Console;
use App\Jobs\Cron\RecurringInvoicesCron;
use App\Jobs\Util\AdjustEmailQuota;
use App\Jobs\Util\ReminderJob;
use App\Jobs\Util\SendFailedEmails;
use App\Jobs\Util\VersionCheck;
use App\Utils\Ninja;
use Illuminate\Console\Scheduling\Schedule;
@ -43,6 +45,11 @@ class Kernel extends ConsoleKernel
$schedule->job(new ReminderJob)->daily();
/* Run hosted specific jobs */
if(Ninja::isHosted()) {
$schedule->json()->daily(new AdjustEmailQuota())->daily;
$schedule->job(new SendFailedEmails())->daily();
}
/* Run queue's in shared hosting with this*/
if (Ninja::isSelfHost()) {
$schedule->command('queue:work')->everyMinute()->withoutOverlapping();

View File

@ -0,0 +1,37 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com)
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2020. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://opensource.org/licenses/AAL
*/
namespace App\DataMapper;
/**
* EmailSpooledForSend
*
* Stubbed class used to store the meta data
* for an email that was unable to be sent
* for a reason such as:
*
* - Quota exceeded
* - SMTP issues
* - Upstream connectivity
*
*/
class EmailSpooledForSend
{
public $entity_name;
public $invitation_key = '';
public $reminder_template = '';
public $subject = '';
public $body = '';
}

View File

@ -9,16 +9,16 @@ use League\CommonMark\CommonMarkConverter;
class EmailBuilder
{
protected $subject;
protected $body;
protected $recipients;
protected $attachments;
protected $footer;
protected $template_style;
protected $variables = [];
protected $contact = null;
protected $view_link;
protected $view_text;
public $subject;
public $body;
public $recipients;
public $attachments;
public $footer;
public $template_style;
public $variables = [];
public $contact = null;
public $view_link;
public $view_text;
private function parseTemplate(string $data, bool $is_markdown = true, $contact = null): string
{

View File

@ -21,6 +21,7 @@ use App\Http\Requests\Company\UpdateCompanyRequest;
use App\Http\Requests\SignupRequest;
use App\Jobs\Company\CreateCompany;
use App\Jobs\Company\CreateCompanyToken;
use App\Jobs\Ninja\RefundCancelledAccount;
use App\Jobs\RegisterNewAccount;
use App\Jobs\Util\UploadAvatar;
use App\Models\Account;
@ -30,6 +31,7 @@ use App\Repositories\CompanyRepository;
use App\Transformers\AccountTransformer;
use App\Transformers\CompanyTransformer;
use App\Transformers\CompanyUserTransformer;
use App\Utils\Ninja;
use App\Utils\Traits\MakesHash;
use App\Utils\Traits\Uploadable;
use Illuminate\Foundation\Bus\DispatchesJobs;
@ -460,15 +462,21 @@ class CompanyController extends BaseController
public function destroy(DestroyCompanyRequest $request, Company $company)
{
$company_count = $company->account->companies->count();
$account = $company->account;
if ($company_count == 1) {
$company->company_users->each(function ($company_user) {
$company_user->user->forceDelete();
});
$company->account->delete();
if(Ninja::isHosted())
RefundCancelledAccount::dispatchNow($account);
$account->delete();
} else {
$account = $company->account;
$company_id = $company->id;
$company->delete();
@ -479,11 +487,6 @@ class CompanyController extends BaseController
$account->save();
}
}
//@todo delete documents also!!
//@todo in the hosted version deleting the last
//account will trigger an account refund.
return response()->json(['message' => 'success'], 200);
}

View File

@ -23,8 +23,8 @@ class ShowInvoiceRequest extends Request
*/
public function authorize() : bool
{info(auth('contact')->user()->client->id);
info($this->invoice->client_id);
{
return auth('contact')->user()->client->id === $this->invoice->client_id;
}
}

View File

@ -57,7 +57,7 @@ class EmailCredit implements ShouldQueue
$template_style = $this->credit->client->getSetting('email_style');
$this->credit->invitations->each(function ($invitation) use ($template_style) {
if ($invitation->contact->send && $invitation->contact->email) {
if ($invitation->contact->send_email && $invitation->contact->email) {
$message_array = $this->credit->getEmailData('', $invitation->contact);
$message_array['title'] = &$message_array['subject'];
$message_array['footer'] = "Sent to ".$invitation->contact->present()->name();

View File

@ -100,7 +100,7 @@ class CreateUbl implements ShouldQueue
try {
return Generator::invoice($ubl_invoice, $invoice->client->getCurrencyCode());
} catch (\Exception $exception) {
info(print_r($exception, 1));
return false;
}
}

View File

@ -53,7 +53,7 @@ class EmailInvoice implements ShouldQueue
*/
public function handle()
{
{
MultiDB::setDB($this->company->db);
Mail::to($this->invoice_invitation->contact->email, $this->invoice_invitation->contact->present()->name())

View File

@ -0,0 +1,80 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com)
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2020. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://opensource.org/licenses/AAL
*/
namespace App\Jobs\Util;
use App\Helpers\Email\InvoiceEmail;
use App\Jobs\Invoice\EmailInvoice;
use App\Libraries\MultiDB;
use App\Models\Account;
use App\Models\SystemLog;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class AdjustEmailQuota implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
const FREE_PLAN_DAILY_QUOTA = 10;
const PRO_PLAN_DAILY_QUOTA = 50;
const ENTERPRISE_PLAN_DAILY_QUOTA = 200;
const FREE_PLAN_DAILY_CAP = 20;
const PRO_PLAN_DAILY_CAP = 100;
const ENTERPRISE_PLAN_DAILY_CAP = 300;
const DAILY_MULTIPLIER = 1.1;
/**
* Create a new job instance.
*
* @return void
*/
public function __construct()
{
//
}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
if (! config('ninja.db.multi_db_enabled')) {
$this->adjust();
} else {
//multiDB environment, need to
foreach (MultiDB::$dbs as $db) {
MultiDB::setDB($db);
$this->adjust();
}
}
}
public function adjust()
{
foreach(Account::cursor() as $account){
//@TODO once we add in the two columns daily_emails_quota daily_emails_sent_
}
}
}

View File

@ -0,0 +1,78 @@
<?php
namespace App\Jobs\Ninja;
use App\Models\Account;
use App\Utils\Ninja;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Carbon;
class RefundCancelledAccount implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $account;
/**
* Create a new job instance.
*
* @return void
*/
public function __construct(Account $account)
{
$this->account = $account;
}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
// if free plan, return
if(Ninja::isSelfHost() || $this->account->isFreeHostedClient())
return;
$plan_details = $account->getPlanDetails();
/* Trial user cancelling early.... */
if($plan_details['trial_active'])
return;
/* Is the plan Active? */
if(!$plan_details['active'])
return;
/* Refundable client! */
$plan_start = $plan_details['started'];
$plan_expires = $plan_details['expires'];
$paid = $plan_details['paid'];
$term = $plan_details['term'];
$refund = $this->calculateRefundAmount($paid, $plan_expires);
/* Are there any edge cases? */
//@TODO process refund by refunding directly to the payment_id;
}
private function calculateRefundAmount($amount, $plan_expires)
{
$end_date = Carbon::parse($plan_expires);
$now = Carbon::now();
$days_left = $now->diffInDays($end_date);
$pro_rata_ratio = $days_left / 365;
$pro_rata_refund = $amount * $pro_rata_ratio;
return $pro_rata_refund;
}
}

View File

@ -1,4 +1,13 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com)
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2020. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://opensource.org/licenses/AAL
*/
namespace App\Jobs\Util;

View File

@ -1,4 +1,13 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com)
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2020. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://opensource.org/licenses/AAL
*/
namespace App\Jobs\Util;

View File

@ -1,4 +1,13 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com)
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2020. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://opensource.org/licenses/AAL
*/
namespace App\Jobs\Util;

View File

@ -0,0 +1,85 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com)
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2020. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://opensource.org/licenses/AAL
*/
namespace App\Jobs\Util;
use App\Helpers\Email\InvoiceEmail;
use App\Jobs\Invoice\EmailInvoice;
use App\Libraries\MultiDB;
use App\Models\SystemLog;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class SendFailedEmails implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* Create a new job instance.
*
* @return void
*/
public function __construct()
{
//
}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
if (! config('ninja.db.multi_db_enabled')) {
$this->processEmails();
} else {
//multiDB environment, need to
foreach (MultiDB::$dbs as $db) {
MultiDB::setDB($db);
$this->processEmails();
}
}
}
private function processEmails()
{
//info("process emails");
//@todo check that the quota is available for the job
$email_jobs = SystemLog::where('event_id', SystemLog::EVENT_MAIL_RETRY_QUEUE)->get();
$email_jobs->each(function($job){
$job_meta_array = $job->log;
$invitation = $job_meta_array['entity_name']::where('key', $job_meta_array['invitation_key'])->with('contact')->first();
if($invitation->invoice){
$email_builder = (new InvoiceEmail())->build($invitation, $job_meta_array['reminder_template']);
if ($invitation->contact->send_email && $invitation->contact->email) {
EmailInvoice::dispatch($email_builder, $invitation, $invitation->company);
}
}
});
}
}

View File

@ -1,4 +1,13 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com)
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2020. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://opensource.org/licenses/AAL
*/
namespace App\Jobs\Util;

View File

@ -1,4 +1,13 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com)
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2020. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://opensource.org/licenses/AAL
*/
namespace App\Jobs\Util;

View File

@ -1,4 +1,13 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com)
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2020. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://opensource.org/licenses/AAL
*/
namespace App\Jobs\Util;

View File

@ -28,6 +28,7 @@ class SystemLog extends Model
const EVENT_GATEWAY_ERROR = 23;
const EVENT_MAIL_SEND = 30;
const EVENT_MAIL_RETRY_QUEUE = 31; //we use this to queue emails that are spooled and not sent due to the email queue quota being exceeded.
/*Type IDs*/
const TYPE_PAYPAL = 300;
@ -35,6 +36,9 @@ class SystemLog extends Model
const TYPE_LEDGER = 302;
const TYPE_FAILURE = 303;
const TYPE_QUOTA_EXCEEDED = 400;
const TYPE_UPSTREAM_FAILURE = 401;
protected $fillable = [
'client_id',
'company_id',

View File

@ -46,7 +46,7 @@ class SendEmail extends AbstractService
$this->invoice->invitations->each(function ($invitation) {
$email_builder = (new InvoiceEmail())->build($invitation, $this->reminder_template);
if ($invitation->contact->send && $invitation->contact->email) {
if ($invitation->contact->send_email && $invitation->contact->email) {
EmailInvoice::dispatch($email_builder, $invitation, $invitation->company);
}
});

View File

@ -27,7 +27,7 @@ class SendEmail
}
$this->quote->invitations->each(function ($invitation) {
if ($invitation->contact->send && $invitation->contact->email) {
if ($invitation->contact->send_email && $invitation->contact->email) {
$email_builder = (new QuoteEmail())->build($invitation, $reminder_template);
EmailQuote::dispatchNow($email_builder, $invitation);

View File

@ -20,6 +20,7 @@ $factory->define(App\Models\ClientContact::class, function (Faker $faker) {
'phone' => $faker->phoneNumber,
'email_verified_at' => now(),
'email' => $faker->unique()->safeEmail,
'send_email' => true,
'password' => bcrypt('password'),
'remember_token' => \Illuminate\Support\Str::random(10),
'contact_key' => \Illuminate\Support\Str::random(40),

View File

@ -144,7 +144,7 @@ class RandomDataSeeder extends Seeder
/** Invoice Factory */
factory(\App\Models\Invoice::class, 20)->create(['user_id' => $user->id, 'company_id' => $company->id, 'client_id' => $client->id]);
$invoices = Invoice::cursor();
$invoices = Invoice::all();
$invoice_repo = new InvoiceRepository();
$invoices->each(function ($invoice) use ($invoice_repo, $user, $company, $client) {
@ -160,12 +160,12 @@ class RandomDataSeeder extends Seeder
$invoice->save();
event(new CreateInvoiceInvitation($invoice));
//event(new CreateInvoiceInvitation($invoice));
$invoice->service()->createInvitations()->markSent()->save();
$invoice->ledger()->updateInvoiceBalance($invoice->balance);
$invoice->service()->markSent()->save();
event(new InvoiceWasMarkedSent($invoice, $company));
if (rand(0, 1)) {

View File

@ -0,0 +1,68 @@
<?php
namespace Tests\Integration;
use App\Jobs\Invoice\EmailInvoice;
use App\Jobs\Util\SendFailedEmails;
use App\Jobs\Util\SystemLogger;
use App\Models\SystemLog;
use Illuminate\Foundation\Testing\Concerns\InteractsWithDatabase;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Queue;
use Tests\MockAccountData;
use Tests\TestCase;
/**
* @test
* @covers App\Jobs\Util\SendFailedEmails
*/
class SendFailedEmailsTest extends TestCase
{
use MockAccountData;
use DatabaseTransactions;
public function setUp() :void
{
parent::setUp();
$this->makeTestData();
}
public function testReminderFires()
{
$invitation = $this->invoice->invitations->first();
$reminder_template = $this->invoice->calculateTemplate();
$sl = [
'entity_name' => 'App\Models\InvoiceInvitation',
'invitation_key' => $invitation->key,
'reminder_template' => $reminder_template,
'subject' => '',
'body' => '',
];
$system_log = new SystemLog;
$system_log->company_id = $this->invoice->company_id;
$system_log->client_id = $this->invoice->client_id;
$system_log->category_id = SystemLog::CATEGORY_MAIL;
$system_log->event_id = SystemLog::EVENT_MAIL_RETRY_QUEUE;
$system_log->type_id = SystemLog::TYPE_QUOTA_EXCEEDED;
$system_log->log = $sl;
$system_log->save();
$sys_log = SystemLog::where('event_id', SystemLog::EVENT_MAIL_RETRY_QUEUE)->first();
$this->assertNotNull($sys_log);
// Queue::fake();
SendFailedEmails::dispatch();
//Queue::assertPushed(SendFailedEmails::class);
//Queue::assertPushed(EmailInvoice::class);
//$this->expectsJobs(EmailInvoice::class);
}
}

View File

@ -200,7 +200,7 @@ trait MockAccountData
$this->invoice->save();
$this->invoice->service()->markSent();
$this->invoice->service()->createInvitations()->markSent();
$this->quote = factory(\App\Models\Quote::class)->create([
'user_id' => $this->user->id,