├── src ├── resources │ ├── jobs │ │ └── AppUninstalledJob.php │ ├── views │ │ ├── billing │ │ │ ├── error.blade.php │ │ │ └── fullpage_redirect.blade.php │ │ ├── layouts │ │ │ ├── default.blade.php │ │ │ └── error.blade.php │ │ ├── auth │ │ │ ├── fullpage_redirect.blade.php │ │ │ └── token.blade.php │ │ ├── home │ │ │ └── index.blade.php │ │ └── partials │ │ │ ├── laravel_skeleton_css.blade.php │ │ │ └── token_handler.blade.php │ ├── database │ │ ├── factories │ │ │ ├── ShopFactory.php │ │ │ ├── PlanFactory.php │ │ │ └── ChargeFactory.php │ │ └── migrations │ │ │ ├── 2021_04_21_103633_add_password_updated_at_to_users_table.php │ │ │ ├── 2022_06_09_104819_add_theme_support_level_to_users_table.php │ │ │ ├── 2020_07_03_211854_add_interval_column_to_plans_table.php │ │ │ ├── 2020_07_03_211514_add_interval_column_to_charges_table.php │ │ │ ├── 2020_01_29_010501_create_plans_table.php │ │ │ ├── 2020_01_29_230905_create_shops_table.php │ │ │ └── 2020_01_29_231006_create_charges_table.php │ └── routes │ │ ├── api.php │ │ └── shopify.php ├── Exceptions │ ├── ApiException.php │ ├── InvalidShopDomainException.php │ ├── MissingAuthUrlException.php │ ├── MissingShopDomainException.php │ ├── SignatureVerificationException.php │ ├── ChargeNotRecurringException.php │ ├── BaseException.php │ ├── ChargeNotRecurringOrOnetimeException.php │ └── HttpException.php ├── Contracts │ ├── Objects │ │ └── Values │ │ │ ├── PlanId.php │ │ │ ├── ShopId.php │ │ │ ├── SessionToken.php │ │ │ ├── ThemeId.php │ │ │ ├── ShopDomain.php │ │ │ ├── ThemeName.php │ │ │ ├── ThemeRole.php │ │ │ ├── SessionId.php │ │ │ ├── ThemeSupportLevel.php │ │ │ └── AccessToken.php │ ├── Queries │ │ ├── Plan.php │ │ ├── Charge.php │ │ └── Shop.php │ ├── Commands │ │ ├── Charge.php │ │ └── Shop.php │ └── ShopModel.php ├── Objects │ ├── Values │ │ ├── ChargeId.php │ │ ├── ChargeReference.php │ │ ├── NullPlanId.php │ │ ├── PlanId.php │ │ ├── ShopId.php │ │ ├── NullThemeId.php │ │ ├── ThemeId.php │ │ ├── ThemeName.php │ │ ├── ThemeRole.php │ │ ├── NullThemeName.php │ │ ├── NullThemeRole.php │ │ ├── NullShopDomain.php │ │ ├── NullSessionId.php │ │ ├── NullSessionToken.php │ │ ├── SessionId.php │ │ ├── ThemeSupportLevel.php │ │ ├── Hmac.php │ │ ├── NullAccessToken.php │ │ ├── AccessToken.php │ │ ├── NullablePlanId.php │ │ ├── NullableThemeId.php │ │ ├── NullableThemeName.php │ │ ├── NullableThemeRole.php │ │ ├── NullableShopDomain.php │ │ ├── NullableSessionId.php │ │ ├── NullableSessionToken.php │ │ ├── NullableAccessToken.php │ │ ├── MainTheme.php │ │ ├── SessionContext.php │ │ └── ShopDomain.php │ ├── Enums │ │ ├── FrontendType.php │ │ ├── AuthMode.php │ │ ├── PlanType.php │ │ ├── PlanInterval.php │ │ ├── ChargeInterval.php │ │ ├── SessionTokenSource.php │ │ ├── DataSource.php │ │ ├── ThemeSupportLevel.php │ │ ├── ApiMethod.php │ │ ├── ChargeType.php │ │ └── ChargeStatus.php │ └── Transfers │ │ ├── UsageChargeDetails.php │ │ ├── UsageCharge.php │ │ ├── Charge.php │ │ ├── PlanDetails.php │ │ └── AbstractTransfer.php ├── Http │ ├── Controllers │ │ ├── ApiController.php │ │ ├── AuthController.php │ │ ├── HomeController.php │ │ ├── WebhookController.php │ │ └── BillingController.php │ ├── Middleware │ │ ├── AuthWebhook.php │ │ ├── Billable.php │ │ ├── IframeProtection.php │ │ ├── AuthProxy.php │ │ └── VerifyScopes.php │ └── Requests │ │ └── StoreUsageCharge.php ├── Directives │ └── SessionToken.php ├── Traits │ ├── ShopAccessible.php │ ├── HomeController.php │ ├── ApiController.php │ ├── WebhookController.php │ ├── AuthController.php │ └── ShopModel.php ├── Storage │ ├── Scopes │ │ └── Namespacing.php │ ├── Queries │ │ ├── Plan.php │ │ ├── Shop.php │ │ └── Charge.php │ ├── Observers │ │ └── Shop.php │ ├── Models │ │ └── Plan.php │ └── Commands │ │ ├── Charge.php │ │ └── Shop.php ├── Messaging │ ├── Events │ │ ├── AppInstalledEvent.php │ │ ├── ShopAuthenticatedEvent.php │ │ ├── ShopDeletedEvent.php │ │ ├── AppUninstalledEvent.php │ │ ├── AppLoggedIn.php │ │ └── PlanActivatedEvent.php │ └── Jobs │ │ ├── ScripttagInstaller.php │ │ ├── WebhookInstaller.php │ │ └── AppUninstalledJob.php ├── Actions │ ├── DeleteWebhooks.php │ ├── CancelCurrentPlan.php │ ├── DispatchWebhooks.php │ ├── DispatchScripts.php │ ├── CancelCharge.php │ ├── AfterAuthorize.php │ ├── VerifyThemeSupport.php │ ├── CreateScripts.php │ ├── GetPlanUrl.php │ ├── AuthenticateShop.php │ ├── CreateWebhooks.php │ ├── ActivateUsageCharge.php │ ├── InstallShop.php │ └── ActivatePlan.php ├── Macros │ ├── TokenRoute.php │ ├── TokenRedirect.php │ └── TokenUrl.php ├── Console │ ├── stubs │ │ └── webhook-job.stub │ ├── WebhookJobMakeCommand.php │ └── AddVariablesCommand.php └── Services │ └── CookieHelper.php ├── .idea ├── vcs.xml └── modules.xml ├── ide.json ├── phpstan.neon ├── LICENSE ├── composer.json └── README.md /src/resources/jobs/AppUninstalledJob.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/Exceptions/ApiException.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/Exceptions/ChargeNotRecurringOrOnetimeException.php: -------------------------------------------------------------------------------- 1 | 5 |
6 |
Oops!
7 |

{{ $message }}

8 |
9 | 10 | 11 | @endsection 12 | -------------------------------------------------------------------------------- /src/Objects/Values/ThemeName.php: -------------------------------------------------------------------------------- 1 | toNative(), $object->toNative()); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Objects/Values/NullAccessToken.php: -------------------------------------------------------------------------------- 1 | expectsJson()) { 13 | return response()->json([ 14 | 'error' => $this->getMessage(), 15 | ], $this->getCode()); 16 | } 17 | 18 | return response($this->getMessage(), $this->getCode()); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | level: 1 3 | paths: 4 | - src 5 | - tests 6 | ignoreErrors: 7 | - '#Access to an undefined property Osiset\\ShopifyApp\\Storage\\Models\\.*::\$.*\.#' 8 | - '#Access to an undefined property Osiset\\ShopifyApp\\Test\\Stubs\\.*::\$.*\.#' 9 | - '#Variable \$factory might not be defined\.#' 10 | - '#Call to an undefined static method Illuminate\\Routing\\Redirector::tokenRedirect\(\).#' 11 | - '#Call to an undefined static method Illuminate\\Routing\\UrlGenerator::tokenRoute\(\).#' 12 | parallel: 13 | maximumNumberOfProcesses: 2 14 | -------------------------------------------------------------------------------- /src/Objects/Values/AccessToken.php: -------------------------------------------------------------------------------- 1 | toNative()); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/resources/database/factories/ShopFactory.php: -------------------------------------------------------------------------------- 1 | define($model, function (Faker $faker) { 9 | return [ 10 | 'name' => "{$faker->domainWord}.myshopify.com", 11 | 'password' => str_replace('-', '', $faker->uuid), 12 | 'email' => $faker->email, 13 | ]; 14 | }); 15 | 16 | $factory->state($model, 'freemium', [ 17 | 'shopify_freemium' => true, 18 | ]); 19 | 20 | $factory->state($model, 'grandfathered', [ 21 | 'shopify_grandfathered' => true, 22 | ]); 23 | -------------------------------------------------------------------------------- /src/Directives/SessionToken.php: -------------------------------------------------------------------------------- 1 | '; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Objects/Enums/AuthMode.php: -------------------------------------------------------------------------------- 1 | shop = $shop; 29 | 30 | return $this; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Objects/Enums/PlanInterval.php: -------------------------------------------------------------------------------- 1 | $request->user()] 26 | ); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Objects/Values/NullableShopDomain.php: -------------------------------------------------------------------------------- 1 | where('shopify_namespace', Util::getShopifyConfig('namespace')); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Messaging/Events/AppInstalledEvent.php: -------------------------------------------------------------------------------- 1 | shopId = $shop_id; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Messaging/Events/ShopAuthenticatedEvent.php: -------------------------------------------------------------------------------- 1 | shopId = $shopId; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Messaging/Events/ShopDeletedEvent.php: -------------------------------------------------------------------------------- 1 | shop = $shop; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Messaging/Events/AppUninstalledEvent.php: -------------------------------------------------------------------------------- 1 | shop = $shop; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Messaging/Events/AppLoggedIn.php: -------------------------------------------------------------------------------- 1 | shop = $shop; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Objects/Enums/DataSource.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Redirecting... 10 | 11 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /src/Objects/Values/NullableAccessToken.php: -------------------------------------------------------------------------------- 1 | value->toNative()); 19 | } 20 | 21 | /** 22 | * @return string 23 | */ 24 | protected static function nonNullImplementation(): string 25 | { 26 | return AccessToken::class; 27 | } 28 | 29 | /** 30 | * @return string 31 | */ 32 | protected static function nullImplementation(): string 33 | { 34 | return NullAccessToken::class; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Actions/DeleteWebhooks.php: -------------------------------------------------------------------------------- 1 | shopQuery->getById($shopId); 20 | $apiHelper = $shop->apiHelper(); 21 | $webhooks = $apiHelper->getWebhooks(); 22 | 23 | $deleted = []; 24 | 25 | foreach (data_get($webhooks, 'data.webhookSubscriptions.container.edges', []) as $webhook) { 26 | $apiHelper->deleteWebhook(data_get($webhook, 'node.id')); 27 | 28 | $deleted[] = $webhook; 29 | } 30 | 31 | return $deleted; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/resources/database/migrations/2021_04_21_103633_add_password_updated_at_to_users_table.php: -------------------------------------------------------------------------------- 1 | date('password_updated_at')->nullable(); 19 | }); 20 | } 21 | 22 | /** 23 | * Reverse the migrations. 24 | * 25 | * @return void 26 | */ 27 | public function down() 28 | { 29 | Schema::table(Util::getShopsTable(), function (Blueprint $table) { 30 | $table->dropColumn('password_updated_at'); 31 | }); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/resources/database/migrations/2022_06_09_104819_add_theme_support_level_to_users_table.php: -------------------------------------------------------------------------------- 1 | integer('theme_support_level')->nullable(); 19 | }); 20 | } 21 | 22 | /** 23 | * Reverse the migrations. 24 | * 25 | * @return void 26 | */ 27 | public function down() 28 | { 29 | Schema::table(Util::getShopsTable(), function (Blueprint $table) { 30 | $table->dropColumn('theme_support_level'); 31 | }); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Objects/Enums/ApiMethod.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | {{ config('shopify-app.app_name') }} 10 | @yield('styles') 11 | 12 | 13 | 14 |
15 |
16 |
17 | @yield('content') 18 |
19 |
20 |
21 | 22 | @if(\Osiset\ShopifyApp\Util::isMPAApplication()) 23 | @include('shopify-app::partials.token_handler') 24 | @endif 25 | @yield('scripts') 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/Macros/TokenRoute.php: -------------------------------------------------------------------------------- 1 | 1]);` 21 | * @example `Order #1` 22 | * 23 | * @return string 24 | */ 25 | public function __invoke(string $route, $params = [], bool $absolute = true): string 26 | { 27 | [$url, $params] = $this->generateParams($route, $params, $absolute); 28 | 29 | return URL::route($url, $params); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Macros/TokenRedirect.php: -------------------------------------------------------------------------------- 1 | 1]);` 22 | * 23 | * @return RedirectResponse 24 | */ 25 | public function __invoke(string $route, $params = [], bool $absolute = true): RedirectResponse 26 | { 27 | [$url, $params] = $this->generateParams($route, $params, $absolute); 28 | 29 | return Redirect::route($url, $params); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/resources/database/migrations/2020_07_03_211854_add_interval_column_to_plans_table.php: -------------------------------------------------------------------------------- 1 | string('interval')->nullable()->after('price'); 19 | }); 20 | } 21 | 22 | /** 23 | * Reverse the migrations. 24 | * 25 | * @return void 26 | */ 27 | public function down() 28 | { 29 | Schema::table(Util::getShopifyConfig('table_names.plans', 'plans'), function (Blueprint $table) { 30 | $table->dropColumn('interval'); 31 | }); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/resources/database/migrations/2020_07_03_211514_add_interval_column_to_charges_table.php: -------------------------------------------------------------------------------- 1 | string('interval')->nullable()->after('price'); 19 | }); 20 | } 21 | 22 | /** 23 | * Reverse the migrations. 24 | * 25 | * @return void 26 | */ 27 | public function down() 28 | { 29 | Schema::table(Util::getShopifyConfig('table_names.charges', 'charges'), function (Blueprint $table) { 30 | $table->dropColumn('interval'); 31 | }); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/resources/views/auth/fullpage_redirect.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | @if(request()->header('sec-fetch-dest') === 'iframe') 8 | 9 | @endif 10 | 11 | Redirecting... 12 | 13 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/resources/views/home/index.blade.php: -------------------------------------------------------------------------------- 1 | @extends('shopify-app::layouts.default') 2 | 3 | @section('styles') 4 | @include('shopify-app::partials.laravel_skeleton_css') 5 | @endsection 6 | 7 | @section('content') 8 | 9 | 10 |
11 |
12 |
13 | Laravel & Shopify 14 |
15 | 16 |

