├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── composer.json ├── config └── shopify.php ├── phpunit.xml ├── src ├── Exceptions │ ├── InvalidOrMissingEndpointException.php │ └── ModelNotFoundException.php ├── HasShopifyClientInterface.php ├── Helpers │ ├── Assets.php │ ├── AssignedFulfillmentOrders.php │ ├── Customers.php │ ├── DiscountCodes.php │ ├── Disputes.php │ ├── Endpoint.php │ ├── FulfillmentOrders.php │ ├── FulfillmentServices.php │ ├── Fulfillments.php │ ├── Images.php │ ├── Metafields.php │ ├── Orders.php │ ├── PriceRules.php │ ├── Products.php │ ├── RecurringApplicationCharges.php │ ├── Risks.php │ ├── SmartCollections.php │ ├── Testing │ │ └── ModelFactory │ │ │ ├── OrderFactory.php │ │ │ └── ProductFactory.php │ ├── Themes.php │ ├── Variants.php │ └── Webhooks.php ├── Integrations │ └── Laravel │ │ ├── Events │ │ └── WebhookEvent.php │ │ ├── Http │ │ ├── WebhookController.php │ │ ├── WebhookMiddleware.php │ │ └── routes.php │ │ ├── ShopifyFacade.php │ │ └── ShopifyServiceProvider.php ├── Models │ ├── AbstractModel.php │ ├── Asset.php │ ├── AssignedFulfillmentOrder.php │ ├── Customer.php │ ├── DiscountCode.php │ ├── Dispute.php │ ├── Fulfillment.php │ ├── FulfillmentOrder.php │ ├── FulfillmentService.php │ ├── Image.php │ ├── Metafield.php │ ├── Order.php │ ├── PriceRule.php │ ├── Product.php │ ├── RecurringApplicationCharge.php │ ├── Risk.php │ ├── SmartCollections.php │ ├── Theme.php │ ├── Variant.php │ └── Webhook.php ├── RateLimit.php ├── Shopify.php └── Util.php └── tests ├── AssignedFulfillmentOrderTest.php ├── FulfillmentOrdersApiTest.php ├── OrdersApiTest.php ├── ProductsApiTest.php ├── RecurringApplicationChargeTest.php ├── RecurringApplicationChargesApiTest.php └── ShopApiTest.php /.gitignore: -------------------------------------------------------------------------------- 1 | vendor/ 2 | composer.phar 3 | composer.lock 4 | .DS_Store 5 | Thumbs.db 6 | /phpunit.xml 7 | /.idea 8 | /.vscode 9 | .phpunit.result.cache -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: php 2 | php: 3 | - 5.6 4 | - 7.0 5 | - 8.0 6 | before_script: composer install -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Dan Richards 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Shopify API 2 | 3 | A fluent and object-oriented approach for using the Shopify API. 4 | 5 | ## Supported Objects / Endpoints: 6 | 7 | * [Asset](https://help.shopify.com/en/api/reference/online-store/asset) 8 | * [Customer](https://help.shopify.com/en/api/reference/customers/customer) 9 | * [Dispute](https://help.shopify.com/en/api/reference/shopify_payments/dispute) 10 | * [Fulfillment](https://help.shopify.com/en/api/reference/shipping-and-fulfillment/fulfillment) 11 | * [FulfillmentService](https://help.shopify.com/en/api/reference/shipping-and-fulfillment/fulfillmentservice) 12 | * [Image](https://help.shopify.com/en/api/reference/products/product-image) 13 | * [Metafield](https://shopify.dev/docs/admin-api/rest/reference/metafield?api) 14 | * [Order](https://help.shopify.com/api/reference/orders) 15 | * [Product](https://help.shopify.com/api/reference/products) 16 | * [Risk](https://help.shopify.com/en/api/reference/orders/order-risk) 17 | * [Shop](https://shopify.dev/docs/admin-api/rest/reference/store-properties/shop) 18 | * [Theme](https://help.shopify.com/en/api/reference/online-store/theme) 19 | * [Variant](https://help.shopify.com/en/api/reference/products/product-variant) 20 | * [Webhook](https://help.shopify.com/en/api/reference/events/webhook) 21 | 22 | ## Versions 23 | 24 | | PHP | Package | 25 | |---|---| 26 | | 8 | 5.0.* | 27 | | 7 | 4.0.* | 28 | | YOLO | dev-master | 29 | 30 | ## Composer 31 | 32 | ```shell 33 | composer require dan/shopify 34 | ``` 35 | 36 | ## Basic Usage 37 | 38 | The APIs all function alike, here is an example of usage of the products API. 39 | 40 | ```php 41 | $api = Dan\Shopify\Shopify::make($shop = 'shop-name.myshopify.com', $token = 'shpua_abc123'); 42 | 43 | // Shop information 44 | $api->shop(); // array dictionary 45 | 46 | // List of products 47 | $api->products->get(); // array of array dictionaries 48 | 49 | // Attach query parameters to a get request 50 | $api->products->get(['created_at_min' => '2023-03-25']); // array of array dictionaries 51 | 52 | // A specific product 53 | $api->products('123456789')->get(); // array dictionary 54 | 55 | // Get all variants for a product 56 | $api->products('123456789')->variants->get(); // array of array dictionaries 57 | 58 | // Get a specific variant for a specific product 59 | $s->api2()->products('123456789')->variants('567891234')->get(); // array dictionary 60 | 61 | // Append URI string to a get request 62 | $api->orders('123456789')->get([], 'risks'); // array dictionary 63 | 64 | // Create a product. 65 | // See https://shopify.dev/docs/api/admin-rest/2023-01/resources/product#post-products 66 | $api->products->post(['title' => 'Simple Test']); // array dictionary 67 | 68 | // Update something specific on a product 69 | $api->products('123456789')->put(['title' => 'My title changed.']); // array dictionary 70 | ``` 71 | 72 | ## Basic (very basic) GraphQL 73 | 74 | The collection and model utilities that are available `->find(...)` and `->findMany(...)` for RESTful endpoints are NOT available for GraphQL. 75 | 76 | Some endpoints are only available through Shopify's GraphQL library. This makes me sad because GraphQL is not as readable or intuitive as RESTful APIs, less people understand it, and it's harder to train people on. That said, if you want to jam out with your graphql, there is a client method to assist you. 77 | 78 | For example, fetch delivery profiles (only available in GraphQL). 79 | 80 | > Note: You can safely use the `graphql(...)` helper method without any concern of changing the state on the `Dan\Shopify\Shopify::class`. 81 | 82 | ```php 83 | $query = "{ 84 | deliveryProfiles (first: 3) { 85 | edges { 86 | node { 87 | id, 88 | name, 89 | } 90 | } 91 | } 92 | }" 93 | 94 | $api->graphql($query); // hipster 95 | ``` 96 | 97 | ## Using cursors 98 | 99 | > Shopify doesn't jam with regular old pagination, sigh ... 100 | 101 | As of the `2019-10` API version, Shopify has removed per page pagination on their busiest endpoints. 102 | With the deprecation of the per page pagination comes a new cursor based pagination. 103 | You can use the `next` method to get paged responses. 104 | Example usage: 105 | 106 | ```php 107 | // First call to next can have all the usual query params you might want. 108 | $api->orders->next(['limit' => 100, 'status' => 'closed'); 109 | 110 | // Further calls will have all query params preset except for limit. 111 | $api->orders->next(['limit' => 100]); 112 | ``` 113 | 114 | ### Metafields! 115 | 116 | There are multiple endpoints in the Shopify API that have support for metafields. 117 | In effort to support them all, this API has been updated to allow chaining `->metafields` from any endpoint. 118 | 119 | This won't always work as not every endpoint supports metafields, and any endpoint that doesn't support metafields will result in a `404`. 120 | 121 | Below are examples of all the endpoints that support metafields. 122 | 123 | ```php 124 | // Get our API 125 | $api = Dan\Shopify\Shopify::make($shop, $token); 126 | 127 | // Store metafields 128 | $api->metafields->get(); 129 | 130 | // Metafields on an Order 131 | $api->orders($order_id)->metafields->get(); 132 | 133 | // Metafields on a Product 134 | $api->products($product_id)->metafields->get(); 135 | 136 | // Metafields on a Variant 137 | $api->products($product_id)->variants($variant_id)->metafields->get(); 138 | 139 | // Metafields on a Customer 140 | $api->customers($customer_id)->metafields->get(); 141 | 142 | // Metafields can also be updated like all other endpoints 143 | $api->products($product_id)->metafields($metafield_id)->put($data); 144 | ``` 145 | 146 | ## Usage with Laravel 147 | 148 | ### Single Store App 149 | 150 | In your `config/app.php` 151 | 152 | ### Add the following to your `providers` array: 153 | 154 | Requires for private app (env token) for single store usage of oauth (multiple stores) 155 | 156 | ```php 157 | Dan\Shopify\Integrations\Laravel\ShopifyServiceProvider::class, 158 | ``` 159 | 160 | ### Add the following to your `aliases` array: 161 | 162 | If your app only interacts with a single store, there is a Facade that may come in handy. 163 | 164 | ```php 165 | 'Shopify' => Dan\Shopify\Integrations\Laravel\ShopifyFacade::class, 166 | ``` 167 | 168 | ### For facade usage, replace the following variables in your `.env` 169 | 170 | ```dotenv 171 | SHOPIFY_DOMAIN=your-shop-name.myshopify.com 172 | SHOPIFY_TOKEN=your-token-here 173 | ``` 174 | 175 | ### Optionally replace following variables in your `.env` 176 | 177 | Empty or `admin` defaults to oldest supported API, [learn more](https://help.shopify.com/en/api/versioning) 178 | 179 | ```dotenv 180 | SHOPIFY_API_BASE="admin/api/2022-07" 181 | ``` 182 | 183 | ### Using the Facade gives you `Dan\Shopify\Shopify` 184 | 185 | > It will be instantiated with your shop and token you set up in `config/shopify.php` 186 | 187 | Review the `Basic Usage` above, using the Facade is more or less the same, except you're only interacting with the one store in your config. 188 | 189 | ```php 190 | // Facade same as $api->shop(), but for just the one store. 191 | Shopify::shop(); 192 | 193 | // Facade same as $api->products->get(), but for just the one store. 194 | Shopify::products()->get(); 195 | 196 | // Facade same as $api->products('123456789')->get(), but for just the one store. 197 | Shopify::products('123456789')->get(); 198 | ``` 199 | 200 | ## Oauth Apps 201 | 202 | Making a public app using oauth, follow the Shopify docs to make your auth url, and use the following helper to retrieve your access token using the code from your callback. 203 | 204 | ### Get a token for a redirect response. 205 | 206 | ```php 207 | Shopify::getAppInstallResponse( 208 | 'your_app_client_id', 209 | 'your_app_client_secret', 210 | 'shop_from_request', 211 | 'code_from_request' 212 | ); 213 | 214 | // returns (object) ['access_token' => '...', 'scopes' => '...'] 215 | ``` 216 | 217 | ### Verify App Hmac (works for callback or redirect) 218 | 219 | ```php 220 | Dan\Shopify\Util::validAppHmac( 221 | 'hmac_from_request', 222 | 'your_app_client_secret', 223 | ['shop' => '...', 'timestamp' => '...', ...] 224 | ); 225 | ``` 226 | 227 | ### Verify App Webhook Hmac 228 | 229 | ```php 230 | Dan\Shopify\Util::validWebhookHmac( 231 | 'hmac_from_request', 232 | 'your_app_client_secret', 233 | file_get_contents('php://input') 234 | ); 235 | ``` 236 | 237 | ## Contributors 238 | 239 | - [Diogo Gomes](https://github.com/diogogomeswww) 240 | - [Hiram Cruz](https://github.com/forgiv) 241 | 242 | ## Todo 243 | 244 | * Artisan Command to create token 245 | 246 | ## License 247 | 248 | MIT. 249 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dan/shopify", 3 | "description": "Shopify API with Laravel integrations using latest Guzzle.", 4 | "license": "MIT", 5 | "keywords": ["shopify", "api", "webhooks", "laravel"], 6 | "type": "project", 7 | "authors": [ 8 | { 9 | "name": "Dan Richards", 10 | "email": "danrichardsri@gmail.com" 11 | } 12 | ], 13 | "autoload" : { 14 | "psr-4": { 15 | "Dan\\Shopify\\": "src/" 16 | } 17 | }, 18 | "require": { 19 | "php": ">=8.0", 20 | "guzzlehttp/guzzle": "^6.2|^7.0", 21 | "nesbot/carbon": "^1.26.3 || ^2.0", 22 | "illuminate/http": "^8.0|^9.0|^10.0|^11.0", 23 | "ext-json": "*" 24 | }, 25 | "autoload-dev": { 26 | "psr-4": { 27 | "Dan\\Shopify\\Test\\": "tests/" 28 | } 29 | }, 30 | "scripts": { 31 | "test": "vendor/bin/phpunit" 32 | }, 33 | "extra": { 34 | "laravel": { 35 | "providers": [ 36 | "Dan\\Shopify\\Integrations\\Laravel\\ShopifyServiceProvider" 37 | ], 38 | "aliases": { 39 | "Shopify": "Dan\\Shopify\\Integrations\\Laravel\\ShopifyFacade" 40 | } 41 | } 42 | }, 43 | "require-dev": { 44 | "orchestra/testbench": "^6.24", 45 | "phpunit/phpunit": "^9.5" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /config/shopify.php: -------------------------------------------------------------------------------- 1 | env('SHOPIFY_API_BASE', 'admin/api/2020-07'), 16 | 17 | /* 18 | |-------------------------------------------------------------------------- 19 | | Shopify Shop 20 | |-------------------------------------------------------------------------- 21 | | 22 | | If your app is managing a single shop, you should configure it here. 23 | | 24 | | e.g. my-cool-store.myshopify.com 25 | */ 26 | 27 | 'shop' => env('SHOPIFY_DOMAIN', ''), 28 | 29 | /* 30 | |-------------------------------------------------------------------------- 31 | | Shopify Token 32 | |-------------------------------------------------------------------------- 33 | | 34 | | Use of a token implies you've already proceeding to Shopify's Oauth flow 35 | | and have a token in your possession to make subsequent requests. See the 36 | | readme.md for help getting your token. 37 | */ 38 | 39 | 'token' => env('SHOPIFY_TOKEN', ''), 40 | 41 | /* 42 | |-------------------------------------------------------------------------- 43 | | Options 44 | |-------------------------------------------------------------------------- 45 | | 46 | | log_api_request_data: 47 | | When enabled will log the data of every API Request to shopify 48 | */ 49 | 50 | 'options' => [ 51 | 'log_api_request_data' => env('SHOPIFY_OPTION_LOG_API_REQUEST', 0), 52 | 'log_api_response_data' => env('SHOPIFY_OPTION_LOG_API_RESPONSE', 0), 53 | 'log_deprecation_warnings' => env('SHOPIFY_OPTIONS_LOG_DEPRECATION_WARNINGS', 1), 54 | ], 55 | 56 | 'webhooks' => [ 57 | /** 58 | * Do not forget to add 'webhook/*' to your VerifyCsrfToken middleware. 59 | */ 60 | 'enabled' => env('SHOPIFY_WEBHOOKS_ENABLED', 1), 61 | 'route_prefix' => env('SHOPIFY_WEBHOOKS_ROUTE_PREFIX', 'webhook/shopify'), 62 | 'secret' => env('SHOPIFY_WEBHOOKS_SECRET'), 63 | 'middleware' => Dan\Shopify\Integrations\Laravel\Http\WebhookMiddleware::class, 64 | 'event_routing' => [ 65 | 'carts/create' => Dan\Shopify\Integrations\Laravel\Events\WebhookEvent::class, 66 | 'carts/update' => Dan\Shopify\Integrations\Laravel\Events\WebhookEvent::class, 67 | 'checkouts/create' => Dan\Shopify\Integrations\Laravel\Events\WebhookEvent::class, 68 | 'checkouts/delete' => Dan\Shopify\Integrations\Laravel\Events\WebhookEvent::class, 69 | 'checkouts/update' => Dan\Shopify\Integrations\Laravel\Events\WebhookEvent::class, 70 | 'collection_listings/add' => Dan\Shopify\Integrations\Laravel\Events\WebhookEvent::class, 71 | 'collection_listings/remove' => Dan\Shopify\Integrations\Laravel\Events\WebhookEvent::class, 72 | 'collection_listings/update' => Dan\Shopify\Integrations\Laravel\Events\WebhookEvent::class, 73 | 'collections/create' => Dan\Shopify\Integrations\Laravel\Events\WebhookEvent::class, 74 | 'collections/delete' => Dan\Shopify\Integrations\Laravel\Events\WebhookEvent::class, 75 | 'collections/update' => Dan\Shopify\Integrations\Laravel\Events\WebhookEvent::class, 76 | 'customer_groups/create' => Dan\Shopify\Integrations\Laravel\Events\WebhookEvent::class, 77 | 'customer_groups/delete' => Dan\Shopify\Integrations\Laravel\Events\WebhookEvent::class, 78 | 'customer_groups/update' => Dan\Shopify\Integrations\Laravel\Events\WebhookEvent::class, 79 | 'customers/create' => Dan\Shopify\Integrations\Laravel\Events\WebhookEvent::class, 80 | 'customers/delete' => Dan\Shopify\Integrations\Laravel\Events\WebhookEvent::class, 81 | 'customers/disable' => Dan\Shopify\Integrations\Laravel\Events\WebhookEvent::class, 82 | 'customers/enable' => Dan\Shopify\Integrations\Laravel\Events\WebhookEvent::class, 83 | 'customers/update' => Dan\Shopify\Integrations\Laravel\Events\WebhookEvent::class, 84 | 'disputes/create' => Dan\Shopify\Integrations\Laravel\Events\WebhookEvent::class, 85 | 'disputes/update' => Dan\Shopify\Integrations\Laravel\Events\WebhookEvent::class, 86 | 'draft_orders/create' => Dan\Shopify\Integrations\Laravel\Events\WebhookEvent::class, 87 | 'draft_orders/delete' => Dan\Shopify\Integrations\Laravel\Events\WebhookEvent::class, 88 | 'draft_orders/update' => Dan\Shopify\Integrations\Laravel\Events\WebhookEvent::class, 89 | 'fulfillment_events/create' => Dan\Shopify\Integrations\Laravel\Events\WebhookEvent::class, 90 | 'fulfillment_events/delete' => Dan\Shopify\Integrations\Laravel\Events\WebhookEvent::class, 91 | 'fulfillments/create' => Dan\Shopify\Integrations\Laravel\Events\WebhookEvent::class, 92 | 'fulfillments/update' => Dan\Shopify\Integrations\Laravel\Events\WebhookEvent::class, 93 | 'order_transactions/create' => Dan\Shopify\Integrations\Laravel\Events\WebhookEvent::class, 94 | 'orders/cancelled' => Dan\Shopify\Integrations\Laravel\Events\WebhookEvent::class, 95 | 'orders/create' => Dan\Shopify\Integrations\Laravel\Events\WebhookEvent::class, 96 | 'orders/delete' => Dan\Shopify\Integrations\Laravel\Events\WebhookEvent::class, 97 | 'orders/fulfilled' => Dan\Shopify\Integrations\Laravel\Events\WebhookEvent::class, 98 | 'orders/paid' => Dan\Shopify\Integrations\Laravel\Events\WebhookEvent::class, 99 | 'orders/partially_fulfilled' => Dan\Shopify\Integrations\Laravel\Events\WebhookEvent::class, 100 | 'orders/updated' => Dan\Shopify\Integrations\Laravel\Events\WebhookEvent::class, 101 | 'product_listings/add' => Dan\Shopify\Integrations\Laravel\Events\WebhookEvent::class, 102 | 'product_listings/remove' => Dan\Shopify\Integrations\Laravel\Events\WebhookEvent::class, 103 | 'product_listings/update' => Dan\Shopify\Integrations\Laravel\Events\WebhookEvent::class, 104 | 'products/create' => Dan\Shopify\Integrations\Laravel\Events\WebhookEvent::class, 105 | 'products/delete' => Dan\Shopify\Integrations\Laravel\Events\WebhookEvent::class, 106 | 'products/update' => Dan\Shopify\Integrations\Laravel\Events\WebhookEvent::class, 107 | 'refunds/create' => Dan\Shopify\Integrations\Laravel\Events\WebhookEvent::class, 108 | 'shop/update' => Dan\Shopify\Integrations\Laravel\Events\WebhookEvent::class, 109 | 'app/uninstalled' => Dan\Shopify\Integrations\Laravel\Events\WebhookEvent::class, 110 | 'themes/create' => Dan\Shopify\Integrations\Laravel\Events\WebhookEvent::class, 111 | 'themes/delete' => Dan\Shopify\Integrations\Laravel\Events\WebhookEvent::class, 112 | 'themes/publish' => Dan\Shopify\Integrations\Laravel\Events\WebhookEvent::class, 113 | 'themes/update' => Dan\Shopify\Integrations\Laravel\Events\WebhookEvent::class, 114 | ], 115 | ], 116 | ]; 117 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 13 | 14 | 15 | ./tests/. 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /src/Exceptions/InvalidOrMissingEndpointException.php: -------------------------------------------------------------------------------- 1 | compact('key')]; 24 | 25 | return $this->client->get($query); 26 | } 27 | 28 | /** 29 | * Post to a resource using the assigned endpoint ($this->endpoint). 30 | * 31 | * @param array|AbstractModel $payload 32 | * @param string $append 33 | * 34 | * @throws BadMethodCallException 35 | * 36 | * @return array|AbstractModel 37 | */ 38 | public function post($payload = []) 39 | { 40 | // Only PUT is allowed on `Asset` 41 | return $this->put($payload); 42 | } 43 | 44 | /** 45 | * Delete a resource using the assigned endpoint ($this->endpoint). 46 | * 47 | * @param string $key 48 | * 49 | * @return array 50 | */ 51 | public function delete($key) 52 | { 53 | return $this->client->delete(['asset' => compact('key')]); 54 | } 55 | 56 | /** 57 | * @param $key 58 | * 59 | * @return Asset|null 60 | */ 61 | public function find($key) 62 | { 63 | $data = $this->get($key); 64 | 65 | if (isset($data['asset'])) { 66 | $data = $data['asset']; 67 | } 68 | 69 | if (empty($data)) { 70 | return; 71 | } 72 | 73 | $model = new Asset($data); 74 | $model->exists = true; 75 | 76 | return $model; 77 | } 78 | 79 | /** 80 | * Return an array of models or Collection (if Laravel present). 81 | * 82 | * @param string|array $keys 83 | * 84 | * @return void 85 | */ 86 | public function findMany($keys) 87 | { 88 | throw new BadMethodCallException('%s does not support findMany()', __CLASS__); 89 | } 90 | 91 | /** 92 | * PUT to `assets` endpoint using a `Asset` model. 93 | * 94 | * @param Asset $model 95 | * 96 | * @return Asset 97 | */ 98 | public function save(Asset $model) 99 | { 100 | $response = $this->request( 101 | $method = 'PUT', 102 | $uri = $this->uri(), 103 | $options = ['json' => $model->getPayload()] 104 | ); 105 | 106 | $data = json_decode($response->getBody()->getContents(), true); 107 | 108 | if (isset($data[$model::$resource_name])) { 109 | $data = $data[$model::$resource_name]; 110 | } 111 | 112 | $model->exists = true; 113 | $model->syncOriginal($data); 114 | 115 | return $model; 116 | } 117 | 118 | /** 119 | * @param Asset $model 120 | * 121 | * @return array 122 | */ 123 | public function destroy(Asset $model) 124 | { 125 | return $this->delete($model->getKey()); 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/Helpers/AssignedFulfillmentOrders.php: -------------------------------------------------------------------------------- 1 | client; 24 | 25 | if (empty($client->ids)) { 26 | throw new InvalidOrMissingEndpointException('The orders endpoint on customers requires a customer ID. e.g. $api->customers(123)->orders->get()'); 27 | } 28 | 29 | $client->queue[] = [$client->api, $client->ids[0] ?? null]; 30 | $client->api = 'orders'; 31 | $client->ids = []; 32 | 33 | return $this; 34 | default: 35 | return parent::__get($endpoint); 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Helpers/DiscountCodes.php: -------------------------------------------------------------------------------- 1 | client = $client; 52 | } 53 | 54 | /** 55 | * Set our endpoint by accessing it via a property. 56 | * 57 | * @param string $property 58 | * 59 | * @return $this 60 | */ 61 | public function __get($property) 62 | { 63 | // If we're accessing another endpoint 64 | if (in_array($property, static::$endpoints)) { 65 | $client = $this->client; 66 | 67 | if (empty($client->ids)) { 68 | throw new InvalidOrMissingEndpointException('Calling ' . $method . ' from ' . $this->client->api . ' requires an id'); 69 | } 70 | 71 | $last = array_reverse($client->ids)[0] ?? null; 72 | array_unshift($client->queue, [$client->api, $last]); 73 | $client->api = $property; 74 | $client->ids = []; 75 | 76 | return $client->__get($property); 77 | } 78 | 79 | return $this->$property ?? $this->client->__get($property); 80 | } 81 | 82 | /** 83 | * Handle dynamic method calls into the model. 84 | * 85 | * @param string $method 86 | * @param array $parameters 87 | * 88 | * @return mixed 89 | */ 90 | public function __call($method, $parameters) 91 | { 92 | if (in_array($method, static::$endpoints)) { 93 | if ($parameters === []) { 94 | throw new InvalidOrMissingEndpointException('Calling ' . $method . ' from ' . $this->client->api . ' requires an id'); 95 | } 96 | 97 | $last = array_reverse($this->client->ids)[0] ?? null; 98 | array_unshift($this->client->queue, [$this->client->api, $last]); 99 | $this->client->api = $method; 100 | $this->client->ids = []; 101 | 102 | return $this->client->$method(...$parameters); 103 | } 104 | 105 | if (in_array($method, ['increment', 'decrement'])) { 106 | return $this->$method(...$parameters); 107 | } 108 | 109 | return $this->client->$method(...$parameters); 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/Helpers/FulfillmentOrders.php: -------------------------------------------------------------------------------- 1 | client->post($payload, 'fulfillment_request/accept'); 18 | } 19 | 20 | /** 21 | * Mark a fulfillment order as cancelled. 22 | * 23 | * @param int|null $id 24 | * @throws \Dan\Shopify\Exceptions\InvalidOrMissingEndpointException 25 | * @throws \GuzzleHttp\Exception\GuzzleException 26 | * @return array|\Dan\Shopify\Models\AbstractModel 27 | */ 28 | public function cancel($id = null) 29 | { 30 | $path = is_null($id) ? 'cancel' : "{$id}/cancel"; 31 | 32 | return $this->client->post([], $path); 33 | } 34 | 35 | /** 36 | * Marks an in progress fulfillment order as incomplete. 37 | * 38 | * @param array $payload 39 | * @throws \Dan\Shopify\Exceptions\InvalidOrMissingEndpointException 40 | * @throws \GuzzleHttp\Exception\GuzzleException 41 | * @return array|\Dan\Shopify\Models\AbstractModel 42 | */ 43 | public function close($payload = []) 44 | { 45 | return $this->client->post($payload, 'close'); 46 | } 47 | 48 | /** 49 | * Move a fulfillment order from one location to another location. 50 | * 51 | * @param array $payload 52 | * @throws \Dan\Shopify\Exceptions\InvalidOrMissingEndpointException 53 | * @throws \GuzzleHttp\Exception\GuzzleException 54 | * @return array|\Dan\Shopify\Models\AbstractModel 55 | */ 56 | public function move($payload = []) 57 | { 58 | return $this->client->post($payload, 'move'); 59 | } 60 | 61 | /** 62 | * Marks a scheduled fulfillment order as ready for fulfillment. 63 | * 64 | * @param array $payload 65 | * @throws \Dan\Shopify\Exceptions\InvalidOrMissingEndpointException 66 | * @throws \GuzzleHttp\Exception\GuzzleException 67 | * @return array|\Dan\Shopify\Models\AbstractModel 68 | */ 69 | public function open($payload = []) 70 | { 71 | return $this->client->post($payload, 'open'); 72 | } 73 | 74 | /** 75 | * Reject a fulfillment request. 76 | * 77 | * @param array $payload 78 | * @throws \Dan\Shopify\Exceptions\InvalidOrMissingEndpointException 79 | * @throws \GuzzleHttp\Exception\GuzzleException 80 | * @return array|\Dan\Shopify\Models\AbstractModel 81 | */ 82 | public function reject($payload = []) 83 | { 84 | return $this->client->post($payload, 'fulfillment_request/reject'); 85 | } 86 | 87 | /** 88 | * Release the fulfillment hold on a fulfillment order. 89 | * 90 | * @throws \Dan\Shopify\Exceptions\InvalidOrMissingEndpointException 91 | * @throws \GuzzleHttp\Exception\GuzzleException 92 | * @return array|\Dan\Shopify\Models\AbstractModel 93 | */ 94 | public function release_hold() 95 | { 96 | return $this->client->post([], 'release_hold'); 97 | } 98 | 99 | /** 100 | * Updates the fulfill_at time of a scheduled fulfillment order. 101 | * 102 | * @param array $payload 103 | * @throws \Dan\Shopify\Exceptions\InvalidOrMissingEndpointException 104 | * @throws \GuzzleHttp\Exception\GuzzleException 105 | * @return array|\Dan\Shopify\Models\AbstractModel 106 | */ 107 | public function reschedule($payload = []) 108 | { 109 | return $this->client->post($payload, 'reschedule'); 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/Helpers/FulfillmentServices.php: -------------------------------------------------------------------------------- 1 | array_merge(json_decode('{"id": 450789469, "email": "bob.norman@hostmail.com", "closed_at": null, "created_at": "2008-01-10T11:00:00-05:00", "updated_at": "2008-01-10T11:00:00-05:00", "number": 1, "note": null, "token": "b1946ac92492d2347c6235b4d2611184", "gateway": "authorize_net", "test": false, "total_price": "409.94", "subtotal_price": "398.00", "total_weight": 0, "total_tax": "11.94", "taxes_included": false, "currency": "USD", "financial_status": "authorized", "confirmed": false, "total_discounts": "0.00", "total_line_items_price": "398.00", "cart_token": "68778783ad298f1c80c3bafcddeea02f", "buyer_accepts_marketing": false, "name": "#1001", "referring_site": "http://www.otherexample.com", "landing_site": "http://www.example.com?source=abc", "cancelled_at": null, "cancel_reason": null, "total_price_usd": "409.94", "checkout_token": "bd5a8aa1ecd019dd3520ff791ee3a24c", "reference": "fhwdgads", "user_id": null, "location_id": null, "source_identifier": "fhwdgads", "source_url": null, "processed_at": "2008-01-10T11:00:00-05:00", "device_id": null, "phone": "+557734881234", "customer_locale": null, "app_id": null, "browser_ip": null, "landing_site_ref": "abc", "order_number": 1001, "discount_applications": [ {"type": "discount_code", "value": "10.0", "value_type": "percentage", "allocation_method": "across", "target_selection": "all", "target_type": "line_item", "code": "TENOFF"}], "discount_codes": [ {"code": "TENOFF", "amount": "10.00", "type": "percentage"}], "note_attributes": [ {"name": "custom engraving", "value": "Happy Birthday"}, {"name": "colour", "value": "green"}], "payment_gateway_names": [ "bogus"], "processing_method": "direct", "checkout_id": 901414060, "source_name": "web", "fulfillment_status": null, "tax_lines": [ {"price": "11.94", "rate": 0.06, "title": "State Tax"}], "tags": "", "contact_email": "bob.norman@hostmail.com", "order_status_url": "https://checkout.local/690933842/orders/b1946ac92492d2347c6235b4d2611184/authenticate?key=c3c5a100d11ad66e72e5d1a88cef4ba2", "admin_graphql_api_id": "gid://shopify/Order/450789469", "line_items": [ {"id": 466157049, "variant_id": 39072856, "title": "IPod Nano - 8gb", "quantity": 1, "price": "199.00", "sku": "IPOD2008GREEN", "variant_title": "green", "vendor": null, "fulfillment_service": "manual", "product_id": 632910392, "requires_shipping": true, "taxable": true, "gift_card": false, "name": "IPod Nano - 8gb - green", "variant_inventory_management": "shopify", "properties": [ {"name": "Custom Engraving Front", "value": "Happy Birthday"}, {"name": "Custom Engraving Back", "value": "Merry Christmas"}], "product_exists": true, "fulfillable_quantity": 1, "grams": 200, "total_discount": "0.00", "fulfillment_status": null, "discount_allocations": [], "admin_graphql_api_id": "gid://shopify/LineItem/466157049", "tax_lines": [ {"title": "State Tax", "price": "3.98", "rate": 0.06}] }, {"id": 518995019, "variant_id": 49148385, "title": "IPod Nano - 8gb", "quantity": 1, "price": "199.00", "sku": "IPOD2008RED", "variant_title": "red", "vendor": null, "fulfillment_service": "manual", "product_id": 632910392, "requires_shipping": true, "taxable": true, "gift_card": false, "name": "IPod Nano - 8gb - red", "variant_inventory_management": "shopify", "properties": [], "product_exists": true, "fulfillable_quantity": 1, "grams": 200, "total_discount": "0.00", "fulfillment_status": null, "discount_allocations": [], "admin_graphql_api_id": "gid://shopify/LineItem/518995019", "tax_lines": [ {"title": "State Tax", "price": "3.98", "rate": 0.06}] }, {"id": 703073504, "variant_id": 457924702, "title": "IPod Nano - 8gb", "quantity": 1, "price": "199.00", "sku": "IPOD2008BLACK", "variant_title": "black", "vendor": null, "fulfillment_service": "manual", "product_id": 632910392, "requires_shipping": true, "taxable": true, "gift_card": false, "name": "IPod Nano - 8gb - black", "variant_inventory_management": "shopify", "properties": [], "product_exists": true, "fulfillable_quantity": 1, "grams": 200, "total_discount": "0.00", "fulfillment_status": null, "discount_allocations": [], "admin_graphql_api_id": "gid://shopify/LineItem/703073504", "tax_lines": [ {"title": "State Tax", "price": "3.98", "rate": 0.06}] }], "shipping_lines": [ {"id": 369256396, "title": "Free Shipping", "price": "0.00", "code": "Free Shipping", "source": "shopify", "phone": null, "requested_fulfillment_service_id": null, "delivery_category": null, "carrier_identifier": null, "discounted_price": "0.00", "discount_allocations": [], "tax_lines": [] }], "billing_address": {"first_name": "Bob", "address1": "Chestnut Street 92", "phone": "555-625-1199", "city": "Louisville", "zip": "40202", "province": "Kentucky", "country": "United States", "last_name": "Norman", "address2": "", "company": null, "latitude": 45.41634, "longitude": -75.6868, "name": "Bob Norman", "country_code": "US", "province_code": "KY"}, "shipping_address": {"first_name": "Bob", "address1": "Chestnut Street 92", "phone": "555-625-1199", "city": "Louisville", "zip": "40202", "province": "Kentucky", "country": "United States", "last_name": "Norman", "address2": "", "company": null, "latitude": 45.41634, "longitude": -75.6868, "name": "Bob Norman", "country_code": "US", "province_code": "KY"}, "fulfillments": [ {"id": 255858046, "order_id": 450789469, "status": "failure", "created_at": "2018-09-25T15:15:37-04:00", "service": "manual", "updated_at": "2018-09-25T15:15:37-04:00", "tracking_company": null, "shipment_status": null, "location_id": 905684977, "tracking_number": "1Z2345", "tracking_numbers": [ "1Z2345"], "tracking_url": "http://wwwapps.ups.com/etracking/tracking.cgi?InquiryNumber1=1Z2345&TypeOfInquiryNumber=T&AcceptUPSLicenseAgreement=yes&submit=Track", "tracking_urls": [ "http://wwwapps.ups.com/etracking/tracking.cgi?InquiryNumber1=1Z2345&TypeOfInquiryNumber=T&AcceptUPSLicenseAgreement=yes&submit=Track"], "receipt": {"testcase": true, "authorization": "123456"}, "name": "#1001.0", "admin_graphql_api_id": "gid://shopify/Fulfillment/255858046", "line_items": [ {"id": 466157049, "variant_id": 39072856, "title": "IPod Nano - 8gb", "quantity": 1, "price": "199.00", "sku": "IPOD2008GREEN", "variant_title": "green", "vendor": null, "fulfillment_service": "manual", "product_id": 632910392, "requires_shipping": true, "taxable": true, "gift_card": false, "name": "IPod Nano - 8gb - green", "variant_inventory_management": "shopify", "properties": [ {"name": "Custom Engraving Front", "value": "Happy Birthday"}, {"name": "Custom Engraving Back", "value": "Merry Christmas"}], "product_exists": true, "fulfillable_quantity": 1, "grams": 200, "total_discount": "0.00", "fulfillment_status": null, "discount_allocations": [], "admin_graphql_api_id": "gid://shopify/LineItem/466157049", "tax_lines": [ {"title": "State Tax", "price": "3.98", "rate": 0.06}] }] }], "client_details": {"browser_ip": "0.0.0.0", "accept_language": null, "user_agent": null, "session_hash": null, "browser_width": null, "browser_height": null}, "refunds": [ {"id": 509562969, "order_id": 450789469, "created_at": "2018-09-25T15:15:37-04:00", "note": "it broke during shipping", "user_id": 799407056, "processed_at": "2018-09-25T15:15:37-04:00", "restock": true, "admin_graphql_api_id": "gid://shopify/Refund/509562969", "refund_line_items": [ {"id": 104689539, "quantity": 1, "line_item_id": 703073504, "location_id": 487838322, "restock_type": "legacy_restock", "subtotal": 195.67, "total_tax": 3.98, "line_item": {"id": 703073504, "variant_id": 457924702, "title": "IPod Nano - 8gb", "quantity": 1, "price": "199.00", "sku": "IPOD2008BLACK", "variant_title": "black", "vendor": null, "fulfillment_service": "manual", "product_id": 632910392, "requires_shipping": true, "taxable": true, "gift_card": false, "name": "IPod Nano - 8gb - black", "variant_inventory_management": "shopify", "properties": [], "product_exists": true, "fulfillable_quantity": 1, "grams": 200, "total_discount": "0.00", "fulfillment_status": null, "discount_allocations": [], "admin_graphql_api_id": "gid://shopify/LineItem/703073504", "tax_lines": [ {"title": "State Tax", "price": "3.98", "rate": 0.06}] }}, {"id": 709875399, "quantity": 1, "line_item_id": 466157049, "location_id": 487838322, "restock_type": "legacy_restock", "subtotal": 195.66, "total_tax": 3.98, "line_item": {"id": 466157049, "variant_id": 39072856, "title": "IPod Nano - 8gb", "quantity": 1, "price": "199.00", "sku": "IPOD2008GREEN", "variant_title": "green", "vendor": null, "fulfillment_service": "manual", "product_id": 632910392, "requires_shipping": true, "taxable": true, "gift_card": false, "name": "IPod Nano - 8gb - green", "variant_inventory_management": "shopify", "properties": [ {"name": "Custom Engraving Front", "value": "Happy Birthday"}, {"name": "Custom Engraving Back", "value": "Merry Christmas"}], "product_exists": true, "fulfillable_quantity": 1, "grams": 200, "total_discount": "0.00", "fulfillment_status": null, "discount_allocations": [], "admin_graphql_api_id": "gid://shopify/LineItem/466157049", "tax_lines": [ {"title": "State Tax", "price": "3.98", "rate": 0.06}] }}], "transactions": [ {"id": 179259969, "order_id": 450789469, "amount": "209.00", "kind": "refund", "gateway": "bogus", "status": "success", "message": null, "created_at": "2005-08-05T12:59:12-04:00", "test": false, "authorization": "authorization-key", "currency": "USD", "location_id": null, "user_id": null, "parent_id": 801038806, "device_id": null, "receipt": {}, "error_code": null, "source_name": "web", "admin_graphql_api_id": "gid://shopify/OrderTransaction/179259969"}], "order_adjustments": [] }], "payment_details": {"credit_card_bin": null, "avs_result_code": null, "cvv_result_code": null, "credit_card_number": "•••• •••• •••• 4242", "credit_card_company": "Visa"}, "customer": {"id": 207119551, "email": "bob.norman@hostmail.com", "accepts_marketing": false, "created_at": "2018-09-25T15:15:37-04:00", "updated_at": "2018-09-25T15:15:37-04:00", "first_name": "Bob", "last_name": "Norman", "orders_count": 1, "state": "disabled", "total_spent": "41.94", "last_order_id": 450789469, "note": null, "verified_email": true, "multipass_identifier": null, "tax_exempt": false, "phone": null, "tags": "", "last_order_name": "#1001", "admin_graphql_api_id": "gid://shopify/Customer/207119551", "default_address": {"id": 207119551, "customer_id": 207119551, "first_name": null, "last_name": null, "company": null, "address1": "Chestnut Street 92", "address2": "", "city": "Louisville", "province": "Kentucky", "country": "United States", "zip": "40202", "phone": "555-625-1199", "name": "", "province_code": "KY", "country_code": "US", "country_name": "United States", "default": true}}}', true), $overrides)]; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Helpers/Testing/ModelFactory/ProductFactory.php: -------------------------------------------------------------------------------- 1 | $data]; 24 | } 25 | 26 | /** 27 | * Example response from https://help.shopify.com/en/api/reference/products/product#show. 28 | * 29 | * @param $overrides 30 | * 31 | * @return array 32 | */ 33 | private static function getSample($overrides = []) 34 | { 35 | return array_merge(json_decode('{ "id": 632910392, "title": "IPod Nano - 8GB", "body_html": "

