├── LICENSE ├── README.md ├── composer.json ├── ide.json ├── phpstan.neon └── src ├── Actions ├── ActivatePlan.php ├── ActivateUsageCharge.php ├── AfterAuthorize.php ├── AuthenticateShop.php ├── CancelCharge.php ├── CancelCurrentPlan.php ├── CreateScripts.php ├── CreateWebhooks.php ├── DeleteWebhooks.php ├── DispatchScripts.php ├── DispatchWebhooks.php ├── GetPlanUrl.php └── InstallShop.php ├── Console ├── AddVariablesCommand.php ├── WebhookJobMakeCommand.php └── stubs │ └── webhook-job.stub ├── Contracts ├── ApiHelper.php ├── Commands │ ├── Charge.php │ └── Shop.php ├── Objects │ └── Values │ │ ├── AccessToken.php │ │ ├── PlanId.php │ │ ├── SessionId.php │ │ ├── SessionToken.php │ │ ├── ShopDomain.php │ │ └── ShopId.php ├── Queries │ ├── Charge.php │ ├── Plan.php │ └── Shop.php └── ShopModel.php ├── Directives └── SessionToken.php ├── Exceptions ├── ApiException.php ├── BaseException.php ├── ChargeNotRecurringException.php ├── ChargeNotRecurringOrOnetimeException.php ├── HttpException.php ├── MissingAuthUrlException.php ├── MissingShopDomainException.php └── SignatureVerificationException.php ├── Http ├── Controllers │ ├── ApiController.php │ ├── AuthController.php │ ├── BillingController.php │ ├── HomeController.php │ └── WebhookController.php ├── Middleware │ ├── AuthProxy.php │ ├── AuthWebhook.php │ ├── Billable.php │ ├── IframeProtection.php │ └── VerifyShopify.php └── Requests │ └── StoreUsageCharge.php ├── Macros ├── TokenRedirect.php ├── TokenRoute.php └── TokenUrl.php ├── Messaging ├── Events │ └── AppLoggedIn.php └── Jobs │ ├── AppUninstalledJob.php │ ├── ScripttagInstaller.php │ └── WebhookInstaller.php ├── Objects ├── Enums │ ├── ApiMethod.php │ ├── AuthMode.php │ ├── ChargeInterval.php │ ├── ChargeStatus.php │ ├── ChargeType.php │ ├── DataSource.php │ ├── FrontendEngine.php │ ├── PlanInterval.php │ └── PlanType.php ├── Transfers │ ├── AbstractTransfer.php │ ├── Charge.php │ ├── PlanDetails.php │ ├── UsageCharge.php │ └── UsageChargeDetails.php └── Values │ ├── AccessToken.php │ ├── ChargeId.php │ ├── ChargeReference.php │ ├── Hmac.php │ ├── NullAccessToken.php │ ├── NullPlanId.php │ ├── NullSessionId.php │ ├── NullSessionToken.php │ ├── NullShopDomain.php │ ├── NullableAccessToken.php │ ├── NullablePlanId.php │ ├── NullableSessionId.php │ ├── NullableSessionToken.php │ ├── NullableShopDomain.php │ ├── PlanId.php │ ├── SessionContext.php │ ├── SessionId.php │ ├── SessionToken.php │ ├── ShopDomain.php │ └── ShopId.php ├── Services ├── ApiHelper.php ├── ChargeHelper.php └── CookieHelper.php ├── ShopifyAppProvider.php ├── Storage ├── Commands │ ├── Charge.php │ └── Shop.php ├── Models │ ├── Charge.php │ └── Plan.php ├── Observers │ └── Shop.php ├── Queries │ ├── Charge.php │ ├── Plan.php │ └── Shop.php └── Scopes │ └── Namespacing.php ├── Traits ├── ApiController.php ├── AuthController.php ├── BillingController.php ├── HomeController.php ├── ShopAccessible.php ├── ShopModel.php └── WebhookController.php ├── Util.php └── resources ├── config └── shopify-app.php ├── database ├── factories │ ├── ChargeFactory.php │ ├── PlanFactory.php │ └── ShopFactory.php └── migrations │ ├── 2020_01_29_010501_create_plans_table.php │ ├── 2020_01_29_230905_create_shops_table.php │ ├── 2020_01_29_231006_create_charges_table.php │ ├── 2020_07_03_211514_add_interval_column_to_charges_table.php │ ├── 2020_07_03_211854_add_interval_column_to_plans_table.php │ └── 2021_04_21_103633_add_password_updated_at_to_users_table.php ├── jobs └── AppUninstalledJob.php ├── routes ├── api.php └── shopify.php └── views ├── auth ├── fullpage_redirect.blade.php └── token.blade.php ├── billing ├── error.blade.php └── fullpage_redirect.blade.php ├── home └── index.blade.php ├── layouts ├── default.blade.php └── error.blade.php └── partials ├── flash_messages.blade.php ├── laravel_skeleton_css.blade.php ├── polaris_skeleton_css.blade.php └── token_handler.blade.php /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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Laravel Shopify App 2 | 3 | ![Tests](https://github.com/osiset/laravel-shopify/workflows/Package%20Test/badge.svg?branch=master) 4 | [![codecov](https://codecov.io/gh/osiset/laravel-shopify/branch/master/graph/badge.svg?token=qqUuLItqJj)](https://codecov.io/gh/osiset/laravel-shopify) 5 | [![License](https://poser.pugx.org/osiset/laravel-shopify/license)](https://packagist.org/packages/osiset/laravel-shopify) 6 | 7 | ---- 8 | 9 | **[Closing]** 10 | 11 | **Please read [this announcement](https://github.com/osiset/laravel-shopify/discussions/1276).** 12 | 13 | @kyon147 is going to maintain a version which you can find here https://github.com/Kyon147/laravel-shopify 14 | 15 | ---- 16 | 17 | A full-featured Laravel package for aiding in Shopify App development, similar to `shopify_app` for Rails. Works for Laravel 7 and up. 18 | 19 | ![Screenshot](https://github.com/osiset/laravel-shopify/raw/master/screenshot.png) 20 | ![Screenshot: Billable](https://github.com/osiset/laravel-shopify/raw/master/screenshot-billable.png) 21 | 22 | ## Table of Contents 23 | 24 | __*__ *Wiki pages* 25 | 26 | - [Goals](#goals) 27 | - [Documentation](#documentation) 28 | - [Installation](https://github.com/osiset/laravel-shopify/wiki/Installation)* 29 | - [Route List](https://github.com/osiset/laravel-shopify/wiki/Route-List)* 30 | - [Usage](https://github.com/osiset/laravel-shopify/wiki/Usage)* 31 | - [Changelog](https://github.com/osiset/laravel-shopify/wiki/Changelog)* 32 | - [Contributing Guide](https://github.com/osiset/laravel-shopify/blob/master/CONTRIBUTING.md) 33 | - [LICENSE](#license) 34 | 35 | For more information, tutorials, etc., please view the project's [wiki](https://github.com/osiset/laravel-shopify/wiki). 36 | 37 | ## Goals 38 | 39 | - [x] Provide assistance in developing Shopify apps with Laravel 40 | - [x] Integration with Shopify API (REST, async REST, GraphQL) 41 | - [x] Authentication & installation for shops (both per-user and offline types) 42 | - [x] Plan & billing integration for single, recurring, and usage-types 43 | - [x] Tracking charges to a shop (recurring, single, usage, etc) with trial support 44 | - [x] Auto install app webhooks and scripttags through background jobs 45 | - [x] Provide basic AppBridge views 46 | - [x] Handles and processes incoming webhooks 47 | - [x] Handles and verifies incoming app proxy requests 48 | - [x] Namespacing abilities to run multiple apps on the same database 49 | 50 | ## Documentation 51 | 52 | For full resources on this package, see the [wiki](https://github.com/osiset/laravel-shopify/wiki). 53 | 54 | For internal documentation, it is [available here](https://osiset.com/laravel-shopify/) from phpDocumentor. 55 | 56 | ## Issue or request? 57 | 58 | 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. 59 | 60 | ## License 61 | 62 | This project is released under the MIT [license](https://github.com/osiset/laravel-shopify/blob/master/LICENSE). 63 | 64 | ## Misc 65 | 66 | ### Repository 67 | 68 | #### Contributors 69 | 70 | Contributions are always welcome! Contibutors are updated each release, pulled from Github. See `CONTRIBUTORS.txt`. 71 | 72 | If you're looking to become a contributor, please see `CONTRIBUTING.md`. 73 | 74 | #### Maintainers 75 | 76 | Maintainers are users who manage the repository itself, whether it's managing the issues, assisting in releases, or helping with pull requests. 77 | 78 | Currently this repository is maintained by: 79 | 80 | - [@osiset](https://github.com/osiset) 81 | - [@kyon147](https://github.com/kyon147) 82 | - [@lucasmichot](https://github.com/lucasmichot) 83 | 84 | Looking to become a maintainer? E-mail @osiset directly. 85 | 86 | ### Special Note 87 | 88 | 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. 89 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "osiset/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": "Tyler King", 23 | "email": "tyler@osiset.com" 24 | } 25 | ], 26 | "require": { 27 | "php": ">=7.2", 28 | "ext-json": "*", 29 | "funeralzone/valueobjects": "^0.5", 30 | "jenssegers/agent": "^2.6", 31 | "laravel/framework": "^7.0 || ^8.0 || ^9.0", 32 | "osiset/basic-shopify-api": "^9.0 || <=10.0.5" 33 | }, 34 | "require-dev": { 35 | "ergebnis/composer-normalize": "^2.8", 36 | "friendsofphp/php-cs-fixer": "^3.0", 37 | "mockery/mockery": "^1.0", 38 | "orchestra/database": "~3.8 || ~4.0 || ~5.0 || ~6.0 || ~7.0", 39 | "orchestra/testbench": "~3.8 || ~4.0 || ~5.0 || ~6.0 || ~7.0", 40 | "phpstan/phpstan": "^0.12", 41 | "phpunit/phpunit": "~8.0 || ^9.0" 42 | }, 43 | "config": { 44 | "sort-packages": true, 45 | "allow-plugins": { 46 | "ergebnis/composer-normalize": true 47 | } 48 | }, 49 | "extra": { 50 | "laravel": { 51 | "providers": [ 52 | "Osiset\\ShopifyApp\\ShopifyAppProvider" 53 | ] 54 | } 55 | }, 56 | "autoload": { 57 | "psr-4": { 58 | "Osiset\\ShopifyApp\\": "src/" 59 | } 60 | }, 61 | "autoload-dev": { 62 | "psr-4": { 63 | "Osiset\\ShopifyApp\\Test\\": "tests/" 64 | } 65 | }, 66 | "minimum-stability": "dev", 67 | "prefer-stable": true, 68 | "scripts": { 69 | "lint": "vendor/bin/php-cs-fixer fix", 70 | "test": "vendor/bin/phpunit", 71 | "test-html-cov": "vendor/bin/phpunit --coverage-html ./build/html/", 72 | "test-no-cov": "vendor/bin/phpunit --no-coverage" 73 | }, 74 | "support": { 75 | "issues": "https://github.com/osiset/laravel-shopify/issues", 76 | "forum": "https://github.com/osiset/laravel-shopify/discussions", 77 | "wiki": "https://github.com/osiset/laravel-shopify/wiki", 78 | "source": "https://github.com/osiset/laravel-shopify" 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /ide.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://laravel-ide.com/schema/laravel-ide-v1.json", 3 | "completion": { 4 | "route": [ 5 | { 6 | "methods": ["tokenRedirect"], 7 | "classes": ["Redirect", "Redirector"], 8 | "parameters": [1] 9 | }, 10 | { 11 | "methods": ["tokenRoute"], 12 | "classes": ["URL", "UrlGenerator"], 13 | "parameters": [1] 14 | } 15 | ] 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /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/Actions/ActivatePlan.php: -------------------------------------------------------------------------------- 1 | cancelCurrentPlan = $cancelCurrentPlanAction; 88 | $this->chargeHelper = $chargeHelper; 89 | $this->shopQuery = $shopQuery; 90 | $this->planQuery = $planQuery; 91 | $this->chargeCommand = $chargeCommand; 92 | $this->shopCommand = $shopCommand; 93 | } 94 | 95 | /** 96 | * Execution. 97 | * TODO: Rethrow an API exception. 98 | * 99 | * @param ShopId $shopId The shop ID. 100 | * @param PlanId $planId The plan to use. 101 | * @param ChargeReference $chargeRef The charge ID from Shopify. 102 | * 103 | * @return ChargeId 104 | */ 105 | public function __invoke(ShopId $shopId, PlanId $planId, ChargeReference $chargeRef): ChargeId 106 | { 107 | // Get the shop 108 | $shop = $this->shopQuery->getById($shopId); 109 | 110 | // Get the plan 111 | $plan = $this->planQuery->getById($planId); 112 | $chargeType = ChargeType::fromNative($plan->getType()->toNative()); 113 | 114 | // Activate the plan on Shopify 115 | $response = $shop->apiHelper()->activateCharge($chargeType, $chargeRef); 116 | 117 | // Cancel the shop's current plan 118 | call_user_func($this->cancelCurrentPlan, $shopId); 119 | 120 | // Cancel the existing charge if it exists (happens if someone refreshes during) 121 | $this->chargeCommand->delete($chargeRef, $shopId); 122 | 123 | // Create the charge transfer 124 | $transfer = new ChargeTransfer(); 125 | $transfer->shopId = $shopId; 126 | $transfer->planId = $planId; 127 | $transfer->chargeReference = $chargeRef; 128 | $transfer->chargeType = $chargeType; 129 | $transfer->chargeStatus = ChargeStatus::fromNative(strtoupper($response['status'])); 130 | if ($plan->isType(PlanType::RECURRING())) { 131 | $transfer->activatedOn = new Carbon($response['activated_on']); 132 | $transfer->billingOn = new Carbon($response['billing_on']); 133 | $transfer->trialEndsOn = new Carbon($response['trial_ends_on']); 134 | } else { 135 | $transfer->activatedOn = Carbon::today(); 136 | $transfer->billingOn = null; 137 | $transfer->trialEndsOn = null; 138 | } 139 | $transfer->planDetails = $this->chargeHelper->details($plan, $shop); 140 | 141 | // Create the charge 142 | $charge = $this->chargeCommand->make($transfer); 143 | $this->shopCommand->setToPlan($shopId, $planId); 144 | 145 | return $charge; 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /src/Actions/ActivateUsageCharge.php: -------------------------------------------------------------------------------- 1 | chargeHelper = $chargeHelper; 58 | $this->chargeCommand = $chargeCommand; 59 | $this->shopQuery = $shopQuery; 60 | } 61 | 62 | /** 63 | * Execute. 64 | * TODO: Rethrow an API exception. 65 | * 66 | * @param ShopId $shopId The shop ID. 67 | * @param UsageChargeDetailsTransfer $ucd The usage charge details (without charge ID). 68 | * 69 | * @throws ChargeNotRecurringException 70 | * 71 | * @return ChargeId|bool 72 | */ 73 | public function __invoke(ShopId $shopId, UsageChargeDetailsTransfer $ucd) 74 | { 75 | // Get the shop 76 | $shop = $this->shopQuery->getById($shopId); 77 | 78 | // Ensure we have a recurring charge 79 | $currentCharge = $this->chargeHelper->chargeForPlan($shop->plan->getId(), $shop); 80 | if (! $currentCharge->isType(ChargeType::RECURRING())) { 81 | throw new ChargeNotRecurringException('Can only create usage charges for recurring charge.'); 82 | } 83 | 84 | // Create the usage charge 85 | $ucd->chargeReference = $currentCharge->getReference(); 86 | $response = $shop->apiHelper()->createUsageCharge($ucd); 87 | if (! $response) { 88 | // Could not make usage charge, limit possibly reached 89 | return false; 90 | } 91 | 92 | // Create the transfer 93 | $uct = new UsageChargeTransfer(); 94 | $uct->shopId = $shopId; 95 | $uct->planId = $shop->plan->getId(); 96 | $uct->chargeReference = ChargeReference::fromNative((int) $response['id']); 97 | $uct->billingOn = new Carbon($response['billing_on']); 98 | $uct->details = $ucd; 99 | 100 | // Save the usage charge 101 | return $this->chargeCommand->makeUsage($uct); 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/Actions/AfterAuthorize.php: -------------------------------------------------------------------------------- 1 | shopQuery = $shopQuery; 33 | } 34 | 35 | /** 36 | * Execution. 37 | * TODO: Rethrow an API exception. 38 | * 39 | * @param ShopIdValue $shopId The shop ID. 40 | * 41 | * @return bool 42 | */ 43 | public function __invoke(ShopIdValue $shopId): bool 44 | { 45 | /** 46 | * Fires the job. 47 | * 48 | * @param array $config The job's configuration. 49 | * @param IShopModel $shop The shop instance. 50 | * 51 | * @return bool 52 | */ 53 | $fireJob = function (array $config, IShopModel $shop): bool { 54 | $job = Arr::get($config, 'job'); 55 | if (Arr::get($config, 'inline', false)) { 56 | // Run this job immediately 57 | $job::dispatchNow($shop); 58 | } else { 59 | // Run later 60 | $job::dispatch($shop) 61 | ->onQueue(Util::getShopifyConfig('job_queues')['after_authenticate']); 62 | } 63 | 64 | return true; 65 | }; 66 | 67 | // Get the shop 68 | $shop = $this->shopQuery->getById($shopId); 69 | 70 | // Grab the jobs config 71 | $jobsConfig = Util::getShopifyConfig('after_authenticate_job'); 72 | if (Arr::has($jobsConfig, 0)) { 73 | // We have multi-jobs 74 | foreach ($jobsConfig as $jobConfig) { 75 | // We have a job, pass the shop object to the constructor 76 | $fireJob($jobConfig, $shop); 77 | } 78 | 79 | return true; 80 | } elseif (Arr::has($jobsConfig, 'job')) { 81 | // We have a single job 82 | return $fireJob($jobsConfig, $shop); 83 | } 84 | 85 | // We have no jobs 86 | return false; 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/Actions/AuthenticateShop.php: -------------------------------------------------------------------------------- 1 | apiHelper = $apiHelper; 68 | $this->installShopAction = $installShopAction; 69 | $this->dispatchScriptsAction = $dispatchScriptsAction; 70 | $this->dispatchWebhooksAction = $dispatchWebhooksAction; 71 | $this->afterAuthorizeAction = $afterAuthorizeAction; 72 | } 73 | 74 | /** 75 | * Execution. 76 | * 77 | * @param Request $request The request object. 78 | * 79 | * @return array 80 | */ 81 | public function __invoke(Request $request): array 82 | { 83 | // Run the check 84 | /** @var $result array */ 85 | $result = call_user_func( 86 | $this->installShopAction, 87 | ShopDomain::fromNative($request->get('shop')), 88 | $request->query('code') 89 | ); 90 | 91 | if (! $result['completed']) { 92 | // No code, redirect to auth URL 93 | return [$result, false]; 94 | } 95 | 96 | // Determine if the HMAC is correct 97 | $this->apiHelper->make(); 98 | if (! $this->apiHelper->verifyRequest($request->all())) { 99 | // Throw exception, something is wrong 100 | return [$result, null]; 101 | } 102 | 103 | // Fire the post processing jobs 104 | call_user_func($this->dispatchScriptsAction, $result['shop_id'], false); 105 | call_user_func($this->dispatchWebhooksAction, $result['shop_id'], false); 106 | call_user_func($this->afterAuthorizeAction, $result['shop_id']); 107 | 108 | return [$result, true]; 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/Actions/CancelCharge.php: -------------------------------------------------------------------------------- 1 | chargeCommand = $chargeCommand; 42 | $this->chargeHelper = $chargeHelper; 43 | } 44 | 45 | /** 46 | * Cancels the charge. 47 | * 48 | * @param ChargeReference $chargeRef The charge ID. 49 | * 50 | * @throws Exception 51 | * 52 | * @return bool 53 | */ 54 | public function __invoke(ChargeReference $chargeRef): bool 55 | { 56 | // Get the charge 57 | $helper = $this->chargeHelper->useCharge($chargeRef); 58 | $charge = $helper->getCharge(); 59 | 60 | if (! $charge->isType(ChargeType::CHARGE()) && ! $charge->isType(ChargeType::RECURRING())) { 61 | // Not a recurring or one-time charge, someone trying to cancel a usage charge? 62 | throw new ChargeNotRecurringOrOnetimeException( 63 | 'Cancel may only be called for single and recurring charges.' 64 | ); 65 | } 66 | 67 | // Save the details to the database 68 | return $this->chargeCommand->cancel( 69 | $chargeRef, 70 | Carbon::today(), 71 | Carbon::today()->addDays($helper->remainingDaysForPeriod()) 72 | ); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/Actions/CancelCurrentPlan.php: -------------------------------------------------------------------------------- 1 | shopQuery = $shopQuery; 48 | $this->chargeCommand = $chargeCommand; 49 | $this->chargeHelper = $chargeHelper; 50 | } 51 | 52 | /** 53 | * Execution. 54 | * 55 | * @param ShopId $shopId The shop ID. 56 | * 57 | * @return bool 58 | */ 59 | public function __invoke(ShopId $shopId): bool 60 | { 61 | // Get the shop and its plan 62 | $shop = $this->shopQuery->getById($shopId); 63 | $plan = $shop->plan; 64 | 65 | if (! $plan) { 66 | // Shop has no plan... 67 | return false; 68 | } 69 | 70 | // Cancel the last charge 71 | $planCharge = $this->chargeHelper->chargeForPlan($shop->plan->getId(), $shop); 72 | if ($planCharge && ! $planCharge->isDeclined() && ! $planCharge->isCancelled()) { 73 | $this->chargeCommand->cancel($planCharge->getReference()); 74 | 75 | // Charge has been cancelled 76 | return true; 77 | } 78 | 79 | // Shop had a plan with no charge attached, usually means its a custom plan 80 | return false; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/Actions/CreateScripts.php: -------------------------------------------------------------------------------- 1 | shopQuery = $shopQuery; 31 | } 32 | 33 | /** 34 | * Execution. 35 | * TODO: Rethrow an API exception. 36 | * 37 | * @param ShopIdValue $shopId The shop ID. 38 | * @param array $configScripts The scripts to add. 39 | * 40 | * @return array 41 | */ 42 | public function __invoke(ShopIdValue $shopId, array $configScripts): array 43 | { 44 | /** 45 | * Checks if a scripttag exists already in the shop. 46 | * 47 | * @param array $script The scripttag config. 48 | * @param ResponseAccess $scripts The current scripttags to search. 49 | * 50 | * @return bool 51 | */ 52 | $exists = function (array $script, ResponseAccess $scripts): bool { 53 | foreach ($scripts as $shopScript) { 54 | if ($shopScript['src'] === $script['src']) { 55 | // Found the scripttag in our list 56 | return true; 57 | } 58 | } 59 | 60 | return false; 61 | }; 62 | 63 | // Get the shop 64 | $shop = $this->shopQuery->getById($shopId); 65 | $apiHelper = $shop->apiHelper(); 66 | 67 | // Get the scripts existing in for the shop 68 | $scripts = $apiHelper->getScriptTags(); 69 | 70 | // Keep track of whats created, deleted, and used 71 | $created = []; 72 | $deleted = []; 73 | $used = []; 74 | foreach ($configScripts as $scripttag) { 75 | // Check if the required scripttag exists on the shop 76 | if (! $exists($scripttag, $scripts)) { 77 | // It does not... create the scripttag 78 | $apiHelper->createScriptTag($scripttag); 79 | $created[] = $scripttag; 80 | } 81 | 82 | $used[] = $scripttag['src']; 83 | } 84 | 85 | // Delete unused scripttags 86 | foreach ($scripts as $scriptTag) { 87 | if (! in_array($scriptTag->src, $used)) { 88 | // Scripttag should be deleted 89 | $apiHelper->deleteScriptTag($scriptTag->id); 90 | $deleted[] = $scriptTag; 91 | } 92 | } 93 | 94 | return [ 95 | 'created' => $created, 96 | 'deleted' => $deleted, 97 | ]; 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/Actions/CreateWebhooks.php: -------------------------------------------------------------------------------- 1 | shopQuery = $shopQuery; 31 | } 32 | 33 | /** 34 | * Execution. 35 | * TODO: Rethrow an API exception. 36 | * 37 | * @param ShopIdValue $shopId The shop ID. 38 | * @param array $configWebhooks The webhooks to add. 39 | * 40 | * @return array 41 | */ 42 | public function __invoke(ShopIdValue $shopId, array $configWebhooks): array 43 | { 44 | /** 45 | * Checks if a webhooks exists already in the shop. 46 | * 47 | * @param array $webhook The webhook config. 48 | * @param ResponseAccess $webhooks The current webhooks to search. 49 | * 50 | * @return bool 51 | */ 52 | $exists = static function (array $webhook, ResponseAccess $webhooks): bool { 53 | foreach (data_get($webhooks, 'data.webhookSubscriptions.container.edges', []) as $shopWebhook) { 54 | if (data_get($shopWebhook, 'node.endpoint.callbackUrl') === $webhook['address']) { 55 | // Found the webhook in our list 56 | return true; 57 | } 58 | } 59 | 60 | return false; 61 | }; 62 | 63 | // Get the shop 64 | $shop = $this->shopQuery->getById($shopId); 65 | $apiHelper = $shop->apiHelper(); 66 | 67 | // Get the webhooks existing in for the shop 68 | $webhooks = $apiHelper->getWebhooks(); 69 | 70 | $created = []; 71 | $deleted = []; 72 | $used = []; 73 | foreach ($configWebhooks as $webhook) { 74 | // Check if the required webhook exists on the shop 75 | if (! $exists($webhook, $webhooks)) { 76 | // It does not... create the webhook 77 | $apiHelper->createWebhook($webhook); 78 | $created[] = $webhook; 79 | } 80 | 81 | $used[] = $webhook['address']; 82 | } 83 | 84 | // Delete unused webhooks 85 | foreach (data_get($webhooks, 'data.webhookSubscriptions.container.edges', []) as $webhook) { 86 | if (! in_array(data_get($webhook, 'node.endpoint.callbackUrl'), $used)) { 87 | // Webhook should be deleted 88 | $apiHelper->deleteWebhook(data_get($webhook, 'node.id')); 89 | $deleted[] = $webhook; 90 | } 91 | } 92 | 93 | return [ 94 | 'created' => $created, 95 | 'deleted' => $deleted, 96 | ]; 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/Actions/DeleteWebhooks.php: -------------------------------------------------------------------------------- 1 | shopQuery = $shopQuery; 30 | } 31 | 32 | /** 33 | * Execution. 34 | * TODO: Rethrow an API exception. 35 | * 36 | * @param ShopId $shopId The shop ID. 37 | * 38 | * @return array 39 | */ 40 | public function __invoke(ShopId $shopId): array 41 | { 42 | // Get the shop 43 | $shop = $this->shopQuery->getById($shopId); 44 | $apiHelper = $shop->apiHelper(); 45 | 46 | // Get the webhooks 47 | $webhooks = $apiHelper->getWebhooks(); 48 | 49 | $deleted = []; 50 | foreach (data_get($webhooks, 'data.webhookSubscriptions.container.edges', []) as $webhook) { 51 | // Its a webhook in the config, delete it 52 | $apiHelper->deleteWebhook(data_get($webhook, 'node.id')); 53 | 54 | // Keep track of what was deleted 55 | $deleted[] = $webhook; 56 | } 57 | 58 | return $deleted; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Actions/DispatchScripts.php: -------------------------------------------------------------------------------- 1 | shopQuery = $shopQuery; 39 | $this->jobClass = $jobClass; 40 | } 41 | 42 | /** 43 | * Execution. 44 | * 45 | * @param ShopIdValue $shopId The shop ID. 46 | * @param bool $inline Fire the job inline (now) or queue. 47 | * 48 | * @return bool 49 | */ 50 | public function __invoke(ShopIdValue $shopId, bool $inline = false): bool 51 | { 52 | // Get the shop 53 | $shop = $this->shopQuery->getById($shopId); 54 | 55 | // Get the scripttags 56 | $scripttags = Util::getShopifyConfig('scripttags'); 57 | if (count($scripttags) === 0) { 58 | // Nothing to do 59 | return false; 60 | } 61 | 62 | // Run the installer job 63 | if ($inline) { 64 | ($this->jobClass)::dispatchNow( 65 | $shop->getId(), 66 | $scripttags 67 | ); 68 | } else { 69 | ($this->jobClass)::dispatch( 70 | $shop->getId(), 71 | $scripttags 72 | )->onQueue(Util::getShopifyConfig('job_queues')['scripttags']); 73 | } 74 | 75 | return true; 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/Actions/DispatchWebhooks.php: -------------------------------------------------------------------------------- 1 | shopQuery = $shopQuery; 39 | $this->jobClass = $jobClass; 40 | } 41 | 42 | /** 43 | * Execution. 44 | * 45 | * @param ShopIdValue $shopId The shop ID. 46 | * @param bool $inline Fire the job inlin e (now) or queue. 47 | * 48 | * @return bool 49 | */ 50 | public function __invoke(ShopIdValue $shopId, bool $inline = false): bool 51 | { 52 | // Get the webhooks 53 | $webhooks = Util::getShopifyConfig('webhooks'); 54 | if (count($webhooks) === 0) { 55 | // Nothing to do 56 | return false; 57 | } 58 | 59 | // Get the shop 60 | $shop = $this->shopQuery->getById($shopId); 61 | 62 | // Run the installer job 63 | if ($inline) { 64 | ($this->jobClass)::dispatchNow( 65 | $shop->getId(), 66 | $webhooks 67 | ); 68 | } else { 69 | ($this->jobClass)::dispatch( 70 | $shop->getId(), 71 | $webhooks 72 | )->onQueue(Util::getShopifyConfig('job_queues')['webhooks']); 73 | } 74 | 75 | return true; 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/Actions/GetPlanUrl.php: -------------------------------------------------------------------------------- 1 | chargeHelper = $chargeHelper; 51 | $this->planQuery = $planQuery; 52 | $this->shopQuery = $shopQuery; 53 | } 54 | 55 | /** 56 | * Execution. 57 | * TODO: Rethrow an API exception. 58 | * 59 | * @param ShopId $shopId The shop ID. 60 | * @param NullablePlanId $planId The plan to present. 61 | * 62 | * @return string 63 | */ 64 | public function __invoke(ShopId $shopId, NullablePlanId $planId): string 65 | { 66 | // Get the shop 67 | $shop = $this->shopQuery->getById($shopId); 68 | 69 | // Get the plan 70 | $plan = $planId->isNull() ? $this->planQuery->getDefault() : $this->planQuery->getById($planId); 71 | 72 | // Confirmation URL 73 | if ($plan->getInterval()->toNative() === ChargeInterval::ANNUAL()->toNative()) { 74 | $api = $shop->apiHelper() 75 | ->createChargeGraphQL($this->chargeHelper->details($plan, $shop)); 76 | 77 | $confirmationUrl = $api['confirmationUrl']; 78 | } else { 79 | $api = $shop->apiHelper() 80 | ->createCharge( 81 | ChargeType::fromNative($plan->getType()->toNative()), 82 | $this->chargeHelper->details($plan, $shop) 83 | ); 84 | 85 | $confirmationUrl = $api['confirmation_url']; 86 | } 87 | 88 | return $confirmationUrl; 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/Actions/InstallShop.php: -------------------------------------------------------------------------------- 1 | shopQuery = $shopQuery; 45 | $this->shopCommand = $shopCommand; 46 | } 47 | 48 | /** 49 | * Execution. 50 | * 51 | * @param ShopDomain $shopDomain The shop ID. 52 | * @param string|null $code The code from Shopify. 53 | * 54 | * @return array 55 | */ 56 | public function __invoke(ShopDomain $shopDomain, ?string $code): array 57 | { 58 | // Get the shop 59 | $shop = $this->shopQuery->getByDomain($shopDomain, [], true); 60 | if ($shop === null) { 61 | // Shop does not exist, make them and re-get 62 | $this->shopCommand->make($shopDomain, NullAccessToken::fromNative(null)); 63 | $shop = $this->shopQuery->getByDomain($shopDomain); 64 | } 65 | 66 | // Access/grant mode 67 | $apiHelper = $shop->apiHelper(); 68 | $grantMode = $shop->hasOfflineAccess() ? 69 | AuthMode::fromNative(Util::getShopifyConfig('api_grant_mode', $shop)) : 70 | AuthMode::OFFLINE(); 71 | 72 | // If there's no code 73 | if (empty($code)) { 74 | return [ 75 | 'completed' => false, 76 | 'url' => $apiHelper->buildAuthUrl($grantMode, Util::getShopifyConfig('api_scopes', $shop)), 77 | 'shop_id' => $shop->getId(), 78 | ]; 79 | } 80 | 81 | try { 82 | // if the store has been deleted, restore the store to set the access token 83 | if ($shop->trashed()) { 84 | $shop->restore(); 85 | } 86 | 87 | // Get the data and set the access token 88 | $data = $apiHelper->getAccessData($code); 89 | $this->shopCommand->setAccessToken($shop->getId(), AccessToken::fromNative($data['access_token'])); 90 | 91 | return [ 92 | 'completed' => true, 93 | 'url' => null, 94 | 'shop_id' => $shop->getId(), 95 | ]; 96 | } catch (Exception $e) { 97 | // Just return the default setting 98 | return [ 99 | 'completed' => false, 100 | 'url' => null, 101 | 'shop_id' => null, 102 | ]; 103 | } 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/Console/AddVariablesCommand.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/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/Console/stubs/webhook-job.stub: -------------------------------------------------------------------------------- 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/Contracts/ApiHelper.php: -------------------------------------------------------------------------------- 1 | '; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Exceptions/ApiException.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 | -------------------------------------------------------------------------------- /src/Exceptions/MissingAuthUrlException.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/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/Http/Middleware/Billable.php: -------------------------------------------------------------------------------- 1 | user(); 29 | if (! $shop->plan && ! $shop->isFreemium() && ! $shop->isGrandfathered()) { 30 | // They're not grandfathered in, and there is no charge or charge was declined... redirect to billing 31 | return Redirect::route( 32 | Util::getShopifyConfig('route_names.billing'), 33 | array_merge($request->input(), ['shop' => $shop->getDomain()->toNative()]) 34 | ); 35 | } 36 | } 37 | 38 | // Move on, everything's fine 39 | return $next($request); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Http/Middleware/IframeProtection.php: -------------------------------------------------------------------------------- 1 | shopQuery = $shopQuery; 34 | } 35 | 36 | /** 37 | * Set frame-ancestors header 38 | * 39 | * @param Request $request The request object. 40 | * @param \Closure $next The next action. 41 | * 42 | * @return mixed 43 | */ 44 | public function handle(Request $request, Closure $next) 45 | { 46 | $response = $next($request); 47 | 48 | $shop = Cache::remember( 49 | 'frame-ancestors_'.$request->get('shop'), 50 | now()->addMinutes(20), 51 | function () use ($request) { 52 | return $this->shopQuery->getByDomain(ShopDomain::fromRequest($request)); 53 | } 54 | ); 55 | 56 | $domain = $shop 57 | ? $shop->name 58 | : '*.myshopify.com'; 59 | 60 | $response->headers->set( 61 | 'Content-Security-Policy', 62 | "frame-ancestors https://$domain https://admin.shopify.com" 63 | ); 64 | 65 | return $response; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/TokenUrl.php: -------------------------------------------------------------------------------- 1 | ShopDomain::fromRequest(Request::instance())->toNative(), 30 | 'target' => URL::route($route, $params, $absolute), 31 | ], 32 | ]; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Messaging/Events/AppLoggedIn.php: -------------------------------------------------------------------------------- 1 | shop = $shop; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Messaging/Jobs/AppUninstalledJob.php: -------------------------------------------------------------------------------- 1 | domain = $domain; 52 | $this->data = $data; 53 | } 54 | 55 | /** 56 | * Execute the job. 57 | * 58 | * @param IShopCommand $shopCommand The commands for shops. 59 | * @param IShopQuery $shopQuery The querier for shops. 60 | * @param CancelCurrentPlan $cancelCurrentPlanAction The action for cancelling the current plan. 61 | * 62 | * @return bool 63 | */ 64 | public function handle( 65 | IShopCommand $shopCommand, 66 | IShopQuery $shopQuery, 67 | CancelCurrentPlan $cancelCurrentPlanAction 68 | ): bool { 69 | // Convert the domain 70 | $this->domain = ShopDomain::fromNative($this->domain); 71 | 72 | // Get the shop 73 | $shop = $shopQuery->getByDomain($this->domain); 74 | $shopId = $shop->getId(); 75 | 76 | // Cancel the current plan 77 | $cancelCurrentPlanAction($shopId); 78 | 79 | // Purge shop of token, plan, etc. 80 | $shopCommand->clean($shopId); 81 | 82 | // Check freemium mode 83 | $freemium = Util::getShopifyConfig('billing_freemium_enabled'); 84 | if ($freemium === true) { 85 | // Add the freemium flag to the shop 86 | $shopCommand->setAsFreemium($shopId); 87 | } 88 | 89 | // Soft delete the shop. 90 | $shopCommand->softDelete($shopId); 91 | 92 | return true; 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /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/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/Objects/Enums/ApiMethod.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/Objects/Transfers/UsageCharge.php: -------------------------------------------------------------------------------- 1 | chargeType = ChargeType::USAGE(); 73 | $this->chargeStatus = ChargeStatus::ACCEPTED(); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/Objects/Transfers/UsageChargeDetails.php: -------------------------------------------------------------------------------- 1 | toNative()); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Objects/Values/ChargeId.php: -------------------------------------------------------------------------------- 1 | toNative(), $object->toNative()); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Objects/Values/NullAccessToken.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/Objects/Values/NullablePlanId.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/Objects/Values/SessionId.php: -------------------------------------------------------------------------------- 1 | string = $this->sanitizeShopDomain($domain); 30 | } 31 | 32 | /** 33 | * Grab the shop, if present, and how it was found. 34 | * Order of precedence is:. 35 | * 36 | * - GET/POST Variable ("shop" or "shopDomain") 37 | * - Headers ("X-Shop-Domain") 38 | * - Referer ("shop" or "shopDomain" query param or decoded "token" query param) 39 | * 40 | * @param Request $request The request object. 41 | * 42 | * @return ShopDomainValue 43 | */ 44 | public static function fromRequest(Request $request): ShopDomainValue 45 | { 46 | // All possible methods 47 | $options = [ 48 | // GET/POST 49 | DataSource::INPUT()->toNative() => $request->input('shop', $request->input('shopDomain')), 50 | 51 | // Headers 52 | DataSource::HEADER()->toNative() => $request->header('X-Shop-Domain'), 53 | 54 | // Headers: Referer 55 | DataSource::REFERER()->toNative() => function () use ($request): ?string { 56 | $url = parse_url($request->header('referer', ''), PHP_URL_QUERY); 57 | if (! $url) { 58 | return null; 59 | } 60 | 61 | $params = Util::parseQueryString($url); 62 | $shop = Arr::get($params, 'shop', Arr::get($params, 'shopDomain')); 63 | if ($shop) { 64 | return $shop; 65 | } 66 | 67 | $token = Arr::get($params, 'token'); 68 | if ($token) { 69 | try { 70 | $token = new SessionToken($token, false); 71 | if ($shopDomain = $token->getShopDomain()) { 72 | return $shopDomain->toNative(); 73 | } 74 | } catch (AssertionFailedException $e) { 75 | // Unable to decode the token 76 | return null; 77 | } 78 | } 79 | 80 | return null; 81 | }, 82 | ]; 83 | 84 | // Loop through each until we find the shop 85 | foreach ($options as $value) { 86 | $result = is_callable($value) ? $value() : $value; 87 | if ($result !== null) { 88 | // Found a shop 89 | return self::fromNative($result); 90 | } 91 | } 92 | 93 | // No shop domain found in any source 94 | return NullShopDomain::fromNative(null); 95 | } 96 | 97 | /** 98 | * Ensures shop domain meets the specs. 99 | * 100 | * @param string $domain The shopify domain 101 | * 102 | * @return string 103 | */ 104 | protected function sanitizeShopDomain(string $domain): ?string 105 | { 106 | $configEndDomain = Util::getShopifyConfig('myshopify_domain'); 107 | $domain = strtolower(preg_replace('/https?:\/\//i', '', trim($domain))); 108 | 109 | if (strpos($domain, $configEndDomain) === false && strpos($domain, '.') === false) { 110 | // No myshopify.com ($configEndDomain) in shop's name 111 | $domain .= ".{$configEndDomain}"; 112 | } 113 | 114 | // Return the host after cleaned up 115 | return parse_url("https://{$domain}", PHP_URL_HOST); 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/Objects/Values/ShopId.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/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->billing_on = $chargeObj->billingOn->format('Y-m-d'); 103 | $charge->price = $chargeObj->details->price; 104 | $charge->description = $chargeObj->details->description; 105 | $charge->reference_charge = $chargeObj->details->chargeReference->toNative(); 106 | 107 | // Save the charge 108 | $charge->save(); 109 | 110 | return $charge->getId(); 111 | } 112 | 113 | /** 114 | * {@inheritdoc} 115 | */ 116 | public function cancel( 117 | ChargeReference $chargeRef, 118 | ?Carbon $expiresOn = null, 119 | ?Carbon $trialEndsOn = null 120 | ): bool { 121 | $charge = $this->query->getByReference($chargeRef); 122 | $charge->status = ChargeStatus::CANCELLED()->toNative(); 123 | $charge->cancelled_on = $expiresOn === null ? Carbon::today()->format('Y-m-d') : $expiresOn->format('Y-m-d'); 124 | $charge->expires_on = $trialEndsOn === null ? Carbon::today()->format('Y-m-d') : $trialEndsOn->format('Y-m-d'); 125 | 126 | return $charge->save(); 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/Storage/Commands/Shop.php: -------------------------------------------------------------------------------- 1 | query = $query; 40 | $this->model = Util::getShopifyConfig('user_model'); 41 | } 42 | 43 | /** 44 | * {@inheritdoc} 45 | */ 46 | public function make(ShopDomainValue $domain, AccessTokenValue $token): ShopIdValue 47 | { 48 | $model = $this->model; 49 | $shop = new $model(); 50 | $shop->name = $domain->toNative(); 51 | $shop->password = $token->isNull() ? '' : $token->toNative(); 52 | $shop->email = "shop@{$domain->toNative()}"; 53 | $shop->save(); 54 | 55 | return $shop->getId(); 56 | } 57 | 58 | /** 59 | * {@inheritdoc} 60 | */ 61 | public function setToPlan(ShopIdValue $shopId, PlanIdValue $planId): bool 62 | { 63 | $shop = $this->getShop($shopId); 64 | $shop->plan_id = $planId->toNative(); 65 | $shop->shopify_freemium = false; 66 | 67 | return $shop->save(); 68 | } 69 | 70 | /** 71 | * {@inheritdoc} 72 | */ 73 | public function setAccessToken(ShopIdValue $shopId, AccessTokenValue $token): bool 74 | { 75 | $shop = $this->getShop($shopId); 76 | $shop->password = $token->toNative(); 77 | $shop->password_updated_at = Carbon::now(); 78 | 79 | return $shop->save(); 80 | } 81 | 82 | /** 83 | * {@inheritdoc} 84 | */ 85 | public function clean(ShopIdValue $shopId): bool 86 | { 87 | $shop = $this->getShop($shopId); 88 | $shop->password = ''; 89 | $shop->plan_id = null; 90 | 91 | return $shop->save(); 92 | } 93 | 94 | /** 95 | * {@inheritdoc} 96 | */ 97 | public function softDelete(ShopIdValue $shopId): bool 98 | { 99 | $shop = $this->getShop($shopId); 100 | $shop->charges()->delete(); 101 | 102 | return $shop->delete(); 103 | } 104 | 105 | /** 106 | * {@inheritdoc} 107 | */ 108 | public function restore(ShopIdValue $shopId): bool 109 | { 110 | $shop = $this->getShop($shopId, true); 111 | $shop->charges()->restore(); 112 | 113 | return $shop->restore(); 114 | } 115 | 116 | /** 117 | * {@inheritdoc} 118 | */ 119 | public function setAsFreemium(ShopIdValue $shopId): bool 120 | { 121 | $shop = $this->getShop($shopId); 122 | $this->setAsFreemiumByRef($shop); 123 | 124 | return $shop->save(); 125 | } 126 | 127 | /** 128 | * {@inheritdoc} 129 | */ 130 | public function setNamespace(ShopIdValue $shopId, string $namespace): bool 131 | { 132 | $shop = $this->getShop($shopId); 133 | $this->setNamespaceByRef($shop, $namespace); 134 | 135 | return $shop->save(); 136 | } 137 | 138 | /** 139 | * Sets a shop as freemium. 140 | * 141 | * @param ShopModel $shop The shop model (reference). 142 | * 143 | * @return void 144 | */ 145 | public function setAsFreemiumByRef(ShopModel &$shop): void 146 | { 147 | $shop->shopify_freemium = true; 148 | } 149 | 150 | /** 151 | * Sets a shop namespace. 152 | * 153 | * @param ShopModel $shop The shop model (reference). 154 | * @param string $namespace The namespace. 155 | * 156 | * @return void 157 | */ 158 | public function setNamespaceByRef(ShopModel &$shop, string $namespace): void 159 | { 160 | $shop->shopify_namespace = $namespace; 161 | } 162 | 163 | /** 164 | * Helper to get the shop. 165 | * 166 | * @param ShopIdValue $shopId The shop's ID. 167 | * @param bool $withTrashed Include trashed shops? 168 | * 169 | * @return ShopModel|null 170 | */ 171 | protected function getShop(ShopIdValue $shopId, bool $withTrashed = false): ?ShopModel 172 | { 173 | return $this->query->getById($shopId, [], $withTrashed); 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /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/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/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/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/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/Storage/Scopes/Namespacing.php: -------------------------------------------------------------------------------- 1 | where('shopify_namespace', Util::getShopifyConfig('namespace')); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /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/Traits/AuthController.php: -------------------------------------------------------------------------------- 1 | missing('shop') && !$request->user()) { 32 | // One or the other is required to authenticate a shop 33 | throw new MissingShopDomainException('No authenticated user or shop domain'); 34 | } 35 | 36 | // Get the shop domain 37 | $shopDomain = $request->has('shop') 38 | ? ShopDomain::fromNative($request->get('shop')) 39 | : $request->user()->getDomain(); 40 | 41 | // If the domain is obtained from $request->user() 42 | if ($request->missing('shop')) { 43 | $request['shop'] = $shopDomain->toNative(); 44 | } 45 | 46 | // Run the action 47 | [$result, $status] = $authShop($request); 48 | 49 | if ($status === null) { 50 | // Show exception, something is wrong 51 | throw new SignatureVerificationException('Invalid HMAC verification'); 52 | } elseif ($status === false) { 53 | if (!$result['url']) { 54 | throw new MissingAuthUrlException('Missing auth url'); 55 | } 56 | 57 | $shopDomain = $shopDomain->toNative(); 58 | $shopOrigin = $shopDomain ?? $request->user()->name; 59 | 60 | return View::make( 61 | 'shopify-app::auth.fullpage_redirect', 62 | [ 63 | 'apiKey' => Util::getShopifyConfig('api_key', $shopOrigin), 64 | 'appBridgeVersion' => Util::getShopifyConfig('appbridge_version') ? '@'.config('shopify-app.appbridge_version') : '', 65 | 'authUrl' => $result['url'], 66 | 'host' => $request->host ?? base64_encode($shopOrigin.'/admin'), 67 | 'shopDomain' => $shopDomain, 68 | 'shopOrigin' => $shopOrigin, 69 | ] 70 | ); 71 | } else { 72 | // Go to home route 73 | return Redirect::route( 74 | Util::getShopifyConfig('route_names.home'), 75 | [ 76 | 'shop' => $shopDomain->toNative(), 77 | 'host' => $request->host, 78 | ] 79 | ); 80 | } 81 | } 82 | 83 | /** 84 | * Get session token for a shop. 85 | * 86 | * @return ViewView 87 | */ 88 | public function token(Request $request) 89 | { 90 | $request->session()->reflash(); 91 | $shopDomain = ShopDomain::fromRequest($request); 92 | $target = $request->query('target'); 93 | $query = parse_url($target, PHP_URL_QUERY); 94 | 95 | $cleanTarget = $target; 96 | if ($query) { 97 | // remove "token" from the target's query string 98 | $params = Util::parseQueryString($query); 99 | $params['shop'] = $params['shop'] ?? $shopDomain->toNative() ?? ''; 100 | unset($params['token']); 101 | 102 | $cleanTarget = trim(explode('?', $target)[0].'?'.http_build_query($params), '?'); 103 | } else { 104 | $params = ['shop' => $shopDomain->toNative() ?? '']; 105 | $cleanTarget = trim(explode('?', $target)[0].'?'.http_build_query($params), '?'); 106 | } 107 | 108 | return View::make( 109 | 'shopify-app::auth.token', 110 | [ 111 | 'shopDomain' => $shopDomain->toNative(), 112 | 'target' => $cleanTarget, 113 | ] 114 | ); 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/Traits/BillingController.php: -------------------------------------------------------------------------------- 1 | getByDomain(ShopDomain::fromNative($request->get('shop'))); 48 | 49 | // Get the plan URL for redirect 50 | $url = $getPlanUrl( 51 | $shop->getId(), 52 | NullablePlanId::fromNative($plan) 53 | ); 54 | 55 | // Do a fullpage redirect 56 | return View::make( 57 | 'shopify-app::billing.fullpage_redirect', 58 | ['url' => $url] 59 | ); 60 | } 61 | 62 | /** 63 | * Processes the response from the customer. 64 | * 65 | * @param int $plan The plan's ID. 66 | * @param Request $request The HTTP request object. 67 | * @param ShopQuery $shopQuery The shop querier. 68 | * @param ActivatePlan $activatePlan The action for activating the plan for a shop. 69 | * 70 | * @return RedirectResponse 71 | */ 72 | public function process( 73 | int $plan, 74 | Request $request, 75 | ShopQuery $shopQuery, 76 | ActivatePlan $activatePlan 77 | ): RedirectResponse { 78 | // Get the shop 79 | $shop = $shopQuery->getByDomain(ShopDomain::fromNative($request->query('shop'))); 80 | if (!$request->has('charge_id')) { 81 | return Redirect::route(Util::getShopifyConfig('route_names.home'), [ 82 | 'shop' => $shop->getDomain()->toNative(), 83 | 'host' => base64_encode($shop->getDomain()->toNative().'/admin'), 84 | ]); 85 | } 86 | // Activate the plan and save 87 | $result = $activatePlan( 88 | $shop->getId(), 89 | PlanId::fromNative($plan), 90 | ChargeReference::fromNative((int) $request->query('charge_id')) 91 | ); 92 | 93 | // Go to homepage of app 94 | return Redirect::route(Util::getShopifyConfig('route_names.home'), array_merge([ 95 | 'shop' => $shop->getDomain()->toNative(), 96 | ], Util::useNativeAppBridge() ? [] : [ 97 | 'host' => base64_encode($shop->getDomain()->toNative().'/admin'), 98 | 'billing' => $result ? 'success' : 'failure', 99 | ]))->with( 100 | $result ? 'success' : 'failure', 101 | 'billing' 102 | ); 103 | } 104 | 105 | /** 106 | * Allows for setting a usage charge. 107 | * 108 | * @param StoreUsageCharge $request The verified request. 109 | * @param ActivateUsageCharge $activateUsageCharge The action for activating a usage charge. 110 | * @param ShopQuery $shopQuery The shop querier. 111 | * 112 | * @throws MissingShopDomainException|ChargeNotRecurringException 113 | * 114 | * @return RedirectResponse 115 | */ 116 | public function usageCharge( 117 | StoreUsageCharge $request, 118 | ActivateUsageCharge $activateUsageCharge, 119 | ShopQuery $shopQuery 120 | ): RedirectResponse { 121 | $shopDomain = NullableShopDomain::fromNative($request->get('shop')); 122 | // Get the shop from the shop param after it has been validated. 123 | if ($shopDomain->isNull()) { 124 | throw new MissingShopDomainException('Shop parameter is missing from request'); 125 | } 126 | $shop = $shopQuery->getByDomain($shopDomain); 127 | 128 | // Valid the request params. 129 | $validated = $request->validated(); 130 | 131 | // Create the transfer object 132 | $ucd = new UsageChargeDetailsTransfer(); 133 | $ucd->price = $validated['price']; 134 | $ucd->description = $validated['description']; 135 | 136 | // Activate and save the usage charge 137 | $activateUsageCharge($shop->getId(), $ucd); 138 | 139 | // All done, return with success 140 | return isset($validated['redirect']) 141 | ? Redirect::to($validated['redirect'])->with('success', 'usage_charge') 142 | : Redirect::back()->with('success', 'usage_charge'); 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /src/Traits/HomeController.php: -------------------------------------------------------------------------------- 1 | $request->user()] 26 | ); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Traits/ShopAccessible.php: -------------------------------------------------------------------------------- 1 | shop = $shop; 29 | 30 | return $this; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Traits/ShopModel.php: -------------------------------------------------------------------------------- 1 | id); 62 | } 63 | 64 | /** 65 | * {@inheritdoc} 66 | */ 67 | public function getDomain(): ShopDomainValue 68 | { 69 | return ShopDomain::fromNative($this->name); 70 | } 71 | 72 | /** 73 | * {@inheritdoc} 74 | */ 75 | public function getAccessToken(): AccessTokenValue 76 | { 77 | return AccessToken::fromNative($this->password); 78 | } 79 | 80 | /** 81 | * {@inheritdoc} 82 | */ 83 | public function charges(): HasMany 84 | { 85 | return $this->hasMany(Util::getShopifyConfig('models.charge', Charge::class)); 86 | } 87 | 88 | /** 89 | * {@inheritdoc} 90 | */ 91 | public function hasCharges(): bool 92 | { 93 | return $this->charges->isNotEmpty(); 94 | } 95 | 96 | /** 97 | * {@inheritdoc} 98 | */ 99 | public function plan(): BelongsTo 100 | { 101 | return $this->belongsTo(Util::getShopifyConfig('models.plan', Plan::class)); 102 | } 103 | 104 | /** 105 | * {@inheritdoc} 106 | */ 107 | public function isGrandfathered(): bool 108 | { 109 | return (bool) $this->shopify_grandfathered === true; 110 | } 111 | 112 | /** 113 | * {@inheritdoc} 114 | */ 115 | public function isFreemium(): bool 116 | { 117 | return (bool) $this->shopify_freemium === true; 118 | } 119 | 120 | /** 121 | * {@inheritdoc} 122 | */ 123 | public function hasOfflineAccess(): bool 124 | { 125 | return ! $this->getAccessToken()->isNull() && ! empty($this->password); 126 | } 127 | 128 | /** 129 | * {@inheritDoc} 130 | */ 131 | public function setSessionContext(SessionContext $session): void 132 | { 133 | $this->sessionContext = $session; 134 | } 135 | 136 | /** 137 | * {@inheritDoc} 138 | */ 139 | public function getSessionContext(): ?SessionContext 140 | { 141 | return $this->sessionContext; 142 | } 143 | 144 | /** 145 | * {@inheritdoc} 146 | */ 147 | public function apiHelper(): IApiHelper 148 | { 149 | if ($this->apiHelper === null) { 150 | // Set the session 151 | $session = new Session( 152 | $this->getDomain()->toNative(), 153 | $this->getAccessToken()->toNative() 154 | ); 155 | $this->apiHelper = resolve(IApiHelper::class)->make($session); 156 | } 157 | 158 | return $this->apiHelper; 159 | } 160 | 161 | /** 162 | * {@inheritdoc} 163 | */ 164 | public function api(): BasicShopifyAPI 165 | { 166 | if ($this->apiHelper === null) { 167 | $this->apiHelper(); 168 | } 169 | 170 | return $this->apiHelper->getApi(); 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /src/Traits/WebhookController.php: -------------------------------------------------------------------------------- 1 | getContent()); 28 | 29 | $jobClass::dispatch( 30 | $request->header('x-shopify-shop-domain'), 31 | $jobData 32 | )->onQueue(Util::getShopifyConfig('job_queues')['webhooks']); 33 | 34 | return Response::make('', ResponseResponse::HTTP_CREATED); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /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/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/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/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/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/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/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/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/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/jobs/AppUninstalledJob.php: -------------------------------------------------------------------------------- 1 | ['api']], function () use ($manualRoutes) { 17 | /* 18 | |-------------------------------------------------------------------------- 19 | | API Routes 20 | |-------------------------------------------------------------------------- 21 | | 22 | | Exposes endpoints for the current user data, and all plans. 23 | | 24 | */ 25 | 26 | if (Util::registerPackageRoute('api', $manualRoutes)) { 27 | Route::group(['prefix' => 'api', 'middleware' => ['verify.shopify']], function () { 28 | Route::get( 29 | '/', 30 | ApiController::class.'@index' 31 | ); 32 | 33 | Route::get( 34 | '/me', 35 | ApiController::class.'@getSelf' 36 | ); 37 | 38 | Route::get( 39 | '/plans', 40 | ApiController::class.'@getPlans' 41 | ); 42 | }); 43 | } 44 | 45 | /* 46 | |-------------------------------------------------------------------------- 47 | | Webhook Handler 48 | |-------------------------------------------------------------------------- 49 | | 50 | | Handles incoming webhooks. 51 | | 52 | */ 53 | 54 | if (Util::registerPackageRoute('webhook', $manualRoutes)) { 55 | Route::post( 56 | '/webhook/{type}', 57 | WebhookController::class.'@handle' 58 | ) 59 | ->middleware('auth.webhook') 60 | ->name(Util::getShopifyConfig('route_names.webhook')); 61 | } 62 | }); 63 | -------------------------------------------------------------------------------- /src/resources/routes/shopify.php: -------------------------------------------------------------------------------- 1 | Util::getShopifyConfig('prefix'), 'middleware' => ['web']], function () use ($manualRoutes) { 27 | /* 28 | |-------------------------------------------------------------------------- 29 | | Home Route 30 | |-------------------------------------------------------------------------- 31 | | 32 | | Homepage for an authenticated store. Store is checked with the 33 | | auth.shopify middleware and redirected to login if not. 34 | | 35 | */ 36 | 37 | if (Util::registerPackageRoute('home', $manualRoutes)) { 38 | Route::get( 39 | '/', 40 | HomeController::class.'@index' 41 | ) 42 | ->middleware(['verify.shopify', 'billable']) 43 | ->name(Util::getShopifyConfig('route_names.home')); 44 | } 45 | 46 | /* 47 | |-------------------------------------------------------------------------- 48 | | Authenticate: Install & Authorize 49 | |-------------------------------------------------------------------------- 50 | | 51 | | Install a shop and go through Shopify OAuth. 52 | | 53 | */ 54 | 55 | if (Util::registerPackageRoute('authenticate', $manualRoutes)) { 56 | Route::match( 57 | ['GET', 'POST'], 58 | '/authenticate', 59 | AuthController::class.'@authenticate' 60 | ) 61 | ->name(Util::getShopifyConfig('route_names.authenticate')); 62 | } 63 | 64 | /* 65 | |-------------------------------------------------------------------------- 66 | | Authenticate: Token 67 | |-------------------------------------------------------------------------- 68 | | 69 | | This route is hit when a shop comes to the app without a session token 70 | | yet. A token will be grabbed from Shopify AppBridge Javascript 71 | | and then forwarded back to the home route. 72 | | 73 | */ 74 | 75 | if (Util::registerPackageRoute('authenticate.token', $manualRoutes)) { 76 | Route::get( 77 | '/authenticate/token', 78 | AuthController::class.'@token' 79 | ) 80 | ->middleware(['verify.shopify']) 81 | ->name(Util::getShopifyConfig('route_names.authenticate.token')); 82 | } 83 | 84 | /* 85 | |-------------------------------------------------------------------------- 86 | | Billing Handler 87 | |-------------------------------------------------------------------------- 88 | | 89 | | Billing handler. Sends to billing screen for Shopify. 90 | | 91 | */ 92 | 93 | if (Util::registerPackageRoute('billing', $manualRoutes)) { 94 | Route::get( 95 | '/billing/{plan?}', 96 | BillingController::class.'@index' 97 | ) 98 | ->middleware(['verify.shopify']) 99 | ->where('plan', '^([0-9]+|)$') 100 | ->name(Util::getShopifyConfig('route_names.billing')); 101 | } 102 | 103 | /* 104 | |-------------------------------------------------------------------------- 105 | | Billing Processor 106 | |-------------------------------------------------------------------------- 107 | | 108 | | Processes the customer's response to the billing screen. 109 | | The shop domain is encrypted. 110 | | 111 | */ 112 | 113 | if (Util::registerPackageRoute('billing.process', $manualRoutes)) { 114 | Route::get( 115 | '/billing/process/{plan?}', 116 | BillingController::class.'@process' 117 | ) 118 | ->middleware(['verify.shopify']) 119 | ->where('plan', '^([0-9]+|)$') 120 | ->name(Util::getShopifyConfig('route_names.billing.process')); 121 | } 122 | 123 | /* 124 | |-------------------------------------------------------------------------- 125 | | Billing Processor for Usage Charges 126 | |-------------------------------------------------------------------------- 127 | | 128 | | Creates a usage charge on a recurring charge. 129 | | 130 | */ 131 | 132 | if (Util::registerPackageRoute('billing.usage_charge', $manualRoutes)) { 133 | Route::match( 134 | ['get', 'post'], 135 | '/billing/usage-charge', 136 | BillingController::class.'@usageCharge' 137 | ) 138 | ->middleware(['verify.shopify']) 139 | ->name(Util::getShopifyConfig('route_names.billing.usage_charge')); 140 | } 141 | }); 142 | -------------------------------------------------------------------------------- /src/resources/views/auth/fullpage_redirect.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Redirecting... 8 | 9 | 10 | 11 | 36 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /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 | 38 | @if(config('shopify-app.appbridge_enabled')) 39 | 44 | @endif 45 | @endsection 46 | -------------------------------------------------------------------------------- /src/resources/views/billing/error.blade.php: -------------------------------------------------------------------------------- 1 | @extends('shopify-app::layouts.error') 2 | 3 | @section('content') 4 |
5 |
6 |
Oops!
7 |

{{ $message }}

8 |
9 |
10 |
11 | @endsection 12 | -------------------------------------------------------------------------------- /src/resources/views/billing/fullpage_redirect.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Redirecting... 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /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 | Laravel & Shopify 12 |
13 | 14 |

Welcome to your Shopify App powered by Laravel.

15 |

 

16 |

{{ $shop->name }}

17 |

 

18 | 19 | 24 |
25 |
26 | @endsection 27 | 28 | @section('scripts') 29 | @parent 30 | 31 | @if(config('shopify-app.appbridge_enabled')) 32 | 35 | @endif 36 | @endsection 37 | -------------------------------------------------------------------------------- /src/resources/views/layouts/default.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | {{ \Osiset\ShopifyApp\Util::getShopifyConfig('app_name') }} 8 | @yield('styles') 9 | 10 | 11 | 12 |
13 |
14 |
15 | @yield('content') 16 |
17 |
18 |
19 | 20 | @if(\Osiset\ShopifyApp\Util::getShopifyConfig('appbridge_enabled') && \Osiset\ShopifyApp\Util::useNativeAppBridge()) 21 | 22 | 23 | 39 | 40 | @include('shopify-app::partials.token_handler') 41 | @include('shopify-app::partials.flash_messages') 42 | @endif 43 | 44 | @yield('scripts') 45 | 46 | 47 | -------------------------------------------------------------------------------- /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/resources/views/partials/flash_messages.blade.php: -------------------------------------------------------------------------------- 1 | 23 | -------------------------------------------------------------------------------- /src/resources/views/partials/laravel_skeleton_css.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 55 | -------------------------------------------------------------------------------- /src/resources/views/partials/token_handler.blade.php: -------------------------------------------------------------------------------- 1 | 62 | --------------------------------------------------------------------------------