diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c20f58e9..a0356cff 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,7 +13,6 @@ Well, you can: You don't have to be a superstar, or someone with experience, you can just dive in and help if you feel you can. - ## Adding a new feature? Its best to: @@ -61,6 +60,8 @@ We use PHPUnit to run tests. Simply run `composer install` and a symlink `phpuni Next, run `bin/phpunit` and the rest will be taken care of. Upon any pull requests and merges, TravisCI will check the code to ensure all test suites pass. +For quicker tests, be sure to disable coverage with `bin/php --no-coverage`. + ----- Thats it! Enjoy. diff --git a/LICENSE b/LICENSE index 9372e8b7..ca83347c 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright (C) 2017 Tyler King +Copyright (C) 2018 Tyler King Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: diff --git a/README.md b/README.md index f4aa56ed..d4bb9f54 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ For more information, tutorials, etc., please view the project's [wiki](https:// - [x] Integration with Shopify API - [x] Authentication & installation for shops - [x] Billing integration built-in for single and recurring application charges +- [x] Tracking charges to a shop (recurring, single, usage, etc) with trial support - [x] Auto install app webhooks and scripttags thorugh background jobs - [x] Provide basic ESDK views - [x] Handles and processes incoming webhooks diff --git a/src/ShopifyApp/Jobs/AppUninstalledJob.php b/src/ShopifyApp/Jobs/AppUninstalledJob.php new file mode 100644 index 00000000..46d5fdd6 --- /dev/null +++ b/src/ShopifyApp/Jobs/AppUninstalledJob.php @@ -0,0 +1,111 @@ +data = $data; + $this->shopDomain = $shopDomain; + $this->shop = $this->findShop(); + } + + /** + * Execute the job. + * + * @return bool + */ + public function handle() + { + if (!$this->shop) { + return false; + } + + $this->softDeleteShop(); + $this->cancelCharge(); + + return true; + } + + /** + * Soft deletes the shop in the database. + * + * @return void + */ + protected function softDeleteShop() + { + $this->shop->delete(); + $this->shop->charges()->delete(); + } + + /** + * Cancels a recurring or one-time charge. + * + * @return void + */ + protected function cancelCharge() + { + $lastCharge = $this->shop->charges() + ->withTrashed() + ->whereIn('type', [Charge::CHARGE_RECURRING, Charge::CHARGE_ONETIME]) + ->orderBy('created_at', 'desc') + ->first(); + + if ($lastCharge && !$lastCharge->isDeclined() && !$lastCharge->isCancelled()) { + $lastCharge->status = 'cancelled'; + $lastCharge->cancelled_on = Carbon::today()->format('Y-m-d'); + $lastCharge->save(); + } + } + + /** + * Finds the shop based on domain from the webhook. + * + * @return Shop|null + */ + protected function findShop() + { + return Shop::where(['shopify_domain' => $this->shopDomain])->first(); + } +} diff --git a/src/ShopifyApp/Middleware/AuthShop.php b/src/ShopifyApp/Middleware/AuthShop.php index 5e112061..eec760cc 100644 --- a/src/ShopifyApp/Middleware/AuthShop.php +++ b/src/ShopifyApp/Middleware/AuthShop.php @@ -26,7 +26,8 @@ public function handle(Request $request, Closure $next) if ( $shop === null || ($shopParam && $shopParam !== $shop->shopify_domain) === true || - $shop->shopify_token === null + $shop->shopify_token === null || + $shop->trashed() ) { // Either no shop session or shops do not match session()->forget('shopify_domain'); diff --git a/src/ShopifyApp/Middleware/Billable.php b/src/ShopifyApp/Middleware/Billable.php index 271e48a1..3d85557d 100644 --- a/src/ShopifyApp/Middleware/Billable.php +++ b/src/ShopifyApp/Middleware/Billable.php @@ -5,6 +5,7 @@ use Closure; use Illuminate\Http\Request; use OhMyBrew\ShopifyApp\Facades\ShopifyApp; +use OhMyBrew\ShopifyApp\Models\Charge; class Billable { @@ -19,9 +20,18 @@ class Billable public function handle(Request $request, Closure $next) { if (config('shopify-app.billing_enabled') === true) { + // Grab the shop and last recurring or one-time charge $shop = ShopifyApp::shop(); - if (!$shop->isPaid() && !$shop->isGrandfathered()) { - // No charge in database and they're not grandfathered in, redirect to billing + $lastCharge = $shop->charges() + ->whereIn('type', [Charge::CHARGE_RECURRING, Charge::CHARGE_ONETIME]) + ->orderBy('created_at', 'desc') + ->first(); + + if ( + !$shop->isGrandfathered() && + (is_null($lastCharge) || $lastCharge->isDeclined() || $lastCharge->isCancelled()) + ) { + // They're not grandfathered in, and there is no charge or charge was declined... redirect to billing return redirect()->route('billing'); } } diff --git a/src/ShopifyApp/Models/Charge.php b/src/ShopifyApp/Models/Charge.php new file mode 100644 index 00000000..d7c8a8d0 --- /dev/null +++ b/src/ShopifyApp/Models/Charge.php @@ -0,0 +1,178 @@ +belongsTo('OhMyBrew\ShopifyApp\Models\Shop'); + } + + /** + * Checks if the charge is a test. + * + * @return bool + */ + public function isTest() + { + return (bool) $this->test; + } + + /** + * Checks if the charge is a type. + * + * @param int $type The charge type. + * + * @return bool + */ + public function isType(int $type) + { + return (int) $this->type === $type; + } + + /** + * Checks if the charge is a trial-type charge. + * + * @return bool + */ + public function isTrial() + { + return !is_null($this->trial_ends_on); + } + + /** + * Checks if the charge is currently in trial. + * + * @return bool + */ + public function isActiveTrial() + { + return $this->isTrial() && Carbon::today()->lte(Carbon::parse($this->trial_ends_on)); + } + + /** + * Returns the remaining trial days. + * + * @return int + */ + public function remainingTrialDays() + { + if (!$this->isTrial()) { + return; + } + + return $this->isActiveTrial() ? Carbon::today()->diffInDays($this->trial_ends_on) : 0; + } + + /** + * Returns the remaining trial days from cancellation date. + * + * @return int + */ + public function remainingTrialDaysFromCancel() + { + if (!$this->isTrial()) { + return; + } + + $cancelledDate = Carbon::parse($this->cancelled_on); + $trialEndsDate = Carbon::parse($this->trial_ends_on); + + // Ensure cancelled date happened before the trial was supposed to end + if ($this->isCancelled() && $cancelledDate->lte($trialEndsDate)) { + // Diffeence the two dates and subtract from the total trial days to get whats remaining + return $this->trial_days - ($this->trial_days - $cancelledDate->diffInDays($trialEndsDate)); + } + + return 0; + } + + /** + * Returns the used trial days. + * + * @return int|null + */ + public function usedTrialDays() + { + if (!$this->isTrial()) { + return; + } + + return $this->trial_days - $this->remainingTrialDays(); + } + + /** + * Checks if the charge is active. + * + * @return bool + */ + public function isActive() + { + return $this->status === 'active'; + } + + /** + * Checks if the charge was accepted (for one-time and reccuring). + * + * @return bool + */ + public function isAccepted() + { + return $this->status === 'accepted'; + } + + /** + * Checks if the charge was declined (for one-time and reccuring). + * + * @return bool + */ + public function isDeclined() + { + return $this->status === 'declined'; + } + + /** + * Checks if the charge was cancelled. + * + * @return bool + */ + public function isCancelled() + { + return !is_null($this->cancelled_on) || $this->status === 'cancelled'; + } + + /** + * Checks if the charge is "active" (non-API check). + * + * @return bool + */ + public function isOngoing() + { + return $this->isActive() && !$this->isCancelled(); + } +} diff --git a/src/ShopifyApp/Models/Shop.php b/src/ShopifyApp/Models/Shop.php index 0669a917..dba8f1e6 100644 --- a/src/ShopifyApp/Models/Shop.php +++ b/src/ShopifyApp/Models/Shop.php @@ -3,10 +3,13 @@ namespace OhMyBrew\ShopifyApp\Models; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\SoftDeletes; use OhMyBrew\ShopifyApp\Facades\ShopifyApp; class Shop extends Model { + use SoftDeletes; + /** * The attributes that are mass assignable. * @@ -18,6 +21,13 @@ class Shop extends Model 'grandfathered', ]; + /** + * The attributes that should be mutated to dates. + * + * @var array + */ + protected $dates = ['deleted_at']; + /** * The API instance. * @@ -45,22 +55,32 @@ public function api() } /** - * Checks if a shop has a charge ID. + * Checks is shop is grandfathered in. * * @return bool */ - public function isPaid() + public function isGrandfathered() + { + return ((bool) $this->grandfathered) === true; + } + + /** + * Get charges. + * + * @return \Illuminate\Database\Eloquent\Collection + */ + public function charges() { - return !is_null($this->charge_id); + return $this->hasMany('OhMyBrew\ShopifyApp\Models\Charge'); } /** - * Checks is shop is grandfathered in. + * Checks if charges have been applied to the shop. * * @return bool */ - public function isGrandfathered() + public function hasCharges() { - return ((bool) $this->grandfathered) === true; + return $this->charges->isNotEmpty(); } } diff --git a/src/ShopifyApp/ShopifyApp.php b/src/ShopifyApp/ShopifyApp.php index cc4d1798..fd121748 100644 --- a/src/ShopifyApp/ShopifyApp.php +++ b/src/ShopifyApp/ShopifyApp.php @@ -44,7 +44,7 @@ public function shop() if (!$this->shop && $shopifyDomain) { // Grab shop from database here $shopModel = config('shopify-app.shop_model'); - $shop = $shopModel::firstOrCreate(['shopify_domain' => $shopifyDomain]); + $shop = $shopModel::withTrashed()->firstOrCreate(['shopify_domain' => $shopifyDomain]); // Update shop instance $this->shop = $shop; diff --git a/src/ShopifyApp/ShopifyAppProvider.php b/src/ShopifyApp/ShopifyAppProvider.php index c98c8131..cee048bb 100644 --- a/src/ShopifyApp/ShopifyAppProvider.php +++ b/src/ShopifyApp/ShopifyAppProvider.php @@ -28,6 +28,11 @@ public function boot() $this->publishes([ __DIR__.'/resources/database/migrations' => database_path('migrations'), ], 'migrations'); + + // Job publish + $this->publishes([ + __DIR__.'/resources/jobs/AppUninstalledJob.php' => app_path().'/Jobs/AppUninstalledJob.php', + ], 'jobs'); } /** diff --git a/src/ShopifyApp/Traits/AuthControllerTrait.php b/src/ShopifyApp/Traits/AuthControllerTrait.php index 6c874093..0b72cfb9 100644 --- a/src/ShopifyApp/Traits/AuthControllerTrait.php +++ b/src/ShopifyApp/Traits/AuthControllerTrait.php @@ -87,8 +87,14 @@ protected function authenticationWithCode() return redirect()->route('login')->with('error', 'Invalid signature'); } - // Save token to shop + // Grab the shop; restore if need-be $shop = ShopifyApp::shop(); + if ($shop->trashed()) { + $shop->restore(); + $shop->charges()->restore(); + } + + // Save the token to the shop $shop->shopify_token = $api->requestAccessToken(request('code')); $shop->save(); diff --git a/src/ShopifyApp/Traits/BillingControllerTrait.php b/src/ShopifyApp/Traits/BillingControllerTrait.php index 82102185..0be7ac17 100644 --- a/src/ShopifyApp/Traits/BillingControllerTrait.php +++ b/src/ShopifyApp/Traits/BillingControllerTrait.php @@ -2,8 +2,11 @@ namespace OhMyBrew\ShopifyApp\Traits; +use Carbon\Carbon; use OhMyBrew\ShopifyApp\Facades\ShopifyApp; use OhMyBrew\ShopifyApp\Libraries\BillingPlan; +use OhMyBrew\ShopifyApp\Models\Charge; +use OhMyBrew\ShopifyApp\Models\Shop; trait BillingControllerTrait { @@ -15,8 +18,9 @@ trait BillingControllerTrait public function index() { // Get the confirmation URL - $plan = new BillingPlan(ShopifyApp::shop(), $this->chargeType()); - $plan->setDetails($this->planDetails()); + $shop = ShopifyApp::shop(); + $plan = new BillingPlan($shop, $this->chargeType()); + $plan->setDetails($this->planDetails($shop)); // Do a fullpage redirect return view('shopify-app::billing.fullpage_redirect', [ @@ -38,38 +42,65 @@ public function process() // Setup the plan and get the charge $plan = new BillingPlan($shop, $this->chargeType()); $plan->setChargeId($chargeId); + $status = $plan->getCharge()->status; - // Check the customer's answer to the billing - $charge = $plan->getCharge(); - if ($charge->status == 'accepted') { - // Customer accepted, activate the charge - $plan->activate(); + // Grab the plan detailed used + $planDetails = $this->planDetails($shop); + unset($planDetails['return_url']); - // Save the charge ID to the shop - $shop->charge_id = $chargeId; - $shop->save(); + // Create a charge (regardless of the status) + $charge = new Charge(); + $charge->type = $this->chargeType() === 'recurring' ? Charge::CHARGE_RECURRING : Charge::CHARGE_ONETIME; + $charge->charge_id = $chargeId; + $charge->status = $status; - // Go to homepage of app - return redirect()->route('home'); + // Check the customer's answer to the billing + if ($status === 'accepted') { + // Activate and add details to our charge + $response = $plan->activate(); + $charge->status = $response->status; + $charge->billing_on = $response->billing_on; + $charge->trial_ends_on = $response->trial_ends_on; + $charge->activated_on = $response->activated_on; } else { - // Customer declined the charge, abort + // Customer declined the charge + $charge->cancelled_on = Carbon::today()->format('Y-m-d'); + } + + // Merge in the plan details since the fields match the database columns + foreach ($planDetails as $key => $value) { + $charge->{$key} = $value; + } + + // Save and link to the shop + $shop->charges()->save($charge); + + if ($status === 'declined') { + // Show the error... don't allow access return abort(403, 'It seems you have declined the billing charge for this application'); } + + // All good... go to homepage of app + return redirect()->route('home'); } /** - * Base plan to use for billing. - * Setup as a function so its patchable. + * Base plan to use for billing. Setup as a function so its patchable. + * Checks for cancelled charge within trial day limit, and issues + * a new trial days number depending on the result for shops who + * resinstall the app. + * + * @param object $shop The shop object. * * @return array */ - protected function planDetails() + protected function planDetails(Shop $shop) { + // Initial plan details $plan = [ 'name' => config('shopify-app.billing_plan'), 'price' => config('shopify-app.billing_price'), 'test' => config('shopify-app.billing_test'), - 'trial_days' => config('shopify-app.billing_trial_days'), 'return_url' => url(config('shopify-app.billing_redirect')), ]; @@ -79,6 +110,20 @@ protected function planDetails() $plan['terms'] = config('shopify-app.billing_terms'); } + // Grab the last charge for the shop (if any) to determine if this shop + // reinstalled the app so we can issue new trial days based on result + $lastCharge = $shop->charges() + ->whereIn('type', [Charge::CHARGE_RECURRING, Charge::CHARGE_ONETIME]) + ->orderBy('created_at', 'desc') + ->first(); + if ($lastCharge && $lastCharge->isCancelled()) { + // Return the new trial days, could result in 0 + $plan['trial_days'] = $lastCharge->remainingTrialDaysFromCancel(); + } else { + // Set initial trial days fromc config + $plan['trial_days'] = config('shopify-app.billing_trial_days'); + } + return $plan; } diff --git a/src/ShopifyApp/resources/database/migrations/2018_06_03_184733_add_soft_delete_to_shops_table.php b/src/ShopifyApp/resources/database/migrations/2018_06_03_184733_add_soft_delete_to_shops_table.php new file mode 100644 index 00000000..820a6d4d --- /dev/null +++ b/src/ShopifyApp/resources/database/migrations/2018_06_03_184733_add_soft_delete_to_shops_table.php @@ -0,0 +1,32 @@ +softDeletes(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('shops', function (Blueprint $table) { + $table->dropSoftDeletes(); + }); + } +} diff --git a/src/ShopifyApp/resources/database/migrations/2018_06_03_185902_create_charges_table.php b/src/ShopifyApp/resources/database/migrations/2018_06_03_185902_create_charges_table.php new file mode 100644 index 00000000..3c4dee99 --- /dev/null +++ b/src/ShopifyApp/resources/database/migrations/2018_06_03_185902_create_charges_table.php @@ -0,0 +1,82 @@ +increments('id'); + + // Filled in when the charge is created, provided by shopify, unique makes it indexed + $table->integer('charge_id')->unique(); + + // Test mode or real + $table->boolean('test'); + + $table->string('status')->nullable(); + + // Name of the charge (for recurring or one time charges) + $table->string('name')->nullable(); + + // Terms for the usage charges + $table->string('terms')->nullable(); + + // Integer value reprecenting a recurring, one time, usage, or application_credit. + // This also allows us to store usage based charges not just subscription or one time charges. + // We will be able to do things like create a charge history for a shop if they have multiple charges. + // For instance, usage based or an app that has multiple purchases. + $table->integer('type'); + + // Store the amount of the charge, this helps if you are experimenting with pricing + $table->decimal('price', 8, 2); + + // Store the amount of the charge, this helps if you are experimenting with pricing + $table->decimal('capped_amount', 8, 2)->nullable(); + + // Nullable in case of 0 trial days + $table->integer('trial_days')->nullable(); + + // The recurring application charge must be accepted or the returned value is null + $table->timestamp('billing_on')->nullable(); + + // When activation happened + $table->timestamp('activated_on')->nullable(); + + // Date the trial period ends + $table->timestamp('trial_ends_on')->nullable(); + + // Not supported on Shopify's initial billing screen, but good for future use + $table->timestamp('cancelled_on')->nullable(); + + // Provides created_at && updated_at columns + $table->timestamps(); + + // Allows for soft deleting + $table->softDeletes(); + + // Linking + $table->integer('shop_id')->unsigned(); + $table->foreign('shop_id')->references('id')->on('shops')->onDelete('cascade'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::drop('charges'); + } +} diff --git a/src/ShopifyApp/resources/database/migrations/2018_06_03_190233_remove_charge_id_from_shops_table.php b/src/ShopifyApp/resources/database/migrations/2018_06_03_190233_remove_charge_id_from_shops_table.php new file mode 100644 index 00000000..ebc0f531 --- /dev/null +++ b/src/ShopifyApp/resources/database/migrations/2018_06_03_190233_remove_charge_id_from_shops_table.php @@ -0,0 +1,32 @@ +dropColumn(['charge_id']); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('shops', function (Blueprint $table) { + $table->bigInteger('charge_id')->nullable(true)->default(null); + }); + } +} diff --git a/src/ShopifyApp/resources/jobs/AppUninstalledJob.php b/src/ShopifyApp/resources/jobs/AppUninstalledJob.php new file mode 100644 index 00000000..5d608ce5 --- /dev/null +++ b/src/ShopifyApp/resources/jobs/AppUninstalledJob.php @@ -0,0 +1,7 @@ +hmac = 'a7448f7c42c9bc025b077ac8b73e7600b6f8012719d21cbeb88db66e5dbbd163'; $this->hmacParams = [ 'hmac' => $this->hmac, @@ -28,6 +28,15 @@ public function setUp() 'timestamp' => '1337178173', ]; + // HMAC for trashed shop testing + $this->hmacTrashed = '77ec82b8dca7ea606e8b69d3cc50beced069b03631fd6a2f367993eb793f4c45'; + $this->hmacTrashedParams = [ + 'hmac' => $this->hmacTrashed, + 'shop' => 'trashed-shop.myshopify.com', + 'code' => '1234678910', + 'timestamp' => '1337178173', + ]; + // Stub in our API class config(['shopify-app.api_class' => new ApiStub()]); } @@ -65,6 +74,17 @@ public function testAuthAcceptsShopWithCodeAndUpdatesTokenForShop() $this->assertEquals('12345678', $shop->shopify_token); // Previous token was 1234 } + public function testAuthRestoresTrashedShop() + { + $shop = Shop::withTrashed()->where('shopify_domain', 'trashed-shop.myshopify.com')->first(); + $this->assertTrue($shop->trashed()); + + $this->call('get', '/authenticate', $this->hmacTrashedParams); + + $shop = $shop->fresh(); + $this->assertFalse($shop->trashed()); + } + public function testAuthAcceptsShopWithCodeAndRedirectsToHome() { $response = $this->call('get', '/authenticate', $this->hmacParams); diff --git a/tests/Controllers/BillingControllerTest.php b/tests/Controllers/BillingControllerTest.php index 20bcf47c..7d3b8c79 100644 --- a/tests/Controllers/BillingControllerTest.php +++ b/tests/Controllers/BillingControllerTest.php @@ -2,7 +2,9 @@ namespace OhMyBrew\ShopifyApp\Test\Controllers; +use Carbon\Carbon; use OhMyBrew\ShopifyApp\Controllers\BillingController; +use OhMyBrew\ShopifyApp\Models\Charge; use OhMyBrew\ShopifyApp\Models\Shop; use OhMyBrew\ShopifyApp\Test\Stubs\ApiStub; use OhMyBrew\ShopifyApp\Test\TestCase; @@ -18,7 +20,8 @@ public function setUp() config(['shopify-app.api_class' => new ApiStub()]); // Base shop for all tests here - session(['shopify_domain' => 'example.myshopify.com']); + $this->shop = Shop::where('shopify_domain', 'example.myshopify.com')->first(); + session(['shopify_domain' => $this->shop->shopify_domain]); } public function testSendsShopToBillingScreen() @@ -33,20 +36,21 @@ public function testSendsShopToBillingScreen() public function testShopAcceptsBilling() { $shop = Shop::where('shopify_domain', 'example.myshopify.com')->first(); - $this->assertEquals(678298290, $shop->charge_id); // Based on seedDatabase() - $response = $this->call('get', '/billing/process', ['charge_id' => 1029266947]); - $shop = $shop->fresh(); // Reload model $response->assertStatus(302); - $this->assertEquals(1029266947, $shop->charge_id); + $this->assertEquals(1029266947, $shop->charges()->get()->last()->charge_id); } public function testShopDeclinesBilling() { + $shop = Shop::where('shopify_domain', 'example.myshopify.com')->first(); $response = $this->call('get', '/billing/process', ['charge_id' => 10292]); + $lastCharge = $shop->charges()->get()->last(); $response->assertStatus(403); + $this->assertEquals(10292, $lastCharge->charge_id); + $this->assertEquals('declined', $lastCharge->status); $this->assertEquals( 'It seems you have declined the billing charge for this application', $response->exception->getMessage() @@ -68,7 +72,7 @@ public function testReturnsBasePlanDetails() 'trial_days' => config('shopify-app.billing_trial_days'), 'return_url' => url(config('shopify-app.billing_redirect')), ], - $method->invoke($controller, 'planDetails') + $method->invoke($controller, $this->shop) ); } @@ -92,7 +96,43 @@ public function testReturnsBasePlanDetailsWithUsage() 'terms' => config('shopify-app.billing_terms'), 'return_url' => url(config('shopify-app.billing_redirect')), ], - $method->invoke($controller, 'planDetails') + $method->invoke($controller, $this->shop) + ); + } + + public function testReturnsBasePlanDetailsChangedByCancelledCharge() + { + $shop = new Shop(); + $shop->shopify_domain = 'test-cancelled-shop.myshopify.com'; + $shop->save(); + + $charge = new Charge(); + $charge->charge_id = 267921978; + $charge->test = false; + $charge->name = 'Base Plan Cancelled'; + $charge->status = 'cancelled'; + $charge->type = 1; + $charge->price = 25.00; + $charge->trial_days = 7; + $charge->trial_ends_on = Carbon::today()->addWeeks(1)->format('Y-m-d'); + $charge->cancelled_on = Carbon::today()->addDays(2)->format('Y-m-d'); + $charge->shop_id = $shop->id; + $charge->save(); + + $controller = new BillingController(); + $method = new ReflectionMethod(BillingController::class, 'planDetails'); + $method->setAccessible(true); + + // Based on default config + $this->assertEquals( + [ + 'name' => config('shopify-app.billing_plan'), + 'price' => config('shopify-app.billing_price'), + 'test' => config('shopify-app.billing_test'), + 'trial_days' => 5, + 'return_url' => url(config('shopify-app.billing_redirect')), + ], + $method->invoke($controller, $shop) ); } @@ -103,6 +143,6 @@ public function testReturnsBaseChargeType() $method->setAccessible(true); // Based on default config - $this->assertEquals(config('shopify-app.billing_type'), $method->invoke($controller, 'chargeType')); + $this->assertEquals(config('shopify-app.billing_type'), $method->invoke($controller)); } } diff --git a/tests/Jobs/AppUninstalledJobTest.php b/tests/Jobs/AppUninstalledJobTest.php new file mode 100644 index 00000000..5eefe898 --- /dev/null +++ b/tests/Jobs/AppUninstalledJobTest.php @@ -0,0 +1,88 @@ +shop = new Shop(); + $this->shop->shopify_domain = 'example-isolated.myshopify.com'; + $this->shop->save(); + + $this->data = json_decode(file_get_contents(__DIR__.'/../fixtures/app_uninstalled.json')); + } + + public function testJobAcceptsLoad() + { + $job = new AppUninstalledJob($this->shop->shopify_domain, $this->data); + + $refJob = new ReflectionObject($job); + $refData = $refJob->getProperty('data'); + $refData->setAccessible(true); + $refShopDomain = $refJob->getProperty('shopDomain'); + $refShopDomain->setAccessible(true); + $refShop = $refJob->getProperty('shop'); + $refShop->setAccessible(true); + + $this->assertEquals($this->shop->shopify_domain, $refShopDomain->getValue($job)); + $this->assertEquals($this->data, $refData->getValue($job)); + $this->assertEquals($this->shop->shopify_domain, $refShop->getValue($job)->shopify_domain); + } + + public function testJobSoftDeletesShopAndCharges() + { + // Create a new charge to test against + $charge = new Charge(); + $charge->charge_id = 987654321; + $charge->test = false; + $charge->name = 'Base Plan Dummy'; + $charge->status = 'active'; + $charge->type = 1; + $charge->price = 25.00; + $charge->trial_days = 0; + $charge->shop_id = $this->shop->id; + $charge->created_at = Carbon::now()->addDays(1); // Test runs too fast to make "latest" work + $charge->save(); + + // Ensure shop is not trashed, and has charges + $this->shop->refresh(); + $this->assertFalse($this->shop->trashed()); + $this->assertEquals(true, $this->shop->hasCharges()); + + // Run the job + $job = new AppUninstalledJob($this->shop->shopify_domain, $this->data); + $result = $job->handle(); + + // Refresh both models to see the changes + $this->shop->refresh(); + $lastCharge = $this->shop->charges() + ->withTrashed() + ->whereIn('type', [Charge::CHARGE_RECURRING, Charge::CHARGE_ONETIME]) + ->orderBy('created_at', 'desc') + ->first(); + + // Confirm job worked... + $this->assertEquals(true, $result); + $this->assertEquals(true, $this->shop->trashed()); + $this->assertFalse($this->shop->hasCharges()); + $this->assertEquals($charge->charge_id, $lastCharge->charge_id); + $this->assertEquals('cancelled', $lastCharge->status); + } + + public function testJobDoesNothingForUnknownShop() + { + $job = new AppUninstalledJob('unknown-shop.myshopify.com', null); + $this->assertEquals(false, $job->handle()); + } +} diff --git a/tests/Libraries/BillingPlanTest.php b/tests/Libraries/BillingPlanTest.php index 16be9d53..9bfd4257 100644 --- a/tests/Libraries/BillingPlanTest.php +++ b/tests/Libraries/BillingPlanTest.php @@ -64,7 +64,7 @@ public function testShouldActivatePlan() $response = (new BillingPlan($this->shop))->setChargeId(1029266947)->activate(); $this->assertEquals(true, is_object($response)); - $this->assertEquals('accepted', $response->status); + $this->assertEquals('active', $response->status); } /** diff --git a/tests/Middleware/AuthShopMiddlewareTest.php b/tests/Middleware/AuthShopMiddlewareTest.php index 77b1e8b1..c0e415e9 100644 --- a/tests/Middleware/AuthShopMiddlewareTest.php +++ b/tests/Middleware/AuthShopMiddlewareTest.php @@ -49,6 +49,21 @@ public function testShopWithNoTokenShouldNotPassMiddleware() $this->assertEquals(true, strpos($result, 'Redirecting to http://localhost/authenticate') !== false); } + public function testShopTrashedShouldNotPassMiddleware() + { + // Set a shop + session(['shopify_domain' => 'trashed-shop.myshopify.com']); + + $called = false; + $result = (new AuthShop())->handle(request(), function ($request) use (&$called) { + // Shouldn never be called + $called = true; + }); + + $this->assertFalse($called); + $this->assertEquals(true, strpos($result, 'Redirecting to http://localhost/authenticate') !== false); + } + public function testShopsWhichDoNotMatchShouldKillSessionAndDirectToReAuthenticate() { // Set a shop diff --git a/tests/Middleware/BillableMiddlewareTest.php b/tests/Middleware/BillableMiddlewareTest.php index 11adfd37..d723eb0d 100644 --- a/tests/Middleware/BillableMiddlewareTest.php +++ b/tests/Middleware/BillableMiddlewareTest.php @@ -23,6 +23,22 @@ public function testEnabledBillingWithUnpaidShop() $this->assertEquals(true, strpos($result, 'Redirecting to http://localhost/billing') !== false); } + public function testEnabledBillingWithShopWhoDeclinedCharges() + { + // Enable billing and set a shop + config(['shopify-app.billing_enabled' => true]); + session(['shopify_domain' => 'trashed-shop.myshopify.com']); + + $called = false; + $result = (new Billable())->handle(request(), function ($request) use (&$called) { + // Should never be called + $called = true; + }); + + $this->assertFalse($called); + $this->assertEquals(true, strpos($result, 'Redirecting to http://localhost/billing') !== false); + } + public function testEnabledBillingWithPaidShop() { // Enable billing and set a shop diff --git a/tests/Models/ChargeModelTest.php b/tests/Models/ChargeModelTest.php new file mode 100644 index 00000000..765547a4 --- /dev/null +++ b/tests/Models/ChargeModelTest.php @@ -0,0 +1,97 @@ +assertInstanceOf( + Shop::class, + Charge::find(1)->shop + ); + } + + public function testChargeImplementsType() + { + $this->assertEquals( + Charge::CHARGE_RECURRING, + Charge::find(1)->type + ); + } + + public function testIsTest() + { + $this->assertEquals(true, Charge::find(1)->isTest()); + } + + public function testIsType() + { + $this->assertTrue(Charge::find(1)->isType(Charge::CHARGE_RECURRING)); + } + + public function testIsTrial() + { + $this->assertTrue(Charge::find(1)->isTrial()); + $this->assertFalse(Charge::find(4)->isTrial()); + } + + public function testIsActiveTrial() + { + $this->assertTrue(Charge::find(2)->isActiveTrial()); + $this->assertFalse(Charge::find(4)->isActiveTrial()); + } + + public function testRemainingTrialDays() + { + $this->assertEquals(0, Charge::find(1)->remainingTrialDays()); + $this->assertEquals(2, Charge::find(2)->remainingTrialDays()); + $this->assertEquals(0, Charge::find(3)->remainingTrialDays()); + $this->assertNull(Charge::find(4)->remainingTrialDays()); + } + + public function testUsedTrialDays() + { + $this->assertEquals(7, Charge::find(1)->usedTrialDays()); + $this->assertEquals(5, Charge::find(2)->usedTrialDays()); + $this->assertEquals(7, Charge::find(3)->usedTrialDays()); + $this->assertNull(Charge::find(4)->usedTrialDays()); + } + + public function testAcceptedAndDeclined() + { + $this->assertTrue(Charge::find(1)->isAccepted()); + $this->assertFalse(Charge::find(1)->isDeclined()); + } + + public function testActive() + { + $this->assertFalse(Charge::find(1)->isActive()); + $this->assertTrue(Charge::find(4)->isActive()); + } + + public function testOngoing() + { + $this->assertFalse(Charge::find(1)->isOngoing()); + $this->assertFalse(Charge::find(6)->isOngoing()); + $this->assertTrue(Charge::find(4)->isOngoing()); + } + + public function testCancelled() + { + $this->assertFalse(Charge::find(1)->isCancelled()); + $this->assertFalse(Charge::find(4)->isCancelled()); + $this->assertTrue(Charge::find(6)->isCancelled()); + } + + public function testRemainingTrialDaysFromCancel() + { + $this->assertEquals(5, Charge::find(7)->remainingTrialDaysFromCancel()); + $this->assertEquals(0, Charge::find(1)->remainingTrialDaysFromCancel()); + $this->assertEquals(0, Charge::find(5)->remainingTrialDaysFromCancel()); + } +} diff --git a/tests/Models/ShopModelTest.php b/tests/Models/ShopModelTest.php index 4e01a630..40ad1282 100644 --- a/tests/Models/ShopModelTest.php +++ b/tests/Models/ShopModelTest.php @@ -53,12 +53,31 @@ public function testShopShouldReturnGrandfatheredState() $this->assertEquals(false, $shop_2->isGrandfathered()); } - public function testShopShouldConfirmPaidState() + public function testShopCanSoftDeleteAndBeRestored() + { + $shop = new Shop(); + $shop->shopify_domain = 'hello.myshopify.com'; + $shop->save(); + $shop->delete(); + + // Test soft delete + $this->assertTrue($shop->trashed()); + $this->assertSoftDeleted('shops', [ + 'id' => $shop->id, + 'shopify_domain' => $shop->shopify_domain, + ]); + + // Test restore + $shop->restore(); + $this->assertFalse($shop->trashed()); + } + + public function testShouldReturnBoolForChargesApplied() { $shop = Shop::where('shopify_domain', 'grandfathered.myshopify.com')->first(); $shop_2 = Shop::where('shopify_domain', 'example.myshopify.com')->first(); - $this->assertEquals(false, $shop->isPaid()); - $this->assertEquals(true, $shop_2->isPaid()); + $this->assertEquals(false, $shop->hasCharges()); + $this->assertEquals(true, $shop_2->hasCharges()); } } diff --git a/tests/TestCase.php b/tests/TestCase.php index e4df0106..8a622508 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -2,6 +2,8 @@ namespace OhMyBrew\ShopifyApp\Test; +use Carbon\Carbon; +use OhMyBrew\ShopifyApp\Models\Charge; use OhMyBrew\ShopifyApp\Models\Shop; use OhMyBrew\ShopifyApp\ShopifyAppProvider; use Orchestra\Database\ConsoleServiceProvider; @@ -60,29 +62,153 @@ protected function setupDatabase($app) protected function seedDatabase() { - // Paid shop, not grandfathered - $shop = new Shop(); - $shop->shopify_domain = 'example.myshopify.com'; - $shop->shopify_token = '1234'; - $shop->charge_id = 678298290; - $shop->save(); + $this->createShops(); + $this->createCharges(); + } - // Non-paid shop, grandfathered - $shop = new Shop(); - $shop->shopify_domain = 'grandfathered.myshopify.com'; - $shop->shopify_token = '1234'; - $shop->grandfathered = true; - $shop->save(); + protected function createShops() + { + $shops = [ + // Paid shop, not grandfathered + [ + 'shopify_domain' => 'example.myshopify.com', + 'shopify_token' => '1234', + ], - // New shop... non-paid, not grandfathered - $shop = new Shop(); - $shop->shopify_domain = 'new-shop.myshopify.com'; - $shop->shopify_token = '1234'; - $shop->save(); + // Non-paid shop, grandfathered + [ + 'shopify_domain' => 'grandfathered.myshopify.com', + 'shopify_token' => '1234', + 'grandfathered' => true, + ], + + // New shop... non-paid, not grandfathered + [ + 'shopify_domain' => 'new-shop.myshopify.com', + 'shopify_token' => '1234', + ], + + // New shop... no token, not grandfathered + [ + 'shopify_domain' => 'no-token-shop.myshopify.com', + ], + ]; + + // Build the shops + foreach ($shops as $shopData) { + $shop = new Shop(); + foreach ($shopData as $key => $value) { + $shop->{$key} = $value; + } + $shop->save(); + } - // New shop... no token, not grandfathered + // Special trashed shop $shop = new Shop(); - $shop->shopify_domain = 'no-token-shop.myshopify.com'; + $shop->shopify_domain = 'trashed-shop.myshopify.com'; $shop->save(); + $shop->delete(); + } + + public function createCharges() + { + $charges = [ + // Test = true, status = accepted, trial = 7, active trial = no + [ + 'charge_id' => 98298298, + 'test' => true, + 'name' => 'Test Plan', + 'status' => 'accepted', + 'type' => 1, + 'price' => 15.00, + 'trial_days' => 7, + 'trial_ends_on' => Carbon::createFromDate(2018, 6, 3, 'UTC')->addWeeks(1)->format('Y-m-d'), + 'shop_id' => Shop::where('shopify_domain', 'example.myshopify.com')->first()->id, + ], + + // Test = false, status = active, trial = 7, active trial = yes + [ + 'charge_id' => 67298298, + 'test' => false, + 'name' => 'Base Plan', + 'status' => 'active', + 'type' => 1, + 'price' => 25.00, + 'trial_days' => 7, + 'trial_ends_on' => Carbon::today()->addDays(2)->format('Y-m-d'), + 'shop_id' => Shop::where('shopify_domain', 'example.myshopify.com')->first()->id, + ], + + // Test = false, status = active, trial = 7, active trial = no + [ + 'charge_id' => 78378873, + 'test' => false, + 'name' => 'Base Plan Old', + 'status' => 'active', + 'type' => 1, + 'price' => 25.00, + 'trial_days' => 7, + 'trial_ends_on' => Carbon::today()->subWeeks(4)->format('Y-m-d'), + 'shop_id' => Shop::where('shopify_domain', 'example.myshopify.com')->first()->id, + ], + + // Test = false, status = active, trial = 0 + [ + 'charge_id' => 89389389, + 'test' => false, + 'name' => 'Base Plan Old Non-Trial', + 'status' => 'active', + 'type' => 1, + 'price' => 25.00, + 'trial_days' => 0, + 'shop_id' => Shop::where('shopify_domain', 'example.myshopify.com')->first()->id, + ], + + // Test = false, status = declined, trial = 7, active trial = true + [ + 'charge_id' => 78378378378, + 'test' => false, + 'name' => 'Base Plan Declined', + 'status' => 'declined', + 'type' => 1, + 'price' => 25.00, + 'shop_id' => Shop::where('shopify_domain', 'no-token-shop.myshopify.com')->first()->id, + ], + + // Test = false, status = cancelled + [ + 'charge_id' => 783873873, + 'test' => false, + 'name' => 'Base Plan Cancelled', + 'status' => 'active', + 'type' => 1, + 'price' => 25.00, + 'shop_id' => Shop::where('shopify_domain', 'example.myshopify.com')->first()->id, + 'cancelled_on' => Carbon::today()->format('Y-m-d'), + ], + + // Test = false, status = cancelled, trial = 7 + [ + 'charge_id' => 928736721, + 'test' => false, + 'name' => 'Base Plan Cancelled', + 'status' => 'cancelled', + 'type' => 1, + 'price' => 25.00, + 'trial_days' => 7, + 'trial_ends_on' => Carbon::today()->addWeeks(1)->format('Y-m-d'), + 'cancelled_on' => Carbon::today()->addDays(2)->format('Y-m-d'), + 'shop_id' => Shop::withTrashed()->where('shopify_domain', 'trashed-shop.myshopify.com')->first()->id, + ], + ]; + + // Build the charges + foreach ($charges as $chargeData) { + $charge = new Charge(); + foreach ($chargeData as $key => $value) { + $charge->{$key} = $value; + } + $charge->save(); + } } } diff --git a/tests/fixtures/602d13096715a537f9bb6e2ea2ac0d493a24344c.json b/tests/fixtures/602d13096715a537f9bb6e2ea2ac0d493a24344c.json index bbe4d18d..e692b417 100644 --- a/tests/fixtures/602d13096715a537f9bb6e2ea2ac0d493a24344c.json +++ b/tests/fixtures/602d13096715a537f9bb6e2ea2ac0d493a24344c.json @@ -1,19 +1,19 @@ { "recurring_application_charge": { - "id": 1029266947, + "id": 455696195, "name": "Super Mega Plan", "api_client_id": 755357713, "price": "15.00", - "status": "accepted", - "return_url": "http:\/\/yourapp.com", - "billing_on": "2017-08-17", - "created_at": "2017-08-17T15:16:08-04:00", - "updated_at": "2017-08-17T15:29:50-04:00", + "status": "active", + "return_url": "http://yourapp.com", + "billing_on": "2018-06-06", + "created_at": "2018-05-07T15:33:38-04:00", + "updated_at": "2018-05-07T15:47:13-04:00", "test": null, - "activated_on": null, - "trial_ends_on": null, + "activated_on": "2018-05-07", + "trial_ends_on": "2018-05-07", "cancelled_on": null, "trial_days": 0, - "decorated_return_url": "http:\/\/yourapp.com?charge_id=1029266947" + "decorated_return_url": "http://yourapp.com?charge_id=455696195" } } \ No newline at end of file diff --git a/tests/fixtures/app_uninstalled.json b/tests/fixtures/app_uninstalled.json new file mode 100644 index 00000000..bf49e29e --- /dev/null +++ b/tests/fixtures/app_uninstalled.json @@ -0,0 +1,54 @@ +{ + "id": 690933842, + "name": "Super Toys", + "email": "super@supertoys.com", + "domain": null, + "province": "Tennessee", + "country": "US", + "address1": "190 MacLaren Street", + "zip": "37178", + "city": "Houston", + "source": null, + "phone": "3213213210", + "latitude": null, + "longitude": null, + "primary_locale": "en", + "address2": null, + "created_at": null, + "updated_at": null, + "country_code": "US", + "country_name": "United States", + "currency": "USD", + "customer_email": "super@supertoys.com", + "timezone": "(GMT-05:00) Eastern Time (US & Canada)", + "iana_timezone": null, + "shop_owner": "Steve Jobs", + "money_format": "$", + "money_with_currency_format": "$ USD", + "weight_unit": "kg", + "province_code": "TN", + "taxes_included": null, + "tax_shipping": null, + "county_taxes": null, + "plan_display_name": "Shopify Plus", + "plan_name": "enterprise", + "has_discounts": true, + "has_gift_cards": true, + "myshopify_domain": "example.myshopify.com", + "google_apps_domain": null, + "google_apps_login_enabled": null, + "money_in_emails_format": "$", + "money_with_currency_in_emails_format": "$ USD", + "eligible_for_payments": true, + "requires_extra_payments_agreement": false, + "password_enabled": null, + "has_storefront": true, + "eligible_for_card_reader_giveaway": false, + "finances": true, + "primary_location_id": 905684977, + "checkout_api_supported": true, + "multi_location_enabled": false, + "setup_required": false, + "force_ssl": false, + "pre_launch_enabled": false +} \ No newline at end of file