Welcome to your Shopify App powered by Laravel.

17 |

 

18 |

{{ $shop->name }}

19 |

 

20 | 21 | 26 |
27 |
28 | @endsection 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (C) 2020 Tyler King 2 | 3 | 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: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /src/Contracts/Queries/Plan.php: -------------------------------------------------------------------------------- 1 | shopQuery->getById($shopId); 22 | $plan = $shop->plan; 23 | 24 | if (! $plan) { 25 | return false; 26 | } 27 | 28 | $lastPlanCharge = $this->chargeHelper->chargeForPlan($shop->plan->getId(), $shop); 29 | 30 | if ($lastPlanCharge && ! $lastPlanCharge->isDeclined() && ! $lastPlanCharge->isCancelled()) { 31 | $this->chargeCommand->cancel($lastPlanCharge->getReference()); 32 | 33 | return true; 34 | } 35 | 36 | return false; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Objects/Enums/ChargeStatus.php: -------------------------------------------------------------------------------- 1 | shop = $shop; 50 | $this->plan = $plan; 51 | $this->chargeId = $chargeId; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Macros/TokenUrl.php: -------------------------------------------------------------------------------- 1 | ShopDomain::fromRequest(Request::instance())->toNative(), 30 | 'target' => URL::route($route, $params, $absolute), 31 | 'host' => Request::instance()->get('host'), 32 | 'locale' => Request::instance()->get('locale'), 33 | ], 34 | ]; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/resources/views/partials/laravel_skeleton_css.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 55 | -------------------------------------------------------------------------------- /src/Actions/DispatchWebhooks.php: -------------------------------------------------------------------------------- 1 | shopQuery->getById($shopId); 26 | 27 | if ($inline) { 28 | ($this->jobClass)::dispatchSync( 29 | $shop->getId(), 30 | $webhooks 31 | ); 32 | } else { 33 | ($this->jobClass)::dispatch( 34 | $shop->getId(), 35 | $webhooks 36 | )->onConnection(Util::getShopifyConfig('job_connections')['webhooks']) 37 | ->onQueue(Util::getShopifyConfig('job_queues')['webhooks']); 38 | } 39 | 40 | return true; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Actions/DispatchScripts.php: -------------------------------------------------------------------------------- 1 | shopQuery->getById($shopId); 20 | $scripttags = Util::getShopifyConfig('scripttags'); 21 | 22 | if (count($scripttags) === 0) { 23 | return false; 24 | } 25 | 26 | if ($inline) { 27 | ($this->jobClass)::dispatchSync( 28 | $shop->getId(), 29 | $scripttags 30 | ); 31 | } else { 32 | ($this->jobClass)::dispatch( 33 | $shop->getId(), 34 | $scripttags 35 | )->onConnection(Util::getShopifyConfig('job_connections')['scripttags']) 36 | ->onQueue(Util::getShopifyConfig('job_queues')['scripttags']); 37 | } 38 | 39 | return true; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Traits/ApiController.php: -------------------------------------------------------------------------------- 1 | json(); 23 | } 24 | 25 | /** 26 | * Returns authenticated users details. 27 | * 28 | * @return JsonResponse 29 | */ 30 | public function getSelf(): JsonResponse 31 | { 32 | return response()->json(Auth::user()->only([ 33 | 'name', 34 | 'shopify_grandfathered', 35 | 'shopify_freemium', 36 | 'plan', 37 | ])); 38 | } 39 | 40 | /** 41 | * Returns currently available plans. 42 | * 43 | * @return JsonResponse 44 | */ 45 | public function getPlans(): JsonResponse 46 | { 47 | $planClass = Util::getShopifyConfig('models.plan', Plan::class); 48 | $planModel = new $planClass(); 49 | 50 | return response()->json($planModel->all()); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Contracts/Queries/Charge.php: -------------------------------------------------------------------------------- 1 | chargeHelper->useCharge($chargeRef); 26 | $charge = $helper->getCharge(); 27 | 28 | if (! $charge->isType(ChargeType::CHARGE()) && ! $charge->isType(ChargeType::RECURRING())) { 29 | // Not a recurring or one-time charge, someone trying to cancel a usage charge? 30 | throw new ChargeNotRecurringOrOnetimeException( 31 | 'Cancel may only be called for single and recurring charges.' 32 | ); 33 | } 34 | 35 | return $this->chargeCommand->cancel( 36 | $chargeRef, 37 | Carbon::today(), 38 | Carbon::today()->addDays($helper->remainingDaysForPeriod()) 39 | ); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/resources/database/factories/PlanFactory.php: -------------------------------------------------------------------------------- 1 | define($planModel, function (Faker $faker) { 12 | return [ 13 | 'name' => $faker->word, 14 | 'price' => $faker->randomFloat(), 15 | ]; 16 | }); 17 | 18 | $factory->state($planModel, 'usage', function ($faker) { 19 | return [ 20 | 'capped_amount' => $faker->randomFloat(), 21 | 'terms' => $faker->sentence, 22 | ]; 23 | }); 24 | 25 | $factory->state($planModel, 'trial', function ($faker) { 26 | return [ 27 | 'trial_days' => $faker->numberBetween(7, 14), 28 | ]; 29 | }); 30 | 31 | $factory->state($planModel, 'test', [ 32 | 'test' => true, 33 | ]); 34 | 35 | $factory->state($planModel, 'installable', [ 36 | 'on_install' => true, 37 | ]); 38 | 39 | $factory->state($planModel, 'type_recurring', [ 40 | 'type' => PlanType::RECURRING()->toNative(), 41 | 'interval' => PlanInterval::EVERY_30_DAYS()->toNative(), 42 | ]); 43 | 44 | $factory->state($planModel, 'type_onetime', [ 45 | 'type' => PlanType::ONETIME()->toNative(), 46 | ]); 47 | 48 | $factory->state($planModel, 'interval_annual', [ 49 | 'interval' => PlanInterval::ANNUAL()->toNative(), 50 | ]); 51 | -------------------------------------------------------------------------------- /src/resources/database/factories/ChargeFactory.php: -------------------------------------------------------------------------------- 1 | define($chargeModel, function (Faker $faker) { 13 | return [ 14 | 'charge_id' => $faker->randomNumber(8), 15 | 'name' => $faker->word, 16 | 'price' => $faker->randomFloat(), 17 | 'status' => ChargeStatus::ACCEPTED()->toNative(), 18 | ]; 19 | }); 20 | 21 | $factory->state($chargeModel, 'test', [ 22 | 'test' => true, 23 | ]); 24 | 25 | $factory->state($chargeModel, 'type_recurring', [ 26 | 'type' => ChargeType::RECURRING()->toNative(), 27 | ]); 28 | 29 | $factory->state($chargeModel, 'type_onetime', [ 30 | 'type' => ChargeType::CHARGE()->toNative(), 31 | ]); 32 | 33 | $factory->state($chargeModel, 'type_usage', [ 34 | 'type' => ChargeType::USAGE()->toNative(), 35 | ]); 36 | 37 | $factory->state($chargeModel, 'type_credit', [ 38 | 'type' => ChargeType::CREDIT()->toNative(), 39 | ]); 40 | 41 | $factory->state($chargeModel, 'trial', function ($faker) { 42 | $days = $faker->numberBetween(7, 14); 43 | 44 | return [ 45 | 'trial_days' => $days, 46 | 'trial_ends_on' => Carbon::today()->addDays($days), 47 | ]; 48 | }); 49 | -------------------------------------------------------------------------------- /src/Contracts/Queries/Shop.php: -------------------------------------------------------------------------------- 1 | shopDomain = $shopDomain; 40 | $this->data = $data; 41 | } 42 | 43 | /** 44 | * Execute the job. 45 | * 46 | * @return void 47 | */ 48 | public function handle() 49 | { 50 | // Convert domain 51 | $this->shopDomain = ShopDomain::fromNative($this->shopDomain); 52 | 53 | // Do what you wish with the data 54 | // Access domain name as $this->shopDomain->toNative() 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Storage/Queries/Plan.php: -------------------------------------------------------------------------------- 1 | planModel = new $chargeClass(); 30 | } 31 | 32 | 33 | /** 34 | * {@inheritdoc} 35 | */ 36 | public function getById(PlanId $planId, array $with = []): ?PlanModel 37 | { 38 | return $this->planModel->with($with) 39 | ->get() 40 | ->where('id', $planId->toNative()) 41 | ->first(); 42 | } 43 | 44 | /** 45 | * {@inheritdoc} 46 | */ 47 | public function getDefault(array $with = []): ?PlanModel 48 | { 49 | return $this->planModel->with($with) 50 | ->get() 51 | ->where('on_install', true) 52 | ->first(); 53 | } 54 | 55 | /** 56 | * {@inheritdoc} 57 | */ 58 | public function getAll(array $with = []): Collection 59 | { 60 | return $this->planModel->with($with) 61 | ->get(); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/Http/Middleware/AuthWebhook.php: -------------------------------------------------------------------------------- 1 | header('x-shopify-hmac-sha256', '')); 29 | $shop = NullableShopDomain::fromNative($request->header('x-shopify-shop-domain')); 30 | $data = $request->getContent(); 31 | $hmacLocal = Util::createHmac( 32 | [ 33 | 'data' => $data, 34 | 'raw' => true, 35 | 'encode' => true, 36 | ], 37 | Util::getShopifyConfig('api_secret', $shop) 38 | ); 39 | 40 | if (! $hmac->isSame($hmacLocal) || $shop->isNull()) { 41 | // Issue with HMAC or missing shop header 42 | return Response::make('Invalid webhook signature.', HttpResponse::HTTP_UNAUTHORIZED); 43 | } 44 | 45 | // All good, process webhook 46 | return $next($request); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Objects/Transfers/UsageCharge.php: -------------------------------------------------------------------------------- 1 | chargeType = ChargeType::USAGE(); 73 | $this->chargeStatus = ChargeStatus::ACCEPTED(); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/Actions/AfterAuthorize.php: -------------------------------------------------------------------------------- 1 | onConnection(Util::getShopifyConfig('job_connections')['after_authenticate']) 30 | ->onQueue(Util::getShopifyConfig('job_queues')['after_authenticate']); 31 | } 32 | 33 | return true; 34 | }; 35 | 36 | $shop = $this->shopQuery->getById($shopId); 37 | $jobsConfig = Util::getShopifyConfig('after_authenticate_job'); 38 | 39 | if (Arr::has($jobsConfig, 0)) { 40 | foreach ($jobsConfig as $jobConfig) { 41 | $fireJob($jobConfig, $shop); 42 | } 43 | 44 | return true; 45 | } elseif (Arr::has($jobsConfig, 'job')) { 46 | return $fireJob($jobsConfig, $shop); 47 | } 48 | 49 | return false; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Storage/Observers/Shop.php: -------------------------------------------------------------------------------- 1 | shopCommand = $shopCommand; 31 | } 32 | 33 | /** 34 | * Listen to the shop creating event. 35 | * TODO: Move partial to command. 36 | * 37 | * @param IShopModel $shop An instance of a shop. 38 | * 39 | * @return void 40 | */ 41 | public function creating(IShopModel $shop): void 42 | { 43 | $namespace = Util::getShopifyConfig('namespace'); 44 | $freemium = Util::getShopifyConfig('billing_freemium_enabled'); 45 | 46 | if (! empty($namespace) && ! isset($shop->shopify_namespace)) { 47 | // Automatically add the current namespace to new records 48 | $this->shopCommand->setNamespaceByRef($shop, $namespace); 49 | } 50 | 51 | if ($freemium === true && ! isset($shop->shopify_freemium)) { 52 | // Add the freemium flag to the shop 53 | $this->shopCommand->setAsFreemiumByRef($shop); 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Actions/VerifyThemeSupport.php: -------------------------------------------------------------------------------- 1 | themeHelper->extractStoreMainTheme($shopId); 21 | 22 | if ($this->themeHelper->themeIsReady()) { 23 | $templateJSONFiles = $this->themeHelper->templateJSONFiles(); 24 | $templateMainSections = $this->themeHelper->mainSections($templateJSONFiles); 25 | $sectionsWithAppBlock = $this->themeHelper->sectionsWithAppBlock($templateMainSections); 26 | 27 | $hasTemplates = count($templateJSONFiles) > 0; 28 | $allTemplatesHasRightType = count($templateJSONFiles) === count($sectionsWithAppBlock); 29 | $templatesСountWithRightType = count($sectionsWithAppBlock); 30 | 31 | switch (true) { 32 | case $hasTemplates && $allTemplatesHasRightType: 33 | return ThemeSupportLevel::FULL; 34 | 35 | case $templatesСountWithRightType: 36 | return ThemeSupportLevel::PARTIAL; 37 | 38 | default: 39 | return ThemeSupportLevel::UNSUPPORTED; 40 | } 41 | } 42 | 43 | return ThemeSupportLevel::UNSUPPORTED; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Objects/Transfers/Charge.php: -------------------------------------------------------------------------------- 1 | $this->name, 72 | 'price' => $this->price, 73 | 'interval' => $this->interval, 74 | 'test' => $this->test, 75 | 'trial_days' => $this->trialDays, 76 | 'return_url' => $this->returnUrl, 77 | 'terms' => $this->terms, 78 | 'capped_amount' => $this->cappedAmount, 79 | ]; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/Actions/CreateScripts.php: -------------------------------------------------------------------------------- 1 | shopQuery->getById($shopId); 18 | $apiHelper = $shop->apiHelper(); 19 | $scripts = $apiHelper->getScriptTags(); 20 | 21 | $created = []; 22 | $deleted = []; 23 | $used = []; 24 | 25 | foreach ($configScripts as $scripttag) { 26 | if (! $this->checkExists($scripttag, $scripts)) { 27 | $apiHelper->createScriptTag($scripttag); 28 | $created[] = $scripttag; 29 | } 30 | 31 | $used[] = $scripttag['src']; 32 | } 33 | 34 | foreach ($scripts as $scriptTag) { 35 | if (! in_array($scriptTag->src, $used)) { 36 | $apiHelper->deleteScriptTag($scriptTag->id); 37 | $deleted[] = $scriptTag; 38 | } 39 | } 40 | 41 | return [ 42 | 'created' => $created, 43 | 'deleted' => $deleted, 44 | ]; 45 | } 46 | 47 | private function checkExists(array $script, ResponseAccess $scripts): bool 48 | { 49 | foreach ($scripts as $shopScript) { 50 | if ($shopScript['src'] === $script['src']) { 51 | return true; 52 | } 53 | } 54 | 55 | return false; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Actions/GetPlanUrl.php: -------------------------------------------------------------------------------- 1 | shopQuery->getById($shopId); 28 | $plan = $planId->isNull() ? $this->planQuery->getDefault() : $this->planQuery->getById($planId); 29 | 30 | if ($plan->getInterval()->toNative() === ChargeInterval::ANNUAL()->toNative()) { 31 | $api = $shop->apiHelper() 32 | ->createChargeGraphQL($this->chargeHelper->details($plan, $shop, $host)); 33 | 34 | $confirmationUrl = $api['confirmationUrl']; 35 | } else { 36 | $api = $shop->apiHelper() 37 | ->createCharge( 38 | ChargeType::fromNative($plan->getType()->toNative()), 39 | $this->chargeHelper->details($plan, $shop, $host) 40 | ); 41 | 42 | $confirmationUrl = $api['confirmation_url']; 43 | } 44 | 45 | return $confirmationUrl; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Traits/WebhookController.php: -------------------------------------------------------------------------------- 1 | getContent()); 28 | 29 | // If we have manually mapped a class, use that instead 30 | $config = Util::getShopifyConfig('webhooks'); 31 | if (!empty($config[$type]['class'])) { 32 | $jobClass = $config[$type]['class']; 33 | } 34 | 35 | $dispatch = $jobClass::dispatch( 36 | $request->header('x-shopify-shop-domain'), 37 | $jobData 38 | ); 39 | 40 | $connection = Util::getShopifyConfig('job_connections')['webhooks'] ?? null; 41 | if ($connection) { 42 | $dispatch->onConnection($connection); 43 | } 44 | 45 | $queue = Util::getShopifyConfig('job_queues')['webhooks'] ?? null; 46 | if ($queue) { 47 | $dispatch->onQueue($queue); 48 | } 49 | 50 | return Response::make('', ResponseResponse::HTTP_CREATED); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Messaging/Jobs/ScripttagInstaller.php: -------------------------------------------------------------------------------- 1 | shopId = $shopId; 48 | $this->configScripts = $configScripts; 49 | } 50 | 51 | /** 52 | * Execute the job. 53 | * 54 | * @param CreateScriptsAction $createScriptsAction The action for creating scripttags. 55 | * 56 | * @return array 57 | */ 58 | public function handle(CreateScriptsAction $createScriptsAction): array 59 | { 60 | return call_user_func( 61 | $createScriptsAction, 62 | $this->shopId, 63 | $this->configScripts 64 | ); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/resources/database/migrations/2020_01_29_010501_create_plans_table.php: -------------------------------------------------------------------------------- 1 | increments('id'); 19 | 20 | // The type of plan, either PlanType::RECURRING (0) or PlanType::ONETIME (1) 21 | $table->string('type'); 22 | 23 | // Name of the plan 24 | $table->string('name'); 25 | 26 | // Price of the plan 27 | $table->decimal('price', 8, 2); 28 | 29 | // Store the amount of the charge, this helps if you are experimenting with pricing 30 | $table->decimal('capped_amount', 8, 2)->nullable(); 31 | 32 | // Terms for the usage charges 33 | $table->string('terms')->nullable(); 34 | 35 | // Nullable in case of 0 trial days 36 | $table->integer('trial_days')->nullable(); 37 | 38 | // Is a test plan or not 39 | $table->boolean('test')->default(false); 40 | 41 | // On-install 42 | $table->boolean('on_install')->default(false); 43 | 44 | // Provides created_at && updated_at columns 45 | $table->timestamps(); 46 | }); 47 | } 48 | 49 | /** 50 | * Reverse the migrations. 51 | * 52 | * @return void 53 | */ 54 | public function down() 55 | { 56 | Schema::drop(Util::getShopifyConfig('table_names.plans', 'plans')); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Contracts/Commands/Charge.php: -------------------------------------------------------------------------------- 1 | installShopAction, 26 | ShopDomain::fromNative($request->get('shop')), 27 | $request->query('code'), 28 | $request->query('id_token'), 29 | ); 30 | 31 | if (! $result['completed']) { 32 | return [$result, false]; 33 | } 34 | 35 | if ($request->has('code')) { 36 | $this->apiHelper->make(); 37 | 38 | if (! $this->apiHelper->verifyRequest($request->all())) { 39 | return [$result, null]; 40 | } 41 | } 42 | 43 | if (in_array($result['theme_support_level'], Util::getShopifyConfig('theme_support.unacceptable_levels'))) { 44 | call_user_func($this->dispatchScriptsAction, $result['shop_id'], false); 45 | } 46 | 47 | call_user_func($this->dispatchWebhooksAction, $result['shop_id'], false); 48 | call_user_func($this->afterAuthorizeAction, $result['shop_id']); 49 | 50 | event(new AppInstalledEvent($result['shop_id'])); 51 | 52 | return [$result, true]; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Messaging/Jobs/WebhookInstaller.php: -------------------------------------------------------------------------------- 1 | shopId = $shopId; 48 | $this->configWebhooks = $configWebhooks; 49 | } 50 | 51 | /** 52 | * Execute the job. 53 | * 54 | * @param CreateWebhooksAction $createWebhooksAction The action for creating webhooks. 55 | * 56 | * @return array 57 | */ 58 | public function handle(CreateWebhooksAction $createWebhooksAction): array 59 | { 60 | return call_user_func( 61 | $createWebhooksAction, 62 | $this->shopId, 63 | $this->configWebhooks 64 | ); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/resources/views/partials/token_handler.blade.php: -------------------------------------------------------------------------------- 1 | 51 | -------------------------------------------------------------------------------- /src/Storage/Queries/Shop.php: -------------------------------------------------------------------------------- 1 | model = Util::getShopifyConfig('user_model'); 32 | } 33 | 34 | /** 35 | * {@inheritdoc} 36 | */ 37 | public function getByID(ShopIdValue $shopId, array $with = [], bool $withTrashed = false): ?ShopModel 38 | { 39 | $result = $this->model::with($with); 40 | if ($withTrashed) { 41 | $result = $result->withTrashed(); 42 | } 43 | 44 | return $result 45 | ->where('id', $shopId->toNative()) 46 | ->first(); 47 | } 48 | 49 | /** 50 | * {@inheritdoc} 51 | */ 52 | public function getByDomain(ShopDomainValue $domain, array $with = [], bool $withTrashed = false): ?ShopModel 53 | { 54 | $result = $this->model::with($with); 55 | if ($withTrashed) { 56 | $result = $result->withTrashed(); 57 | } 58 | 59 | return $result 60 | ->where('name', $domain->toNative()) 61 | ->first(); 62 | } 63 | 64 | /** 65 | * {@inheritdoc} 66 | */ 67 | public function getAll(array $with = []): Collection 68 | { 69 | return $this->model::with($with) 70 | ->get(); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/resources/views/layouts/error.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Oops! 6 | 7 | 8 | 9 | 53 | 54 | 55 | 56 |
57 |
58 |
59 | @yield('content') 60 |
61 |
62 |
63 | 64 | 65 | -------------------------------------------------------------------------------- /src/Actions/CreateWebhooks.php: -------------------------------------------------------------------------------- 1 | shopQuery->getById($shopId); 21 | $apiHelper = $shop->apiHelper(); 22 | $webhooks = $apiHelper->getWebhooks(); 23 | 24 | $created = []; 25 | $deleted = []; 26 | $used = []; 27 | 28 | foreach ($configWebhooks as $webhook) { 29 | if (! $this->checkExists($webhook, $webhooks)) { 30 | $apiHelper->createWebhook($webhook); 31 | $created[] = $webhook; 32 | } 33 | 34 | $used[] = $webhook['address']; 35 | } 36 | 37 | foreach (data_get($webhooks, 'data.webhookSubscriptions.container.edges', []) as $webhook) { 38 | if (! in_array(data_get($webhook, 'node.endpoint.callbackUrl'), $used)) { 39 | $apiHelper->deleteWebhook(data_get($webhook, 'node.id')); 40 | $deleted[] = $webhook; 41 | } 42 | } 43 | 44 | return [ 45 | 'created' => $created, 46 | 'deleted' => $deleted, 47 | ]; 48 | } 49 | 50 | private function checkExists(array $webhook, ResponseAccess $webhooks) 51 | { 52 | foreach (data_get($webhooks, 'data.webhookSubscriptions.container.edges', []) as $shopWebhook) { 53 | if (data_get($shopWebhook, 'node.endpoint.callbackUrl') === $webhook['address']) { 54 | return true; 55 | } 56 | } 57 | 58 | return false; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Http/Middleware/Billable.php: -------------------------------------------------------------------------------- 1 | ajax()) { 35 | return $next($request); 36 | } 37 | 38 | /** @var $shop IShopModel */ 39 | $shop = auth()->user(); 40 | 41 | // if shop has plan or is on freemium or is grandfathered then move on with request 42 | if (! $shop || $shop->plan || $shop->isFreemium() || $shop->isGrandfathered()) { 43 | return $next($request); 44 | } 45 | 46 | $args = [ 47 | Util::getShopifyConfig('route_names.billing'), 48 | array_merge($request->input(), [ 49 | 'shop' => $shop->getDomain()->toNative(), 50 | 'host' => $request->get('host'), 51 | 'locale' => $request->get('locale'), 52 | ]), 53 | ]; 54 | 55 | if ($request->ajax()) { 56 | return response()->json( 57 | ['forceRedirectUrl' => route(...$args)], 58 | 402 59 | ); 60 | } 61 | 62 | return Redirect::route(...$args); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/Storage/Queries/Charge.php: -------------------------------------------------------------------------------- 1 | chargeModel = new $chargeClass(); 31 | } 32 | 33 | 34 | /** 35 | * {@inheritdoc} 36 | */ 37 | public function getById(ChargeId $chargeId, array $with = []): ?ChargeModel 38 | { 39 | return $this->chargeModel->with($with) 40 | ->where('id', $chargeId->toNative()) 41 | ->get() 42 | ->first(); 43 | } 44 | 45 | /** 46 | * {@inheritdoc} 47 | */ 48 | public function getByReference(ChargeReference $chargeRef, array $with = []): ?ChargeModel 49 | { 50 | return $this->chargeModel->with($with) 51 | ->where('charge_id', $chargeRef->toNative()) 52 | ->withTrashed() 53 | ->get() 54 | ->first(); 55 | } 56 | 57 | /** 58 | * {@inheritdoc} 59 | */ 60 | public function getByReferenceAndShopId(ChargeReference $chargeRef, ShopId $shopId): ?ChargeModel 61 | { 62 | return $this->chargeModel->query() 63 | ->where('charge_id', $chargeRef->toNative()) 64 | ->where(Util::getShopsTableForeignKey(), $shopId->toNative()) 65 | ->get() 66 | ->first(); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/Objects/Transfers/AbstractTransfer.php: -------------------------------------------------------------------------------- 1 | shopQuery = $shopQuery; 35 | } 36 | 37 | /** 38 | * Set frame-ancestors header 39 | * 40 | * @param Request $request The request object. 41 | * @param \Closure $next The next action. 42 | * 43 | * @return mixed 44 | */ 45 | public function handle(Request $request, Closure $next) 46 | { 47 | $response = $next($request); 48 | $ancestors = Util::getShopifyConfig('iframe_ancestors'); 49 | 50 | $shop = Cache::remember( 51 | 'frame-ancestors_'.$request->get('shop'), 52 | now()->addMinutes(20), 53 | function () use ($request) { 54 | return $this->shopQuery->getByDomain(ShopDomain::fromRequest($request)); 55 | } 56 | ); 57 | 58 | $domain = $shop 59 | ? $shop->name 60 | : '*.myshopify.com'; 61 | 62 | $iframeAncestors = "frame-ancestors https://$domain https://admin.shopify.com"; 63 | 64 | if (!blank($ancestors)) { 65 | $iframeAncestors .= ' '.$ancestors; 66 | } 67 | 68 | $response->headers->set( 69 | 'Content-Security-Policy', 70 | $iframeAncestors 71 | ); 72 | 73 | return $response; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/resources/routes/api.php: -------------------------------------------------------------------------------- 1 | Util::getShopifyConfig('domain'), 18 | 'middleware' => ['api'], 19 | ], function () use ($manualRoutes) { 20 | /* 21 | |-------------------------------------------------------------------------- 22 | | API Routes 23 | |-------------------------------------------------------------------------- 24 | | 25 | | Exposes endpoints for the current user data, and all plans. 26 | | 27 | */ 28 | 29 | if (Util::registerPackageRoute('api', $manualRoutes)) { 30 | Route::group(['prefix' => 'api', 'middleware' => ['verify.shopify']], function () { 31 | Route::get( 32 | '/', 33 | ApiController::class.'@index' 34 | ); 35 | 36 | Route::get( 37 | '/me', 38 | ApiController::class.'@getSelf' 39 | ); 40 | 41 | Route::get( 42 | '/plans', 43 | ApiController::class.'@getPlans' 44 | ); 45 | }); 46 | } 47 | 48 | /* 49 | |-------------------------------------------------------------------------- 50 | | Webhook Handler 51 | |-------------------------------------------------------------------------- 52 | | 53 | | Handles incoming webhooks. 54 | | 55 | */ 56 | 57 | if (Util::registerPackageRoute('webhook', $manualRoutes)) { 58 | Route::post( 59 | '/webhook/{type}', 60 | WebhookController::class.'@handle' 61 | ) 62 | ->middleware('auth.webhook') 63 | ->name(Util::getShopifyConfig('route_names.webhook')); 64 | } 65 | }); 66 | -------------------------------------------------------------------------------- /src/resources/database/migrations/2020_01_29_230905_create_shops_table.php: -------------------------------------------------------------------------------- 1 | boolean('shopify_grandfathered')->default(false); 19 | $table->string('shopify_namespace')->nullable(true)->default(null); 20 | $table->boolean('shopify_freemium')->default(false); 21 | $table->integer('plan_id')->unsigned()->nullable(); 22 | 23 | if (! Schema::hasColumn(Util::getShopsTable(), 'deleted_at')) { 24 | $table->softDeletes(); 25 | } 26 | 27 | if (! Schema::hasColumn(Util::getShopsTable(), 'name')) { 28 | $table->string('name')->nullable(); 29 | } 30 | 31 | if (! Schema::hasColumn(Util::getShopsTable(), 'email')) { 32 | $table->string('email')->nullable(); 33 | } 34 | 35 | if (! Schema::hasColumn(Util::getShopsTable(), 'password')) { 36 | $table->string('password', 100)->nullable(); 37 | } 38 | 39 | $table->foreign('plan_id')->references('id')->on(Util::getShopifyConfig('table_names.plans', 'plans')); 40 | }); 41 | } 42 | 43 | /** 44 | * Reverse the migrations. 45 | * 46 | * @return void 47 | */ 48 | public function down(): void 49 | { 50 | Schema::table(Util::getShopsTable(), function (Blueprint $table) { 51 | $table->dropForeign(Util::getShopsTable().'_plan_id_foreign'); 52 | $table->dropColumn([ 53 | 'name', 54 | 'email', 55 | 'password', 56 | 'shopify_grandfathered', 57 | 'shopify_namespace', 58 | 'shopify_freemium', 59 | 'plan_id', 60 | ]); 61 | 62 | $table->dropSoftDeletes(); 63 | }); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/Actions/ActivateUsageCharge.php: -------------------------------------------------------------------------------- 1 | shopQuery->getById($shopId); 33 | // Ensure we have a recurring charge 34 | $currentCharge = $this->chargeHelper->chargeForPlan($shop->plan->getId(), $shop); 35 | 36 | if (! $currentCharge->isType(ChargeType::RECURRING())) { 37 | throw new ChargeNotRecurringException('Can only create usage charges for recurring charge.'); 38 | } 39 | 40 | // Create the usage charge 41 | $ucd->chargeReference = $currentCharge->getReference(); 42 | $response = $shop->apiHelper()->createUsageCharge($ucd); 43 | 44 | if (! $response) { 45 | // Could not make usage charge, limit possibly reached 46 | return false; 47 | } 48 | 49 | $transfer = new UsageChargeTransfer(); 50 | $transfer->shopId = $shopId; 51 | $transfer->planId = $shop->plan->getId(); 52 | $transfer->chargeReference = ChargeReference::fromNative((int) $response['id']); 53 | $transfer->details = $ucd; 54 | 55 | return $this->chargeCommand->makeUsage($transfer); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Objects/Values/MainTheme.php: -------------------------------------------------------------------------------- 1 | id = $id; 51 | $this->name = $name; 52 | $this->role = $role; 53 | } 54 | 55 | /** 56 | * {@inheritDoc} 57 | */ 58 | public static function fromNative($native) 59 | { 60 | return new static( 61 | NullableThemeId::fromNative(Arr::get($native, 'id')), 62 | NullableThemeName::fromNative(Arr::get($native, 'name')), 63 | NullableThemeRole::fromNative(Arr::get($native, 'role')) 64 | ); 65 | } 66 | 67 | /** 68 | * Get theme id 69 | * 70 | * @return ThemeId 71 | */ 72 | public function getId() 73 | { 74 | return $this->id; 75 | } 76 | 77 | /** 78 | * Get theme name 79 | * 80 | * @return ThemeName 81 | */ 82 | public function getName() 83 | { 84 | return $this->name; 85 | } 86 | 87 | /** 88 | * Get theme role 89 | * 90 | * @return ThemeRole 91 | */ 92 | public function getRole() 93 | { 94 | return $this->role; 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/Http/Requests/StoreUsageCharge.php: -------------------------------------------------------------------------------- 1 | after(function (Validator $validator) { 36 | // Get the input values needed 37 | $data = [ 38 | 'price' => $this->request->get('price'), 39 | 'description' => $this->request->get('description'), 40 | 'signature' => $this->request->get('signature'), 41 | ]; 42 | if ($this->request->has('redirect')) { 43 | $data['redirect'] = $this->request->get('redirect'); 44 | } 45 | 46 | $signature = Hmac::fromNative($data['signature']); 47 | unset($data['signature']); 48 | 49 | // Confirm the charge hasn't been tampered with 50 | $signatureLocal = Util::createHmac( 51 | [ 52 | 'data' => $data, 53 | 'buildQuery' => true, 54 | ], 55 | Util::getShopifyConfig('api_secret') 56 | ); 57 | if (! $signature->isSame($signatureLocal)) { 58 | // Possible tampering 59 | $validator->errors()->add('signature', 'Signature does not match.'); 60 | } 61 | }); 62 | } 63 | 64 | /** 65 | * Get the validation rules that apply to the request. 66 | * 67 | * @return array 68 | */ 69 | public function rules(): array 70 | { 71 | return [ 72 | 'signature' => 'required|string', 73 | 'price' => 'required|numeric', 74 | 'description' => 'required|string', 75 | 'redirect' => 'nullable|string', 76 | ]; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "kyon147/laravel-shopify", 3 | "description": "Shopify package for Laravel to aide in app development", 4 | "keywords": [ 5 | "api", 6 | "callback-url", 7 | "driver", 8 | "facade", 9 | "laravel", 10 | "laravel-package", 11 | "laravel-shopify", 12 | "scripttags", 13 | "sdk", 14 | "shopify", 15 | "shopify-api", 16 | "shopify-app", 17 | "webhook" 18 | ], 19 | "license": "MIT", 20 | "authors": [ 21 | { 22 | "name": "Luke (Kyon147)", 23 | "email": "support@appydesign.co.uk" 24 | } 25 | ], 26 | "require": { 27 | "php": "^8.0 || ^8.1 || ^8.2 || ^8.3 || ^8.4", 28 | "ext-json": "*", 29 | "funeralzone/valueobjects": "^0.5", 30 | "jenssegers/agent": "^2.6", 31 | "laravel/framework": "^8.0 || ^9.0 || ^10.0 || ^11.0 || ^12.0", 32 | "gnikyt/basic-shopify-api": "^9.0 || ^10.0 || ^11.0" 33 | }, 34 | "require-dev": { 35 | "laravel/legacy-factories": "^v1.3.0", 36 | "ergebnis/composer-normalize": "^2.8", 37 | "friendsofphp/php-cs-fixer": "^3.0", 38 | "mockery/mockery": "^1.0", 39 | "orchestra/database": "~3.8 || ~4.0 || ~5.0 || ~6.0 || ~7.0 || ~8.0 || ~9.0", 40 | "orchestra/testbench": "~3.8 || ~4.0 || ~5.0 || ~6.0 || ~7.0 || ~8.0 || ~9.0", 41 | "phpstan/phpstan": "^0.12", 42 | "phpunit/phpunit": "~8.0 || ^9.0 || ^10.0 || ^11.0" 43 | }, 44 | "config": { 45 | "sort-packages": true, 46 | "allow-plugins": { 47 | "ergebnis/composer-normalize": true 48 | } 49 | }, 50 | "extra": { 51 | "laravel": { 52 | "providers": [ 53 | "Osiset\\ShopifyApp\\ShopifyAppProvider" 54 | ] 55 | } 56 | }, 57 | "autoload": { 58 | "psr-4": { 59 | "Osiset\\ShopifyApp\\": "src/" 60 | } 61 | }, 62 | "autoload-dev": { 63 | "psr-4": { 64 | "Osiset\\ShopifyApp\\Test\\": "tests/" 65 | } 66 | }, 67 | "minimum-stability": "dev", 68 | "prefer-stable": true, 69 | "scripts": { 70 | "lint": "vendor/bin/php-cs-fixer fix", 71 | "test": "vendor/bin/phpunit", 72 | "test-html-cov": "vendor/bin/phpunit --coverage-html ./build/html/", 73 | "test-no-cov": "vendor/bin/phpunit --no-coverage" 74 | }, 75 | "support": { 76 | "issues": "https://github.com/Kyon147/laravel-shopify/issues", 77 | "forum": "https://github.com/Kyon147/laravel-shopify/discussions", 78 | "wiki": "https://github.com/Kyon147/laravel-shopify/wiki", 79 | "source": "https://github.com/Kyon147/laravel-shopify" 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/resources/views/auth/token.blade.php: -------------------------------------------------------------------------------- 1 | @extends('shopify-app::layouts.default') 2 | 3 | @section('styles') 4 | @include('shopify-app::partials.polaris_skeleton_css') 5 | @endsection 6 | 7 | @section('content') 8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 | @endsection 34 | 35 | @section('scripts') 36 | @parent 37 | 64 | @endsection 65 | -------------------------------------------------------------------------------- /src/Console/WebhookJobMakeCommand.php: -------------------------------------------------------------------------------- 1 | argument('topic')); 58 | $type = $this->getUrlFromName($this->argument('name')); 59 | $address = route(Util::getShopifyConfig('route_names.webhook'), $type); 60 | 61 | // Remind user to enter job into config 62 | $this->info("For non-GDPR webhooks, don't forget to register the webhook in config/shopify-app.php. Example:"); 63 | $this->info(" 64 | 'webhooks' => [ 65 | [ 66 | 'topic' => '$topic', 67 | 'address' => '$address' 68 | ] 69 | ] 70 | "); 71 | 72 | return $result; 73 | } 74 | 75 | /** 76 | * Append "Job" to the end of class name. 77 | * 78 | * @return string 79 | */ 80 | protected function getNameInput(): string 81 | { 82 | return Str::finish(parent::getNameInput(), 'Job'); 83 | } 84 | 85 | /** 86 | * Converts the job class name into a URL endpoint. 87 | * 88 | * @param string $name The name of the job 89 | * 90 | * @return string 91 | */ 92 | protected function getUrlFromName(string $name): string 93 | { 94 | return Str::of($name) 95 | ->trim() 96 | ->replaceMatches('/Job$/', '') 97 | ->replaceMatches('/(?lower(); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/Http/Middleware/AuthProxy.php: -------------------------------------------------------------------------------- 1 | auth = $auth; 46 | $this->shopQuery = $shopQuery; 47 | } 48 | 49 | /** 50 | * Handle an incoming request to ensure it is valid. 51 | * 52 | * @param Request $request The request object. 53 | * @param Closure $next The next action. 54 | * 55 | * @return mixed 56 | */ 57 | public function handle(Request $request, Closure $next) 58 | { 59 | // Grab the query parameters we need 60 | $query = Util::parseQueryString($request->server->get('QUERY_STRING')); 61 | $signature = Arr::get($query, 'signature', ''); 62 | $shop = NullableShopDomain::fromNative(Arr::get($query, 'shop')); 63 | 64 | if (! empty($signature)) { 65 | // Remove signature since its not part of the signature calculation 66 | Arr::forget($query, 'signature'); 67 | } 68 | 69 | // Build a local signature 70 | $signatureLocal = Util::createHmac( 71 | [ 72 | 'data' => $query, 73 | 'buildQuery' => true, 74 | ], 75 | Util::getShopifyConfig('api_secret', $shop) 76 | ); 77 | if ($shop->isNull() || ! Hmac::fromNative($signature)->isSame($signatureLocal)) { 78 | // Issue with HMAC or missing shop header 79 | return Response::make('Invalid proxy signature.', HttpResponse::HTTP_UNAUTHORIZED); 80 | } 81 | 82 | // Login the shop 83 | $shop = $this->shopQuery->getByDomain($shop); 84 | if ($shop) { 85 | // Override auth guard 86 | if (($guard = Util::getShopifyConfig('shop_auth_guard'))) { 87 | $this->auth->setDefaultDriver($guard); 88 | } 89 | 90 | $this->auth->login($shop); 91 | } 92 | 93 | // All good, process proxy request 94 | return $next($request); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/Contracts/ShopModel.php: -------------------------------------------------------------------------------- 1 | shopQuery->getByDomain($shopDomain, [], true); 28 | 29 | if ($shop === null) { 30 | $this->shopCommand->make($shopDomain, NullAccessToken::fromNative(null)); 31 | $shop = $this->shopQuery->getByDomain($shopDomain); 32 | } 33 | 34 | $apiHelper = $shop->apiHelper(); 35 | $grantMode = $shop->hasOfflineAccess() ? 36 | AuthMode::fromNative(Util::getShopifyConfig('api_grant_mode', $shop)) : 37 | AuthMode::OFFLINE(); 38 | 39 | if (empty($code) && empty($idToken)) { 40 | return [ 41 | 'completed' => false, 42 | 'url' => $apiHelper->buildAuthUrl($grantMode, Util::getShopifyConfig('api_scopes', $shop)), 43 | 'shop_id' => $shop->getId(), 44 | ]; 45 | } 46 | 47 | try { 48 | if ($shop->trashed()) { 49 | $shop->restore(); 50 | } 51 | 52 | // Get the data and set the access token 53 | $data = $idToken !== null ? $apiHelper->performOfflineTokenExchange($idToken) : $apiHelper->getAccessData($code); 54 | $this->shopCommand->setAccessToken($shop->getId(), AccessToken::fromNative($data['access_token'])); 55 | 56 | try { 57 | $themeSupportLevel = call_user_func($this->verifyThemeSupport, $shop->getId()); 58 | $this->shopCommand->setThemeSupportLevel($shop->getId(), ThemeSupportLevel::fromNative($themeSupportLevel)); 59 | } catch (Exception $e) { 60 | $themeSupportLevel = ThemeSupportLevelEnum::NONE; 61 | } 62 | 63 | 64 | return [ 65 | 'completed' => true, 66 | 'url' => null, 67 | 'shop_id' => $shop->getId(), 68 | 'theme_support_level' => $themeSupportLevel, 69 | ]; 70 | } catch (Exception $e) { 71 | return [ 72 | 'completed' => false, 73 | 'url' => null, 74 | 'shop_id' => null, 75 | 'theme_support_level' => null, 76 | ]; 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/Messaging/Jobs/AppUninstalledJob.php: -------------------------------------------------------------------------------- 1 | domain = $domain; 53 | $this->data = $data; 54 | } 55 | 56 | /** 57 | * Execute the job. 58 | * 59 | * @param IShopCommand $shopCommand The commands for shops. 60 | * @param IShopQuery $shopQuery The querier for shops. 61 | * @param CancelCurrentPlan $cancelCurrentPlanAction The action for cancelling the current plan. 62 | * 63 | * @return bool 64 | */ 65 | public function handle( 66 | IShopCommand $shopCommand, 67 | IShopQuery $shopQuery, 68 | CancelCurrentPlan $cancelCurrentPlanAction 69 | ): bool { 70 | // Convert the domain 71 | $this->domain = ShopDomain::fromNative($this->domain); 72 | 73 | // Get the shop 74 | $shop = $shopQuery->getByDomain($this->domain); 75 | if (!$shop) { 76 | return true; 77 | } 78 | $shopId = $shop->getId(); 79 | 80 | // Cancel the current plan 81 | $cancelCurrentPlanAction($shopId); 82 | 83 | // Purge shop of token, plan, etc. 84 | $shopCommand->clean($shopId); 85 | 86 | // Check freemium mode 87 | if (Util::getShopifyConfig('billing_freemium_enabled') === true) { 88 | // Add the freemium flag to the shop 89 | $shopCommand->setAsFreemium($shopId); 90 | } 91 | 92 | // Soft delete the shop. 93 | $shopCommand->softDelete($shopId); 94 | 95 | event(new AppUninstalledEvent($shop)); 96 | 97 | return true; 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/Http/Middleware/VerifyScopes.php: -------------------------------------------------------------------------------- 1 | user(); 24 | 25 | if ($shop) { 26 | $response = $this->currentScopes($shop); 27 | 28 | if ($response['hasErrors']) { 29 | return $next($request); 30 | } 31 | 32 | $hasMissingScopes = filled( 33 | array_diff( 34 | explode(',', config('shopify-app.api_scopes')), 35 | $response['result'] 36 | ) 37 | ); 38 | 39 | if ($hasMissingScopes) { 40 | Cache::forget($this->cacheKey($shop->getDomain()->toNative())); 41 | 42 | return Redirect::route(Util::getShopifyConfig('route_names.authenticate'), [ 43 | 'shop' => $shop->getDomain()->toNative(), 44 | 'host' => $request->get('host'), 45 | 'locale' => $request->get('locale'), 46 | ]); 47 | } 48 | } 49 | 50 | return $next($request); 51 | } 52 | 53 | /** 54 | * @return array{hasErrors: bool, result: string[]} 55 | */ 56 | private function currentScopes(ShopModel $shop): array 57 | { 58 | /** @var array{errors: bool, status: int, body: \Gnikyt\BasicShopifyAPI\ResponseAccess} */ 59 | $response = Cache::remember( 60 | $this->cacheKey($shop->getDomain()->toNative()), 61 | now()->addDay(), 62 | fn () => $shop->api()->graph('{ 63 | currentAppInstallation { 64 | accessScopes { 65 | handle 66 | } 67 | } 68 | }') 69 | ); 70 | 71 | if (! $response['errors'] && blank(data_get($response['body']->toArray(), 'data.currentAppInstallation.userErrors'))) { 72 | return [ 73 | 'hasErrors' => false, 74 | 'result' => array_column( 75 | data_get($response['body'], 'data.currentAppInstallation.accessScopes')->toArray(), 76 | 'handle' 77 | ), 78 | ]; 79 | } 80 | 81 | Log::error('Fetch current app installation access scopes error: '.json_encode(data_get($response['body']->toArray(), 'data.currentAppInstallation.userErrors'))); 82 | 83 | return [ 84 | 'hasErrors' => true, 85 | 'result' => [], 86 | ]; 87 | } 88 | 89 | private function cacheKey(string $shopDomain): string 90 | { 91 | return sprintf("{$shopDomain}.%s", self::CURRENT_SCOPES_CACHE_KEY); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Laravel Shopify App 2 | 3 | ![Tests](https://github.com/kyon147/laravel-shopify/workflows/Package%20Test/badge.svg?branch=master) 4 | [![codecov](https://codecov.io/gh/kyon147/laravel-shopify/branch/master/graph/badge.svg?token=qqUuLItqJj)](https://codecov.io/gh/kyon147/laravel-shopify) 5 | [![License](https://poser.pugx.org/kyon147/laravel-shopify/license)](https://packagist.org/packages/osiset/laravel-shopify) 6 | 7 | ---- 8 | 9 | This is a maintained version of the wonderful but now deprecated original [Laravel Shopify App](https://github.com/gnikyt/laravel-shopify/). To keep things clean, this has been detached from the original. 10 | 11 | ---- 12 | To install this package run: 13 | ``` 14 | composer require kyon147/laravel-shopify 15 | ``` 16 | Publish the config file: 17 | ``` 18 | php artisan vendor:publish --tag=shopify-config 19 | ``` 20 | ---- 21 | 22 | A full-featured Laravel package for aiding in Shopify App development, similar to `shopify_app` for Rails. Works for Laravel 8 and up. 23 | 24 | ![Screenshot](screenshot.png) 25 | ![Screenshot: Billable](screenshot-billable.png) 26 | 27 | ## Table of Contents 28 | 29 | __*__ *Wiki pages* 30 | 31 | - [Goals](#goals) 32 | - [Documentation](#documentation) 33 | - [Installation](../../wiki/Installation)* 34 | - [Route List](../../wiki/Route-List)* 35 | - [Usage](../../wiki/Usage)* 36 | - [Changelog](../../wiki/Changelog)* 37 | - [Contributing Guide](CONTRIBUTING.md) 38 | - [LICENSE](#license) 39 | 40 | For more information, tutorials, etc., please view the project's [wiki](../../wiki). 41 | 42 | ## Goals 43 | 44 | - [ ] Per User Auth Working 45 | - [ ] Better support for SPA apps using VueJS 46 | - [ ] Getting "Blade" templates working better with Shopify's new auth process??? 47 | 48 | ## Documentation 49 | 50 | For full resources on this package, see the [wiki](../..//wiki). 51 | 52 | ## Issue or request? 53 | 54 | If you have found a bug or would like to request a feature for discussion, please use the `ISSUE_TEMPLATE` in this repo when creating your issue. Any issue submitted without this template will be closed. 55 | 56 | ## License 57 | 58 | This project is released under the MIT [license](LICENSE). 59 | 60 | ## Misc 61 | 62 | ### Repository 63 | 64 | #### Contributors 65 | 66 | Contributions are always welcome! Contibutors are updated each release, pulled from Github. See [`CONTRIBUTORS.txt`](CONTRIBUTORS.txt). 67 | 68 | If you're looking to become a contributor, please see [`CONTRIBUTING.md`](CONTRIBUTING.md). 69 | 70 | #### Maintainers 71 | 72 | Maintainers are users who manage the repository itself, whether it's managing the issues, assisting in releases, or helping with pull requests. 73 | 74 | Currently this repository is maintained by: 75 | 76 | - [@kyon147](https://github.com/kyon147) 77 | - ~[@gnikyt](https://github.com/gnikyt)~ Original author of the package. See [announcement](https://github.com/gnikyt/laravel-shopify/discussions/1276) for details. 78 | 79 | Looking to become a maintainer? E-mail @kyon147 directly. 80 | 81 | ### Special Note 82 | 83 | I develop this package in my spare time, with a busy family/work life like many of you! So, I would like to thank everyone who's helped me out from submitting PRs, to assisting on issues, and plain using the package (I hope its useful). Cheers. 84 | -------------------------------------------------------------------------------- /src/Actions/ActivatePlan.php: -------------------------------------------------------------------------------- 1 | shopQuery->getById($shopId); 42 | $plan = $this->planQuery->getById($planId); 43 | $chargeType = ChargeType::fromNative($plan->getType()->toNative()); 44 | 45 | // Activate the plan on Shopify 46 | $response = $shop->apiHelper()->activateCharge($chargeType, $chargeRef); 47 | // Cancel the shop's current plan 48 | call_user_func($this->cancelCurrentPlan, $shopId); 49 | // Cancel the existing charge if it exists (happens if someone refreshes during) 50 | $this->chargeCommand->delete($chargeRef, $shopId); 51 | 52 | $transfer = new ChargeTransfer(); 53 | $transfer->shopId = $shopId; 54 | $transfer->planId = $planId; 55 | $transfer->chargeReference = $chargeRef; 56 | $transfer->chargeType = $chargeType; 57 | $transfer->chargeStatus = ChargeStatus::fromNative(strtoupper($response['status'])); 58 | $transfer->planDetails = $this->chargeHelper->details($plan, $shop, $host); 59 | 60 | if ($plan->isType(PlanType::RECURRING())) { 61 | $transfer->activatedOn = new Carbon($response['activated_on']); 62 | $transfer->billingOn = new Carbon($response['billing_on']); 63 | $transfer->trialEndsOn = new Carbon($response['trial_ends_on']); 64 | } else { 65 | $transfer->activatedOn = Carbon::today(); 66 | $transfer->billingOn = null; 67 | $transfer->trialEndsOn = null; 68 | } 69 | 70 | $charge = $this->chargeCommand->make($transfer); 71 | $this->shopCommand->setToPlan($shopId, $planId); 72 | 73 | event(new PlanActivatedEvent($shop, $plan, $charge)); 74 | 75 | return $charge; 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/Contracts/Commands/Shop.php: -------------------------------------------------------------------------------- 1 | envPath(); 41 | 42 | if (file_exists($env)) { 43 | foreach ($this->shopifyVariables() as $key => $variable) { 44 | if (Str::contains(file_get_contents($env), $key) === false) { 45 | file_put_contents($env, PHP_EOL."$key=$variable", FILE_APPEND); 46 | } else { 47 | if ($this->option('always-no')) { 48 | $this->comment("Variable $key already exists. Skipping..."); 49 | 50 | continue; 51 | } 52 | 53 | if ($this->isConfirmed($key) === false) { 54 | $this->comment('There has been no change.'); 55 | 56 | continue; 57 | } 58 | } 59 | } 60 | } 61 | 62 | $this->successResult(); 63 | } 64 | 65 | /** 66 | * Display result. 67 | * 68 | * @return void 69 | */ 70 | protected function successResult(): void 71 | { 72 | $this->info('All variables will be set'); 73 | } 74 | 75 | /** 76 | * Check if the modification is confirmed. 77 | * 78 | * @param string $key 79 | * 80 | * @return bool 81 | */ 82 | protected function isConfirmed(string $key): bool 83 | { 84 | return $this->option('force') 85 | ? true 86 | : $this->confirm( 87 | "This will invalidate {$key} variable. Are you sure you want to override {$key}?" 88 | ); 89 | } 90 | 91 | /** 92 | * Get shopify env variables 93 | * 94 | * @return array 95 | */ 96 | public function shopifyVariables(): array 97 | { 98 | return [ 99 | 'SHOPIFY_APP_NAME' => config('app.name'), 100 | 'SHOPIFY_API_KEY' => '', 101 | 'SHOPIFY_API_SECRET' => '', 102 | 'SHOPIFY_API_SCOPES' => config('shopify-app.api_scopes'), 103 | 'AFTER_AUTHENTICATE_JOB' => "\App\Jobs\AfterAuthenticateJob", 104 | ]; 105 | } 106 | 107 | /** 108 | * Get the .env file path. 109 | * 110 | * @return string 111 | */ 112 | protected function envPath() 113 | { 114 | $enviromemtFile = $this->laravel->environmentFile(); 115 | $baseEnvFile = $this->laravel->basePath('.env'); 116 | 117 | if (file_exists($enviromemtFile) && method_exists($this->laravel, 'environmentFile')) { 118 | return $enviromemtFile; 119 | } 120 | 121 | return $baseEnvFile; 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/Storage/Models/Plan.php: -------------------------------------------------------------------------------- 1 | 'bool', 24 | 'on_install' => 'bool', 25 | 'capped_amount' => 'float', 26 | 'price' => 'float', 27 | ]; 28 | 29 | /** 30 | * Get table name. 31 | * 32 | * @return string 33 | */ 34 | public function getTable(): string 35 | { 36 | return Util::getShopifyConfig('table_names.plans', parent::getTable()); 37 | } 38 | 39 | /** 40 | * Get the plan ID as a value object. 41 | * 42 | * @return PlanId 43 | */ 44 | public function getId(): PlanId 45 | { 46 | return PlanId::fromNative((int) $this->id); 47 | } 48 | 49 | /** 50 | * Get charges. 51 | * 52 | * @return HasMany 53 | */ 54 | public function charges(): HasMany 55 | { 56 | return $this->hasMany(Charge::class); 57 | } 58 | 59 | /** 60 | * Gets the type of plan. 61 | * 62 | * @return PlanType 63 | */ 64 | public function getType(): PlanType 65 | { 66 | return PlanType::fromNative($this->type); 67 | } 68 | 69 | /** 70 | * Gets the interval of plan. 71 | * 72 | * @return PlanInterval 73 | */ 74 | public function getInterval(): PlanInterval 75 | { 76 | return $this->interval ? PlanInterval::fromNative($this->interval) : PlanInterval::EVERY_30_DAYS(); 77 | } 78 | 79 | /** 80 | * Checks the plan type. 81 | * 82 | * @param PlanType $type The plan type. 83 | * 84 | * @return bool 85 | */ 86 | public function isType(PlanType $type): bool 87 | { 88 | return $this->getType()->isSame($type); 89 | } 90 | 91 | /** 92 | * Returns the plan type as a string (for API). 93 | * 94 | * @param bool $plural Return the plural form or not. 95 | * 96 | * @return string 97 | */ 98 | public function getTypeApiString($plural = false): string 99 | { 100 | $types = [ 101 | PlanType::ONETIME()->toNative() => 'application_charge', 102 | PlanType::RECURRING()->toNative() => 'recurring_application_charge', 103 | ]; 104 | $type = $types[$this->getType()->toNative()]; 105 | 106 | return $plural ? "{$type}s" : $type; 107 | } 108 | 109 | /** 110 | * Checks if this plan has a trial. 111 | * 112 | * @return bool 113 | */ 114 | public function hasTrial(): bool 115 | { 116 | return $this->trial_days !== null && $this->trial_days > 0; 117 | } 118 | 119 | /** 120 | * Checks if this plan should be presented on install. 121 | * 122 | * @return bool 123 | */ 124 | public function isOnInstall(): bool 125 | { 126 | return (bool) $this->on_install; 127 | } 128 | 129 | /** 130 | * Checks if the plan is a test. 131 | * 132 | * @return bool 133 | */ 134 | public function isTest(): bool 135 | { 136 | return (bool) $this->test; 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /src/Services/CookieHelper.php: -------------------------------------------------------------------------------- 1 | agent = new Agent(); 28 | } 29 | 30 | /** 31 | * Sets the cookie policy. 32 | * 33 | * From Chrome 80+ there is a new requirement that the SameSite 34 | * cookie flag be set to `none` and the cookies be marked with 35 | * `secure`. 36 | * 37 | * Reference: https://www.chromium.org/updates/same-site/incompatible-clients 38 | * 39 | * Enables SameSite none and Secure cookies on: 40 | * 41 | * - Chrome v67+ 42 | * - Safari on OSX 10.14+ 43 | * - iOS 13+ 44 | * - UCBrowser 12.13+ 45 | * 46 | * @return void 47 | */ 48 | public function setCookiePolicy(): void 49 | { 50 | Config::set('session.expire_on_close', true); 51 | 52 | if ($this->checkSameSiteNoneCompatible()) { 53 | Config::set('session.secure', true); 54 | Config::set('session.same_site', 'none'); 55 | } 56 | } 57 | 58 | /** 59 | * Checks to see if the current browser session should be 60 | * using the SameSite=none cookie policy. 61 | * 62 | * @return bool 63 | */ 64 | public function checkSameSiteNoneCompatible(): bool 65 | { 66 | $compatible = false; 67 | $browser = $this->getBrowserDetails(); 68 | $platform = $this->getPlatformDetails(); 69 | 70 | if ($browser['major'] >= 67 && $this->agent->is('Chrome')) { 71 | $compatible = true; 72 | } 73 | 74 | if ($platform['major'] > 12 && $this->agent->is('iOS')) { 75 | $compatible = true; 76 | } 77 | 78 | if ($platform['float'] > 10.14 && 79 | $this->agent->is('OS X') && $this->agent->is('Safari') && ! $this->agent->is('iOS') 80 | ) { 81 | $compatible = true; 82 | } 83 | 84 | if ($browser['float'] > 12.13 && $this->agent->is('UCBrowser')) { 85 | $compatible = true; 86 | } 87 | 88 | return $compatible; 89 | } 90 | 91 | /** 92 | * Returns details about the current web browser. 93 | * 94 | * @return array 95 | */ 96 | public function getBrowserDetails(): array 97 | { 98 | return $this->version($this->agent->browser()); 99 | } 100 | 101 | /** 102 | * Returns details about the current operating system. 103 | * 104 | * @return array 105 | */ 106 | public function getPlatformDetails(): array 107 | { 108 | return $this->version($this->agent->platform()); 109 | } 110 | 111 | /** 112 | * Create a versioned array from a source. 113 | * 114 | * @param string $source The source string to version. 115 | * 116 | * @return array 117 | */ 118 | protected function version(string $source): array 119 | { 120 | $version = $this->agent->version($source); 121 | $pieces = explode('.', str_replace('_', '.', $version)); 122 | 123 | return [ 124 | 'major' => $pieces[0] ?? null, 125 | 'minor' => $pieces[1] ?? null, 126 | 'float' => isset($pieces[0]) && isset($pieces[1]) ? (float) sprintf('%s.%s', $pieces[0], $pieces[1]) : null, 127 | ]; 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /src/Objects/Values/SessionContext.php: -------------------------------------------------------------------------------- 1 | sessionToken = $sessionToken; 56 | $this->sessionId = $sessionId; 57 | $this->accessToken = $accessToken; 58 | } 59 | 60 | /** 61 | * Get the session token. 62 | * 63 | * @return SessionTokenValue 64 | */ 65 | public function getSessionToken(): SessionTokenValue 66 | { 67 | return $this->sessionToken; 68 | } 69 | 70 | /** 71 | * Get the access token. 72 | * 73 | * @return AccessTokenValue 74 | */ 75 | public function getAccessToken(): AccessTokenValue 76 | { 77 | return $this->accessToken; 78 | } 79 | 80 | /** 81 | * Get the session ID. 82 | * 83 | * @return SessionIdValue 84 | */ 85 | public function getSessionId(): SessionIdValue 86 | { 87 | return $this->sessionId; 88 | } 89 | 90 | /** 91 | * {@inheritDoc} 92 | */ 93 | public static function fromNative($native) 94 | { 95 | return new static( 96 | NullableSessionToken::fromNative(Arr::get($native, 'session_token')), 97 | NullableSessionId::fromNative(Arr::get($native, 'session_id')), 98 | NullableAccessToken::fromNative(Arr::get($native, 'access_token')) 99 | ); 100 | } 101 | 102 | /** 103 | * Confirm session is valid. 104 | * TODO: Add per-user support. 105 | * 106 | * @param SessionContext|null $previousContext The last session context (if available). 107 | * 108 | * @return bool 109 | */ 110 | public function isValid(?self $previousContext = null): bool 111 | { 112 | // Confirm access token and session token are good 113 | $tokenCheck = ! $this->getAccessToken()->isEmpty() && ! $this->getSessionToken()->isNull(); 114 | 115 | // Compare data 116 | $sidCheck = true; 117 | $domainCheck = true; 118 | if ($previousContext !== null) { 119 | /** @var $previousToken SessionToken */ 120 | $previousToken = $previousContext->getSessionToken(); 121 | /** @var $currentToken SessionToken */ 122 | $currentToken = $this->getSessionToken(); 123 | 124 | // Compare the domains 125 | $domainCheck = $previousToken->getShopDomain()->isSame($currentToken->getShopDomain()); 126 | 127 | // Compare the session IDs 128 | if (! $previousContext->getSessionId()->isNull() && ! $this->getSessionId()->isNull()) { 129 | $sidCheck = $previousContext->getSessionId()->isSame($this->getSessionId()); 130 | } 131 | } 132 | 133 | return $tokenCheck && $sidCheck && $domainCheck; 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/resources/database/migrations/2020_01_29_231006_create_charges_table.php: -------------------------------------------------------------------------------- 1 | increments('id'); 21 | 22 | // Filled in when the charge is created, provided by shopify, unique makes it indexed 23 | $table->bigInteger('charge_id'); 24 | 25 | // Test mode or real 26 | $table->boolean('test')->default(false); 27 | 28 | $table->string('status')->nullable(); 29 | 30 | // Name of the charge (for recurring or one time charges) 31 | $table->string('name')->nullable(); 32 | 33 | // Terms for the usage charges 34 | $table->string('terms')->nullable(); 35 | 36 | // Integer value representing a recurring, one time, usage, or application_credit. 37 | // This also allows us to store usage based charges not just subscription or one time charges. 38 | // We will be able to do things like create a charge history for a shop if they have multiple charges. 39 | // For instance, usage based or an app that has multiple purchases. 40 | $table->string('type'); 41 | 42 | // Store the amount of the charge, this helps if you are experimenting with pricing 43 | $table->decimal('price', 8, 2); 44 | 45 | // Store the amount of the charge, this helps if you are experimenting with pricing 46 | $table->decimal('capped_amount', 8, 2)->nullable(); 47 | 48 | // Nullable in case of 0 trial days 49 | $table->integer('trial_days')->nullable(); 50 | 51 | // The recurring application charge must be accepted or the returned value is null 52 | $table->timestamp('billing_on')->nullable(); 53 | 54 | // When activation happened 55 | $table->timestamp('activated_on')->nullable(); 56 | 57 | // Date the trial period ends 58 | $table->timestamp('trial_ends_on')->nullable(); 59 | 60 | // Not supported on Shopify initial billing screen, but good for future use 61 | $table->timestamp('cancelled_on')->nullable(); 62 | 63 | // Expires on 64 | $table->timestamp('expires_on')->nullable(); 65 | 66 | // Plan ID for the charge 67 | $table->integer('plan_id')->unsigned()->nullable(); 68 | 69 | // Description support 70 | $table->string('description')->nullable(); 71 | 72 | // Linking to charge_id 73 | $table->bigInteger('reference_charge')->nullable(); 74 | 75 | // Provides created_at && updated_at columns 76 | $table->timestamps(); 77 | 78 | // Allows for soft deleting 79 | $table->softDeletes(); 80 | 81 | if ($this->getLaravelVersion() < 5.8) { 82 | $table->integer(Util::getShopsTableForeignKey())->unsigned(); 83 | } else { 84 | $table->bigInteger(Util::getShopsTableForeignKey())->unsigned(); 85 | } 86 | 87 | // Linking 88 | $table->foreign(Util::getShopsTableForeignKey())->references('id')->on(Util::getShopsTable())->onDelete('cascade'); 89 | $table->foreign('plan_id')->references('id')->on(Util::getShopifyConfig('table_names.plans', 'plans')); 90 | }); 91 | } 92 | 93 | /** 94 | * Reverse the migrations. 95 | * 96 | * @return void 97 | */ 98 | public function down() 99 | { 100 | Schema::drop(Util::getShopifyConfig('table_names.charges', 'charges')); 101 | } 102 | 103 | /** 104 | * Get Laravel version. 105 | * 106 | * @return float 107 | */ 108 | private function getLaravelVersion() 109 | { 110 | $version = Application::VERSION; 111 | 112 | return (float) substr($version, 0, strrpos($version, '.')); 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/Objects/Values/ShopDomain.php: -------------------------------------------------------------------------------- 1 | string = $this->sanitizeShopDomain($domain); 33 | 34 | if ($this->string === '') { 35 | throw new InvalidShopDomainException("Invalid shop domain [{$domain}]"); 36 | } 37 | } 38 | 39 | /** 40 | * Grab the shop, if present, and how it was found. 41 | * Order of precedence is:. 42 | * 43 | * - GET/POST Variable ("shop" or "shopDomain") 44 | * - Headers ("X-Shop-Domain") 45 | * - Referer ("shop" or "shopDomain" query param or decoded "token" query param) 46 | * 47 | * @param Request $request The request object. 48 | * 49 | * @return ShopDomainValue 50 | */ 51 | public static function fromRequest(Request $request): ShopDomainValue 52 | { 53 | // All possible methods 54 | $options = [ 55 | // GET/POST 56 | DataSource::INPUT()->toNative() => $request->input('shop', $request->input('shopDomain')), 57 | 58 | // Headers 59 | DataSource::HEADER()->toNative() => $request->header('X-Shop-Domain'), 60 | 61 | // Headers: Referer 62 | DataSource::REFERER()->toNative() => function () use ($request): ?string { 63 | $url = parse_url($request->header('referer', ''), PHP_URL_QUERY); 64 | if (! $url) { 65 | return null; 66 | } 67 | 68 | $params = Util::parseQueryString($url); 69 | $shop = Arr::get($params, 'shop', Arr::get($params, 'shopDomain')); 70 | if ($shop) { 71 | return $shop; 72 | } 73 | 74 | $token = Arr::get($params, 'token'); 75 | if ($token) { 76 | try { 77 | $token = new SessionToken($token, false); 78 | if ($shopDomain = $token->getShopDomain()) { 79 | return $shopDomain->toNative(); 80 | } 81 | } catch (AssertionFailedException $e) { 82 | // Unable to decode the token 83 | return null; 84 | } 85 | } 86 | 87 | return null; 88 | }, 89 | ]; 90 | 91 | // Loop through each until we find the shop 92 | foreach ($options as $value) { 93 | $result = is_callable($value) ? $value() : $value; 94 | if ($result !== null) { 95 | // Found a shop 96 | return self::fromNative($result); 97 | } 98 | } 99 | 100 | // No shop domain found in any source 101 | return NullShopDomain::fromNative(null); 102 | } 103 | 104 | /** 105 | * Ensures shop domain meets the specs. 106 | * 107 | * @param string $domain The shopify domain 108 | * 109 | * @return string 110 | */ 111 | protected function sanitizeShopDomain(string $domain): string 112 | { 113 | $configEndDomain = Util::getShopifyConfig('myshopify_domain'); 114 | $domain = strtolower(preg_replace('/^https?:\/\//i', '', trim($domain))); 115 | 116 | if (strpos($domain, $configEndDomain) === false && strpos($domain, '.') === false) { 117 | // No myshopify.com ($configEndDomain) in shop's name 118 | $domain .= ".{$configEndDomain}"; 119 | } 120 | 121 | $hostname = parse_url("https://{$domain}", PHP_URL_HOST); 122 | 123 | if (! preg_match('/^[a-zA-Z0-9][a-zA-Z0-9\-]*\.'.preg_quote($configEndDomain, '/').'$/', $hostname)) { 124 | return ''; 125 | } 126 | 127 | return $hostname; 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /src/Traits/AuthController.php: -------------------------------------------------------------------------------- 1 | missing('shop') && !$request->user()) { 33 | // One or the other is required to authenticate a shop 34 | throw new MissingShopDomainException('No authenticated user or shop domain'); 35 | } 36 | 37 | // Get the shop domain 38 | $shopDomain = $request->has('shop') 39 | ? ShopDomain::fromNative($request->get('shop')) 40 | : $request->user()->getDomain(); 41 | 42 | // If the domain is obtained from $request->user() 43 | if ($request->missing('shop')) { 44 | $request['shop'] = $shopDomain->toNative(); 45 | } 46 | 47 | // Run the action 48 | [$result, $status] = $authShop($request); 49 | 50 | if ($status === null) { 51 | // Show exception, something is wrong 52 | throw new SignatureVerificationException('Invalid HMAC verification'); 53 | } elseif ($status === false) { 54 | if (!$result['url']) { 55 | throw new MissingAuthUrlException('Missing auth url'); 56 | } 57 | 58 | $shopDomain = $shopDomain->toNative(); 59 | $shopOrigin = $shopDomain ?? $request->user()->name; 60 | 61 | event(new ShopAuthenticatedEvent($result['shop_id'])); 62 | 63 | return View::make( 64 | 'shopify-app::auth.fullpage_redirect', 65 | [ 66 | 'apiKey' => Util::getShopifyConfig('api_key', $shopOrigin), 67 | 'url' => $result['url'], 68 | 'host' => $request->get('host'), 69 | 'shopDomain' => $shopDomain, 70 | 'locale' => $request->get('locale'), 71 | ] 72 | ); 73 | } else { 74 | // Go to home route 75 | return Redirect::route( 76 | Util::getShopifyConfig('route_names.home'), 77 | [ 78 | 'shop' => $shopDomain->toNative(), 79 | 'host' => $request->get('host'), 80 | 'locale' => $request->get('locale'), 81 | ] 82 | ); 83 | } 84 | } 85 | 86 | /** 87 | * Get session token for a shop. 88 | * 89 | * @return ViewView 90 | */ 91 | public function token(Request $request) 92 | { 93 | $request->session()->reflash(); 94 | $shopDomain = ShopDomain::fromRequest($request); 95 | $target = $request->query('target'); 96 | $query = parse_url($target, PHP_URL_QUERY); 97 | 98 | $cleanTarget = $target; 99 | if ($query) { 100 | // remove "token" from the target's query string 101 | $params = Util::parseQueryString($query); 102 | $params['shop'] = $params['shop'] ?? $shopDomain->toNative() ?? ''; 103 | $params['host'] = $request->get('host'); 104 | $params['locale'] = $request->get('locale'); 105 | unset($params['token']); 106 | 107 | $cleanTarget = trim(explode('?', $target)[0].'?'.http_build_query($params), '?'); 108 | } else { 109 | $params = [ 110 | 'shop' => $shopDomain->toNative() ?? '', 111 | 'host' => $request->get('host'), 112 | 'locale' => $request->get('locale'), 113 | ]; 114 | $cleanTarget = trim(explode('?', $target)[0].'?'.http_build_query($params), '?'); 115 | } 116 | 117 | return View::make( 118 | 'shopify-app::auth.token', 119 | [ 120 | 'shopDomain' => $shopDomain->toNative(), 121 | 'target' => $cleanTarget, 122 | ] 123 | ); 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/Storage/Commands/Charge.php: -------------------------------------------------------------------------------- 1 | query = $query; 35 | } 36 | 37 | /** 38 | * {@inheritdoc} 39 | */ 40 | public function make(ChargeTransfer $chargeObj): ChargeId 41 | { 42 | /** 43 | * Is an instance of Carbon? 44 | * 45 | * @param mixed $obj The object to check. 46 | * 47 | * @return bool 48 | */ 49 | $isCarbon = function ($obj): bool { 50 | return $obj instanceof Carbon; 51 | }; 52 | 53 | $shopTableId = Util::getShopsTableForeignKey(); 54 | 55 | $chargeClass = Util::getShopifyConfig('models.charge', ChargeModel::class); 56 | $charge = new $chargeClass(); 57 | $charge->plan_id = $chargeObj->planId->toNative(); 58 | $charge->$shopTableId = $chargeObj->shopId->toNative(); 59 | $charge->charge_id = $chargeObj->chargeReference->toNative(); 60 | $charge->type = $chargeObj->chargeType->toNative(); 61 | $charge->status = $chargeObj->chargeStatus->toNative(); 62 | $charge->name = $chargeObj->planDetails->name; 63 | $charge->price = $chargeObj->planDetails->price; 64 | $charge->interval = $chargeObj->planDetails->interval; 65 | $charge->test = $chargeObj->planDetails->test; 66 | $charge->trial_days = $chargeObj->planDetails->trialDays; 67 | $charge->capped_amount = $chargeObj->planDetails->cappedAmount; 68 | $charge->terms = $chargeObj->planDetails->terms; 69 | $charge->activated_on = $isCarbon($chargeObj->activatedOn) ? $chargeObj->activatedOn->format('Y-m-d') : null; 70 | $charge->billing_on = $isCarbon($chargeObj->billingOn) ? $chargeObj->billingOn->format('Y-m-d') : null; 71 | $charge->trial_ends_on = $isCarbon($chargeObj->trialEndsOn) ? $chargeObj->trialEndsOn->format('Y-m-d') : null; 72 | 73 | // Save the charge 74 | $charge->save(); 75 | 76 | return $charge->getId(); 77 | } 78 | 79 | /** 80 | * {@inheritdoc} 81 | */ 82 | public function delete(ChargeReference $chargeRef, ShopId $shopId): bool 83 | { 84 | $charge = $this->query->getByReferenceAndShopId($chargeRef, $shopId); 85 | 86 | return $charge === null ? false : $charge->delete(); 87 | } 88 | 89 | /** 90 | * {@inheritdoc} 91 | */ 92 | public function makeUsage(UsageChargeTransfer $chargeObj): ChargeId 93 | { 94 | $shopTableId = Util::getShopsTableForeignKey(); 95 | // Create the charge 96 | $chargeClass = Util::getShopifyConfig('models.charge', ChargeModel::class); 97 | $charge = new $chargeClass(); 98 | $charge->$shopTableId = $chargeObj->shopId->toNative(); 99 | $charge->charge_id = $chargeObj->chargeReference->toNative(); 100 | $charge->type = $chargeObj->chargeType->toNative(); 101 | $charge->status = $chargeObj->chargeStatus->toNative(); 102 | $charge->price = $chargeObj->details->price; 103 | $charge->description = $chargeObj->details->description; 104 | $charge->reference_charge = $chargeObj->details->chargeReference->toNative(); 105 | 106 | // Save the charge 107 | $charge->save(); 108 | 109 | return $charge->getId(); 110 | } 111 | 112 | /** 113 | * {@inheritdoc} 114 | */ 115 | public function cancel( 116 | ChargeReference $chargeRef, 117 | ?Carbon $expiresOn = null, 118 | ?Carbon $trialEndsOn = null 119 | ): bool { 120 | $charge = $this->query->getByReference($chargeRef); 121 | $charge->status = ChargeStatus::CANCELLED()->toNative(); 122 | $charge->cancelled_on = $expiresOn === null ? Carbon::today()->format('Y-m-d') : $expiresOn->format('Y-m-d'); 123 | $charge->expires_on = $trialEndsOn === null ? Carbon::today()->format('Y-m-d') : $trialEndsOn->format('Y-m-d'); 124 | 125 | return $charge->save(); 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/Traits/ShopModel.php: -------------------------------------------------------------------------------- 1 | id); 67 | } 68 | 69 | /** 70 | * {@inheritdoc} 71 | */ 72 | public function getDomain(): ShopDomainValue 73 | { 74 | return ShopDomain::fromNative($this->name); 75 | } 76 | 77 | /** 78 | * {@inheritdoc} 79 | */ 80 | public function getAccessToken(): AccessTokenValue 81 | { 82 | return AccessToken::fromNative($this->password); 83 | } 84 | 85 | /** 86 | * {@inheritdoc} 87 | */ 88 | public function charges(): HasMany 89 | { 90 | return $this->hasMany( 91 | Util::getShopifyConfig('models.charge', Charge::class), 92 | Util::getShopsTableForeignKey(), 93 | 'id' 94 | ); 95 | } 96 | 97 | /** 98 | * {@inheritdoc} 99 | */ 100 | public function hasCharges(): bool 101 | { 102 | return $this->charges->isNotEmpty(); 103 | } 104 | 105 | /** 106 | * {@inheritdoc} 107 | */ 108 | public function plan(): BelongsTo 109 | { 110 | return $this->belongsTo(Util::getShopifyConfig('models.plan', Plan::class)); 111 | } 112 | 113 | /** 114 | * {@inheritdoc} 115 | */ 116 | public function isGrandfathered(): bool 117 | { 118 | return (bool) $this->shopify_grandfathered === true; 119 | } 120 | 121 | /** 122 | * {@inheritdoc} 123 | */ 124 | public function isFreemium(): bool 125 | { 126 | return (bool) $this->shopify_freemium === true; 127 | } 128 | 129 | /** 130 | * {@inheritdoc} 131 | */ 132 | public function hasOfflineAccess(): bool 133 | { 134 | return ! $this->getAccessToken()->isNull() && ! empty($this->password); 135 | } 136 | 137 | /** 138 | * {@inheritDoc} 139 | */ 140 | public function setSessionContext(SessionContext $session): void 141 | { 142 | $this->sessionContext = $session; 143 | } 144 | 145 | /** 146 | * {@inheritDoc} 147 | */ 148 | public function getSessionContext(): ?SessionContext 149 | { 150 | return $this->sessionContext; 151 | } 152 | 153 | /** 154 | * {@inheritdoc} 155 | */ 156 | public function apiHelper(): IApiHelper 157 | { 158 | if ($this->apiHelper === null) { 159 | // Set the session 160 | $session = new Session( 161 | $this->getDomain()->toNative(), 162 | $this->getAccessToken()->toNative() 163 | ); 164 | $this->apiHelper = resolve(IApiHelper::class)->make($session); 165 | } 166 | 167 | return $this->apiHelper; 168 | } 169 | 170 | /** 171 | * {@inheritdoc} 172 | */ 173 | public function api(): BasicShopifyAPI 174 | { 175 | if ($this->apiHelper === null) { 176 | $this->apiHelper(); 177 | } 178 | 179 | return $this->apiHelper->getApi(); 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /src/resources/routes/shopify.php: -------------------------------------------------------------------------------- 1 | Util::getShopifyConfig('domain'), 28 | 'prefix' => Util::getShopifyConfig('prefix'), 29 | 'middleware' => ['web'], 30 | ], function () use ($manualRoutes) { 31 | /* 32 | |-------------------------------------------------------------------------- 33 | | Home Route 34 | |-------------------------------------------------------------------------- 35 | | 36 | | Homepage for an authenticated store. Store is checked with the 37 | | auth.shopify middleware and redirected to login if not. 38 | | 39 | */ 40 | 41 | if (Util::registerPackageRoute('home', $manualRoutes)) { 42 | Route::get( 43 | '/', 44 | HomeController::class.'@index' 45 | ) 46 | ->middleware(['verify.shopify', 'billable']) 47 | ->name(Util::getShopifyConfig('route_names.home')); 48 | } 49 | 50 | /* 51 | |-------------------------------------------------------------------------- 52 | | Authenticate: Install & Authorize 53 | |-------------------------------------------------------------------------- 54 | | 55 | | Install a shop and go through Shopify OAuth. 56 | | 57 | */ 58 | 59 | if (Util::registerPackageRoute('authenticate', $manualRoutes)) { 60 | Route::match( 61 | ['GET', 'POST'], 62 | '/authenticate', 63 | AuthController::class.'@authenticate' 64 | ) 65 | ->name(Util::getShopifyConfig('route_names.authenticate')); 66 | } 67 | 68 | /* 69 | |-------------------------------------------------------------------------- 70 | | Authenticate: Token 71 | |-------------------------------------------------------------------------- 72 | | 73 | | This route is hit when a shop comes to the app without a session token 74 | | yet. A token will be grabbed from Shopify AppBridge Javascript 75 | | and then forwarded back to the home route. 76 | | 77 | */ 78 | 79 | if (Util::registerPackageRoute('authenticate.token', $manualRoutes)) { 80 | Route::get( 81 | '/authenticate/token', 82 | AuthController::class.'@token' 83 | ) 84 | ->middleware(['verify.shopify']) 85 | ->name(Util::getShopifyConfig('route_names.authenticate.token')); 86 | } 87 | 88 | /* 89 | |-------------------------------------------------------------------------- 90 | | Billing Handler 91 | |-------------------------------------------------------------------------- 92 | | 93 | | Billing handler. Sends to billing screen for Shopify. 94 | | 95 | */ 96 | 97 | if (Util::registerPackageRoute('billing', $manualRoutes)) { 98 | Route::get( 99 | '/billing/{plan?}', 100 | BillingController::class.'@index' 101 | ) 102 | ->middleware(['verify.shopify']) 103 | ->where('plan', '^([0-9]+|)$') 104 | ->name(Util::getShopifyConfig('route_names.billing')); 105 | } 106 | 107 | /* 108 | |-------------------------------------------------------------------------- 109 | | Billing Processor 110 | |-------------------------------------------------------------------------- 111 | | 112 | | Processes the customer's response to the billing screen. 113 | | The shop domain is encrypted. 114 | | 115 | */ 116 | 117 | if (Util::registerPackageRoute('billing.process', $manualRoutes)) { 118 | Route::get( 119 | '/billing/process/{plan?}', 120 | BillingController::class.'@process' 121 | ) 122 | ->middleware(['verify.shopify']) 123 | ->where('plan', '^([0-9]+|)$') 124 | ->name(Util::getShopifyConfig('route_names.billing.process')); 125 | } 126 | 127 | /* 128 | |-------------------------------------------------------------------------- 129 | | Billing Processor for Usage Charges 130 | |-------------------------------------------------------------------------- 131 | | 132 | | Creates a usage charge on a recurring charge. 133 | | 134 | */ 135 | 136 | if (Util::registerPackageRoute('billing.usage_charge', $manualRoutes)) { 137 | Route::match( 138 | ['get', 'post'], 139 | '/billing/usage-charge', 140 | BillingController::class.'@usageCharge' 141 | ) 142 | ->middleware(['verify.shopify']) 143 | ->name(Util::getShopifyConfig('route_names.billing.usage_charge')); 144 | } 145 | }); 146 | -------------------------------------------------------------------------------- /src/Storage/Commands/Shop.php: -------------------------------------------------------------------------------- 1 | query = $query; 41 | $this->model = Util::getShopifyConfig('user_model'); 42 | } 43 | 44 | /** 45 | * {@inheritdoc} 46 | */ 47 | public function make(ShopDomainValue $domain, AccessTokenValue $token): ShopIdValue 48 | { 49 | $model = $this->model; 50 | $shop = new $model(); 51 | $shop->name = $domain->toNative(); 52 | $shop->password = $token->isNull() ? '' : $token->toNative(); 53 | $shop->email = "shop@{$domain->toNative()}"; 54 | $shop->save(); 55 | 56 | return $shop->getId(); 57 | } 58 | 59 | /** 60 | * {@inheritdoc} 61 | */ 62 | public function setToPlan(ShopIdValue $shopId, PlanIdValue $planId): bool 63 | { 64 | $shop = $this->getShop($shopId); 65 | $shop->plan_id = $planId->toNative(); 66 | $shop->shopify_freemium = false; 67 | 68 | return $shop->save(); 69 | } 70 | 71 | /** 72 | * {@inheritdoc} 73 | */ 74 | public function setAccessToken(ShopIdValue $shopId, AccessTokenValue $token): bool 75 | { 76 | $shop = $this->getShop($shopId); 77 | $shop->password = $token->toNative(); 78 | $shop->password_updated_at = Carbon::now(); 79 | 80 | return $shop->save(); 81 | } 82 | 83 | /** 84 | * {@inheritdoc} 85 | */ 86 | public function setThemeSupportLevel(ShopIdValue $shopId, ThemeSupportLevelValue $themeSupportLevel): bool 87 | { 88 | $shop = $this->getShop($shopId); 89 | $shop->theme_support_level = $themeSupportLevel->toNative(); 90 | 91 | return $shop->save(); 92 | } 93 | 94 | /** 95 | * {@inheritdoc} 96 | */ 97 | public function clean(ShopIdValue $shopId): bool 98 | { 99 | $shop = $this->getShop($shopId); 100 | $shop->password = ''; 101 | $shop->plan_id = null; 102 | 103 | return $shop->save(); 104 | } 105 | 106 | /** 107 | * {@inheritdoc} 108 | */ 109 | public function softDelete(ShopIdValue $shopId): bool 110 | { 111 | $shop = $this->getShop($shopId); 112 | $shop->charges()->delete(); 113 | 114 | return $shop->delete(); 115 | } 116 | 117 | /** 118 | * {@inheritdoc} 119 | */ 120 | public function restore(ShopIdValue $shopId): bool 121 | { 122 | $shop = $this->getShop($shopId, true); 123 | $shop->charges()->restore(); 124 | 125 | return $shop->restore(); 126 | } 127 | 128 | /** 129 | * {@inheritdoc} 130 | */ 131 | public function setAsFreemium(ShopIdValue $shopId): bool 132 | { 133 | $shop = $this->getShop($shopId); 134 | $this->setAsFreemiumByRef($shop); 135 | 136 | return $shop->save(); 137 | } 138 | 139 | /** 140 | * {@inheritdoc} 141 | */ 142 | public function setNamespace(ShopIdValue $shopId, string $namespace): bool 143 | { 144 | $shop = $this->getShop($shopId); 145 | $this->setNamespaceByRef($shop, $namespace); 146 | 147 | return $shop->save(); 148 | } 149 | 150 | /** 151 | * Sets a shop as freemium. 152 | * 153 | * @param ShopModel $shop The shop model (reference). 154 | * 155 | * @return void 156 | */ 157 | public function setAsFreemiumByRef(ShopModel &$shop): void 158 | { 159 | $shop->shopify_freemium = true; 160 | } 161 | 162 | /** 163 | * Sets a shop namespace. 164 | * 165 | * @param ShopModel $shop The shop model (reference). 166 | * @param string $namespace The namespace. 167 | * 168 | * @return void 169 | */ 170 | public function setNamespaceByRef(ShopModel &$shop, string $namespace): void 171 | { 172 | $shop->shopify_namespace = $namespace; 173 | } 174 | 175 | /** 176 | * Helper to get the shop. 177 | * 178 | * @param ShopIdValue $shopId The shop's ID. 179 | * @param bool $withTrashed Include trashed shops? 180 | * 181 | * @return ShopModel|null 182 | */ 183 | protected function getShop(ShopIdValue $shopId, bool $withTrashed = false): ?ShopModel 184 | { 185 | return $this->query->getById($shopId, [], $withTrashed); 186 | } 187 | } 188 | --------------------------------------------------------------------------------