From ccaa5c1d31a28cedcba89b757c68212c2585572f Mon Sep 17 00:00:00 2001 From: David Bomba Date: Sat, 10 Apr 2021 12:47:47 +1000 Subject: [PATCH 1/5] Improve mock data quality: --- app/Console/Commands/CreateSingleAccount.php | 50 ++++++++++++++++++++ app/Factory/GroupSettingFactory.php | 6 ++- 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/app/Console/Commands/CreateSingleAccount.php b/app/Console/Commands/CreateSingleAccount.php index bcddc6b2b75d..8ec26e7238ef 100644 --- a/app/Console/Commands/CreateSingleAccount.php +++ b/app/Console/Commands/CreateSingleAccount.php @@ -14,8 +14,10 @@ namespace App\Console\Commands; use App\DataMapper\CompanySettings; use App\DataMapper\FeesAndLimits; use App\Events\Invoice\InvoiceWasCreated; +use App\Factory\GroupSettingFactory; use App\Factory\InvoiceFactory; use App\Factory\InvoiceItemFactory; +use App\Factory\SubscriptionFactory; use App\Helpers\Invoice\InvoiceSum; use App\Jobs\Company\CreateCompanyTaskStatuses; use App\Models\Account; @@ -201,6 +203,54 @@ class CreateSingleAccount extends Command } $this->createGateways($company, $user); + + $this->createSubsData($company, $user); + } + + private function createSubsData($company, $user) + { + $gs = GroupSettingFactory::create($company->id, $user->id); + $gs->name = "plans"; + $gs->save(); + + $p1 = Product::factory()->create([ + 'user_id' => $user->id, + 'company_id' => $company->id, + 'product_key' => 'pro_plan', + 'notes' => 'The Pro Plan', + 'cost' => 10, + 'price' => 10, + 'quantity' => 1, + ]); + + $p2 = Product::factory()->create([ + 'user_id' => $user->id, + 'company_id' => $company->id, + 'product_key' => 'enterprise_plan', + 'notes' => 'The Pro Plan', + 'cost' => 10, + 'price' => 10, + 'quantity' => 1, + ]); + + $webhook_config = [ + 'post_purchase_url' => 'http://ninja.test:8000/api/admin/plan', + 'post_purchase_rest_method' => 'POST', + ]; + + $sub = SubscriptionFactory::create($company->id, $user->id); + $sub->name = "Pro Plan"; + $sub->group_id = $gs->id; + $sub->recurring_product_ids = "{$p1->hashed_id}"; + $sub->webhook_configuration = $webhook_config; + $sub->save(); + + $sub = SubscriptionFactory::create($company->id, $user->id); + $sub->name = "Enterprise Plan"; + $sub->group_id = $gs->id; + $sub->recurring_product_ids = "{$p2->hashed_id}"; + $sub->webhook_configuration = $webhook_config; + $sub->save(); } private function createClient($company, $user) diff --git a/app/Factory/GroupSettingFactory.php b/app/Factory/GroupSettingFactory.php index 9260f6eca82d..9b6ab2a1fc89 100644 --- a/app/Factory/GroupSettingFactory.php +++ b/app/Factory/GroupSettingFactory.php @@ -11,17 +11,21 @@ namespace App\Factory; +use App\Models\Client; use App\Models\GroupSetting; class GroupSettingFactory { public static function create(int $company_id, int $user_id) :GroupSetting { + $settings = new \stdClass; + $settings->entity = Client::class; + $gs = new GroupSetting; $gs->name = ''; $gs->company_id = $company_id; $gs->user_id = $user_id; - $gs->settings = '{}'; + $gs->settings = $settings; return $gs; } From 62401555cd638ad88dacd93509822b3cb4deb2f5 Mon Sep 17 00:00:00 2001 From: David Bomba Date: Sat, 10 Apr 2021 14:07:08 +1000 Subject: [PATCH 2/5] Working on pro rata refunds --- .../SubscriptionPlanSwitchController.php | 10 ++- app/Services/Invoice/InvoiceService.php | 1 - .../Subscription/SubscriptionService.php | 71 ++++++++++++++++++- .../recurring_invoices/show.blade.php | 2 +- routes/client.php | 2 +- tests/Unit/DatesTest.php | 47 ++++++++++++ 6 files changed, 125 insertions(+), 8 deletions(-) create mode 100644 tests/Unit/DatesTest.php diff --git a/app/Http/Controllers/ClientPortal/SubscriptionPlanSwitchController.php b/app/Http/Controllers/ClientPortal/SubscriptionPlanSwitchController.php index 9f5a4275a9d1..2a185cf82fad 100644 --- a/app/Http/Controllers/ClientPortal/SubscriptionPlanSwitchController.php +++ b/app/Http/Controllers/ClientPortal/SubscriptionPlanSwitchController.php @@ -14,6 +14,7 @@ namespace App\Http\Controllers\ClientPortal; use App\Http\Controllers\Controller; use App\Http\Requests\ClientPortal\Subscriptions\ShowPlanSwitchRequest; +use App\Models\RecurringInvoice; use App\Models\Subscription; use Illuminate\Http\Request; @@ -23,15 +24,20 @@ class SubscriptionPlanSwitchController extends Controller * Show the page for switching between plans. * * @param ShowPlanSwitchRequest $request - * @param Subscription $subscription + * @param RecurringInvoice $recurring_invoice * @param string $target * @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View */ - public function index(ShowPlanSwitchRequest $request, Subscription $subscription, Subscription $target) + public function index(ShowPlanSwitchRequest $request, RecurringInvoice $recurring_invoice, Subscription $target) { + //calculate whether a payment is required or whether we pass through a credit for this. + + $amount = $recurring_invoice->subscription->service()->calculateUpgradePrice($recurring_invoice, $target); + return render('subscriptions.switch', [ 'subscription' => $subscription, 'target' => $target, + 'amount' => $amount, ]); } } diff --git a/app/Services/Invoice/InvoiceService.php b/app/Services/Invoice/InvoiceService.php index 86296b632a15..4ae0787809df 100644 --- a/app/Services/Invoice/InvoiceService.php +++ b/app/Services/Invoice/InvoiceService.php @@ -297,7 +297,6 @@ class InvoiceService public function deletePdf() { - nlog("delete PDF"); //UnlinkFile::dispatchNow(config('filesystems.default'), $this->invoice->client->invoice_filepath() . $this->invoice->numberFormatter().'.pdf'); Storage::disk(config('filesystems.default'))->delete($this->invoice->client->invoice_filepath() . $this->invoice->numberFormatter().'.pdf'); diff --git a/app/Services/Subscription/SubscriptionService.php b/app/Services/Subscription/SubscriptionService.php index ec0867065619..44ff80daf285 100644 --- a/app/Services/Subscription/SubscriptionService.php +++ b/app/Services/Subscription/SubscriptionService.php @@ -33,6 +33,7 @@ use App\Utils\Ninja; use App\Utils\Traits\CleanLineItems; use App\Utils\Traits\MakesHash; use App\Utils\Traits\SubscriptionHooker; +use Carbon\Carbon; use GuzzleHttp\RequestOptions; class SubscriptionService @@ -44,14 +45,15 @@ class SubscriptionService /** @var subscription */ private $subscription; - /** @var client_subscription */ - // private $client_subscription; - public function __construct(Subscription $subscription) { $this->subscription = $subscription; } + /* + Performs the initial purchase of a + one time or recurring product + */ public function completePurchase(PaymentHash $payment_hash) { @@ -181,9 +183,72 @@ class SubscriptionService return redirect('/client/recurring_invoices/'.$recurring_invoice->hashed_id); } + public function calculateUpgradePrice(RecurringInvoice $recurring_invoice, Subscription $target) + { + //calculate based on daily prices + + $current_amount = $recurring_invoice->amount; + $currency_frequency = $recurring_invoice->frequency_id; + + $outstanding = $recurring_invoice->invoices + ->where('is_deleted', 0) + ->whereIn('status_id', [Invoice::STATUS_SENT, Invoice::STATUS_PARTIAL]) + ->where('balance', '>', 0); + + $outstanding_amounts = $outstanding->sum('balance'); + $outstanding_invoices = $outstanding->get(); + + if ($outstanding->count() == 0){ + //nothing outstanding + } + elseif ($outstanding_amounts > $current_amount){ + //user has multiple amounts outstanding + } + elseif ($outstanding_amounts < $current_amount && $outstanding_amounts > 0) { + //user is changing plan mid frequency cycle + } + + // $current_plan_price = $this->subscription + } + + private function calculateProRataRefund($invoice) + { + //determine the start date + + $start_date = Carbon::parse($invoice->date); + + $current_date = now(); + + $days_to_refund = $start_date->diffInDays($current_date); + } public function createChangePlanInvoice($data) { + //Data array structure + /** + * [ + * 'subscription' => Subscription::class, + * 'target' => Subscription::class + * ] + */ + + //logic + + // Is the user paid up to date? ie are there any outstanding invoices for this subscription + + // User in arrears. + + + // User paid up to date (in credit!) + + //generate credit amount. + // + //generate new billable amount + // + + //if billable amount is LESS than 0 -> generate a credit and pass through. + // + //if billable amoun is GREATER than 0 -> gener return Invoice::where('status_id', Invoice::STATUS_SENT)->first(); } diff --git a/resources/views/portal/ninja2020/recurring_invoices/show.blade.php b/resources/views/portal/ninja2020/recurring_invoices/show.blade.php index 23faca8dcbf1..4858ebb8aa70 100644 --- a/resources/views/portal/ninja2020/recurring_invoices/show.blade.php +++ b/resources/views/portal/ninja2020/recurring_invoices/show.blade.php @@ -92,7 +92,7 @@
@foreach($invoice->subscription->service()->getPlans() as $subscription) - {{ $subscription->name }} + {{ $subscription->name }} @endforeach
diff --git a/routes/client.php b/routes/client.php index 32ba6eb1b9de..8ddac89510a8 100644 --- a/routes/client.php +++ b/routes/client.php @@ -72,7 +72,7 @@ Route::group(['middleware' => ['auth:contact', 'locale', 'check_client_existence Route::get('documents/{document}/download', 'ClientPortal\DocumentController@download')->name('documents.download'); Route::resource('documents', 'ClientPortal\DocumentController')->only(['index', 'show']); - Route::get('subscriptions/{subscription}/plan_switch/{target}', 'ClientPortal\SubscriptionPlanSwitchController@index')->name('subscription.plan_switch'); + Route::get('subscriptions/{recurring_invoice}/plan_switch/{target}', 'ClientPortal\SubscriptionPlanSwitchController@index')->name('subscription.plan_switch'); Route::resource('subscriptions', 'ClientPortal\SubscriptionController')->only(['index']); diff --git a/tests/Unit/DatesTest.php b/tests/Unit/DatesTest.php new file mode 100644 index 000000000000..c76f7aca980e --- /dev/null +++ b/tests/Unit/DatesTest.php @@ -0,0 +1,47 @@ +makeTestData(); + + } + + public function testDaysDiff() + { + $string_date = '2021-06-01'; + + $start_date = Carbon::parse($string_date); + $current_date = Carbon::parse('2021-06-20'); + + $diff_in_days = $start_date->diffInDays($current_date); + + $this->assertEquals(19, $diff_in_days); +; + } +} From d91e8c438e580546c2b2955a0faa5dced7ae1372 Mon Sep 17 00:00:00 2001 From: David Bomba Date: Sat, 10 Apr 2021 14:53:16 +1000 Subject: [PATCH 3/5] Working on Pro Rata Refunds --- .../Subscription/SubscriptionService.php | 39 +++++++++++++++++++ tests/Unit/DatesTest.php | 11 +++++- 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/app/Services/Subscription/SubscriptionService.php b/app/Services/Subscription/SubscriptionService.php index 44ff80daf285..5442488dbc39 100644 --- a/app/Services/Subscription/SubscriptionService.php +++ b/app/Services/Subscription/SubscriptionService.php @@ -220,6 +220,11 @@ class SubscriptionService $current_date = now(); $days_to_refund = $start_date->diffInDays($current_date); + $days_in_frequency = $this->getDaysInFrequency(); + + $pro_rata_refund = round(($days_to_refund/$days_in_frequency) * $invoice->amount ,2); + + return $pro_rata_refund; } public function createChangePlanInvoice($data) @@ -399,4 +404,38 @@ class SubscriptionService return 1; } + + private function getDaysInFrequency() + { + + switch ($this->subscription->frequency_id) { + case self::FREQUENCY_DAILY: + return 1; + case self::FREQUENCY_WEEKLY: + return 7; + case self::FREQUENCY_TWO_WEEKS: + return 14; + case self::FREQUENCY_FOUR_WEEKS: + return now()->diffInDays(now()->addWeeks(4)); + case self::FREQUENCY_MONTHLY: + return now()->diffInDays(now()->addMonthNoOverflow()); + case self::FREQUENCY_TWO_MONTHS: + return now()->diffInDays(now()->addMonthNoOverflow(2)); + case self::FREQUENCY_THREE_MONTHS: + return now()->diffInDays(now()->addMonthNoOverflow(3)); + case self::FREQUENCY_FOUR_MONTHS: + return now()->diffInDays(now()->addMonthNoOverflow(4)); + case self::FREQUENCY_SIX_MONTHS: + return now()->diffInDays(now()->addMonthNoOverflow(6)); + case self::FREQUENCY_ANNUALLY: + return now()->diffInDays(now()->addYear()); + case self::FREQUENCY_TWO_YEARS: + return now()->diffInDays(now()->addYears(2)); + case self::FREQUENCY_THREE_YEARS: + return now()->diffInDays(now()->addYears(3)); + default: + return 0; + } + + } } diff --git a/tests/Unit/DatesTest.php b/tests/Unit/DatesTest.php index c76f7aca980e..ebf6e1dd17b2 100644 --- a/tests/Unit/DatesTest.php +++ b/tests/Unit/DatesTest.php @@ -42,6 +42,15 @@ class DatesTest extends TestCase $diff_in_days = $start_date->diffInDays($current_date); $this->assertEquals(19, $diff_in_days); -; + + } + + public function testDiffInDaysRange() + { + $now = Carbon::parse('2020-01-01'); + + $x = now()->diffInDays(now()->addDays(7)); + + $this->assertEquals(7, $x); } } From f5092e8cf42c232d1a6c407ddf38de227d85c922 Mon Sep 17 00:00:00 2001 From: David Bomba Date: Sun, 11 Apr 2021 13:46:40 +1000 Subject: [PATCH 4/5] Fixes for custom email templates --- app/Mail/TemplateEmail.php | 4 ++++ .../Subscription/SubscriptionService.php | 10 ++++++---- resources/views/email/template/custom.blade.php | 17 ++++++++++++++++- 3 files changed, 26 insertions(+), 5 deletions(-) diff --git a/app/Mail/TemplateEmail.php b/app/Mail/TemplateEmail.php index 7598a54c4ea2..68350ecb1298 100644 --- a/app/Mail/TemplateEmail.php +++ b/app/Mail/TemplateEmail.php @@ -49,6 +49,10 @@ class TemplateEmail extends Mailable { $template_name = 'email.template.'.$this->build_email->getTemplate(); + if($this->build_email->getTemplate() == 'custom') { + $this->build_email->setBody(str_replace('$body', $this->build_email->getBody(), $this->client->getSetting('email_style_custom'))); + } + $settings = $this->client->getMergedSettings(); $company = $this->client->company; diff --git a/app/Services/Subscription/SubscriptionService.php b/app/Services/Subscription/SubscriptionService.php index 5442488dbc39..494e53d2b141 100644 --- a/app/Services/Subscription/SubscriptionService.php +++ b/app/Services/Subscription/SubscriptionService.php @@ -200,15 +200,17 @@ class SubscriptionService if ($outstanding->count() == 0){ //nothing outstanding + return $target->price; } - elseif ($outstanding_amounts > $current_amount){ + elseif ($outstanding->count() == 1){ //user has multiple amounts outstanding + return $target->price - $this->calculateProRataRefund($outstanding->first()); } - elseif ($outstanding_amounts < $current_amount && $outstanding_amounts > 0) { + elseif ($outstanding->count > 1) { //user is changing plan mid frequency cycle + //we cannot handle this if there are more than one invoice outstanding. } - // $current_plan_price = $this->subscription } private function calculateProRataRefund($invoice) @@ -278,7 +280,7 @@ class SubscriptionService } - private function convertInvoiceToRecurring($client_id) + private function convertInvoiceToRecurring($client_id) :RecurringInvoice { $subscription_repo = new SubscriptionRepository(); diff --git a/resources/views/email/template/custom.blade.php b/resources/views/email/template/custom.blade.php index c6e607594375..07dff7f28c56 100644 --- a/resources/views/email/template/custom.blade.php +++ b/resources/views/email/template/custom.blade.php @@ -1 +1,16 @@ -{!! $body !!} \ No newline at end of file +{!! $body !!} +@isset($whitelabel) + @if(!$whitelabel) + + + + +
+

+ + {{ __('texts.ninja_email_footer', ['site' => 'Invoice Ninja']) }} + +

+
+ @endif +@endif \ No newline at end of file From 4ede6bd41ef25d77f483fa784076ae90edb684e8 Mon Sep 17 00:00:00 2001 From: David Bomba Date: Sun, 11 Apr 2021 13:52:37 +1000 Subject: [PATCH 5/5] Add subscription delete routes --- .../Controllers/SubscriptionController.php | 71 +++++++++++++++++++ routes/api.php | 2 + 2 files changed, 73 insertions(+) diff --git a/app/Http/Controllers/SubscriptionController.php b/app/Http/Controllers/SubscriptionController.php index 1b4a6154cbde..a3a82c09fdc9 100644 --- a/app/Http/Controllers/SubscriptionController.php +++ b/app/Http/Controllers/SubscriptionController.php @@ -24,9 +24,12 @@ use App\Models\Subscription; use App\Repositories\SubscriptionRepository; use App\Transformers\SubscriptionTransformer; use App\Utils\Ninja; +use App\Utils\Traits\MakesHash; class SubscriptionController extends BaseController { + use MakesHash; + protected $entity_type = Subscription::class; protected $entity_transformer = SubscriptionTransformer::class; @@ -407,4 +410,72 @@ class SubscriptionController extends BaseController return $this->itemResponse($subscription->fresh()); } + + /** + * Perform bulk actions on the list view. + * + * @return Response + * + * + * @OA\Post( + * path="/api/v1/subscriptions/bulk", + * operationId="bulkSubscriptions", + * tags={"subscriptions"}, + * summary="Performs bulk actions on an array of subscriptions", + * description="", + * @OA\Parameter(ref="#/components/parameters/X-Api-Secret"), + * @OA\Parameter(ref="#/components/parameters/X-Api-Token"), + * @OA\Parameter(ref="#/components/parameters/X-Requested-With"), + * @OA\Parameter(ref="#/components/parameters/index"), + * @OA\RequestBody( + * description="User credentials", + * required=true, + * @OA\MediaType( + * mediaType="application/json", + * @OA\Schema( + * type="array", + * @OA\Items( + * type="integer", + * description="Array of hashed IDs to be bulk 'actioned", + * example="[0,1,2,3]", + * ), + * ) + * ) + * ), + * @OA\Response( + * response=200, + * description="The Subscription response", + * @OA\Header(header="X-MINIMUM-CLIENT-VERSION", ref="#/components/headers/X-MINIMUM-CLIENT-VERSION"), + * @OA\Header(header="X-RateLimit-Remaining", ref="#/components/headers/X-RateLimit-Remaining"), + * @OA\Header(header="X-RateLimit-Limit", ref="#/components/headers/X-RateLimit-Limit"), + * @OA\JsonContent(ref="#/components/schemas/Subscription"), + * ), + * @OA\Response( + * response=422, + * description="Validation error", + * @OA\JsonContent(ref="#/components/schemas/ValidationError"), + * ), + * @OA\Response( + * response="default", + * description="Unexpected Error", + * @OA\JsonContent(ref="#/components/schemas/Error"), + * ), + * ) + */ + public function bulk() + { + $action = request()->input('action'); + + $ids = request()->input('ids'); + $subscriptions = Subscription::withTrashed()->find($this->transformKeys($ids)); + + $subscriptions->each(function ($subscription, $key) use ($action) { + if (auth()->user()->can('edit', $subscription)) { + $this->subscription_repo->{$action}($subscription); + } + }); + + return $this->listResponse(Subscription::withTrashed()->whereIn('id', $this->transformKeys($ids))); + } + } diff --git a/routes/api.php b/routes/api.php index 99ffe08a17a8..a527ad0a6ae4 100644 --- a/routes/api.php +++ b/routes/api.php @@ -177,6 +177,8 @@ Route::group(['middleware' => ['api_db', 'token_auth', 'locale'], 'prefix' => 'a // Route::delete('hooks/{subscription_id}', 'SubscriptionController@unsubscribe')->name('hooks.unsubscribe'); Route::resource('subscriptions', 'SubscriptionController'); + Route::post('subscriptions/bulk', 'SubscriptionController@bulk')->name('subscriptions.bulk'); + Route::resource('cliente_subscriptions', 'ClientSubscriptionController'); });