9 |
33 | @endsection
34 |
35 | @section('scripts')
36 | @parent
37 |
64 | @endsection
65 |
--------------------------------------------------------------------------------
/src/Console/WebhookJobMakeCommand.php:
--------------------------------------------------------------------------------
1 | argument('topic'));
58 | $type = $this->getUrlFromName($this->argument('name'));
59 | $address = route(Util::getShopifyConfig('route_names.webhook'), $type);
60 |
61 | // Remind user to enter job into config
62 | $this->info("For non-GDPR webhooks, don't forget to register the webhook in config/shopify-app.php. Example:");
63 | $this->info("
64 | 'webhooks' => [
65 | [
66 | 'topic' => '$topic',
67 | 'address' => '$address'
68 | ]
69 | ]
70 | ");
71 |
72 | return $result;
73 | }
74 |
75 | /**
76 | * Append "Job" to the end of class name.
77 | *
78 | * @return string
79 | */
80 | protected function getNameInput(): string
81 | {
82 | return Str::finish(parent::getNameInput(), 'Job');
83 | }
84 |
85 | /**
86 | * Converts the job class name into a URL endpoint.
87 | *
88 | * @param string $name The name of the job
89 | *
90 | * @return string
91 | */
92 | protected function getUrlFromName(string $name): string
93 | {
94 | return Str::of($name)
95 | ->trim()
96 | ->replaceMatches('/Job$/', '')
97 | ->replaceMatches('/(?lower();
99 | }
100 | }
101 |
--------------------------------------------------------------------------------
/src/Http/Middleware/AuthProxy.php:
--------------------------------------------------------------------------------
1 | auth = $auth;
46 | $this->shopQuery = $shopQuery;
47 | }
48 |
49 | /**
50 | * Handle an incoming request to ensure it is valid.
51 | *
52 | * @param Request $request The request object.
53 | * @param Closure $next The next action.
54 | *
55 | * @return mixed
56 | */
57 | public function handle(Request $request, Closure $next)
58 | {
59 | // Grab the query parameters we need
60 | $query = Util::parseQueryString($request->server->get('QUERY_STRING'));
61 | $signature = Arr::get($query, 'signature', '');
62 | $shop = NullableShopDomain::fromNative(Arr::get($query, 'shop'));
63 |
64 | if (! empty($signature)) {
65 | // Remove signature since its not part of the signature calculation
66 | Arr::forget($query, 'signature');
67 | }
68 |
69 | // Build a local signature
70 | $signatureLocal = Util::createHmac(
71 | [
72 | 'data' => $query,
73 | 'buildQuery' => true,
74 | ],
75 | Util::getShopifyConfig('api_secret', $shop)
76 | );
77 | if ($shop->isNull() || ! Hmac::fromNative($signature)->isSame($signatureLocal)) {
78 | // Issue with HMAC or missing shop header
79 | return Response::make('Invalid proxy signature.', HttpResponse::HTTP_UNAUTHORIZED);
80 | }
81 |
82 | // Login the shop
83 | $shop = $this->shopQuery->getByDomain($shop);
84 | if ($shop) {
85 | // Override auth guard
86 | if (($guard = Util::getShopifyConfig('shop_auth_guard'))) {
87 | $this->auth->setDefaultDriver($guard);
88 | }
89 |
90 | $this->auth->login($shop);
91 | }
92 |
93 | // All good, process proxy request
94 | return $next($request);
95 | }
96 | }
97 |
--------------------------------------------------------------------------------
/src/Contracts/ShopModel.php:
--------------------------------------------------------------------------------
1 | shopQuery->getByDomain($shopDomain, [], true);
28 |
29 | if ($shop === null) {
30 | $this->shopCommand->make($shopDomain, NullAccessToken::fromNative(null));
31 | $shop = $this->shopQuery->getByDomain($shopDomain);
32 | }
33 |
34 | $apiHelper = $shop->apiHelper();
35 | $grantMode = $shop->hasOfflineAccess() ?
36 | AuthMode::fromNative(Util::getShopifyConfig('api_grant_mode', $shop)) :
37 | AuthMode::OFFLINE();
38 |
39 | if (empty($code) && empty($idToken)) {
40 | return [
41 | 'completed' => false,
42 | 'url' => $apiHelper->buildAuthUrl($grantMode, Util::getShopifyConfig('api_scopes', $shop)),
43 | 'shop_id' => $shop->getId(),
44 | ];
45 | }
46 |
47 | try {
48 | if ($shop->trashed()) {
49 | $shop->restore();
50 | }
51 |
52 | // Get the data and set the access token
53 | $data = $idToken !== null ? $apiHelper->performOfflineTokenExchange($idToken) : $apiHelper->getAccessData($code);
54 | $this->shopCommand->setAccessToken($shop->getId(), AccessToken::fromNative($data['access_token']));
55 |
56 | try {
57 | $themeSupportLevel = call_user_func($this->verifyThemeSupport, $shop->getId());
58 | $this->shopCommand->setThemeSupportLevel($shop->getId(), ThemeSupportLevel::fromNative($themeSupportLevel));
59 | } catch (Exception $e) {
60 | $themeSupportLevel = ThemeSupportLevelEnum::NONE;
61 | }
62 |
63 |
64 | return [
65 | 'completed' => true,
66 | 'url' => null,
67 | 'shop_id' => $shop->getId(),
68 | 'theme_support_level' => $themeSupportLevel,
69 | ];
70 | } catch (Exception $e) {
71 | return [
72 | 'completed' => false,
73 | 'url' => null,
74 | 'shop_id' => null,
75 | 'theme_support_level' => null,
76 | ];
77 | }
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/src/Messaging/Jobs/AppUninstalledJob.php:
--------------------------------------------------------------------------------
1 | domain = $domain;
53 | $this->data = $data;
54 | }
55 |
56 | /**
57 | * Execute the job.
58 | *
59 | * @param IShopCommand $shopCommand The commands for shops.
60 | * @param IShopQuery $shopQuery The querier for shops.
61 | * @param CancelCurrentPlan $cancelCurrentPlanAction The action for cancelling the current plan.
62 | *
63 | * @return bool
64 | */
65 | public function handle(
66 | IShopCommand $shopCommand,
67 | IShopQuery $shopQuery,
68 | CancelCurrentPlan $cancelCurrentPlanAction
69 | ): bool {
70 | // Convert the domain
71 | $this->domain = ShopDomain::fromNative($this->domain);
72 |
73 | // Get the shop
74 | $shop = $shopQuery->getByDomain($this->domain);
75 | if (!$shop) {
76 | return true;
77 | }
78 | $shopId = $shop->getId();
79 |
80 | // Cancel the current plan
81 | $cancelCurrentPlanAction($shopId);
82 |
83 | // Purge shop of token, plan, etc.
84 | $shopCommand->clean($shopId);
85 |
86 | // Check freemium mode
87 | if (Util::getShopifyConfig('billing_freemium_enabled') === true) {
88 | // Add the freemium flag to the shop
89 | $shopCommand->setAsFreemium($shopId);
90 | }
91 |
92 | // Soft delete the shop.
93 | $shopCommand->softDelete($shopId);
94 |
95 | event(new AppUninstalledEvent($shop));
96 |
97 | return true;
98 | }
99 | }
100 |
--------------------------------------------------------------------------------
/src/Http/Middleware/VerifyScopes.php:
--------------------------------------------------------------------------------
1 | user();
24 |
25 | if ($shop) {
26 | $response = $this->currentScopes($shop);
27 |
28 | if ($response['hasErrors']) {
29 | return $next($request);
30 | }
31 |
32 | $hasMissingScopes = filled(
33 | array_diff(
34 | explode(',', config('shopify-app.api_scopes')),
35 | $response['result']
36 | )
37 | );
38 |
39 | if ($hasMissingScopes) {
40 | Cache::forget($this->cacheKey($shop->getDomain()->toNative()));
41 |
42 | return Redirect::route(Util::getShopifyConfig('route_names.authenticate'), [
43 | 'shop' => $shop->getDomain()->toNative(),
44 | 'host' => $request->get('host'),
45 | 'locale' => $request->get('locale'),
46 | ]);
47 | }
48 | }
49 |
50 | return $next($request);
51 | }
52 |
53 | /**
54 | * @return array{hasErrors: bool, result: string[]}
55 | */
56 | private function currentScopes(ShopModel $shop): array
57 | {
58 | /** @var array{errors: bool, status: int, body: \Gnikyt\BasicShopifyAPI\ResponseAccess} */
59 | $response = Cache::remember(
60 | $this->cacheKey($shop->getDomain()->toNative()),
61 | now()->addDay(),
62 | fn () => $shop->api()->graph('{
63 | currentAppInstallation {
64 | accessScopes {
65 | handle
66 | }
67 | }
68 | }')
69 | );
70 |
71 | if (! $response['errors'] && blank(data_get($response['body']->toArray(), 'data.currentAppInstallation.userErrors'))) {
72 | return [
73 | 'hasErrors' => false,
74 | 'result' => array_column(
75 | data_get($response['body'], 'data.currentAppInstallation.accessScopes')->toArray(),
76 | 'handle'
77 | ),
78 | ];
79 | }
80 |
81 | Log::error('Fetch current app installation access scopes error: '.json_encode(data_get($response['body']->toArray(), 'data.currentAppInstallation.userErrors')));
82 |
83 | return [
84 | 'hasErrors' => true,
85 | 'result' => [],
86 | ];
87 | }
88 |
89 | private function cacheKey(string $shopDomain): string
90 | {
91 | return sprintf("{$shopDomain}.%s", self::CURRENT_SCOPES_CACHE_KEY);
92 | }
93 | }
94 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Laravel Shopify App
2 |
3 | 
4 | [](https://codecov.io/gh/kyon147/laravel-shopify)
5 | [](https://packagist.org/packages/osiset/laravel-shopify)
6 |
7 | ----
8 |
9 | This is a maintained version of the wonderful but now deprecated original [Laravel Shopify App](https://github.com/gnikyt/laravel-shopify/). To keep things clean, this has been detached from the original.
10 |
11 | ----
12 | To install this package run:
13 | ```
14 | composer require kyon147/laravel-shopify
15 | ```
16 | Publish the config file:
17 | ```
18 | php artisan vendor:publish --tag=shopify-config
19 | ```
20 | ----
21 |
22 | A full-featured Laravel package for aiding in Shopify App development, similar to `shopify_app` for Rails. Works for Laravel 8 and up.
23 |
24 | 
25 | 
26 |
27 | ## Table of Contents
28 |
29 | __*__ *Wiki pages*
30 |
31 | - [Goals](#goals)
32 | - [Documentation](#documentation)
33 | - [Installation](../../wiki/Installation)*
34 | - [Route List](../../wiki/Route-List)*
35 | - [Usage](../../wiki/Usage)*
36 | - [Changelog](../../wiki/Changelog)*
37 | - [Contributing Guide](CONTRIBUTING.md)
38 | - [LICENSE](#license)
39 |
40 | For more information, tutorials, etc., please view the project's [wiki](../../wiki).
41 |
42 | ## Goals
43 |
44 | - [ ] Per User Auth Working
45 | - [ ] Better support for SPA apps using VueJS
46 | - [ ] Getting "Blade" templates working better with Shopify's new auth process???
47 |
48 | ## Documentation
49 |
50 | For full resources on this package, see the [wiki](../..//wiki).
51 |
52 | ## Issue or request?
53 |
54 | If you have found a bug or would like to request a feature for discussion, please use the `ISSUE_TEMPLATE` in this repo when creating your issue. Any issue submitted without this template will be closed.
55 |
56 | ## License
57 |
58 | This project is released under the MIT [license](LICENSE).
59 |
60 | ## Misc
61 |
62 | ### Repository
63 |
64 | #### Contributors
65 |
66 | Contributions are always welcome! Contibutors are updated each release, pulled from Github. See [`CONTRIBUTORS.txt`](CONTRIBUTORS.txt).
67 |
68 | If you're looking to become a contributor, please see [`CONTRIBUTING.md`](CONTRIBUTING.md).
69 |
70 | #### Maintainers
71 |
72 | Maintainers are users who manage the repository itself, whether it's managing the issues, assisting in releases, or helping with pull requests.
73 |
74 | Currently this repository is maintained by:
75 |
76 | - [@kyon147](https://github.com/kyon147)
77 | - ~[@gnikyt](https://github.com/gnikyt)~ Original author of the package. See [announcement](https://github.com/gnikyt/laravel-shopify/discussions/1276) for details.
78 |
79 | Looking to become a maintainer? E-mail @kyon147 directly.
80 |
81 | ### Special Note
82 |
83 | I develop this package in my spare time, with a busy family/work life like many of you! So, I would like to thank everyone who's helped me out from submitting PRs, to assisting on issues, and plain using the package (I hope its useful). Cheers.
84 |
--------------------------------------------------------------------------------
/src/Actions/ActivatePlan.php:
--------------------------------------------------------------------------------
1 | shopQuery->getById($shopId);
42 | $plan = $this->planQuery->getById($planId);
43 | $chargeType = ChargeType::fromNative($plan->getType()->toNative());
44 |
45 | // Activate the plan on Shopify
46 | $response = $shop->apiHelper()->activateCharge($chargeType, $chargeRef);
47 | // Cancel the shop's current plan
48 | call_user_func($this->cancelCurrentPlan, $shopId);
49 | // Cancel the existing charge if it exists (happens if someone refreshes during)
50 | $this->chargeCommand->delete($chargeRef, $shopId);
51 |
52 | $transfer = new ChargeTransfer();
53 | $transfer->shopId = $shopId;
54 | $transfer->planId = $planId;
55 | $transfer->chargeReference = $chargeRef;
56 | $transfer->chargeType = $chargeType;
57 | $transfer->chargeStatus = ChargeStatus::fromNative(strtoupper($response['status']));
58 | $transfer->planDetails = $this->chargeHelper->details($plan, $shop, $host);
59 |
60 | if ($plan->isType(PlanType::RECURRING())) {
61 | $transfer->activatedOn = new Carbon($response['activated_on']);
62 | $transfer->billingOn = new Carbon($response['billing_on']);
63 | $transfer->trialEndsOn = new Carbon($response['trial_ends_on']);
64 | } else {
65 | $transfer->activatedOn = Carbon::today();
66 | $transfer->billingOn = null;
67 | $transfer->trialEndsOn = null;
68 | }
69 |
70 | $charge = $this->chargeCommand->make($transfer);
71 | $this->shopCommand->setToPlan($shopId, $planId);
72 |
73 | event(new PlanActivatedEvent($shop, $plan, $charge));
74 |
75 | return $charge;
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/src/Contracts/Commands/Shop.php:
--------------------------------------------------------------------------------
1 | envPath();
41 |
42 | if (file_exists($env)) {
43 | foreach ($this->shopifyVariables() as $key => $variable) {
44 | if (Str::contains(file_get_contents($env), $key) === false) {
45 | file_put_contents($env, PHP_EOL."$key=$variable", FILE_APPEND);
46 | } else {
47 | if ($this->option('always-no')) {
48 | $this->comment("Variable $key already exists. Skipping...");
49 |
50 | continue;
51 | }
52 |
53 | if ($this->isConfirmed($key) === false) {
54 | $this->comment('There has been no change.');
55 |
56 | continue;
57 | }
58 | }
59 | }
60 | }
61 |
62 | $this->successResult();
63 | }
64 |
65 | /**
66 | * Display result.
67 | *
68 | * @return void
69 | */
70 | protected function successResult(): void
71 | {
72 | $this->info('All variables will be set');
73 | }
74 |
75 | /**
76 | * Check if the modification is confirmed.
77 | *
78 | * @param string $key
79 | *
80 | * @return bool
81 | */
82 | protected function isConfirmed(string $key): bool
83 | {
84 | return $this->option('force')
85 | ? true
86 | : $this->confirm(
87 | "This will invalidate {$key} variable. Are you sure you want to override {$key}?"
88 | );
89 | }
90 |
91 | /**
92 | * Get shopify env variables
93 | *
94 | * @return array
95 | */
96 | public function shopifyVariables(): array
97 | {
98 | return [
99 | 'SHOPIFY_APP_NAME' => config('app.name'),
100 | 'SHOPIFY_API_KEY' => '',
101 | 'SHOPIFY_API_SECRET' => '',
102 | 'SHOPIFY_API_SCOPES' => config('shopify-app.api_scopes'),
103 | 'AFTER_AUTHENTICATE_JOB' => "\App\Jobs\AfterAuthenticateJob",
104 | ];
105 | }
106 |
107 | /**
108 | * Get the .env file path.
109 | *
110 | * @return string
111 | */
112 | protected function envPath()
113 | {
114 | $enviromemtFile = $this->laravel->environmentFile();
115 | $baseEnvFile = $this->laravel->basePath('.env');
116 |
117 | if (file_exists($enviromemtFile) && method_exists($this->laravel, 'environmentFile')) {
118 | return $enviromemtFile;
119 | }
120 |
121 | return $baseEnvFile;
122 | }
123 | }
124 |
--------------------------------------------------------------------------------
/src/Storage/Models/Plan.php:
--------------------------------------------------------------------------------
1 | 'bool',
24 | 'on_install' => 'bool',
25 | 'capped_amount' => 'float',
26 | 'price' => 'float',
27 | ];
28 |
29 | /**
30 | * Get table name.
31 | *
32 | * @return string
33 | */
34 | public function getTable(): string
35 | {
36 | return Util::getShopifyConfig('table_names.plans', parent::getTable());
37 | }
38 |
39 | /**
40 | * Get the plan ID as a value object.
41 | *
42 | * @return PlanId
43 | */
44 | public function getId(): PlanId
45 | {
46 | return PlanId::fromNative((int) $this->id);
47 | }
48 |
49 | /**
50 | * Get charges.
51 | *
52 | * @return HasMany
53 | */
54 | public function charges(): HasMany
55 | {
56 | return $this->hasMany(Charge::class);
57 | }
58 |
59 | /**
60 | * Gets the type of plan.
61 | *
62 | * @return PlanType
63 | */
64 | public function getType(): PlanType
65 | {
66 | return PlanType::fromNative($this->type);
67 | }
68 |
69 | /**
70 | * Gets the interval of plan.
71 | *
72 | * @return PlanInterval
73 | */
74 | public function getInterval(): PlanInterval
75 | {
76 | return $this->interval ? PlanInterval::fromNative($this->interval) : PlanInterval::EVERY_30_DAYS();
77 | }
78 |
79 | /**
80 | * Checks the plan type.
81 | *
82 | * @param PlanType $type The plan type.
83 | *
84 | * @return bool
85 | */
86 | public function isType(PlanType $type): bool
87 | {
88 | return $this->getType()->isSame($type);
89 | }
90 |
91 | /**
92 | * Returns the plan type as a string (for API).
93 | *
94 | * @param bool $plural Return the plural form or not.
95 | *
96 | * @return string
97 | */
98 | public function getTypeApiString($plural = false): string
99 | {
100 | $types = [
101 | PlanType::ONETIME()->toNative() => 'application_charge',
102 | PlanType::RECURRING()->toNative() => 'recurring_application_charge',
103 | ];
104 | $type = $types[$this->getType()->toNative()];
105 |
106 | return $plural ? "{$type}s" : $type;
107 | }
108 |
109 | /**
110 | * Checks if this plan has a trial.
111 | *
112 | * @return bool
113 | */
114 | public function hasTrial(): bool
115 | {
116 | return $this->trial_days !== null && $this->trial_days > 0;
117 | }
118 |
119 | /**
120 | * Checks if this plan should be presented on install.
121 | *
122 | * @return bool
123 | */
124 | public function isOnInstall(): bool
125 | {
126 | return (bool) $this->on_install;
127 | }
128 |
129 | /**
130 | * Checks if the plan is a test.
131 | *
132 | * @return bool
133 | */
134 | public function isTest(): bool
135 | {
136 | return (bool) $this->test;
137 | }
138 | }
139 |
--------------------------------------------------------------------------------
/src/Services/CookieHelper.php:
--------------------------------------------------------------------------------
1 | agent = new Agent();
28 | }
29 |
30 | /**
31 | * Sets the cookie policy.
32 | *
33 | * From Chrome 80+ there is a new requirement that the SameSite
34 | * cookie flag be set to `none` and the cookies be marked with
35 | * `secure`.
36 | *
37 | * Reference: https://www.chromium.org/updates/same-site/incompatible-clients
38 | *
39 | * Enables SameSite none and Secure cookies on:
40 | *
41 | * - Chrome v67+
42 | * - Safari on OSX 10.14+
43 | * - iOS 13+
44 | * - UCBrowser 12.13+
45 | *
46 | * @return void
47 | */
48 | public function setCookiePolicy(): void
49 | {
50 | Config::set('session.expire_on_close', true);
51 |
52 | if ($this->checkSameSiteNoneCompatible()) {
53 | Config::set('session.secure', true);
54 | Config::set('session.same_site', 'none');
55 | }
56 | }
57 |
58 | /**
59 | * Checks to see if the current browser session should be
60 | * using the SameSite=none cookie policy.
61 | *
62 | * @return bool
63 | */
64 | public function checkSameSiteNoneCompatible(): bool
65 | {
66 | $compatible = false;
67 | $browser = $this->getBrowserDetails();
68 | $platform = $this->getPlatformDetails();
69 |
70 | if ($browser['major'] >= 67 && $this->agent->is('Chrome')) {
71 | $compatible = true;
72 | }
73 |
74 | if ($platform['major'] > 12 && $this->agent->is('iOS')) {
75 | $compatible = true;
76 | }
77 |
78 | if ($platform['float'] > 10.14 &&
79 | $this->agent->is('OS X') && $this->agent->is('Safari') && ! $this->agent->is('iOS')
80 | ) {
81 | $compatible = true;
82 | }
83 |
84 | if ($browser['float'] > 12.13 && $this->agent->is('UCBrowser')) {
85 | $compatible = true;
86 | }
87 |
88 | return $compatible;
89 | }
90 |
91 | /**
92 | * Returns details about the current web browser.
93 | *
94 | * @return array
95 | */
96 | public function getBrowserDetails(): array
97 | {
98 | return $this->version($this->agent->browser());
99 | }
100 |
101 | /**
102 | * Returns details about the current operating system.
103 | *
104 | * @return array
105 | */
106 | public function getPlatformDetails(): array
107 | {
108 | return $this->version($this->agent->platform());
109 | }
110 |
111 | /**
112 | * Create a versioned array from a source.
113 | *
114 | * @param string $source The source string to version.
115 | *
116 | * @return array
117 | */
118 | protected function version(string $source): array
119 | {
120 | $version = $this->agent->version($source);
121 | $pieces = explode('.', str_replace('_', '.', $version));
122 |
123 | return [
124 | 'major' => $pieces[0] ?? null,
125 | 'minor' => $pieces[1] ?? null,
126 | 'float' => isset($pieces[0]) && isset($pieces[1]) ? (float) sprintf('%s.%s', $pieces[0], $pieces[1]) : null,
127 | ];
128 | }
129 | }
130 |
--------------------------------------------------------------------------------
/src/Objects/Values/SessionContext.php:
--------------------------------------------------------------------------------
1 | sessionToken = $sessionToken;
56 | $this->sessionId = $sessionId;
57 | $this->accessToken = $accessToken;
58 | }
59 |
60 | /**
61 | * Get the session token.
62 | *
63 | * @return SessionTokenValue
64 | */
65 | public function getSessionToken(): SessionTokenValue
66 | {
67 | return $this->sessionToken;
68 | }
69 |
70 | /**
71 | * Get the access token.
72 | *
73 | * @return AccessTokenValue
74 | */
75 | public function getAccessToken(): AccessTokenValue
76 | {
77 | return $this->accessToken;
78 | }
79 |
80 | /**
81 | * Get the session ID.
82 | *
83 | * @return SessionIdValue
84 | */
85 | public function getSessionId(): SessionIdValue
86 | {
87 | return $this->sessionId;
88 | }
89 |
90 | /**
91 | * {@inheritDoc}
92 | */
93 | public static function fromNative($native)
94 | {
95 | return new static(
96 | NullableSessionToken::fromNative(Arr::get($native, 'session_token')),
97 | NullableSessionId::fromNative(Arr::get($native, 'session_id')),
98 | NullableAccessToken::fromNative(Arr::get($native, 'access_token'))
99 | );
100 | }
101 |
102 | /**
103 | * Confirm session is valid.
104 | * TODO: Add per-user support.
105 | *
106 | * @param SessionContext|null $previousContext The last session context (if available).
107 | *
108 | * @return bool
109 | */
110 | public function isValid(?self $previousContext = null): bool
111 | {
112 | // Confirm access token and session token are good
113 | $tokenCheck = ! $this->getAccessToken()->isEmpty() && ! $this->getSessionToken()->isNull();
114 |
115 | // Compare data
116 | $sidCheck = true;
117 | $domainCheck = true;
118 | if ($previousContext !== null) {
119 | /** @var $previousToken SessionToken */
120 | $previousToken = $previousContext->getSessionToken();
121 | /** @var $currentToken SessionToken */
122 | $currentToken = $this->getSessionToken();
123 |
124 | // Compare the domains
125 | $domainCheck = $previousToken->getShopDomain()->isSame($currentToken->getShopDomain());
126 |
127 | // Compare the session IDs
128 | if (! $previousContext->getSessionId()->isNull() && ! $this->getSessionId()->isNull()) {
129 | $sidCheck = $previousContext->getSessionId()->isSame($this->getSessionId());
130 | }
131 | }
132 |
133 | return $tokenCheck && $sidCheck && $domainCheck;
134 | }
135 | }
136 |
--------------------------------------------------------------------------------
/src/resources/database/migrations/2020_01_29_231006_create_charges_table.php:
--------------------------------------------------------------------------------
1 | increments('id');
21 |
22 | // Filled in when the charge is created, provided by shopify, unique makes it indexed
23 | $table->bigInteger('charge_id');
24 |
25 | // Test mode or real
26 | $table->boolean('test')->default(false);
27 |
28 | $table->string('status')->nullable();
29 |
30 | // Name of the charge (for recurring or one time charges)
31 | $table->string('name')->nullable();
32 |
33 | // Terms for the usage charges
34 | $table->string('terms')->nullable();
35 |
36 | // Integer value representing a recurring, one time, usage, or application_credit.
37 | // This also allows us to store usage based charges not just subscription or one time charges.
38 | // We will be able to do things like create a charge history for a shop if they have multiple charges.
39 | // For instance, usage based or an app that has multiple purchases.
40 | $table->string('type');
41 |
42 | // Store the amount of the charge, this helps if you are experimenting with pricing
43 | $table->decimal('price', 8, 2);
44 |
45 | // Store the amount of the charge, this helps if you are experimenting with pricing
46 | $table->decimal('capped_amount', 8, 2)->nullable();
47 |
48 | // Nullable in case of 0 trial days
49 | $table->integer('trial_days')->nullable();
50 |
51 | // The recurring application charge must be accepted or the returned value is null
52 | $table->timestamp('billing_on')->nullable();
53 |
54 | // When activation happened
55 | $table->timestamp('activated_on')->nullable();
56 |
57 | // Date the trial period ends
58 | $table->timestamp('trial_ends_on')->nullable();
59 |
60 | // Not supported on Shopify initial billing screen, but good for future use
61 | $table->timestamp('cancelled_on')->nullable();
62 |
63 | // Expires on
64 | $table->timestamp('expires_on')->nullable();
65 |
66 | // Plan ID for the charge
67 | $table->integer('plan_id')->unsigned()->nullable();
68 |
69 | // Description support
70 | $table->string('description')->nullable();
71 |
72 | // Linking to charge_id
73 | $table->bigInteger('reference_charge')->nullable();
74 |
75 | // Provides created_at && updated_at columns
76 | $table->timestamps();
77 |
78 | // Allows for soft deleting
79 | $table->softDeletes();
80 |
81 | if ($this->getLaravelVersion() < 5.8) {
82 | $table->integer(Util::getShopsTableForeignKey())->unsigned();
83 | } else {
84 | $table->bigInteger(Util::getShopsTableForeignKey())->unsigned();
85 | }
86 |
87 | // Linking
88 | $table->foreign(Util::getShopsTableForeignKey())->references('id')->on(Util::getShopsTable())->onDelete('cascade');
89 | $table->foreign('plan_id')->references('id')->on(Util::getShopifyConfig('table_names.plans', 'plans'));
90 | });
91 | }
92 |
93 | /**
94 | * Reverse the migrations.
95 | *
96 | * @return void
97 | */
98 | public function down()
99 | {
100 | Schema::drop(Util::getShopifyConfig('table_names.charges', 'charges'));
101 | }
102 |
103 | /**
104 | * Get Laravel version.
105 | *
106 | * @return float
107 | */
108 | private function getLaravelVersion()
109 | {
110 | $version = Application::VERSION;
111 |
112 | return (float) substr($version, 0, strrpos($version, '.'));
113 | }
114 | }
115 |
--------------------------------------------------------------------------------
/src/Objects/Values/ShopDomain.php:
--------------------------------------------------------------------------------
1 | string = $this->sanitizeShopDomain($domain);
33 |
34 | if ($this->string === '') {
35 | throw new InvalidShopDomainException("Invalid shop domain [{$domain}]");
36 | }
37 | }
38 |
39 | /**
40 | * Grab the shop, if present, and how it was found.
41 | * Order of precedence is:.
42 | *
43 | * - GET/POST Variable ("shop" or "shopDomain")
44 | * - Headers ("X-Shop-Domain")
45 | * - Referer ("shop" or "shopDomain" query param or decoded "token" query param)
46 | *
47 | * @param Request $request The request object.
48 | *
49 | * @return ShopDomainValue
50 | */
51 | public static function fromRequest(Request $request): ShopDomainValue
52 | {
53 | // All possible methods
54 | $options = [
55 | // GET/POST
56 | DataSource::INPUT()->toNative() => $request->input('shop', $request->input('shopDomain')),
57 |
58 | // Headers
59 | DataSource::HEADER()->toNative() => $request->header('X-Shop-Domain'),
60 |
61 | // Headers: Referer
62 | DataSource::REFERER()->toNative() => function () use ($request): ?string {
63 | $url = parse_url($request->header('referer', ''), PHP_URL_QUERY);
64 | if (! $url) {
65 | return null;
66 | }
67 |
68 | $params = Util::parseQueryString($url);
69 | $shop = Arr::get($params, 'shop', Arr::get($params, 'shopDomain'));
70 | if ($shop) {
71 | return $shop;
72 | }
73 |
74 | $token = Arr::get($params, 'token');
75 | if ($token) {
76 | try {
77 | $token = new SessionToken($token, false);
78 | if ($shopDomain = $token->getShopDomain()) {
79 | return $shopDomain->toNative();
80 | }
81 | } catch (AssertionFailedException $e) {
82 | // Unable to decode the token
83 | return null;
84 | }
85 | }
86 |
87 | return null;
88 | },
89 | ];
90 |
91 | // Loop through each until we find the shop
92 | foreach ($options as $value) {
93 | $result = is_callable($value) ? $value() : $value;
94 | if ($result !== null) {
95 | // Found a shop
96 | return self::fromNative($result);
97 | }
98 | }
99 |
100 | // No shop domain found in any source
101 | return NullShopDomain::fromNative(null);
102 | }
103 |
104 | /**
105 | * Ensures shop domain meets the specs.
106 | *
107 | * @param string $domain The shopify domain
108 | *
109 | * @return string
110 | */
111 | protected function sanitizeShopDomain(string $domain): string
112 | {
113 | $configEndDomain = Util::getShopifyConfig('myshopify_domain');
114 | $domain = strtolower(preg_replace('/^https?:\/\//i', '', trim($domain)));
115 |
116 | if (strpos($domain, $configEndDomain) === false && strpos($domain, '.') === false) {
117 | // No myshopify.com ($configEndDomain) in shop's name
118 | $domain .= ".{$configEndDomain}";
119 | }
120 |
121 | $hostname = parse_url("https://{$domain}", PHP_URL_HOST);
122 |
123 | if (! preg_match('/^[a-zA-Z0-9][a-zA-Z0-9\-]*\.'.preg_quote($configEndDomain, '/').'$/', $hostname)) {
124 | return '';
125 | }
126 |
127 | return $hostname;
128 | }
129 | }
130 |
--------------------------------------------------------------------------------
/src/Traits/AuthController.php:
--------------------------------------------------------------------------------
1 | missing('shop') && !$request->user()) {
33 | // One or the other is required to authenticate a shop
34 | throw new MissingShopDomainException('No authenticated user or shop domain');
35 | }
36 |
37 | // Get the shop domain
38 | $shopDomain = $request->has('shop')
39 | ? ShopDomain::fromNative($request->get('shop'))
40 | : $request->user()->getDomain();
41 |
42 | // If the domain is obtained from $request->user()
43 | if ($request->missing('shop')) {
44 | $request['shop'] = $shopDomain->toNative();
45 | }
46 |
47 | // Run the action
48 | [$result, $status] = $authShop($request);
49 |
50 | if ($status === null) {
51 | // Show exception, something is wrong
52 | throw new SignatureVerificationException('Invalid HMAC verification');
53 | } elseif ($status === false) {
54 | if (!$result['url']) {
55 | throw new MissingAuthUrlException('Missing auth url');
56 | }
57 |
58 | $shopDomain = $shopDomain->toNative();
59 | $shopOrigin = $shopDomain ?? $request->user()->name;
60 |
61 | event(new ShopAuthenticatedEvent($result['shop_id']));
62 |
63 | return View::make(
64 | 'shopify-app::auth.fullpage_redirect',
65 | [
66 | 'apiKey' => Util::getShopifyConfig('api_key', $shopOrigin),
67 | 'url' => $result['url'],
68 | 'host' => $request->get('host'),
69 | 'shopDomain' => $shopDomain,
70 | 'locale' => $request->get('locale'),
71 | ]
72 | );
73 | } else {
74 | // Go to home route
75 | return Redirect::route(
76 | Util::getShopifyConfig('route_names.home'),
77 | [
78 | 'shop' => $shopDomain->toNative(),
79 | 'host' => $request->get('host'),
80 | 'locale' => $request->get('locale'),
81 | ]
82 | );
83 | }
84 | }
85 |
86 | /**
87 | * Get session token for a shop.
88 | *
89 | * @return ViewView
90 | */
91 | public function token(Request $request)
92 | {
93 | $request->session()->reflash();
94 | $shopDomain = ShopDomain::fromRequest($request);
95 | $target = $request->query('target');
96 | $query = parse_url($target, PHP_URL_QUERY);
97 |
98 | $cleanTarget = $target;
99 | if ($query) {
100 | // remove "token" from the target's query string
101 | $params = Util::parseQueryString($query);
102 | $params['shop'] = $params['shop'] ?? $shopDomain->toNative() ?? '';
103 | $params['host'] = $request->get('host');
104 | $params['locale'] = $request->get('locale');
105 | unset($params['token']);
106 |
107 | $cleanTarget = trim(explode('?', $target)[0].'?'.http_build_query($params), '?');
108 | } else {
109 | $params = [
110 | 'shop' => $shopDomain->toNative() ?? '',
111 | 'host' => $request->get('host'),
112 | 'locale' => $request->get('locale'),
113 | ];
114 | $cleanTarget = trim(explode('?', $target)[0].'?'.http_build_query($params), '?');
115 | }
116 |
117 | return View::make(
118 | 'shopify-app::auth.token',
119 | [
120 | 'shopDomain' => $shopDomain->toNative(),
121 | 'target' => $cleanTarget,
122 | ]
123 | );
124 | }
125 | }
126 |
--------------------------------------------------------------------------------
/src/Storage/Commands/Charge.php:
--------------------------------------------------------------------------------
1 | query = $query;
35 | }
36 |
37 | /**
38 | * {@inheritdoc}
39 | */
40 | public function make(ChargeTransfer $chargeObj): ChargeId
41 | {
42 | /**
43 | * Is an instance of Carbon?
44 | *
45 | * @param mixed $obj The object to check.
46 | *
47 | * @return bool
48 | */
49 | $isCarbon = function ($obj): bool {
50 | return $obj instanceof Carbon;
51 | };
52 |
53 | $shopTableId = Util::getShopsTableForeignKey();
54 |
55 | $chargeClass = Util::getShopifyConfig('models.charge', ChargeModel::class);
56 | $charge = new $chargeClass();
57 | $charge->plan_id = $chargeObj->planId->toNative();
58 | $charge->$shopTableId = $chargeObj->shopId->toNative();
59 | $charge->charge_id = $chargeObj->chargeReference->toNative();
60 | $charge->type = $chargeObj->chargeType->toNative();
61 | $charge->status = $chargeObj->chargeStatus->toNative();
62 | $charge->name = $chargeObj->planDetails->name;
63 | $charge->price = $chargeObj->planDetails->price;
64 | $charge->interval = $chargeObj->planDetails->interval;
65 | $charge->test = $chargeObj->planDetails->test;
66 | $charge->trial_days = $chargeObj->planDetails->trialDays;
67 | $charge->capped_amount = $chargeObj->planDetails->cappedAmount;
68 | $charge->terms = $chargeObj->planDetails->terms;
69 | $charge->activated_on = $isCarbon($chargeObj->activatedOn) ? $chargeObj->activatedOn->format('Y-m-d') : null;
70 | $charge->billing_on = $isCarbon($chargeObj->billingOn) ? $chargeObj->billingOn->format('Y-m-d') : null;
71 | $charge->trial_ends_on = $isCarbon($chargeObj->trialEndsOn) ? $chargeObj->trialEndsOn->format('Y-m-d') : null;
72 |
73 | // Save the charge
74 | $charge->save();
75 |
76 | return $charge->getId();
77 | }
78 |
79 | /**
80 | * {@inheritdoc}
81 | */
82 | public function delete(ChargeReference $chargeRef, ShopId $shopId): bool
83 | {
84 | $charge = $this->query->getByReferenceAndShopId($chargeRef, $shopId);
85 |
86 | return $charge === null ? false : $charge->delete();
87 | }
88 |
89 | /**
90 | * {@inheritdoc}
91 | */
92 | public function makeUsage(UsageChargeTransfer $chargeObj): ChargeId
93 | {
94 | $shopTableId = Util::getShopsTableForeignKey();
95 | // Create the charge
96 | $chargeClass = Util::getShopifyConfig('models.charge', ChargeModel::class);
97 | $charge = new $chargeClass();
98 | $charge->$shopTableId = $chargeObj->shopId->toNative();
99 | $charge->charge_id = $chargeObj->chargeReference->toNative();
100 | $charge->type = $chargeObj->chargeType->toNative();
101 | $charge->status = $chargeObj->chargeStatus->toNative();
102 | $charge->price = $chargeObj->details->price;
103 | $charge->description = $chargeObj->details->description;
104 | $charge->reference_charge = $chargeObj->details->chargeReference->toNative();
105 |
106 | // Save the charge
107 | $charge->save();
108 |
109 | return $charge->getId();
110 | }
111 |
112 | /**
113 | * {@inheritdoc}
114 | */
115 | public function cancel(
116 | ChargeReference $chargeRef,
117 | ?Carbon $expiresOn = null,
118 | ?Carbon $trialEndsOn = null
119 | ): bool {
120 | $charge = $this->query->getByReference($chargeRef);
121 | $charge->status = ChargeStatus::CANCELLED()->toNative();
122 | $charge->cancelled_on = $expiresOn === null ? Carbon::today()->format('Y-m-d') : $expiresOn->format('Y-m-d');
123 | $charge->expires_on = $trialEndsOn === null ? Carbon::today()->format('Y-m-d') : $trialEndsOn->format('Y-m-d');
124 |
125 | return $charge->save();
126 | }
127 | }
128 |
--------------------------------------------------------------------------------
/src/Traits/ShopModel.php:
--------------------------------------------------------------------------------
1 | id);
67 | }
68 |
69 | /**
70 | * {@inheritdoc}
71 | */
72 | public function getDomain(): ShopDomainValue
73 | {
74 | return ShopDomain::fromNative($this->name);
75 | }
76 |
77 | /**
78 | * {@inheritdoc}
79 | */
80 | public function getAccessToken(): AccessTokenValue
81 | {
82 | return AccessToken::fromNative($this->password);
83 | }
84 |
85 | /**
86 | * {@inheritdoc}
87 | */
88 | public function charges(): HasMany
89 | {
90 | return $this->hasMany(
91 | Util::getShopifyConfig('models.charge', Charge::class),
92 | Util::getShopsTableForeignKey(),
93 | 'id'
94 | );
95 | }
96 |
97 | /**
98 | * {@inheritdoc}
99 | */
100 | public function hasCharges(): bool
101 | {
102 | return $this->charges->isNotEmpty();
103 | }
104 |
105 | /**
106 | * {@inheritdoc}
107 | */
108 | public function plan(): BelongsTo
109 | {
110 | return $this->belongsTo(Util::getShopifyConfig('models.plan', Plan::class));
111 | }
112 |
113 | /**
114 | * {@inheritdoc}
115 | */
116 | public function isGrandfathered(): bool
117 | {
118 | return (bool) $this->shopify_grandfathered === true;
119 | }
120 |
121 | /**
122 | * {@inheritdoc}
123 | */
124 | public function isFreemium(): bool
125 | {
126 | return (bool) $this->shopify_freemium === true;
127 | }
128 |
129 | /**
130 | * {@inheritdoc}
131 | */
132 | public function hasOfflineAccess(): bool
133 | {
134 | return ! $this->getAccessToken()->isNull() && ! empty($this->password);
135 | }
136 |
137 | /**
138 | * {@inheritDoc}
139 | */
140 | public function setSessionContext(SessionContext $session): void
141 | {
142 | $this->sessionContext = $session;
143 | }
144 |
145 | /**
146 | * {@inheritDoc}
147 | */
148 | public function getSessionContext(): ?SessionContext
149 | {
150 | return $this->sessionContext;
151 | }
152 |
153 | /**
154 | * {@inheritdoc}
155 | */
156 | public function apiHelper(): IApiHelper
157 | {
158 | if ($this->apiHelper === null) {
159 | // Set the session
160 | $session = new Session(
161 | $this->getDomain()->toNative(),
162 | $this->getAccessToken()->toNative()
163 | );
164 | $this->apiHelper = resolve(IApiHelper::class)->make($session);
165 | }
166 |
167 | return $this->apiHelper;
168 | }
169 |
170 | /**
171 | * {@inheritdoc}
172 | */
173 | public function api(): BasicShopifyAPI
174 | {
175 | if ($this->apiHelper === null) {
176 | $this->apiHelper();
177 | }
178 |
179 | return $this->apiHelper->getApi();
180 | }
181 | }
182 |
--------------------------------------------------------------------------------
/src/resources/routes/shopify.php:
--------------------------------------------------------------------------------
1 | Util::getShopifyConfig('domain'),
28 | 'prefix' => Util::getShopifyConfig('prefix'),
29 | 'middleware' => ['web'],
30 | ], function () use ($manualRoutes) {
31 | /*
32 | |--------------------------------------------------------------------------
33 | | Home Route
34 | |--------------------------------------------------------------------------
35 | |
36 | | Homepage for an authenticated store. Store is checked with the
37 | | auth.shopify middleware and redirected to login if not.
38 | |
39 | */
40 |
41 | if (Util::registerPackageRoute('home', $manualRoutes)) {
42 | Route::get(
43 | '/',
44 | HomeController::class.'@index'
45 | )
46 | ->middleware(['verify.shopify', 'billable'])
47 | ->name(Util::getShopifyConfig('route_names.home'));
48 | }
49 |
50 | /*
51 | |--------------------------------------------------------------------------
52 | | Authenticate: Install & Authorize
53 | |--------------------------------------------------------------------------
54 | |
55 | | Install a shop and go through Shopify OAuth.
56 | |
57 | */
58 |
59 | if (Util::registerPackageRoute('authenticate', $manualRoutes)) {
60 | Route::match(
61 | ['GET', 'POST'],
62 | '/authenticate',
63 | AuthController::class.'@authenticate'
64 | )
65 | ->name(Util::getShopifyConfig('route_names.authenticate'));
66 | }
67 |
68 | /*
69 | |--------------------------------------------------------------------------
70 | | Authenticate: Token
71 | |--------------------------------------------------------------------------
72 | |
73 | | This route is hit when a shop comes to the app without a session token
74 | | yet. A token will be grabbed from Shopify AppBridge Javascript
75 | | and then forwarded back to the home route.
76 | |
77 | */
78 |
79 | if (Util::registerPackageRoute('authenticate.token', $manualRoutes)) {
80 | Route::get(
81 | '/authenticate/token',
82 | AuthController::class.'@token'
83 | )
84 | ->middleware(['verify.shopify'])
85 | ->name(Util::getShopifyConfig('route_names.authenticate.token'));
86 | }
87 |
88 | /*
89 | |--------------------------------------------------------------------------
90 | | Billing Handler
91 | |--------------------------------------------------------------------------
92 | |
93 | | Billing handler. Sends to billing screen for Shopify.
94 | |
95 | */
96 |
97 | if (Util::registerPackageRoute('billing', $manualRoutes)) {
98 | Route::get(
99 | '/billing/{plan?}',
100 | BillingController::class.'@index'
101 | )
102 | ->middleware(['verify.shopify'])
103 | ->where('plan', '^([0-9]+|)$')
104 | ->name(Util::getShopifyConfig('route_names.billing'));
105 | }
106 |
107 | /*
108 | |--------------------------------------------------------------------------
109 | | Billing Processor
110 | |--------------------------------------------------------------------------
111 | |
112 | | Processes the customer's response to the billing screen.
113 | | The shop domain is encrypted.
114 | |
115 | */
116 |
117 | if (Util::registerPackageRoute('billing.process', $manualRoutes)) {
118 | Route::get(
119 | '/billing/process/{plan?}',
120 | BillingController::class.'@process'
121 | )
122 | ->middleware(['verify.shopify'])
123 | ->where('plan', '^([0-9]+|)$')
124 | ->name(Util::getShopifyConfig('route_names.billing.process'));
125 | }
126 |
127 | /*
128 | |--------------------------------------------------------------------------
129 | | Billing Processor for Usage Charges
130 | |--------------------------------------------------------------------------
131 | |
132 | | Creates a usage charge on a recurring charge.
133 | |
134 | */
135 |
136 | if (Util::registerPackageRoute('billing.usage_charge', $manualRoutes)) {
137 | Route::match(
138 | ['get', 'post'],
139 | '/billing/usage-charge',
140 | BillingController::class.'@usageCharge'
141 | )
142 | ->middleware(['verify.shopify'])
143 | ->name(Util::getShopifyConfig('route_names.billing.usage_charge'));
144 | }
145 | });
146 |
--------------------------------------------------------------------------------
/src/Storage/Commands/Shop.php:
--------------------------------------------------------------------------------
1 | query = $query;
41 | $this->model = Util::getShopifyConfig('user_model');
42 | }
43 |
44 | /**
45 | * {@inheritdoc}
46 | */
47 | public function make(ShopDomainValue $domain, AccessTokenValue $token): ShopIdValue
48 | {
49 | $model = $this->model;
50 | $shop = new $model();
51 | $shop->name = $domain->toNative();
52 | $shop->password = $token->isNull() ? '' : $token->toNative();
53 | $shop->email = "shop@{$domain->toNative()}";
54 | $shop->save();
55 |
56 | return $shop->getId();
57 | }
58 |
59 | /**
60 | * {@inheritdoc}
61 | */
62 | public function setToPlan(ShopIdValue $shopId, PlanIdValue $planId): bool
63 | {
64 | $shop = $this->getShop($shopId);
65 | $shop->plan_id = $planId->toNative();
66 | $shop->shopify_freemium = false;
67 |
68 | return $shop->save();
69 | }
70 |
71 | /**
72 | * {@inheritdoc}
73 | */
74 | public function setAccessToken(ShopIdValue $shopId, AccessTokenValue $token): bool
75 | {
76 | $shop = $this->getShop($shopId);
77 | $shop->password = $token->toNative();
78 | $shop->password_updated_at = Carbon::now();
79 |
80 | return $shop->save();
81 | }
82 |
83 | /**
84 | * {@inheritdoc}
85 | */
86 | public function setThemeSupportLevel(ShopIdValue $shopId, ThemeSupportLevelValue $themeSupportLevel): bool
87 | {
88 | $shop = $this->getShop($shopId);
89 | $shop->theme_support_level = $themeSupportLevel->toNative();
90 |
91 | return $shop->save();
92 | }
93 |
94 | /**
95 | * {@inheritdoc}
96 | */
97 | public function clean(ShopIdValue $shopId): bool
98 | {
99 | $shop = $this->getShop($shopId);
100 | $shop->password = '';
101 | $shop->plan_id = null;
102 |
103 | return $shop->save();
104 | }
105 |
106 | /**
107 | * {@inheritdoc}
108 | */
109 | public function softDelete(ShopIdValue $shopId): bool
110 | {
111 | $shop = $this->getShop($shopId);
112 | $shop->charges()->delete();
113 |
114 | return $shop->delete();
115 | }
116 |
117 | /**
118 | * {@inheritdoc}
119 | */
120 | public function restore(ShopIdValue $shopId): bool
121 | {
122 | $shop = $this->getShop($shopId, true);
123 | $shop->charges()->restore();
124 |
125 | return $shop->restore();
126 | }
127 |
128 | /**
129 | * {@inheritdoc}
130 | */
131 | public function setAsFreemium(ShopIdValue $shopId): bool
132 | {
133 | $shop = $this->getShop($shopId);
134 | $this->setAsFreemiumByRef($shop);
135 |
136 | return $shop->save();
137 | }
138 |
139 | /**
140 | * {@inheritdoc}
141 | */
142 | public function setNamespace(ShopIdValue $shopId, string $namespace): bool
143 | {
144 | $shop = $this->getShop($shopId);
145 | $this->setNamespaceByRef($shop, $namespace);
146 |
147 | return $shop->save();
148 | }
149 |
150 | /**
151 | * Sets a shop as freemium.
152 | *
153 | * @param ShopModel $shop The shop model (reference).
154 | *
155 | * @return void
156 | */
157 | public function setAsFreemiumByRef(ShopModel &$shop): void
158 | {
159 | $shop->shopify_freemium = true;
160 | }
161 |
162 | /**
163 | * Sets a shop namespace.
164 | *
165 | * @param ShopModel $shop The shop model (reference).
166 | * @param string $namespace The namespace.
167 | *
168 | * @return void
169 | */
170 | public function setNamespaceByRef(ShopModel &$shop, string $namespace): void
171 | {
172 | $shop->shopify_namespace = $namespace;
173 | }
174 |
175 | /**
176 | * Helper to get the shop.
177 | *
178 | * @param ShopIdValue $shopId The shop's ID.
179 | * @param bool $withTrashed Include trashed shops?
180 | *
181 | * @return ShopModel|null
182 | */
183 | protected function getShop(ShopIdValue $shopId, bool $withTrashed = false): ?ShopModel
184 | {
185 | return $this->query->getById($shopId, [], $withTrashed);
186 | }
187 | }
188 |
--------------------------------------------------------------------------------