It\'s the small iPod with one very big idea: Video . Now the world\'s most popular music player, available in 4GB and 8GB models, lets you enjoy TV shows, movies, video podcasts, and more. The larger, brighter display means amazing picture quality. In six eye-catching colors, iPod nano is stunning all around. And with models starting at just $149, little speaks volumes.

", "vendor": "Apple", "product_type": "Cult Products", "created_at": "2018-09-25T15:15:37-04:00", "handle": "ipod-nano", "updated_at": "2018-09-25T15:15:37-04:00", "published_at": "2007-12-31T19:00:00-05:00", "template_suffix": null, "tags": "Emotive, Flash Memory, MP3, Music", "published_scope": "web", "admin_graphql_api_id": "gid://shopify/Product/632910392", "variants": [ { "id": 808950810, "product_id": 632910392, "title": "Pink", "price": "199.00", "sku": "IPOD2008PINK", "position": 1, "inventory_policy": "continue", "compare_at_price": null, "fulfillment_service": "manual", "inventory_management": "shopify", "option1": "Pink", "option2": null, "option3": null, "created_at": "2018-09-25T15:15:37-04:00", "updated_at": "2018-09-25T15:15:37-04:00", "taxable": true, "barcode": "1234_pink", "grams": 567, "image_id": 562641783, "inventory_quantity": 10, "weight": 1.25, "weight_unit": "lb", "inventory_item_id": 808950810, "old_inventory_quantity": 10, "requires_shipping": true, "admin_graphql_api_id": "gid://shopify/ProductVariant/808950810" }, { "id": 49148385, "product_id": 632910392, "title": "Red", "price": "199.00", "sku": "IPOD2008RED", "position": 2, "inventory_policy": "continue", "compare_at_price": null, "fulfillment_service": "manual", "inventory_management": "shopify", "option1": "Red", "option2": null, "option3": null, "created_at": "2018-09-25T15:15:37-04:00", "updated_at": "2018-09-25T15:15:37-04:00", "taxable": true, "barcode": "1234_red", "grams": 567, "image_id": null, "inventory_quantity": 20, "weight": 1.25, "weight_unit": "lb", "inventory_item_id": 49148385, "old_inventory_quantity": 20, "requires_shipping": true, "admin_graphql_api_id": "gid://shopify/ProductVariant/49148385" }, { "id": 39072856, "product_id": 632910392, "title": "Green", "price": "199.00", "sku": "IPOD2008GREEN", "position": 3, "inventory_policy": "continue", "compare_at_price": null, "fulfillment_service": "manual", "inventory_management": "shopify", "option1": "Green", "option2": null, "option3": null, "created_at": "2018-09-25T15:15:37-04:00", "updated_at": "2018-09-25T15:15:37-04:00", "taxable": true, "barcode": "1234_green", "grams": 567, "image_id": null, "inventory_quantity": 30, "weight": 1.25, "weight_unit": "lb", "inventory_item_id": 39072856, "old_inventory_quantity": 30, "requires_shipping": true, "admin_graphql_api_id": "gid://shopify/ProductVariant/39072856" }, { "id": 457924702, "product_id": 632910392, "title": "Black", "price": "199.00", "sku": "IPOD2008BLACK", "position": 4, "inventory_policy": "continue", "compare_at_price": null, "fulfillment_service": "manual", "inventory_management": "shopify", "option1": "Black", "option2": null, "option3": null, "created_at": "2018-09-25T15:15:37-04:00", "updated_at": "2018-09-25T15:15:37-04:00", "taxable": true, "barcode": "1234_black", "grams": 567, "image_id": null, "inventory_quantity": 40, "weight": 1.25, "weight_unit": "lb", "inventory_item_id": 457924702, "old_inventory_quantity": 40, "requires_shipping": true, "admin_graphql_api_id": "gid://shopify/ProductVariant/457924702" } ], "options": [ { "id": 594680422, "product_id": 632910392, "name": "Color", "position": 1, "values": [ "Pink", "Red", "Green", "Black" ] } ], "images": [ { "id": 850703190, "product_id": 632910392, "position": 1, "created_at": "2018-09-25T15:15:37-04:00", "updated_at": "2018-09-25T15:15:37-04:00", "alt": null, "width": 123, "height": 456, "src": "https://cdn.shopify.com/s/files/1/0006/9093/3842/products/ipod-nano.png?v=1537902937", "variant_ids": [], "admin_graphql_api_id": "gid://shopify/ProductImage/850703190" }, { "id": 562641783, "product_id": 632910392, "position": 2, "created_at": "2018-09-25T15:15:37-04:00", "updated_at": "2018-09-25T15:15:37-04:00", "alt": null, "width": 123, "height": 456, "src": "https://cdn.shopify.com/s/files/1/0006/9093/3842/products/ipod-nano-2.png?v=1537902937", "variant_ids": [ 808950810 ], "admin_graphql_api_id": "gid://shopify/ProductImage/562641783" } ], "image": { "id": 850703190, "product_id": 632910392, "position": 1, "created_at": "2018-09-25T15:15:37-04:00", "updated_at": "2018-09-25T15:15:37-04:00", "alt": null, "width": 123, "height": 456, "src": "https://cdn.shopify.com/s/files/1/0006/9093/3842/products/ipod-nano.png?v=1537902937", "variant_ids": [], "admin_graphql_api_id": "gid://shopify/ProductImage/850703190" } }', true), $overrides); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Helpers/Themes.php: -------------------------------------------------------------------------------- 1 | topic = $topic; 34 | $this->data = $data; 35 | $this->shop = $shop; 36 | } 37 | 38 | /** 39 | * @return array 40 | */ 41 | public function getData() 42 | { 43 | return $this->data; 44 | } 45 | 46 | /** 47 | * @return string 48 | */ 49 | public function getTopic() 50 | { 51 | return $this->topic; 52 | } 53 | 54 | /** 55 | * @return string|null 56 | */ 57 | public function getShop() 58 | { 59 | return $this->shop; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/Integrations/Laravel/Http/WebhookController.php: -------------------------------------------------------------------------------- 1 | all(), $shop)); 39 | } 40 | 41 | return Response::json(['success' => true]); 42 | } 43 | 44 | /** 45 | * @return string 46 | */ 47 | protected static function getShop(Request $request) 48 | { 49 | return $request->headers->get('x-shopify-shop-domain'); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Integrations/Laravel/Http/WebhookMiddleware.php: -------------------------------------------------------------------------------- 1 | request = $request; 45 | 46 | if (empty($this->shop = $shop = $request->headers->get('x-shopify-shop-domain'))) { 47 | return $this->errorWithNoShopProvided(); 48 | } 49 | 50 | if (empty($this->hmac = $hmac = $request->headers->get('x-shopify-hmac-sha256'))) { 51 | return $this->errorWithNoHmacProvided(); 52 | } 53 | 54 | if (empty($this->data = $data = file_get_contents('php://input'))) { 55 | return $this->errorWithNoInputData(); 56 | } 57 | 58 | $this->json = json_decode($data); 59 | 60 | if (! empty($json_error_code = json_last_error())) { 61 | return $this->errorWithJsonDecoding($json_error_code); 62 | } 63 | 64 | if (! Util::validWebhookHmac($hmac, $this->getSecret(), $data)) { 65 | return $this->errorWithHmacValidation(); 66 | } 67 | 68 | return $next($request); 69 | } 70 | 71 | /** 72 | * @return JsonResponse 73 | */ 74 | protected function errorWithNoShopProvided() 75 | { 76 | $msg = 'Header `x-shopify-shop-domain` missing.'; 77 | $details = $this->getErrorDetails(); 78 | Log::error($msg, $details); 79 | 80 | return Response::json($details, 400); 81 | } 82 | 83 | /** 84 | * @return JsonResponse 85 | */ 86 | protected function errorWithNoHmacProvided() 87 | { 88 | $msg = 'Header `x-shopify-hmac-sha256` missing.'; 89 | $details = $this->getErrorDetails(); 90 | Log::error($msg, $details); 91 | 92 | return Response::json($details, 400); 93 | } 94 | 95 | /** 96 | * @return JsonResponse 97 | */ 98 | protected function errorWithShopNotFound() 99 | { 100 | $url = config('services.shopify.app.app_url'); 101 | $msg = "Shop not installed. Install at: {$url}"; 102 | $details = $this->getErrorDetails(); 103 | Log::error($msg, $details); 104 | 105 | return Response::json($details, 400); 106 | } 107 | 108 | /** 109 | * @return JsonResponse 110 | */ 111 | protected function errorWithNoInputData() 112 | { 113 | $msg = 'No input data provided.'; 114 | $details = $this->getErrorDetails(); 115 | Log::error($msg, $details); 116 | 117 | return Response::json($details, 422); 118 | } 119 | 120 | /** 121 | * @param int $json_error_code 122 | * 123 | * @return JsonResponse 124 | */ 125 | protected function errorWithJsonDecoding($json_error_code) 126 | { 127 | switch ($json_error_code) { 128 | case JSON_ERROR_DEPTH: 129 | $msg = 'The maximum stack depth has been exceeded'; 130 | break; 131 | case JSON_ERROR_STATE_MISMATCH: 132 | $msg = 'Invalid or malformed JSON'; 133 | break; 134 | case JSON_ERROR_CTRL_CHAR: 135 | $msg = 'Control character error, possibly incorrectly encoded'; 136 | break; 137 | case JSON_ERROR_SYNTAX: 138 | $msg = 'Syntax error'; 139 | break; 140 | case JSON_ERROR_UTF8: 141 | $msg = 'Malformed UTF-8 characters, possibly incorrectly encoded PHP 5.3.3'; 142 | break; 143 | case JSON_ERROR_RECURSION: 144 | $msg = 'One or more recursive references in the value to be encoded: PHP 5.5.0'; 145 | break; 146 | case JSON_ERROR_INF_OR_NAN: 147 | $msg = 'One or more NAN or INF values in the value to be encoded: PHP 5.5.0'; 148 | break; 149 | case JSON_ERROR_UNSUPPORTED_TYPE: 150 | $msg = 'A value of a type that cannot be encoded was given: PHP 5.5.0'; 151 | break; 152 | case JSON_ERROR_INVALID_PROPERTY_NAME: 153 | $msg = 'A property name that cannot be encoded was given: PHP 7.0.0'; 154 | break; 155 | case JSON_ERROR_UTF16: 156 | $msg = 'Malformed UTF-16 characters, possibly incorrectly encoded'; 157 | break; 158 | default: 159 | $msg = 'Unknown error'; 160 | } 161 | 162 | $msg = "Json error: {$msg}"; 163 | $details = compact('json_error_code') + $this->getErrorDetails(); 164 | Log::error($msg, $details); 165 | 166 | return Response::json($details, 422); 167 | } 168 | 169 | /** 170 | * @return JsonResponse 171 | */ 172 | protected function errorWithHmacValidation() 173 | { 174 | $msg = 'Unable to verify hmac.'; 175 | $details = compact('json_error_code') + $this->getErrorDetails(); 176 | Log::error($msg, $details); 177 | 178 | return Response::json($details, 401); 179 | } 180 | 181 | /** 182 | * @return array 183 | */ 184 | protected function getErrorDetails() 185 | { 186 | return [ 187 | 'path' => request()->path(), 188 | 'success' => 'false', 189 | 'shop' => $this->shop, 190 | 'hmac' => $this->hmac, 191 | 'data' => $this->data, 192 | ]; 193 | } 194 | 195 | /** 196 | * Private apps used shared secret while installable applications use 197 | * application key. 198 | * 199 | * If your application uses both implementation, you may benefit from 200 | * overriding this method. 201 | * 202 | * @return string 203 | */ 204 | protected function getSecret() 205 | { 206 | return config('shopify.webhooks.secret'); 207 | } 208 | } 209 | -------------------------------------------------------------------------------- /src/Integrations/Laravel/Http/routes.php: -------------------------------------------------------------------------------- 1 | where('topic', '(.*)') 5 | ->name('shopify-webhook'); 6 | -------------------------------------------------------------------------------- /src/Integrations/Laravel/ShopifyFacade.php: -------------------------------------------------------------------------------- 1 | publishes([ 23 | __DIR__ . '/../../../config/shopify.php' => config_path('shopify.php'), 24 | ]); 25 | } 26 | 27 | /** 28 | * Register any application services. 29 | * 30 | * @return void 31 | */ 32 | public function register() 33 | { 34 | if (Util::isLaravel()) { 35 | $this->mergeConfigFrom( 36 | __DIR__ . '/../../../config/shopify.php', 'shopify' 37 | ); 38 | } 39 | 40 | $shop = config('shopify.shop'); 41 | $token = config('shopify.token'); 42 | 43 | if ($shop && $token) { 44 | $this->app->singleton('shopify', function ($app) use ($shop, $token) { 45 | return new Shopify($shop, $token); 46 | }); 47 | } 48 | 49 | if (config('shopify.webhooks.enabled')) { 50 | $this->registerWebhookRoutes(); 51 | } 52 | } 53 | 54 | /** 55 | * Register the package routes. 56 | * 57 | * @return void 58 | */ 59 | protected function registerWebhookRoutes() 60 | { 61 | Route::group($this->routeWebhookConfiguration(), function () { 62 | $this->loadRoutesFrom(__DIR__ . '/Http/routes.php'); 63 | }); 64 | } 65 | 66 | /** 67 | * Get the route group configuration array. 68 | * 69 | * @return array 70 | */ 71 | protected function routeWebhookConfiguration() 72 | { 73 | return [ 74 | //'domain' => config('shopify.webhooks.route_domain', config('app.url')), 75 | 'namespace' => 'Dan\Shopify\Integrations\Laravel\Http', 76 | 'prefix' => config('shopify.webhooks.route_prefix'), 77 | 'middleware' => array_filter(['web', config('shopify.webhooks.middleware')]), 78 | ]; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/Models/AbstractModel.php: -------------------------------------------------------------------------------- 1 | fill($data); 59 | 60 | // Unlike Laravel, we sync the original after filling 61 | if (isset($data[static::$identifier])) { 62 | $this->syncOriginal(); 63 | $this->exists = true; 64 | } 65 | } 66 | 67 | /** 68 | * @return int|string|null 69 | */ 70 | public function getKey() 71 | { 72 | return $this->original[static::$identifier] ?? null; 73 | } 74 | 75 | /** 76 | * Get the primary key for the model. 77 | * 78 | * @return string 79 | */ 80 | public function getKeyName() 81 | { 82 | return static::$identifier; 83 | } 84 | 85 | /** 86 | * @param array $attributes 87 | * 88 | * @return $this 89 | */ 90 | public function fill($attributes) 91 | { 92 | foreach ($attributes as $key => $value) { 93 | $this->setAttribute($key, $value); 94 | } 95 | 96 | return $this; 97 | } 98 | 99 | /** 100 | * Sync the original attributes with the current. 101 | * 102 | * @param array $attributes 103 | * 104 | * @return $this 105 | */ 106 | public function syncOriginal($attributes = []) 107 | { 108 | $attributes = json_decode(json_encode($attributes), true); 109 | $this->attributes = $attributes + $this->attributes; 110 | $this->original = $this->attributes; 111 | 112 | return $this; 113 | } 114 | 115 | /** 116 | * @return array 117 | */ 118 | public function getAttributes() 119 | { 120 | return $this->attributes; 121 | } 122 | 123 | /** 124 | * Get an attribute from the model. 125 | * 126 | * @param string $key 127 | * 128 | * @return mixed 129 | */ 130 | public function getAttribute($key) 131 | { 132 | if (! $key) { 133 | return; 134 | } 135 | 136 | // If the attribute exists in the attribute array or has a "get" mutator we will 137 | // get the attribute's value. Otherwise, we will proceed as if the developers 138 | // are asking for a relationship's value. This covers both types of values. 139 | if (array_key_exists($key, $this->attributes) 140 | || $this->hasGetMutator($key)) { 141 | return $this->getAttributeValue($key); 142 | } 143 | } 144 | 145 | /** 146 | * Get an attribute from the $attributes array. 147 | * 148 | * @param string $key 149 | * 150 | * @return mixed 151 | */ 152 | protected function getAttributeFromArray($key) 153 | { 154 | if (isset($this->attributes[$key])) { 155 | return $this->attributes[$key]; 156 | } 157 | } 158 | 159 | /** 160 | * Get the model's original attribute values. 161 | * 162 | * @param string|null $key 163 | * @param mixed $default 164 | * 165 | * @return mixed|array 166 | */ 167 | public function getOriginal($key = null, $default = null) 168 | { 169 | return $this->original[$key] ?? $default; 170 | } 171 | 172 | /** 173 | * Get the attributes that have been changed since last sync. 174 | * 175 | * @return array 176 | */ 177 | public function getDirty() 178 | { 179 | $dirty = []; 180 | 181 | foreach ($this->attributes as $key => $value) { 182 | if (! array_key_exists($key, $this->original)) { 183 | $dirty[$key] = $value; 184 | } elseif ($value !== $this->original[$key] && 185 | ! $this->originalIsNumericallyEquivalent($key)) { 186 | $dirty[$key] = $value; 187 | } 188 | } 189 | 190 | return $dirty; 191 | } 192 | 193 | /** 194 | * Determine if the new and old values for a given key are numerically equivalent. 195 | * 196 | * @param string $key 197 | * 198 | * @return bool 199 | */ 200 | protected function originalIsNumericallyEquivalent($key) 201 | { 202 | $current = $this->attributes[$key]; 203 | 204 | $original = $this->original[$key]; 205 | 206 | // This method checks if the two values are numerically equivalent even if they 207 | // are different types. This is in case the two values are not the same type 208 | // we can do a fair comparison of the two values to know if this is dirty. 209 | return is_numeric($current) && is_numeric($original) 210 | && strcmp((string) $current, (string) $original) === 0; 211 | } 212 | 213 | /** 214 | * Get a plain attribute (not a relationship). 215 | * 216 | * @param string $key 217 | * 218 | * @return mixed 219 | */ 220 | public function getAttributeValue($key) 221 | { 222 | $value = $this->getAttributeFromArray($key); 223 | 224 | // If the attribute has a get mutator, we will call that then return what 225 | // it returns as the value, which is useful for transforming values on 226 | // retrieval from the model to a form that is more useful for usage. 227 | if ($this->hasGetMutator($key)) { 228 | return $this->mutateAttribute($key, $value); 229 | } 230 | 231 | // If the attribute exists within the cast array, we will convert it to 232 | // an appropriate native PHP type dependant upon the associated value 233 | // given with the key in the pair. Dayle made this comment line up. 234 | if (array_key_exists($key, $this->casts)) { 235 | return $this->castAttribute($key, $value); 236 | } 237 | 238 | // If the attribute is listed as a date, we will convert it to a DateTime 239 | // instance on retrieval, which makes it quite convenient to work with 240 | // date fields without having to create a mutator for each property. 241 | if (in_array($key, $this->dates) && ! is_null($value)) { 242 | return $this->asDateTime($value); 243 | } 244 | 245 | return $value; 246 | } 247 | 248 | /** 249 | * Determine if a get mutator exists for an attribute. 250 | * 251 | * @param string $key 252 | * 253 | * @return bool 254 | */ 255 | public function hasGetMutator($key) 256 | { 257 | return method_exists($this, 'get'.Util::studly($key).'Attribute'); 258 | } 259 | 260 | /** 261 | * Get the value of an attribute using its mutator. 262 | * 263 | * @param string $key 264 | * @param mixed $value 265 | * 266 | * @return mixed 267 | */ 268 | protected function mutateAttribute($key, $value) 269 | { 270 | return $this->{'get'.Util::studly($key).'Attribute'}($value); 271 | } 272 | 273 | /** 274 | * Determine if a set mutator exists for an attribute. 275 | * 276 | * @param string $key 277 | * 278 | * @return bool 279 | */ 280 | public function hasSetMutator($key) 281 | { 282 | return method_exists($this, 'set'.Util::studly($key).'Attribute'); 283 | } 284 | 285 | /** 286 | * Set a given attribute on the model. 287 | * 288 | * @param string $key 289 | * @param mixed $value 290 | * 291 | * @return $this 292 | */ 293 | public function setAttribute($key, $value) 294 | { 295 | // First we will check for the presence of a mutator for the set operation 296 | // which simply lets the developers tweak the attribute as it is set on 297 | // the model, such as "json_encoding" an listing of data for storage. 298 | if ($this->hasSetMutator($key)) { 299 | $method = 'set'.Util::studly($key).'Attribute'; 300 | 301 | return $this->{$method}($value); 302 | } 303 | 304 | // If an attribute is listed as a "date", we'll convert it from a DateTime 305 | // instance into a form proper for storage on the database tables using 306 | // the Shopify's date time format. 307 | elseif ($value && in_array($key, $this->dates)) { 308 | $value = $this->fromDateTime($value); 309 | } 310 | 311 | $this->attributes[$key] = $value; 312 | 313 | return $this; 314 | } 315 | 316 | /** 317 | * Set the array of model attributes. No checking is done. 318 | * 319 | * @param array $attributes 320 | * @param bool $sync 321 | * 322 | * @return $this 323 | */ 324 | public function setRawAttributes(array $attributes, $sync = false) 325 | { 326 | $this->attributes = $attributes; 327 | 328 | if ($sync) { 329 | $this->syncOriginal(); 330 | } 331 | 332 | return $this; 333 | } 334 | 335 | /** 336 | * Return a timestamp as DateTime object. 337 | * 338 | * @param mixed $value 339 | * 340 | * @throws Exception 341 | * 342 | * @return Carbon 343 | */ 344 | protected function asDateTime($value) 345 | { 346 | // If this value is already a Carbon instance, we shall just return it as is. 347 | // This prevents us having to re-instantiate a Carbon instance when we know 348 | // it already is one, which wouldn't be fulfilled by the DateTime check. 349 | if ($value instanceof Carbon) { 350 | return $value; 351 | } 352 | 353 | // If the value is already a DateTime instance, we will just skip the rest of 354 | // these checks since they will be a waste of time, and hinder performance 355 | // when checking the field. We will just return the DateTime right away. 356 | if ($value instanceof DateTimeInterface) { 357 | return new Carbon( 358 | $value->format('Y-m-d H:i:s.u'), $value->getTimezone() 359 | ); 360 | } 361 | 362 | // If this value is an integer, we will assume it is a UNIX timestamp's value 363 | // and format a Carbon object from this timestamp. This allows flexibility 364 | // when defining your date fields as they might be UNIX timestamps here. 365 | if (is_numeric($value)) { 366 | return Carbon::createFromTimestamp($value); 367 | } 368 | 369 | // If the value is in simply year, month, day format, we will instantiate the 370 | // Carbon instances from that format. Again, this provides for simple date 371 | // fields on the database, while still supporting Carbonized conversion. 372 | if ($this->isStandardDateFormat($value)) { 373 | return Carbon::createFromFormat('Y-m-d', $value)->startOfDay(); 374 | } 375 | 376 | // Finally, we will just assume this date is in the format used by default on 377 | // the database connection and use that format to create the Carbon object 378 | // that is returned back out to the developers after we convert it here. 379 | return Carbon::createFromFormat( 380 | $this->getDateFormat(), $value 381 | ); 382 | } 383 | 384 | /** 385 | * Convert a DateTime to a storable string. 386 | * 387 | * @param DateTime|int $value 388 | * 389 | * @throws Exception 390 | * 391 | * @return string 392 | */ 393 | public function fromDateTime($value) 394 | { 395 | return $this->asDateTime($value)->format( 396 | $this->getDateFormat() 397 | ); 398 | } 399 | 400 | /** 401 | * Determine if the given value is a standard date format. 402 | * 403 | * @param string $value 404 | * 405 | * @return bool 406 | */ 407 | protected function isStandardDateFormat($value) 408 | { 409 | return preg_match('/^(\d{4})-(\d{1,2})-(\d{1,2})$/', $value); 410 | } 411 | 412 | /** 413 | * Get the format for database stored dates. 414 | * 415 | * @return string 416 | */ 417 | protected function getDateFormat() 418 | { 419 | return DateTime::ISO8601; 420 | } 421 | 422 | /** 423 | * Cast an attribute to a native PHP type. 424 | * 425 | * @param string $key 426 | * @param mixed $value 427 | * 428 | * @throws Exception 429 | * 430 | * @return mixed 431 | */ 432 | protected function castAttribute($key, $value) 433 | { 434 | // All types to permit null 435 | if (is_null($value)) { 436 | return $value; 437 | } 438 | 439 | switch ($this->getCastType($key)) { 440 | case 'int': 441 | case 'integer': 442 | return (int) $value; 443 | case 'real': 444 | case 'float': 445 | case 'double': 446 | return (float) $value; 447 | case 'string': 448 | return (string) $value; 449 | case 'bool': 450 | case 'boolean': 451 | return (bool) $value; 452 | case 'datetime': 453 | return $this->asDateTime($value); 454 | case 'array': 455 | return (array) $value; 456 | case 'object': 457 | return (object) $value; 458 | default: 459 | return $value; 460 | } 461 | } 462 | 463 | /** 464 | * Get the type of cast for a model attribute. 465 | * 466 | * @param string $key 467 | * 468 | * @return string 469 | */ 470 | protected function getCastType($key) 471 | { 472 | return isset($this->casts[$key]) 473 | ? strtolower(trim($this->casts[$key])) 474 | : null; 475 | } 476 | 477 | /** 478 | * IMPORTANT: We only serialize what's dirty, plus the id. 479 | * 480 | * This way our api updates only receive changes. 481 | * 482 | * Be sure to call syncOriginal 483 | * 484 | * @return array 485 | */ 486 | public function getPayload() 487 | { 488 | $payload = isset($this->original[static::$identifier]) 489 | ? [static::$identifier => $this->original[static::$identifier]] + $this->getDirty() 490 | : $this->getDirty(); 491 | 492 | return [static::$resource_name => $payload]; 493 | } 494 | 495 | /** 496 | * @return string 497 | */ 498 | public function jsonSerialize(): mixed 499 | { 500 | return json_encode($this->attributes + $this->original); 501 | } 502 | 503 | /** 504 | * @return string 505 | */ 506 | public function __serialize(): array 507 | { 508 | return $this->getAttributes(); 509 | } 510 | 511 | /** 512 | * @param string $data 513 | */ 514 | public function __unserialize(array $data): void 515 | { 516 | $this->attributes = $data; 517 | } 518 | 519 | /** 520 | * The origin. 521 | * 522 | * @return array 523 | */ 524 | public function toArray() 525 | { 526 | $arr = []; 527 | 528 | foreach ($this->attributes as $key => $value) { 529 | $arr[$key] = $this->getAttribute($key); 530 | } 531 | 532 | return $arr; 533 | } 534 | 535 | /** 536 | * Determine if the given attribute exists. 537 | * 538 | * @param mixed $offset 539 | * 540 | * @return bool 541 | */ 542 | public function offsetExists($offset): bool 543 | { 544 | return isset($this->$offset); 545 | } 546 | 547 | /** 548 | * Get the value for a given offset. 549 | * 550 | * @param mixed $offset 551 | * 552 | * @return mixed 553 | */ 554 | public function offsetGet($offset): mixed 555 | { 556 | return $this->$offset; 557 | } 558 | 559 | /** 560 | * Set the value for a given offset. 561 | * 562 | * @param mixed $offset 563 | * @param mixed $value 564 | * 565 | * @return void 566 | */ 567 | public function offsetSet($offset, $value): void 568 | { 569 | $this->$offset = $value; 570 | } 571 | 572 | /** 573 | * Unset the value for a given offset. 574 | * 575 | * @param mixed $offset 576 | * 577 | * @return void 578 | */ 579 | public function offsetUnset($offset): void 580 | { 581 | unset($this->$offset); 582 | } 583 | 584 | /** 585 | * Dynamically retrieve attributes on the model. 586 | * 587 | * @param string $key 588 | * 589 | * @return mixed 590 | */ 591 | public function __get($key) 592 | { 593 | return $this->getAttribute($key); 594 | } 595 | 596 | /** 597 | * Dynamically set attributes on the model. 598 | * 599 | * @param string $key 600 | * @param mixed $value 601 | * 602 | * @return void 603 | */ 604 | public function __set($key, $value) 605 | { 606 | $this->setAttribute($key, $value); 607 | } 608 | 609 | /** 610 | * Determine if an attribute or relation exists on the model. 611 | * 612 | * @param string $key 613 | * 614 | * @return bool 615 | */ 616 | public function __isset($key) 617 | { 618 | return ! is_null($this->getAttribute($key)); 619 | } 620 | 621 | /** 622 | * Unset an attribute on the model. 623 | * 624 | * @param string $key 625 | * 626 | * @return void 627 | */ 628 | public function __unset($key) 629 | { 630 | unset($this->attributes[$key], $this->relations[$key]); 631 | } 632 | 633 | /** 634 | * Convert the model to its string representation. 635 | * 636 | * @return string 637 | */ 638 | public function __toString() 639 | { 640 | return $this->toJson(); 641 | } 642 | 643 | /** 644 | * @param $attribute 645 | * @param $prop 646 | * @param $value 647 | * @param bool $unset 648 | * 649 | * @return $this 650 | */ 651 | public function prop($attribute, $prop, $value, $unset = false) 652 | { 653 | $obj = $this->$attribute ?: (object) []; 654 | 655 | if ($unset) { 656 | unset($obj->$prop); 657 | } else { 658 | $obj->$prop = $value; 659 | } 660 | 661 | $this->$attribute = $obj; 662 | 663 | return $this; 664 | } 665 | 666 | /** 667 | * Helper for pushing to an attribute that is an array. 668 | * 669 | * @param $attribute 670 | * @param $args 671 | * 672 | * @return int 673 | */ 674 | public function push($attribute, ...$args) 675 | { 676 | $arr = (array) $this->getAttribute($attribute); 677 | 678 | $count = count($arr); 679 | 680 | foreach ($args as $arg) { 681 | $count = array_push($arr, $arg); 682 | } 683 | 684 | $this->setAttribute($attribute, $arr); 685 | 686 | return $count; 687 | } 688 | 689 | /** 690 | * Helper for popping from an attribute that is an array. 691 | * 692 | * @param $attribute 693 | * 694 | * @return mixed|null 695 | */ 696 | public function pop($attribute) 697 | { 698 | $arr = (array) $this->getAttribute($attribute); 699 | $value = array_pop($arr); 700 | $this->setAttribute($attribute, $arr); 701 | 702 | return $value; 703 | } 704 | 705 | /** 706 | * Helper for unshifting to an attribute that is array. 707 | * 708 | * @param $attribute 709 | * @param $args 710 | * 711 | * @return int 712 | */ 713 | public function unshift($attribute, ...$args) 714 | { 715 | $arr = (array) $this->getAttribute($attribute); 716 | 717 | $count = count($arr); 718 | 719 | foreach ($args as $arg) { 720 | $count = array_unshift($arr, $arg); 721 | } 722 | 723 | $this->setAttribute($attribute, $arr); 724 | 725 | return $count; 726 | } 727 | 728 | /** 729 | * Helper for shifting from an attribute that is an array. 730 | * 731 | * @param $attribute 732 | * 733 | * @return mixed|null 734 | */ 735 | public function shift($attribute) 736 | { 737 | $arr = (array) $this->getAttribute($attribute); 738 | $value = array_shift($arr); 739 | $this->setAttribute($attribute, $arr); 740 | 741 | return $value; 742 | } 743 | 744 | /** 745 | * @return array 746 | */ 747 | public function getCasts() 748 | { 749 | return $this->casts; 750 | } 751 | 752 | /** 753 | * @return static 754 | */ 755 | public function replicate() 756 | { 757 | $attr = $this->getAttributes(); 758 | 759 | $data = array_diff_key($attr, array_fill_keys(static::$omit_on_replication, null)); 760 | 761 | return new static($data); 762 | } 763 | } 764 | -------------------------------------------------------------------------------- /src/Models/Asset.php: -------------------------------------------------------------------------------- 1 | 'string', 50 | 'public_url' => 'string', 51 | 'value' => 'string', 52 | 'content_type' => 'string', 53 | 'size' => 'integer', 54 | 'theme_id' => 'integer', 55 | ]; 56 | 57 | /** 58 | * @param array|object $data 59 | */ 60 | public function __construct($data = [], $exists = true) 61 | { 62 | $data = json_decode(json_encode($data), true); 63 | 64 | $this->fill($data); 65 | 66 | $this->exists = $exists; 67 | 68 | // An identifier doesn't necessarily mean it exists. 69 | if (isset($data[static::$identifier]) && $exists) { 70 | $this->syncOriginal(); 71 | } 72 | } 73 | 74 | /** 75 | * It'll be groovy if we append `_copy` before the extension. 76 | * 77 | * @return static 78 | */ 79 | public function replicate() 80 | { 81 | $attr = $this->getAttributes(); 82 | 83 | $dot = strrpos($attr['key'], '.') ?: strlen($attr['key']); 84 | $key = substr($attr['key'], 0, $dot) 85 | .'.copy'.substr($attr['key'], $dot); 86 | 87 | $data = compact('key'); 88 | $data += array_diff_key($attr, array_fill_keys(static::$omit_on_replication, null)); 89 | 90 | return new static($data); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/Models/AssignedFulfillmentOrder.php: -------------------------------------------------------------------------------- 1 | 'integer', 101 | 'shop_id' => 'integer', 102 | 'assigned_location_id' => 'integer', 103 | ]; 104 | } 105 | -------------------------------------------------------------------------------- /src/Models/Customer.php: -------------------------------------------------------------------------------- 1 | 'bool', 57 | 'addresses' => 'array', 58 | 'default_address' => 'object', 59 | 'orders_count' => 'integer', 60 | 'tax_exempt' => 'bool', 61 | 'total_spent' => 'float', 62 | 'verified_email' => 'bool', 63 | ]; 64 | } 65 | -------------------------------------------------------------------------------- /src/Models/DiscountCode.php: -------------------------------------------------------------------------------- 1 | 'string', 40 | 'currency' => 'string', 41 | 'amount' => 'float', 42 | 'reason' => 'string', 43 | 'network_reason_code' => 'string', 44 | 'status' => 'string', 45 | ]; 46 | 47 | const TYPE_CHARGEBACK = 'chargeback'; 48 | const TYPE_INQUIRY = 'inquiry'; 49 | 50 | /** @var array $types */ 51 | public static $types = [ 52 | self::TYPE_CHARGEBACK, 53 | self::TYPE_INQUIRY, 54 | ]; 55 | 56 | const REASON_BANK_NOT_PROCESS = 'bank_not_process'; 57 | const REASON_CREDIT_NOT_PROCESSED = 'credit_not_processed'; 58 | const REASON_CUSTOMER_INITIATED = 'customer_initiated'; 59 | const REASON_DEBIT_NOT_AUTHORIZED = 'debit_not_authorized'; 60 | const REASON_DUPLICATE = 'duplicate'; 61 | const REASON_FRAUDULENT = 'fraudulent'; 62 | const REASON_GENERAL = 'general'; 63 | const REASON_INCORRECT_ACCOUNT_DETAILS = 'incorrect_account_details'; 64 | const REASON_INSUFFICIENT_FUNDS = 'insufficient_funds'; 65 | const REASON_PRODUCT_NOT_RECEIVED = 'product_not_received'; 66 | const REASON_PRODUCT_UNACCEPTABLE = 'product_unacceptable'; 67 | const REASON_SUBSCRIPTION_CANCELED = 'subscription_canceled'; 68 | const REASON_UNRECOGNIZED = 'unrecognized'; 69 | 70 | /** @var array $reasons */ 71 | public static $reasons = [ 72 | self::REASON_BANK_NOT_PROCESS, 73 | self::REASON_CREDIT_NOT_PROCESSED, 74 | self::REASON_CUSTOMER_INITIATED, 75 | self::REASON_DEBIT_NOT_AUTHORIZED, 76 | self::REASON_DUPLICATE, 77 | self::REASON_FRAUDULENT, 78 | self::REASON_GENERAL, 79 | self::REASON_INCORRECT_ACCOUNT_DETAILS, 80 | self::REASON_INSUFFICIENT_FUNDS, 81 | self::REASON_PRODUCT_NOT_RECEIVED, 82 | self::REASON_PRODUCT_UNACCEPTABLE, 83 | self::REASON_SUBSCRIPTION_CANCELED, 84 | self::REASON_UNRECOGNIZED, 85 | ]; 86 | 87 | const STATUS_NEEDS_RESPONSE = 'needs_response'; 88 | const STATUS_UNDER_REVIEW = 'under_review'; 89 | const STATUS_CHARGE_REFUNDED = 'charge_refunded'; 90 | const STATUS_ACCEPTED = 'accepted'; 91 | const STATUS_WON = 'won'; 92 | const STATUS_LOST = 'lost'; 93 | 94 | /** @var array $statuses */ 95 | public static $statuses = [ 96 | self::STATUS_NEEDS_RESPONSE, 97 | self::STATUS_UNDER_REVIEW, 98 | self::STATUS_CHARGE_REFUNDED, 99 | self::STATUS_ACCEPTED, 100 | self::STATUS_WON, 101 | self::STATUS_LOST, 102 | ]; 103 | } 104 | -------------------------------------------------------------------------------- /src/Models/Fulfillment.php: -------------------------------------------------------------------------------- 1 | 'integer', 45 | 'order_id' => 'integer', 46 | 'status' => 'string', 47 | 'service' => 'string', 48 | 'tracking_company' => 'string', 49 | 'shipment_status' => 'string', 50 | 'int' => 'location_id', 51 | 'tracking_number' => 'string', 52 | 'tracking_numbers' => 'array', 53 | 'tracking_url' => 'string', 54 | 'tracking_urls' => 'array', 55 | 'receipt' => 'object', 56 | 'name' => 'string', 57 | 'admin_graphql_api_id' => 'string', 58 | ]; 59 | } 60 | -------------------------------------------------------------------------------- /src/Models/FulfillmentOrder.php: -------------------------------------------------------------------------------- 1 | 'integer', 34 | 'name' => 'string', 35 | 'handle' => 'string', 36 | 'email' => 'string', 37 | 'include_pending_stock' => 'bool', 38 | 'requires_shipping_method' => 'bool', 39 | 'service_name' => 'string', 40 | 'inventory_management' => 'bool', 41 | 'tracking_support' => 'bool', 42 | 'provider_id' => 'integer', 43 | 'location_id' => 'integer', 44 | ]; 45 | } 46 | -------------------------------------------------------------------------------- /src/Models/Image.php: -------------------------------------------------------------------------------- 1 | 'int', 35 | 'position' => 'int', 36 | 'width' => 'int', 37 | 'height' => 'int', 38 | 'src' => 'string', 39 | 'variant_ids' => 'array', 40 | ]; 41 | } 42 | -------------------------------------------------------------------------------- /src/Models/Metafield.php: -------------------------------------------------------------------------------- 1 | 'bool', 94 | 'confirmed' => 'bool', 95 | 'total_price' => 'float', 96 | 'subtotal_price' => 'float', 97 | 'total_weight' => 'float', 98 | 'total_tax' => 'float', 99 | 'taxes_included' => 'bool', 100 | 'total_discounts' => 'float', 101 | 'total_line_items_price' => 'float', 102 | 'buyer_accepts_marketing' => 'float', 103 | 'total_price_usd' => 'float', 104 | 'discount_codes' => 'array', 105 | 'note_attributes' => 'array', 106 | 'payment_gateway_names' => 'array', 107 | 'line_items' => 'array', 108 | 'shipping_lines' => 'array', 109 | 'shipping_address' => 'object', 110 | 'billing_address' => 'object', 111 | 'tax_lines' => 'array', 112 | 'fulfillments' => 'array', 113 | 'refunds' => 'array', 114 | 'customer' => 'object', 115 | 'client_details' => 'object', 116 | 'payment_details' => 'object', 117 | ]; 118 | 119 | // Financial statuses from Shopify 120 | const FINANCIAL_STATUS_AUTHORIZED = 'authorized'; 121 | const FINANCIAL_STATUS_PAID = 'paid'; 122 | const FINANCIAL_STATUS_PARTIALLY_PAID = 'partially_paid'; 123 | const FINANCIAL_STATUS_PARTIALLY_REFUNDED = 'partially_refunded'; 124 | const FINANCIAL_STATUS_PENDING = 'pending'; 125 | const FINANCIAL_STATUS_REFUNDED = 'refunded'; 126 | const FINANCIAL_STATUS_VOIDED = 'voided'; 127 | 128 | /** @var array $financial_statuses */ 129 | public static $financial_statuses = [ 130 | self::FINANCIAL_STATUS_AUTHORIZED, 131 | self::FINANCIAL_STATUS_PAID, 132 | self::FINANCIAL_STATUS_PARTIALLY_PAID, 133 | self::FINANCIAL_STATUS_PARTIALLY_REFUNDED, 134 | self::FINANCIAL_STATUS_PENDING, 135 | self::FINANCIAL_STATUS_REFUNDED, 136 | self::FINANCIAL_STATUS_VOIDED, 137 | ]; 138 | 139 | // Fulfillment statuses from Shopify 140 | const FULFILLMENT_STATUS_FILLED = 'fulfilled'; 141 | const FULFILLMENT_STATUS_PARTIAL = 'partial'; 142 | const FULFILLMENT_STATUS_UNFILLED = null; 143 | 144 | /** @var array $fulfillment_statuses */ 145 | public static $fulfillment_statuses = [ 146 | self::FULFILLMENT_STATUS_FILLED, 147 | self::FULFILLMENT_STATUS_PARTIAL, 148 | self::FULFILLMENT_STATUS_UNFILLED, 149 | ]; 150 | 151 | // Risk recommendations from Shopify 152 | const RISK_RECOMMENDATION_LOW = 'accept'; 153 | const RISK_RECOMMENDATION_MEDIUM = 'investigate'; 154 | const RISK_RECOMMENDATION_HIGH = 'cancel'; 155 | 156 | /** @var array $risk_statuses */ 157 | public static $risk_statuses = [ 158 | self::RISK_RECOMMENDATION_LOW, 159 | self::RISK_RECOMMENDATION_MEDIUM, 160 | self::RISK_RECOMMENDATION_HIGH, 161 | ]; 162 | 163 | const FILTER_STATUS_ANY = 'any'; 164 | const FILTER_STATUS_CANCELLED = 'cancelled'; 165 | const FILTER_STATUS_CLOSED = 'closed'; 166 | const FILTER_STATUS_OPEN = 'open'; 167 | 168 | /** @var array $filter_statuses */ 169 | public static $filter_statuses = [ 170 | self::FILTER_STATUS_ANY, 171 | self::FILTER_STATUS_CANCELLED, 172 | self::FILTER_STATUS_CLOSED, 173 | self::FILTER_STATUS_OPEN, 174 | ]; 175 | } 176 | -------------------------------------------------------------------------------- /src/Models/PriceRule.php: -------------------------------------------------------------------------------- 1 | 'array', 90 | 'entitled_country_ids' => 'array', 91 | 'entitled_product_ids' => 'array', 92 | 'entitled_variant_ids' => 'array', 93 | 'once_per_customer' => 'boolean', 94 | 'prerequisite_collection_ids' => 'array', 95 | 'prerequisite_customer_ids' => 'array', 96 | 'prerequisite_product_ids' => 'array', 97 | 'prerequisite_quantity_range' => 'array', 98 | 'prerequisite_saved_search_ids' => 'array', 99 | 'prerequisite_shipping_price_range' => 'array', 100 | 'prerequisite_subtotal_range' => 'array', 101 | 'prerequisite_variant_ids' => 'array', 102 | ]; 103 | } 104 | -------------------------------------------------------------------------------- /src/Models/Product.php: -------------------------------------------------------------------------------- 1 | 'array', 44 | 'options' => 'array', 45 | 'images' => 'array', 46 | 'image' => 'object', 47 | ]; 48 | 49 | const PUBLISHED_SCOPE_GLOBAL = 'global'; 50 | const PUBLISHED_SCOPE_WEB = 'web'; 51 | 52 | /** @var array $published_scopes */ 53 | public static $published_scopes = [ 54 | self::PUBLISHED_SCOPE_GLOBAL, 55 | self::PUBLISHED_SCOPE_WEB, 56 | ]; 57 | 58 | const WEIGHT_UNIT_GRAMS = 'g'; 59 | const WEIGHT_UNIT_KG = 'kg'; 60 | const WEIGHT_UNIT_LB = 'lb'; 61 | const WEIGHT_UNIT_OUNCE = 'oz'; 62 | 63 | /** @var array $weight_units */ 64 | public static $weight_units = [ 65 | self::WEIGHT_UNIT_GRAMS, 66 | self::WEIGHT_UNIT_KG, 67 | self::WEIGHT_UNIT_LB, 68 | self::WEIGHT_UNIT_OUNCE, 69 | ]; 70 | 71 | /** 72 | * @return array 73 | */ 74 | public function getTagsAsArray(): array 75 | { 76 | return explode(', ', $this->tags); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/Models/RecurringApplicationCharge.php: -------------------------------------------------------------------------------- 1 | 'datetime', 60 | 'billing_on' => 'datetime', 61 | 'cancelled_on' => 'datetime', 62 | 'capped_amount' => 'float', 63 | 'confirmation_url' => 'string', 64 | 'created_at' => 'datetime', 65 | 'id' => 'integer', 66 | 'name' => 'string', 67 | 'price' => 'float', 68 | 'return_url' => 'string', 69 | 'status' => 'string', 70 | 'terms' => 'string', 71 | 'test' => 'bool', 72 | 'trial_days' => 'integer', 73 | 'trial_ends_on' => 'datetime', 74 | 'updated_at' => 'datetime', 75 | ]; 76 | } 77 | -------------------------------------------------------------------------------- /src/Models/Risk.php: -------------------------------------------------------------------------------- 1 | 'string', 36 | 'source' => 'string', 37 | 'score' => 'float', 38 | 'recommendation' => 'string', 39 | 'display' => 'bool', 40 | 'cause_cancel' => 'bool', 41 | 'message' => 'string', 42 | 'merchant_message' => 'string', 43 | ]; 44 | } 45 | -------------------------------------------------------------------------------- /src/Models/SmartCollections.php: -------------------------------------------------------------------------------- 1 | 'array', 66 | 'rules' => 'array', 67 | ]; 68 | } 69 | -------------------------------------------------------------------------------- /src/Models/Theme.php: -------------------------------------------------------------------------------- 1 | 'string', 35 | 'role' => 'string', 36 | 'previewable' => 'bool', 37 | 'processing' => 'bool', 38 | ]; 39 | 40 | const THEME_ROLE_MAIN = 'main'; 41 | const THEME_ROLE_UNPUBLISHED = 'unpublished'; 42 | 43 | /** @var array $theme_roles */ 44 | public static $theme_roles = [ 45 | self::THEME_ROLE_MAIN, 46 | self::THEME_ROLE_UNPUBLISHED, 47 | ]; 48 | 49 | const FILE_LAYOUT_GIFT_CARD = 'layout/gift_card.liquid'; 50 | const FILE_LAYOUT_PASSWORD = 'layout/password.liquid'; 51 | const FILE_LAYOUT_THEME = 'layout/theme.liquid'; 52 | const FILE_SETTINGS_SCHEMA_JSON = 'config/settings_schema.json'; 53 | const FILE_SETTINGS_DATA_JSON = 'config/settings_data.json'; 54 | const FILE_TEMPLATES_404 = 'templates/404.liquid'; 55 | const FILE_TEMPLATES_ARTICLE = 'templates/article.liquid'; 56 | const FILE_TEMPLATES_BLOG = 'templates/blog.liquid'; 57 | const FILE_TEMPLATES_CART = 'templates/cart.liquid'; 58 | const FILE_TEMPLATES_COLLECTION = 'templates/collection.liquid'; 59 | const FILE_TEMPLATES_CUSTOMERS_ACCOUNT = 'templates/customers/account.liquid'; 60 | const FILE_TEMPLATES_CUSTOMERS_ACTIVATE = 'templates/customers/activate.liquid'; 61 | const FILE_TEMPLATES_CUSTOMERS_ADDRESSES = 'templates/customers/addresses.liquid'; 62 | const FILE_TEMPLATES_CUSTOMERS_LOGIN = 'templates/customers/login.liquid'; 63 | const FILE_TEMPLATES_CUSTOMERS_ORDER = 'templates/customers/order.liquid'; 64 | const FILE_TEMPLATES_CUSTOMERS_REGISTER = 'templates/customers/register.liquid'; 65 | const FILE_TEMPLATES_CUSTOMERS_RESET_PASSWORD = 'templates/customers/reset_password.liquid'; 66 | const FILE_TEMPLATES_GIFT_CARD = 'templates/gift_card.liquid'; 67 | const FILE_TEMPLATES_INDEX = 'templates/index.liquid'; 68 | const FILE_TEMPLATES_LIST_COLLECTIONS = 'templates/list-collections.liquid'; 69 | const FILE_TEMPLATES_PAGE = 'templates/page.liquid'; 70 | const FILE_TEMPLATES_PASSWORD = 'templates/password.liquid'; 71 | const FILE_TEMPLATES_PRODUCT = 'templates/product.liquid'; 72 | const FILE_TEMPLATES_SEARCH = 'templates/search.liquid'; 73 | const FILE_LOCALES_EN_DEFAULT = 'locales/en.default.json'; 74 | 75 | /** @var array $core_files */ 76 | public static $core_files = [ 77 | self::FILE_LAYOUT_GIFT_CARD, 78 | self::FILE_LAYOUT_PASSWORD, 79 | self::FILE_LAYOUT_THEME, 80 | self::FILE_SETTINGS_DATA_JSON, 81 | self::FILE_SETTINGS_SCHEMA_JSON, 82 | self::FILE_TEMPLATES_404, 83 | self::FILE_TEMPLATES_ARTICLE, 84 | self::FILE_TEMPLATES_BLOG, 85 | self::FILE_TEMPLATES_CART, 86 | self::FILE_TEMPLATES_COLLECTION, 87 | self::FILE_TEMPLATES_CUSTOMERS_ACCOUNT, 88 | self::FILE_TEMPLATES_CUSTOMERS_ACTIVATE, 89 | self::FILE_TEMPLATES_CUSTOMERS_ADDRESSES, 90 | self::FILE_TEMPLATES_CUSTOMERS_LOGIN, 91 | self::FILE_TEMPLATES_CUSTOMERS_ORDER, 92 | self::FILE_TEMPLATES_CUSTOMERS_REGISTER, 93 | self::FILE_TEMPLATES_CUSTOMERS_RESET_PASSWORD, 94 | self::FILE_TEMPLATES_GIFT_CARD, 95 | self::FILE_TEMPLATES_INDEX, 96 | self::FILE_TEMPLATES_LIST_COLLECTIONS, 97 | self::FILE_TEMPLATES_PAGE, 98 | self::FILE_TEMPLATES_PASSWORD, 99 | self::FILE_TEMPLATES_PRODUCT, 100 | self::FILE_TEMPLATES_SEARCH, 101 | ]; 102 | } 103 | -------------------------------------------------------------------------------- /src/Models/Variant.php: -------------------------------------------------------------------------------- 1 | 'string', 54 | 'title' => 'string', 55 | 'price' => 'float', 56 | 'sku' => 'string', 57 | 'position' => 'int', 58 | 'inventory_policy' => 'string', 59 | 'compare_at_price' => 'float', 60 | 'fulfillment_service' => 'string', 61 | 'option1' => 'string', 62 | 'taxable' => 'bool', 63 | 'grams' => 'int', 64 | 'image_id' => 'string', 65 | 'inventory_quantity' => 'int', 66 | 'weight' => 'float', 67 | 'weight_unit' => 'string', 68 | 'inventory_item_id' => 'string', 69 | 'old_inventory_quantity' => 'int', 70 | 'requires_shipping' => 'bool', 71 | 'metafields' => 'array', 72 | ]; 73 | } 74 | -------------------------------------------------------------------------------- /src/Models/Webhook.php: -------------------------------------------------------------------------------- 1 | 'integer', 34 | 'address' => 'string', 35 | 'topic' => 'string', 36 | 'fields' => 'array', 37 | 'format' => 'string', 38 | 'metafield_namespaces' => 'array', 39 | ]; 40 | 41 | const CARTS_CREATE = 'carts/create'; 42 | const CARTS_UPDATE = 'carts/update'; 43 | const CHECKOUTS_CREATE = 'checkouts/create'; 44 | const CHECKOUTS_DELETE = 'checkouts/delete'; 45 | const CHECKOUTS_UPDATE = 'checkouts/update'; 46 | const COLLECTION_LISTINGS_ADD = 'collection_listings/add'; 47 | const COLLECTION_LISTINGS_REMOVE = 'collection_listings/remove'; 48 | const COLLECTION_LISTINGS_UPDATE = 'collection_listings/update'; 49 | const COLLECTIONS_CREATE = 'collections/create'; 50 | const COLLECTIONS_DELETE = 'collections/delete'; 51 | const COLLECTIONS_UPDATE = 'collections/update'; 52 | const CUSTOMER_GROUPS_CREATE = 'customer_groups/create'; 53 | const CUSTOMER_GROUPS_DELETE = 'customer_groups/delete'; 54 | const CUSTOMER_GROUPS_UPDATE = 'customer_groups/update'; 55 | const CUSTOMERS_CREATE = 'customers/create'; 56 | const CUSTOMERS_DELETE = 'customers/delete'; 57 | const CUSTOMERS_DISABLE = 'customers/disable'; 58 | const CUSTOMERS_ENABLE = 'customers/enable'; 59 | const CUSTOMERS_UPDATE = 'customers/update'; 60 | const DISPUTES_CREATE = 'disputes/create'; 61 | const DISPUTES_UPDATE = 'disputes/update'; 62 | const DRAFT_ORDERS_CREATE = 'draft_orders/create'; 63 | const DRAFT_ORDERS_DELETE = 'draft_orders/delete'; 64 | const DRAFT_ORDERS_UPDATE = 'draft_orders/update'; 65 | const FULFILLMENT_EVENTS_CREATE = 'fulfillment_events/create'; 66 | const FULFILLMENT_EVENTS_DELETE = 'fulfillment_events/delete'; 67 | const FULFILLMENTS_CREATE = 'fulfillments/create'; 68 | const FULFILLMENTS_UPDATE = 'fulfillments/update'; 69 | const ORDER_TRANSACTIONS_CREATE = 'order_transactions/create'; 70 | const ORDERS_CANCELLED = 'orders/cancelled'; 71 | const ORDERS_CREATE = 'orders/create'; 72 | const ORDERS_DELETE = 'orders/delete'; 73 | const ORDERS_FULFILLED = 'orders/fulfilled'; 74 | const ORDERS_PAID = 'orders/paid'; 75 | const ORDERS_PARTIALLY_FULFILLED = 'orders/partially_fulfilled'; 76 | const ORDERS_UPDATED = 'orders/updated'; 77 | const PRODUCT_LISTINGS_ADD = 'product_listings/add'; 78 | const PRODUCT_LISTINGS_REMOVE = 'product_listings/remove'; 79 | const PRODUCT_LISTINGS_UPDATE = 'product_listings/update'; 80 | const PRODUCTS_CREATE = 'products/create'; 81 | const PRODUCTS_DELETE = 'products/delete'; 82 | const PRODUCTS_UPDATE = 'products/update'; 83 | const REFUNDS_CREATE = 'refunds/create'; 84 | const SHOP_UPDATE = 'shop/update'; 85 | const APP_UNINSTALLED = 'app/uninstalled'; 86 | const THEMES_CREATE = 'themes/create'; 87 | const THEMES_DELETE = 'themes/delete'; 88 | const THEMES_PUBLISH = 'themes/publish'; 89 | const THEMES_UPDATE = 'themes/update'; 90 | 91 | /** @var array $topics */ 92 | public static $topics = [ 93 | self::CARTS_CREATE, 94 | self::CARTS_UPDATE, 95 | self::CHECKOUTS_CREATE, 96 | self::CHECKOUTS_DELETE, 97 | self::CHECKOUTS_UPDATE, 98 | self::COLLECTION_LISTINGS_ADD, 99 | self::COLLECTION_LISTINGS_REMOVE, 100 | self::COLLECTION_LISTINGS_UPDATE, 101 | self::COLLECTIONS_CREATE, 102 | self::COLLECTIONS_DELETE, 103 | self::COLLECTIONS_UPDATE, 104 | self::CUSTOMER_GROUPS_CREATE, 105 | self::CUSTOMER_GROUPS_DELETE, 106 | self::CUSTOMER_GROUPS_UPDATE, 107 | self::CUSTOMERS_CREATE, 108 | self::CUSTOMERS_DELETE, 109 | self::CUSTOMERS_DISABLE, 110 | self::CUSTOMERS_ENABLE, 111 | self::CUSTOMERS_UPDATE, 112 | self::DISPUTES_CREATE, 113 | self::DISPUTES_UPDATE, 114 | self::DRAFT_ORDERS_CREATE, 115 | self::DRAFT_ORDERS_DELETE, 116 | self::DRAFT_ORDERS_UPDATE, 117 | self::FULFILLMENT_EVENTS_CREATE, 118 | self::FULFILLMENT_EVENTS_DELETE, 119 | self::FULFILLMENTS_CREATE, 120 | self::FULFILLMENTS_UPDATE, 121 | self::ORDER_TRANSACTIONS_CREATE, 122 | self::ORDERS_CANCELLED, 123 | self::ORDERS_CREATE, 124 | self::ORDERS_DELETE, 125 | self::ORDERS_FULFILLED, 126 | self::ORDERS_PAID, 127 | self::ORDERS_PARTIALLY_FULFILLED, 128 | self::ORDERS_UPDATED, 129 | self::PRODUCT_LISTINGS_ADD, 130 | self::PRODUCT_LISTINGS_REMOVE, 131 | self::PRODUCT_LISTINGS_UPDATE, 132 | self::PRODUCTS_CREATE, 133 | self::PRODUCTS_DELETE, 134 | self::PRODUCTS_UPDATE, 135 | self::REFUNDS_CREATE, 136 | self::SHOP_UPDATE, 137 | self::THEMES_CREATE, 138 | self::THEMES_DELETE, 139 | self::THEMES_PUBLISH, 140 | self::THEMES_UPDATE, 141 | ]; 142 | } 143 | -------------------------------------------------------------------------------- /src/RateLimit.php: -------------------------------------------------------------------------------- 1 | header(static::HEADER_CALL_LIMIT) ?: '0/40'; 35 | 36 | [$this->calls, $this->cap] = explode('/', $call_limit); 37 | 38 | $this->retry_after = $response->header(static::HEADER_RETRY_AFTER) ?: 0; 39 | } 40 | } 41 | 42 | /** 43 | * @return bool 44 | */ 45 | public function accepts() 46 | { 47 | return $this->calls < $this->cap; 48 | } 49 | 50 | /** 51 | * @return int 52 | */ 53 | public function calls() 54 | { 55 | return $this->calls; 56 | } 57 | 58 | /** 59 | * @param callable $exceeded 60 | * @param callable $remaining 61 | * 62 | * @return mixed|bool 63 | */ 64 | public function exceeded(callable $exceeded = null, callable $remaining = null) 65 | { 66 | $state = $this->calls >= $this->cap; 67 | 68 | if ($state && $exceeded) { 69 | return $exceeded($this); 70 | } 71 | 72 | if (! $state && $remaining) { 73 | return $remaining($this); 74 | } 75 | 76 | return $state; 77 | } 78 | 79 | /** 80 | * @param callable $remaining 81 | * @param callable $exceeded 82 | * 83 | * @return mixed|bool 84 | */ 85 | public function remaining(callable $remaining = null, callable $exceeded = null) 86 | { 87 | $state = ($this->cap - $this->calls) > 0; 88 | 89 | if ($state && $remaining) { 90 | return $remaining($this); 91 | } 92 | 93 | if (! $state && $exceeded) { 94 | return $exceeded($this); 95 | } 96 | 97 | return $state; 98 | } 99 | 100 | /** 101 | * @param callable $retry 102 | * @param callable $continue 103 | * 104 | * @return int 105 | */ 106 | public function retryAfter(callable $retry = null, callable $continue = null) 107 | { 108 | $state = $this->retry_after; 109 | 110 | if ($state && $retry) { 111 | sleep($state); 112 | return $retry($this); 113 | } 114 | 115 | if (! $state && $continue) { 116 | return $continue($this); 117 | } 118 | 119 | return $state; 120 | } 121 | 122 | /** 123 | * @param mixed $on_this 124 | * 125 | * @return static|Shopify|mixed 126 | */ 127 | public function wait($on_this = null) 128 | { 129 | if ($this->exceeded()) { 130 | sleep($this->retryAfter()); 131 | } 132 | 133 | return $on_this ?: $this; 134 | } 135 | 136 | /** 137 | * @return array 138 | */ 139 | public function jsonSerialize() 140 | { 141 | return [ 142 | 'calls' => $this->calls, 143 | 'cap' => $this->cap, 144 | 'retry_after' => $this->retry_after, 145 | ]; 146 | } 147 | 148 | /** 149 | * @return string 150 | */ 151 | public function __toString() 152 | { 153 | return json_encode($this); 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /src/Shopify.php: -------------------------------------------------------------------------------- 1 | 'assets.json', 183 | 'assigned_fulfillment_orders' => 'assigned_fulfillment_orders/%s.json', 184 | 'customers' => 'customers/%s.json', 185 | 'discount_codes' => 'discount_codes/%s.json', 186 | 'disputes' => 'shopify_payments/disputes/%s.json', 187 | 'fulfillments' => 'fulfillments/%s.json', 188 | 'fulfillment_orders' => 'fulfillment_orders/%s.json', 189 | 'fulfillment_services' => 'fulfillment_services/%s.json', 190 | 'graphql' => 'graphql.json', 191 | 'images' => 'images/%s.json', 192 | 'metafields' => 'metafields/%s.json', 193 | 'orders' => 'orders/%s.json', 194 | 'price_rules' => 'price_rules/%s.json', 195 | 'products' => 'products/%s.json', 196 | 'recurring_application_charges' => 'recurring_application_charges/%s.json', 197 | 'risks' => 'risks/%s.json', 198 | 'smart_collections' => 'smart_collections/%s.json', 199 | 'themes' => 'themes/%s.json', 200 | 'variants' => 'variants/%s.json', 201 | 'webhooks' => 'webhooks/%s.json', 202 | ]; 203 | 204 | /** @var array $resource_models */ 205 | protected static $resource_models = [ 206 | 'assigned_fulfillment_orders' => AssignedFulfillmentOrder::class, 207 | 'assets' => Asset::class, 208 | 'customers' => Customer::class, 209 | 'discount_codes' => DiscountCode::class, 210 | 'disputes' => Dispute::class, 211 | 'fulfillments' => Fulfillment::class, 212 | 'fulfillment_orders' => FulfillmentOrder::class, 213 | 'fulfillment_services' => FulfillmentService::class, 214 | 'images' => Image::class, 215 | 'metafields' => Metafield::class, 216 | 'orders' => Order::class, 217 | 'price_rules' => PriceRule::class, 218 | 'products' => Product::class, 219 | 'recurring_application_charges' => RecurringApplicationCharge::class, 220 | 'risks' => Risk::class, 221 | 'smart_collections' => SmartCollections::class, 222 | 'themes' => Theme::class, 223 | 'variants' => Variant::class, 224 | 'webhooks' => Webhook::class, 225 | ]; 226 | 227 | /** @var array $cursored_enpoints */ 228 | protected static $cursored_enpoints = [ 229 | 'customers', 230 | 'discount_codes', 231 | 'disputes', 232 | 'fulfillments', 233 | 'orders', 234 | 'price_rules', 235 | 'products', 236 | 'smart_collections', 237 | 'variants', 238 | 'webhooks', 239 | ]; 240 | 241 | /** 242 | * @var \Illuminate\Http\Client\PendingRequest 243 | */ 244 | protected $client; 245 | 246 | /** 247 | * Shopify constructor. 248 | * 249 | * @param string $shop 250 | * @param string $token 251 | * @param null $base 252 | */ 253 | public function __construct($shop, $token, $base = null) 254 | { 255 | $shop = Util::normalizeDomain($shop); 256 | $base_uri = "https://{$shop}"; 257 | 258 | $this->setBase($base); 259 | 260 | $this->client = Http::baseUrl($base_uri) 261 | ->withHeaders([ 262 | 'X-Shopify-Access-Token' => $token, 263 | 'Accept' => 'application/json', 264 | 'Content-Type' => 'application/json; charset=utf-8;', 265 | ]); 266 | } 267 | 268 | /** 269 | * @param string $shop 270 | * @param string $token 271 | * 272 | * @return Shopify 273 | */ 274 | public static function make($shop, $token) 275 | { 276 | return new static($shop, $token); 277 | } 278 | 279 | /** 280 | * Leverage the graphql API safetly without changing instance state 281 | * 282 | * EXAMPLE $query (string) 283 | * 284 | * { 285 | * deliveryProfiles (first: 3) { 286 | * edges { 287 | * node { 288 | * id, 289 | * name, 290 | * } 291 | * } 292 | * } 293 | * } 294 | * 295 | * @param string $query 296 | * @return array 297 | * 298 | * @throws InvalidOrMissingEndpointException 299 | * @throws \Illuminate\Http\Client\RequestException 300 | */ 301 | public function graphql($query) 302 | { 303 | $uri = static::makeUri('graphql'); 304 | 305 | $options = ['json' => compact('query')]; 306 | 307 | $response = $this->client->send('POST', $uri, $options)->throw(); 308 | 309 | return json_decode($response->getBody()->getContents(), true); 310 | } 311 | 312 | /** 313 | * Get a resource using the assigned endpoint ($this->endpoint). 314 | * 315 | * @param array $query 316 | * @param string $append 317 | * 318 | * @return array 319 | * 320 | * @throws InvalidOrMissingEndpointException 321 | * @throws \Illuminate\Http\Client\RequestException 322 | */ 323 | public function get($query = [], $append = '') 324 | { 325 | $api = $this->api; 326 | 327 | // Don't allow use of page query on cursored endpoints 328 | if (isset($query['page']) && in_array($api, static::$cursored_enpoints, true)) { 329 | if (Util::isLaravel()) { 330 | if (config('shopify.options.log_deprecation_warnings')) { 331 | \Log::warning('vendor:dan:shopify:get', ['Use of deprecated query parameter. Use cursor navigation instead.']); 332 | } 333 | } 334 | 335 | return []; 336 | } 337 | 338 | // Do request and store response in variable 339 | $response = $this->request( 340 | $method = 'GET', 341 | $uri = $this->uri($append), 342 | $options = ['query' => $query] 343 | ); 344 | 345 | // If response has Link header, parse it and set the cursors 346 | if ($response->hasHeader('Link')) { 347 | $this->cursors = static::parseLinkHeader($response->header('Link')); 348 | } 349 | // If we don't have Link on a cursored endpoint then it was the only page. Set cursors to null to avoid breaking next. 350 | elseif (in_array($api, self::$cursored_enpoints, true)) { 351 | $this->cursors = [ 352 | 'prev' => null, 353 | 'next' => null, 354 | ]; 355 | } 356 | 357 | $data = json_decode($response->getBody()->getContents(), true); 358 | 359 | return $data[static::apiCollectionProperty($api)] ?? $data[static::apiEntityProperty($api)] ?? $data; 360 | } 361 | 362 | /** 363 | * @param array $query 364 | * @param string $append 365 | * 366 | * @return array|null 367 | * 368 | * @throws GuzzleException 369 | * @throws InvalidOrMissingEndpointException 370 | */ 371 | public function next($query = [], $append = '') 372 | { 373 | // Only allow use of next on cursored endpoints 374 | if (! in_array($this->api, static::$cursored_enpoints, true)) { 375 | if (Util::isLaravel()) { 376 | if (config('shopify.options.log_deprecation_warnings')) { 377 | \Log::warning('vendor:dan:shopify:get', ['Use of cursored method on non-cursored endpoint.']); 378 | } 379 | } 380 | 381 | return []; 382 | } 383 | 384 | // If cursors haven't been set, then just call get normally. 385 | if (empty($this->cursors)) { 386 | return $this->get($query, $append); 387 | } 388 | 389 | // Only limit key is allowed to exist with cursor based navigation 390 | foreach (array_keys($query) as $key) { 391 | if ($key !== 'limit') { 392 | if (Util::isLaravel()) { 393 | if (config('shopify.options.log_deprecation_warnings')) { 394 | \Log::warning('vendor:dan:shopify:get', ['Limit param is not allowed with cursored queries.']); 395 | } 396 | } 397 | 398 | return []; 399 | } 400 | } 401 | 402 | // If cursors have been set and next hasn't been set, then return null. 403 | if (empty($this->cursors['next'])) { 404 | return []; 405 | } 406 | 407 | // If cursors have been set and next has been set, then return get with next. 408 | $query['page_info'] = $this->cursors['next']; 409 | 410 | return $this->get($query, $append); 411 | } 412 | 413 | /** 414 | * Get the shop resource. 415 | * 416 | * @return array 417 | * @throws GuzzleException 418 | */ 419 | public function shop() 420 | { 421 | $response = $this->request('GET', "{$this->base}/shop.json"); 422 | 423 | $data = json_decode($response->getBody()->getContents(), true); 424 | 425 | return $data['shop']; 426 | } 427 | 428 | /** 429 | * Post to a resource using the assigned endpoint ($this->api). 430 | * 431 | * @param array|AbstractModel $payload 432 | * @param string $append 433 | * 434 | * @return array|AbstractModel 435 | * @throws GuzzleException 436 | * 437 | * @throws InvalidOrMissingEndpointException 438 | */ 439 | public function post($payload = [], $append = '') 440 | { 441 | return $this->post_or_put('POST', $payload, $append); 442 | } 443 | 444 | /** 445 | * Update a resource using the assigned endpoint ($this->api). 446 | * 447 | * @param array|AbstractModel $payload 448 | * @param string $append 449 | * 450 | * @return array|AbstractModel 451 | * @throws GuzzleException 452 | * 453 | * @throws InvalidOrMissingEndpointException 454 | */ 455 | public function put($payload = [], $append = '') 456 | { 457 | return $this->post_or_put('PUT', $payload, $append); 458 | } 459 | 460 | /** 461 | * @param $post_or_post 462 | * @param array $payload 463 | * @param string $append 464 | * 465 | * @throws InvalidOrMissingEndpointException 466 | * @throws GuzzleException 467 | * 468 | * @return mixed 469 | */ 470 | private function post_or_put($post_or_post, $payload = [], $append = '') 471 | { 472 | $payload = $this->normalizePayload($payload); 473 | $api = $this->api; 474 | $uri = $this->uri($append); 475 | 476 | $json = $payload instanceof AbstractModel 477 | ? $payload->getPayload() 478 | : $payload; 479 | 480 | $response = $this->request( 481 | $method = $post_or_post, 482 | $uri, 483 | $options = compact('json') 484 | ); 485 | 486 | $data = json_decode($response->getBody()->getContents(), true); 487 | 488 | if (isset($data[static::apiEntityProperty($api)])) { 489 | $data = $data[static::apiEntityProperty($api)]; 490 | 491 | if ($payload instanceof AbstractModel) { 492 | $payload->syncOriginal($data); 493 | 494 | return $payload; 495 | } 496 | } 497 | 498 | return $data; 499 | } 500 | 501 | /** 502 | * Delete a resource using the assigned endpoint ($this->api). 503 | * 504 | * @param array|string $query 505 | * 506 | * @throws GuzzleException 507 | * @throws InvalidOrMissingEndpointException 508 | * 509 | * @return array 510 | */ 511 | public function delete($query = []) 512 | { 513 | $response = $this->request( 514 | $method = 'DELETE', 515 | $uri = $this->uri(), 516 | $options = ['query' => $query] 517 | ); 518 | 519 | return json_decode($response->getBody()->getContents(), true); 520 | } 521 | 522 | /** 523 | * @param $id 524 | * 525 | * @throws GuzzleException 526 | * @throws InvalidOrMissingEndpointException 527 | * @throws ModelNotFoundException 528 | * 529 | * @return AbstractModel|null 530 | */ 531 | public function find($id) 532 | { 533 | try { 534 | $data = $this->get([], $args = $id); 535 | 536 | if (isset(static::$resource_models[$this->api])) { 537 | $class = static::$resource_models[$this->api]; 538 | 539 | if (isset($data[$class::$resource_name])) { 540 | $data = $data[$class::$resource_name]; 541 | } 542 | 543 | return empty($data) ? null : new $class($data); 544 | } 545 | } catch (ClientException $ce) { 546 | if ($ce->getResponse()->getStatusCode() == 404) { 547 | $msg = sprintf('Model(%s) not found for `%s`', 548 | $id, $this->api); 549 | 550 | throw new ModelNotFoundException($msg); 551 | } 552 | 553 | throw $ce; 554 | } 555 | } 556 | 557 | /** 558 | * Return an array of models or Collection (if Laravel present). 559 | * 560 | * @param string|array $ids 561 | * 562 | * @throws GuzzleException 563 | * @throws InvalidOrMissingEndpointException 564 | * 565 | * @return array|Collection 566 | */ 567 | public function findMany($ids) 568 | { 569 | if (is_array($ids)) { 570 | $ids = implode(',', array_filter($ids)); 571 | } 572 | 573 | return $this->all(compact('ids')); 574 | } 575 | 576 | /** 577 | * Shopify limits to 250 results. 578 | * 579 | * @param array $query 580 | * @param string $append 581 | * 582 | * @throws GuzzleException 583 | * @throws InvalidOrMissingEndpointException 584 | * 585 | * @return array|Collection 586 | */ 587 | public function all($query = [], $append = '') 588 | { 589 | $data = $this->get($query, $append); 590 | 591 | if (static::$resource_models[$this->api]) { 592 | $class = static::$resource_models[$this->api]; 593 | 594 | if (isset($data[$class::$resource_name_many])) { 595 | $data = $data[$class::$resource_name_many]; 596 | } 597 | 598 | $data = array_map(static function ($arr) use ($class) { 599 | return new $class($arr); 600 | }, $data); 601 | 602 | return defined('LARAVEL_START') ? collect($data) : $data; 603 | } 604 | 605 | return $data; 606 | } 607 | 608 | /** 609 | * Post to a resource using the assigned endpoint ($this->api). 610 | * 611 | * @param AbstractModel $model 612 | * 613 | * @throws GuzzleException 614 | * @throws InvalidOrMissingEndpointException 615 | * 616 | * @return AbstractModel 617 | */ 618 | public function save(AbstractModel $model) 619 | { 620 | // Filtered by uri() if falsy 621 | $id = $model->getAttribute($model::$identifier); 622 | 623 | $this->api = $model::$resource_name_many; 624 | 625 | $response = $this->request( 626 | $method = $id ? 'PUT' : 'POST', 627 | $uri = $this->uri(), 628 | $options = ['json' => $model->getPayload()] 629 | ); 630 | 631 | $data = json_decode($response->getBody()->getContents(), true); 632 | 633 | if (isset($data[$model::$resource_name])) { 634 | $data = $data[$model::$resource_name]; 635 | } 636 | 637 | $model->syncOriginal($data); 638 | 639 | return $model; 640 | } 641 | 642 | /** 643 | * @param AbstractModel $model 644 | * 645 | * @throws GuzzleException 646 | * @throws InvalidOrMissingEndpointException 647 | * 648 | * @return bool 649 | */ 650 | public function destroy(AbstractModel $model) 651 | { 652 | $response = $this->delete($model->getOriginal($model::$identifier)); 653 | 654 | if ($success = is_array($response) && empty($response)) { 655 | $model->exists = false; 656 | } 657 | 658 | return $success; 659 | } 660 | 661 | /** 662 | * @param array $query 663 | * 664 | * @throws GuzzleException 665 | * @throws InvalidOrMissingEndpointException 666 | * 667 | * @return int 668 | */ 669 | public function count($query = []) 670 | { 671 | $data = $this->get($query, 'count'); 672 | 673 | $data = count($data) == 1 674 | ? array_values($data)[0] 675 | : $data; 676 | 677 | return $data; 678 | } 679 | 680 | /** 681 | * @param string $append 682 | * 683 | * @throws InvalidOrMissingEndpointException 684 | * 685 | * @return string 686 | */ 687 | public function uri($append = '') 688 | { 689 | $uri = static::makeUri($this->api, $this->ids, $this->queue, $append, $this->base); 690 | 691 | $this->ids = []; 692 | $this->queue = []; 693 | 694 | return $uri; 695 | } 696 | 697 | /** 698 | * @return string 699 | */ 700 | public function getBase() 701 | { 702 | return $this->base; 703 | } 704 | 705 | /** 706 | * @param string|null $base 707 | * 708 | * @return $this 709 | */ 710 | public function setBase($base = null) 711 | { 712 | if (is_null($base)) { 713 | $this->base = Util::isLaravel() 714 | ? config('shopify.api_base', 'admin') 715 | : 'admin'; 716 | 717 | return $this; 718 | } 719 | 720 | $this->base = $base; 721 | 722 | return $this; 723 | } 724 | 725 | /** 726 | * @param string $api 727 | * @param array $ids 728 | * @param array $queue 729 | * @param string $append 730 | * @param string $base 731 | * 732 | * @throws InvalidOrMissingEndpointException 733 | * 734 | * @return string 735 | */ 736 | private static function makeUri($api, $ids = [], $queue = [], $append = '', $base = 'admin') 737 | { 738 | $base = $base ?: 'admin'; 739 | 740 | if (Util::isLaravel() && $base === 'admin') { 741 | $base = config('shopify.api_base', 'admin'); 742 | } 743 | 744 | // Is it an entity endpoint? 745 | if (substr_count(static::$endpoints[$api], '%') == count($ids)) { 746 | $endpoint = vsprintf(static::$endpoints[$api], $ids); 747 | 748 | // Is it a collection endpoint? 749 | } elseif (substr_count(static::$endpoints[$api], '%') == (count($ids) + 1)) { 750 | $endpoint = vsprintf(str_replace('/%s.json', '.json', static::$endpoints[$api]), $ids); 751 | 752 | // Is it just plain wrong? 753 | } else { 754 | $msg = sprintf('You did not specify enough ids for endpoint `%s`, ids(%s).', 755 | static::$endpoints[$api], 756 | implode($ids)); 757 | 758 | throw new InvalidOrMissingEndpointException($msg); 759 | } 760 | 761 | // Prepend parent APIs until none left. 762 | while ($parent = array_shift($queue)) { 763 | $endpoint = implode('/', array_filter($parent)).'/'.$endpoint; 764 | } 765 | 766 | $endpoint = "/{$base}/{$endpoint}"; 767 | 768 | if ($append) { 769 | $endpoint = str_replace('.json', '/'.$append.'.json', $endpoint); 770 | } 771 | 772 | return $endpoint; 773 | } 774 | 775 | /** 776 | * @param $payload 777 | * 778 | * @return mixed 779 | */ 780 | private function normalizePayload($payload) 781 | { 782 | if ($payload instanceof AbstractModel) { 783 | return $payload; 784 | } 785 | 786 | if (! isset($payload['id'])) { 787 | if ($count = count($args = array_filter($this->ids))) { 788 | $last = $args[$count - 1]; 789 | if (is_numeric($last)) { 790 | $payload['id'] = $last; 791 | } 792 | } 793 | } 794 | 795 | $entity = $this->getApiEntityProperty(); 796 | 797 | return [$entity => $payload]; 798 | } 799 | 800 | /** 801 | * @return string 802 | */ 803 | private function getApiCollectionProperty() 804 | { 805 | return static::apiCollectionProperty($this->api); 806 | } 807 | 808 | /** 809 | * @param string $api 810 | * 811 | * @return string 812 | */ 813 | private static function apiCollectionProperty($api) 814 | { 815 | /** @var AbstractModel $model */ 816 | $model = static::$resource_models[$api]; 817 | 818 | return $model::$resource_name_many; 819 | } 820 | 821 | /** 822 | * @return string 823 | */ 824 | private function getApiEntityProperty() 825 | { 826 | return static::apiEntityProperty($this->api); 827 | } 828 | 829 | /** 830 | * @param string $api 831 | * 832 | * @return string 833 | */ 834 | private static function apiEntityProperty($api) 835 | { 836 | /** @var AbstractModel $model */ 837 | $model = static::$resource_models[$api]; 838 | 839 | return $model::$resource_name; 840 | } 841 | 842 | /** 843 | * Set our endpoint by accessing it like a property. 844 | * 845 | * @param string $endpoint 846 | * 847 | * @return $this|Endpoint 848 | * @throws \Exception 849 | */ 850 | public function __get($endpoint) 851 | { 852 | if (array_key_exists($endpoint, static::$endpoints)) { 853 | $this->api = $endpoint; 854 | } 855 | 856 | $className = "Dan\Shopify\\Helpers\\".Util::studly($endpoint); 857 | 858 | if (class_exists($className)) { 859 | return new $className($this); 860 | } 861 | 862 | // If user tries to access property that doesn't exist, scold them. 863 | throw new \RuntimeException('Property does not exist on API'); 864 | } 865 | 866 | /** 867 | * Set ids for one uri() call. 868 | * 869 | * @param string $method 870 | * @param array $parameters 871 | * 872 | * @throws BadMethodCallException 873 | * 874 | * @return $this 875 | */ 876 | public function __call($method, $parameters) 877 | { 878 | if (array_key_exists($method, static::$endpoints)) { 879 | $this->ids = $parameters; 880 | 881 | return $this->__get($method); 882 | } 883 | $msg = sprintf('Method %s does not exist.', $method); 884 | 885 | throw new BadMethodCallException($msg); 886 | } 887 | 888 | /** 889 | * Wrapper to the $client->request method. 890 | * 891 | * @param string $method 892 | * @param string $uri 893 | * @param array $options 894 | * 895 | * @return \Illuminate\Http\Client\Response 896 | * @throws \Illuminate\Http\Client\RequestException|\Exception 897 | */ 898 | public function request($method, $uri = '', array $options = []) 899 | { 900 | if (Util::isLaravel() && config('shopify.options.log_api_request_data')) { 901 | \Log::info('vendor:dan:shopify:api:request', compact('method', 'uri') + $options); 902 | } 903 | 904 | $this->last_response = $r = $this->client->send($method, $uri, $options)->throw(); 905 | $this->last_headers = $r->headers(); 906 | $this->rate_limit = new RateLimit($r); 907 | 908 | if (Util::isLaravel() && config('shopify.options.log_api_response_data')) { 909 | \Log::info('vendor:dan:shopify:api:response', (array) $r); 910 | } 911 | 912 | $api_deprecated_reason = $r->header('X-Shopify-API-Deprecated-Reason'); 913 | $api_version_warning = $r->header('X-Shopify-Api-Version-Warning'); 914 | if ($api_deprecated_reason || $api_version_warning) { 915 | $api_version = $r->header('X-Shopify-Api-Version'); 916 | if (Util::isLaravel()) { 917 | if (config('shopify.options.log_deprecation_warnings')) { 918 | $api = compact('api_version', 'api_version_warning', 'api_deprecated_reason'); 919 | $request = compact('method', 'uri') + $options; 920 | \Log::warning('vendor:dan:shopify:api:deprecated', compact('api', 'request')); 921 | } 922 | } 923 | } 924 | 925 | return $r; 926 | } 927 | 928 | /** 929 | * @param callable $request 930 | * 931 | * @return array 932 | * @throws \Illuminate\Http\Client\RequestException 933 | */ 934 | public function rateLimited(callable $request) 935 | { 936 | try { 937 | return $request($this)->throw(); 938 | } catch (\Illuminate\Http\Client\RequestException $re) { 939 | if ($re->response->status() == 429) { 940 | return $this->rateLimited($request); 941 | } else { 942 | throw $re; 943 | } 944 | } 945 | } 946 | 947 | /** 948 | * @param bool $fetch_if_empty 949 | * 950 | * @return RateLimit 951 | * 952 | * @throws GuzzleException 953 | */ 954 | public function rateLimit($fetch_if_empty = true) 955 | { 956 | if ($fetch_if_empty && empty($this->rate_limit)) { 957 | $this->shop(); 958 | } 959 | 960 | return $this->rate_limit = $this->rate_limit 961 | ?: new RateLimit($this->lastResponse()); 962 | } 963 | 964 | /** 965 | * @return array 966 | */ 967 | protected function lastHeaders() 968 | { 969 | return $this->last_headers; 970 | } 971 | 972 | /** 973 | * @return MessageInterface 974 | */ 975 | protected function lastResponse() 976 | { 977 | return $this->last_response; 978 | } 979 | 980 | /** 981 | * @param $linkHeader 982 | * 983 | * @return array 984 | */ 985 | protected static function parseLinkHeader($linkHeader) 986 | { 987 | $cursors = []; 988 | 989 | foreach (explode(',', $linkHeader) as $link) { 990 | $data = explode(';', trim($link)); 991 | $matches = []; 992 | if (preg_match('/page_info=[A-Za-z0-9]+/', $data[0], $matches)) { 993 | $page_info = str_replace('page_info=', '', $matches[0]); 994 | $rel = str_replace('"', '', str_replace('rel=', '', trim($data[1]))); 995 | $cursors[$rel] = $page_info; 996 | } 997 | } 998 | 999 | return $cursors; 1000 | } 1001 | } 1002 | -------------------------------------------------------------------------------- /src/Util.php: -------------------------------------------------------------------------------- 1 | id)) { 200 | return $mixed->id; 201 | } elseif ($mixed instanceof AbstractModel) { 202 | return $mixed->getKey(); 203 | } else { 204 | return; 205 | } 206 | } 207 | 208 | /** 209 | * @param string $client_id 210 | * @param string $client_secret 211 | * @param string $shop 212 | * @param string $code 213 | * @return array 214 | */ 215 | public static function appAccessRequest($client_id, $client_secret, $shop, $code) 216 | { 217 | $shop = static::normalizeDomain($shop); 218 | $base_uri = "https://{$shop}/"; 219 | 220 | // By default, let's setup our main shopify shop. 221 | $config = compact('base_uri') + [ 222 | 'headers' => [ 223 | 'Accept' => 'application/json', 224 | 'Content-Type' => 'application/json; charset=utf-8;', 225 | ], 226 | ]; 227 | 228 | $client = new Client($config); 229 | $json = compact('client_id', 'client_secret', 'code'); 230 | 231 | $response = $client->post('admin/oauth/access_token', compact('json')); 232 | $body = json_decode($response->getBody(), true); 233 | 234 | return $body ?? []; 235 | } 236 | 237 | /** 238 | * @param string $client_id 239 | * @param string $client_secret 240 | * @param string $shop 241 | * @param string $code 242 | * @return string|false 243 | */ 244 | public static function appAccessToken($client_id, $client_secret, $shop, $code) 245 | { 246 | $body = static::appAccessRequest($client_id, $client_secret, $shop, $code); 247 | 248 | return $body['access_token'] ?? false; 249 | } 250 | 251 | /** 252 | * @param $shop 253 | * @param $client_id 254 | * @param $redirect_uri 255 | * @param array $scopes 256 | * @param array $attributes 257 | * @return string 258 | */ 259 | public static function appAuthUrl($shop, $client_id, $redirect_uri, $scopes = [], $attributes = []) 260 | { 261 | $shop = static::normalizeDomain($shop); 262 | 263 | $url = $attributes + compact('client_id', 'redirect_uri') + [ 264 | 'client_id' => config('services.shopify.app.key'), 265 | 'scope' => implode(',', (array) $scopes), 266 | 'redirect_uri' => config('services.shopify.app.redirect'), 267 | 'state' => md5($shop), 268 | 'grant_options[]' => '', 269 | 'nounce' => 'ok', 270 | ]; 271 | 272 | $url = "https://{$shop}/admin/oauth/authorize?".http_build_query($url); 273 | 274 | return $url; 275 | } 276 | 277 | /** 278 | * @param $hmac 279 | * @param $secret 280 | * @param $data 281 | * @return bool 282 | */ 283 | public static function appValidHmac($hmac, $secret, $data) 284 | { 285 | return static::validAppHmac($hmac, $secret, $data); 286 | } 287 | 288 | /** 289 | * @return bool 290 | */ 291 | public static function isLaravel() 292 | { 293 | return defined('LARAVEL_START') && ! static::isLumen(); 294 | } 295 | 296 | /** 297 | * @return bool 298 | */ 299 | public static function isLumen() 300 | { 301 | return function_exists('app') 302 | && preg_match('/lumen/i', app()->version()); 303 | } 304 | } 305 | -------------------------------------------------------------------------------- /tests/AssignedFulfillmentOrderTest.php: -------------------------------------------------------------------------------- 1 | getModel(); 14 | 15 | $this->assertIsInt($model->id); 16 | $this->assertIsInt($model->shop_id); 17 | $this->assertIsInt($model->assigned_location_id); 18 | } 19 | 20 | /** 21 | * Make a new AssignedFulfillmentOrder instance. 22 | * 23 | * @return \Dan\Shopify\Models\AssignedFulfillmentOrder 24 | */ 25 | protected function getModel() 26 | { 27 | return new AssignedFulfillmentOrder([ 28 | 'assigned_location_id' => '3183479', 29 | 'destination' => [ 30 | 'id' => 54433189, 31 | 'address1' => '123 Amoebobacterieae St', 32 | 'address2' => 'Unit 806', 33 | 'city' => 'Ottawa', 34 | 'company' => '', 35 | 'country' => 'Canada', 36 | 'email' => 'bob@example.com', 37 | 'first_name' => 'Bob', 38 | 'last_name' => 'Bobsen', 39 | 'phone' => '(555)555-5555', 40 | 'province' => 'Ontario', 41 | 'zip' => 'K2P0V6', 42 | ], 43 | 'id' => '255858046', 44 | 'line_items' => [ 45 | [ 46 | 'id' => 466157049, 47 | 'shop_id' => 3998762, 48 | 'fulfillment_order_id' => 1568020, 49 | 'line_item_id' => 466157049, 50 | 'inventory_item_id' => 6588097, 51 | 'quantity' => 1, 52 | 'fulfillable_quantity' => 1, 53 | ], 54 | ], 55 | 'order_id' => 3183479, 56 | 'request_status' => 'unsubmitted', 57 | 'shop_id' => 255858046, 58 | 'status' => 'open', 59 | ]); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /tests/FulfillmentOrdersApiTest.php: -------------------------------------------------------------------------------- 1 | fulfillment_orders(123)->cancel(); 18 | 19 | Http::assertSent(function (Request $request) { 20 | return $request->url() == 'https://shop.myshopify.com/admin/fulfillment_orders/123/cancel.json' 21 | && $request->method() == 'POST'; 22 | }); 23 | } 24 | 25 | /** @test */ 26 | public function it_closes_a_fulfillment_order(): void 27 | { 28 | Http::fake(); 29 | 30 | (new Shopify('shop', 'token'))->fulfillment_orders(123)->close( 31 | ['message' => 'Close message'] 32 | ); 33 | 34 | Http::assertSent(function (Request $request) { 35 | return $request->url() == 'https://shop.myshopify.com/admin/fulfillment_orders/123/close.json' 36 | && $request->method() == 'POST' 37 | && Arr::get($request->data(), 'fulfillment_order.message') === 'Close message'; 38 | }); 39 | } 40 | 41 | /** @test */ 42 | public function it_gets_a_fulfillment_order(): void 43 | { 44 | Http::fake(); 45 | 46 | (new Shopify('shop', 'token'))->fulfillment_orders->find(123); 47 | 48 | Http::assertSent(function (Request $request) { 49 | return $request->url() == 'https://shop.myshopify.com/admin/fulfillment_orders/123.json' 50 | && $request->method() == 'GET'; 51 | }); 52 | } 53 | 54 | /** @test */ 55 | public function it_gets_a_list_of_fulfillment_orders(): void 56 | { 57 | Http::fake(); 58 | 59 | (new Shopify('shop', 'token'))->fulfillment_orders->get(); 60 | 61 | Http::assertSent(function (Request $request) { 62 | return $request->url() == 'https://shop.myshopify.com/admin/fulfillment_orders.json' 63 | && $request->method() == 'GET'; 64 | }); 65 | } 66 | 67 | /** @test */ 68 | public function it_moves_a_fulfillment_order(): void 69 | { 70 | Http::fake(); 71 | (new Shopify('shop', 'token'))->fulfillment_orders(123)->move(); 72 | 73 | Http::assertSent(function (Request $request) { 74 | return $request->url() == 'https://shop.myshopify.com/admin/fulfillment_orders/123/move.json' 75 | && $request->method() == 'POST'; 76 | }); 77 | } 78 | 79 | /** @test */ 80 | public function it_opens_a_fulfillment_order(): void 81 | { 82 | Http::fake(); 83 | (new Shopify('shop', 'token'))->fulfillment_orders(123)->open(); 84 | 85 | Http::assertSent(function (Request $request) { 86 | return $request->url() == 'https://shop.myshopify.com/admin/fulfillment_orders/123/open.json' 87 | && $request->method() == 'POST'; 88 | }); 89 | } 90 | 91 | /** @test */ 92 | public function it_releases_a_fulfillment_order_hold(): void 93 | { 94 | Http::fake(); 95 | (new Shopify('shop', 'token'))->fulfillment_orders(123)->release_hold(); 96 | 97 | Http::assertSent(function (Request $request) { 98 | return $request->url() == 'https://shop.myshopify.com/admin/fulfillment_orders/123/release_hold.json' 99 | && $request->method() == 'POST'; 100 | }); 101 | } 102 | 103 | /** @test */ 104 | public function it_reschedules_a_fulfillment_order(): void 105 | { 106 | Http::fake(); 107 | (new Shopify('shop', 'token'))->fulfillment_orders(123)->reschedule(); 108 | 109 | Http::assertSent(function (Request $request) { 110 | return $request->url() == 'https://shop.myshopify.com/admin/fulfillment_orders/123/reschedule.json' 111 | && $request->method() == 'POST'; 112 | }); 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /tests/OrdersApiTest.php: -------------------------------------------------------------------------------- 1 | orders->get(); 25 | 26 | Http::assertSent(function (Request $request) { 27 | return $request->url() == 'https://shop.myshopify.com/admin/orders.json' 28 | && $request->method() == 'GET'; 29 | }); 30 | } 31 | 32 | /** 33 | * GET /admin/orders/count.json 34 | * Retrieve a count of all orders. 35 | * 36 | * @test 37 | * @throws Throwable 38 | */ 39 | public function it_gets_a_count_of_orders() 40 | { 41 | Http::fake([ 42 | 'https://shop.myshopify.com/admin/orders/count.json' => Http::response(['count' => 2]) 43 | ]); 44 | 45 | (new Shopify('shop', 'token'))->orders->count(); 46 | 47 | Http::assertSent(function (Request $request) { 48 | return $request->url() == 'https://shop.myshopify.com/admin/orders/count.json' 49 | && $request->method() == 'GET'; 50 | }); 51 | } 52 | 53 | /** 54 | * GET /admin/orders/123.json 55 | * Retrieves a single order. 56 | * 57 | * @test 58 | * @throws Throwable 59 | */ 60 | public function it_gets_an_order() 61 | { 62 | Http::fake(); 63 | 64 | (new Shopify('shop', 'token'))->orders->find(123); 65 | 66 | Http::assertSent(function (Request $request) { 67 | return $request->url() == 'https://shop.myshopify.com/admin/orders/123.json' 68 | && $request->method() == 'GET'; 69 | }); 70 | } 71 | 72 | /** 73 | * POST /admin/orders.json 74 | * Creates a new order. 75 | * 76 | * @test 77 | * @throws Throwable 78 | */ 79 | public function it_creates_a_new_order() 80 | { 81 | Http::fake(); 82 | 83 | (new Shopify('shop', 'token'))->orders->post($order = [ 84 | 'key1' => 'value1' 85 | ]); 86 | 87 | Http::assertSent(function (Request $request) use ($order) { 88 | return $request->url() == 'https://shop.myshopify.com/admin/orders.json' 89 | && $request->method() == 'POST' 90 | && $request->data() == compact('order'); 91 | }); 92 | } 93 | 94 | /** 95 | * PUT /admin/orders/123.json 96 | * Updates a order. 97 | * 98 | * @test 99 | * @throws Throwable 100 | */ 101 | public function it_updates_a_order() 102 | { 103 | Http::fake(); 104 | 105 | (new Shopify('shop', 'token'))->orders(123)->put($order = [ 106 | 'key1' => 'value1' 107 | ]); 108 | 109 | $order['id'] = 123; 110 | 111 | Http::assertSent(function (Request $request) use ($order) { 112 | return $request->url() == 'https://shop.myshopify.com/admin/orders/123.json' 113 | && $request->method() == 'PUT' 114 | && $request->data() == compact('order'); 115 | }); 116 | } 117 | 118 | /** 119 | * DELETE /admin/orders/123.json 120 | * Delete a order. 121 | * 122 | * @test 123 | * @throws Throwable 124 | */ 125 | public function it_deletes_a_order() 126 | { 127 | Http::fake(); 128 | 129 | (new Shopify('shop', 'token'))->orders(123)->delete(); 130 | 131 | Http::assertSent(function (Request $request) { 132 | return $request->url() == 'https://shop.myshopify.com/admin/orders/123.json' 133 | && $request->method() == 'DELETE'; 134 | }); 135 | } 136 | 137 | /** 138 | * POST /admin/orders/123/close.json 139 | * Closes an order. 140 | * 141 | * @test 142 | * @throws Throwable 143 | */ 144 | public function it_closes_an_order() 145 | { 146 | Http::fake(); 147 | 148 | (new Shopify('shop', 'token'))->orders(123)->post([], 'close'); 149 | 150 | Http::assertSent(function (Request $request) { 151 | return $request->url() == 'https://shop.myshopify.com/admin/orders/123/close.json' 152 | && $request->method() == 'POST'; 153 | }); 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /tests/ProductsApiTest.php: -------------------------------------------------------------------------------- 1 | ProductFactory::create(2) 26 | ]); 27 | 28 | $response = (new Shopify('shop', 'token'))->products->get(); 29 | 30 | Http::assertSent(function (Request $request) { 31 | return $request->url() == 'https://shop.myshopify.com/admin/products.json' 32 | && $request->method() == 'GET'; 33 | }); 34 | 35 | $this->assertCount(2, $response); 36 | } 37 | 38 | /** 39 | * GET /admin/products/count.json 40 | * Retrieve a count of all products. 41 | * 42 | * @test 43 | * @throws Throwable 44 | */ 45 | public function it_gets_a_count_of_products() 46 | { 47 | Http::fake([ 48 | '*admin/products/count.json' => ['count' => 2] 49 | ]); 50 | 51 | $response = (new Shopify('shop', 'token'))->products->count(); 52 | 53 | Http::assertSent(function (Request $request) { 54 | return $request->url() == 'https://shop.myshopify.com/admin/products/count.json' 55 | && $request->method() == 'GET'; 56 | }); 57 | 58 | $this->assertEquals(2, $response); 59 | } 60 | 61 | /** 62 | * GET /admin/products/123.json 63 | * Retrieves a single product. 64 | * 65 | * @test 66 | * @throws Throwable 67 | */ 68 | public function it_gets_a_product() 69 | { 70 | Http::fake([ 71 | '*admin/products/123.json' => ProductFactory::create(1, ['id' => 123]) 72 | ]); 73 | 74 | $response = (new Shopify('shop', 'token'))->products->find(123); 75 | 76 | Http::assertSent(function (Request $request) { 77 | return $request->url() == 'https://shop.myshopify.com/admin/products/123.json' 78 | && $request->method() == 'GET'; 79 | }); 80 | 81 | $this->assertEquals(123, $response['id']); 82 | } 83 | 84 | /** 85 | * POST /admin/products.json 86 | * Creates a new product. 87 | * 88 | * @test 89 | * @throws Throwable 90 | */ 91 | public function it_creates_a_new_product() 92 | { 93 | Http::fake([ 94 | '*admin/products.json' => ProductFactory::create(1, ['id' => 123, 'title' => 'some title']) 95 | ]); 96 | 97 | $response = (new Shopify('shop', 'token'))->products->post([ 98 | 'title' => 'some title' 99 | ]); 100 | 101 | Http::assertSent(function (Request $request) { 102 | return $request->url() == 'https://shop.myshopify.com/admin/products.json' 103 | && $request->method() == 'POST'; 104 | }); 105 | 106 | $this->assertEquals(123, $response['id']); 107 | $this->assertEquals('some title', $response['title']); 108 | } 109 | 110 | /** 111 | * PUT /admin/products/123.json 112 | * Updates a product and its variants and images. 113 | * 114 | * @test 115 | * @throws Throwable 116 | */ 117 | public function it_updates_a_product() 118 | { 119 | Http::fake([ 120 | '*admin/products/123.json' => ProductFactory::create(1, ['title' => 'new title']) 121 | ]); 122 | 123 | $response = (new Shopify('shop', 'token'))->products(123)->put([ 124 | 'title' => 'new title' 125 | ]); 126 | 127 | Http::assertSent(function (Request $request) { 128 | return $request->url() == 'https://shop.myshopify.com/admin/products/123.json' 129 | && $request->method() == 'PUT'; 130 | }); 131 | 132 | $this->assertEquals('new title', $response['title']); 133 | } 134 | 135 | /** 136 | * DELETE /admin/products/123.json 137 | * Delete a product along with all its variants and images. 138 | * 139 | * @test 140 | * @throws Throwable 141 | */ 142 | public function it_deletes_a_product() 143 | { 144 | Http::fake(); 145 | 146 | (new Shopify('shop', 'token'))->products(123)->delete(); 147 | 148 | Http::assertSent(function (Request $request) { 149 | return $request->url() == 'https://shop.myshopify.com/admin/products/123.json' 150 | && $request->method() == 'DELETE'; 151 | }); 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /tests/RecurringApplicationChargeTest.php: -------------------------------------------------------------------------------- 1 | getModel(); 15 | 16 | $this->assertIsFloat($model->capped_amount); 17 | $this->assertIsInt($model->id); 18 | $this->assertIsFloat($model->price); 19 | $this->assertIsInt($model->trial_days); 20 | 21 | $this->assertInstanceOf(Carbon::class, $model->activated_on); 22 | $this->assertInstanceOf(Carbon::class, $model->billing_on); 23 | $this->assertInstanceOf(Carbon::class, $model->cancelled_on); 24 | $this->assertInstanceOf(Carbon::class, $model->created_at); 25 | $this->assertInstanceOf(Carbon::class, $model->trial_ends_on); 26 | $this->assertInstanceOf(Carbon::class, $model->updated_at); 27 | } 28 | 29 | /** 30 | * Make a new RecurringApplicationCharge instance. 31 | * 32 | * @return \Dan\Shopify\Models\RecurringApplicationCharge 33 | */ 34 | protected function getModel() 35 | { 36 | return new RecurringApplicationCharge([ 37 | 'activated_on' => '2022-09-20T12:00:00-00:00', 38 | 'billing_on' => '2022-09-28T12:00:00-00:00', 39 | 'cancelled_on' => '2022-09-30T12:00:00-00:00', 40 | 'capped_amount' => "100", 41 | 'confirmation_url' => "https://jsmith.myshopify.com/admin/charges/confirm_recurring_application_charge?id=654381177&signature=BAhpBHkQASc%3D--374c02da2ea0371b23f40781b8a6d5f4a520e77b", 42 | 'created_at' => '2022-09-20T12:00:00-00:00', 43 | 'id' => 675931192, 44 | 'name' => "Super Duper Expensive action", 45 | 'price' => "100.00", 46 | 'return_url' => "http://super-duper.shopifyapps.com", 47 | 'status' => "accepted", 48 | 'terms' => "$1 for 1000 emails", 49 | 'test' => null, 50 | 'trial_days' => 7, 51 | 'trial_ends_on' => '2022-09-27T12:00:00-00:00', 52 | 'updated_at' => '2022-09-20T12:00:00-00:00' 53 | ]); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /tests/RecurringApplicationChargesApiTest.php: -------------------------------------------------------------------------------- 1 | $this->getResource(), 23 | ]); 24 | 25 | $response = (new Shopify('shop', 'token'))->recurring_application_charges->post([ 26 | 'name' => 'Charge Name', 27 | 'price' => 15.99, 28 | 'return_url' => 'https://phpunit-store.myshopifyapps.com', 29 | 'test' => true, 30 | ]); 31 | 32 | Http::assertSent(function (Request $request) { 33 | return $request->url() == 'https://shop.myshopify.com/admin/recurring_application_charges.json' 34 | && $request->method() == 'POST'; 35 | }); 36 | 37 | $this->assertEquals(123, $response['id']); 38 | $this->assertEquals('Charge Name', $response['name']); 39 | } 40 | 41 | /** 42 | * Get a list of recurring application charges. 43 | * 44 | * @test 45 | * @throws Throwable 46 | */ 47 | public function it_gets_a_list_of_orders(): void 48 | { 49 | Http::fake(); 50 | 51 | (new Shopify('shop', 'token'))->recurring_application_charges->get(); 52 | 53 | Http::assertSent(function (Request $request) { 54 | return $request->url() == 'https://shop.myshopify.com/admin/recurring_application_charges.json' 55 | && $request->method() == 'GET'; 56 | }); 57 | } 58 | 59 | /** 60 | * Fetch a recurring application charge by id. 61 | * 62 | * @test 63 | * @throws Throwable 64 | */ 65 | public function it_gets_a_recurring_application_charge(): void 66 | { 67 | Http::fake(); 68 | 69 | (new Shopify('shop', 'token'))->recurring_application_charges->find(123); 70 | 71 | Http::assertSent(function (Request $request) { 72 | return $request->url() == 'https://shop.myshopify.com/admin/recurring_application_charges/123.json' 73 | && $request->method() == 'GET'; 74 | }); 75 | } 76 | 77 | /** 78 | * Get the resource response data for a recurring application charge. 79 | * 80 | * @return array 81 | */ 82 | protected function getResource() 83 | { 84 | return [ 85 | 'activated_on' => '2022-09-20T12:00:00-00:00', 86 | 'billing_on' => '2022-09-28T12:00:00-00:00', 87 | 'cancelled_on' => '2022-09-30T12:00:00-00:00', 88 | 'capped_amount' => '100', 89 | 'confirmation_url' => 'https://jsmith.myshopify.com/admin/charges/confirm_recurring_application_charge?id=654381177&signature=BAhpBHkQASc%3D--374c02da2ea0371b23f40781b8a6d5f4a520e77b', 90 | 'created_at' => '2022-09-20T12:00:00-00:00', 91 | 'id' => 123, 92 | 'name' => 'Charge Name', 93 | 'price' => '100.00', 94 | 'return_url' => 'http://super-duper.shopifyapps.com', 95 | 'status' => 'accepted', 96 | 'terms' => '$1 for 1000 emails', 97 | 'test' => null, 98 | 'trial_days' => 7, 99 | 'trial_ends_on' => '2022-09-27T12:00:00-00:00', 100 | 'updated_at' => '2022-09-20T12:00:00-00:00', 101 | ]; 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /tests/ShopApiTest.php: -------------------------------------------------------------------------------- 1 | ['shop' => []]]); 23 | 24 | (new Shopify('shop', 'token'))->shop(); 25 | 26 | Http::assertSent(function (Request $request) { 27 | return $request->url() == 'https://shop.myshopify.com/admin/shop.json' 28 | && $request->method() == 'GET' 29 | && $request->hasHeader('X-Shopify-Access-Token', 'token'); 30 | }); 31 | } 32 | } --------------------------------------------------------------------------------