diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 00000000..e6060269 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,24 @@ +name: Go + +on: [push, pull_request] + +jobs: + build: + + runs-on: ubuntu-latest + strategy: + matrix: + go-version: [ '1.18', '1.19', '1.20' ] + + steps: + - uses: actions/checkout@v3 + - name: Setup Go ${{ matrix.go-version }} + uses: actions/setup-go@v3 + with: + go-version: ${{ matrix.go-version }} + - name: Build + run: go build -v ./... + - name: Test + run: go test -coverprofile=coverage.txt -v ./... + - name: Upload code coverage results + run: bash <(curl -s https://codecov.io/bash) diff --git a/README.md b/README.md index a70b0ad0..02f14e96 100644 --- a/README.md +++ b/README.md @@ -8,14 +8,7 @@ The new home of Bold Commerce's Shopify Go library which is originally forked fr [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/MIT) ## Supported Go Versions -This library has been tested against the following versions of Go -* 1.10 -* 1.11 -* 1.12 -* 1.13 -* 1.14 -* 1.15 -* 1.16 +This library is tested automatically against the latest version of Go (currently 1.20) and the two previous versions (1.19, 1.18) but should also work with older versions. ## Install @@ -108,14 +101,16 @@ client := goshopify.NewClient(app, "shopname", "") // Fetch the number of products. numProducts, err := client.Product.Count(nil) ``` + ### Client Options When creating a client there are configuration options you can pass to NewClient. Simply use the last variadic param and pass in the built in options or create your own and manipulate the client. See [options.go](https://github.com/belong-inc/go-shopify/blob/master/options.go) for more details. #### WithVersion + Read more details on the [Shopify API Versioning](https://shopify.dev/concepts/about-apis/versioning) -to understand the format and release schedules. You can use `WithVersion` to specify a specific version +to understand the format and release schedules. You can use `WithVersion` to specify a specific version of the API. If you do not use this option you will be defaulted to the oldest stable API. ```go @@ -123,9 +118,10 @@ client := goshopify.NewClient(app, "shopname", "", goshopify.WithVersion("2019-0 ``` #### WithRetry -Shopify [Rate Limits](https://shopify.dev/concepts/about-apis/rate-limits) their API and if this happens to you they -will send a back off (usually 2s) to tell you to retry your request. To support this functionality seamlessly within -the client a `WithRetry` option exists where you can pass an `int` of how many times you wish to retry per-request + +Shopify [Rate Limits](https://shopify.dev/concepts/about-apis/rate-limits) their API and if this happens to you they +will send a back off (usually 2s) to tell you to retry your request. To support this functionality seamlessly within +the client a `WithRetry` option exists where you can pass an `int` of how many times you wish to retry per-request before returning an error. `WithRetry` additionally supports retrying HTTP503 errors. ```go @@ -202,6 +198,7 @@ In order to be sure that a webhook is sent from ShopifyApi you could easily veri it with the `VerifyWebhookRequest` method. For example: + ```go func ValidateWebhook(httpRequest *http.Request) (bool) { shopifyApp := goshopify.App{ApiSecret: "ratz"} @@ -210,38 +207,47 @@ func ValidateWebhook(httpRequest *http.Request) (bool) { ``` ## Develop and test + `docker` and `docker-compose` must be installed ### Mac/Linux/Windows with make + Using the make file is the easiest way to get started with the tests and wraps the manual steps below with easy to use make commands. ```shell make && make test ``` + #### Makefile goals -* `make` or `make container`: default goal is to make the `go-shopify:latest` build container -* `make test`: run go test in the container -* `make clean`: deletes the `go-shopify:latest` image and coverage output -* `make coverage`: generates the coverage.html and opens it + +- `make` or `make container`: default goal is to make the `go-shopify:latest` build container +- `make test`: run go test in the container +- `make clean`: deletes the `go-shopify:latest` image and coverage output +- `make coverage`: generates the coverage.html and opens it ### Manually + To run the tests you will need the `go-shopify:latest` image built to run your tests, to do this run + ``` docker-compose build test ``` To run tests you can use run + ```shell docker-compose run --rm tests ``` To create a coverage profile run the following to generate a coverage.html + ``` docker-compose run --rm dev sh -c 'go test -coverprofile=coverage.out ./... && go tool cover -html coverage.out -o coverage.html' ``` When done testing and you want to cleanup simply run + ``` docker image rm go-shopify:latest ``` diff --git a/abandoned_checkout.go b/abandoned_checkout.go new file mode 100644 index 00000000..cfbc06e0 --- /dev/null +++ b/abandoned_checkout.go @@ -0,0 +1,91 @@ +package goshopify + +import ( + "fmt" + "time" + + "github.com/shopspring/decimal" +) + +const abandonedCheckoutsBasePath = "checkouts" + +// AbandonedCheckoutService is an interface for interfacing with the abandonedCheckouts endpoints +// of the Shopify API. +// See: https://shopify.dev/docs/api/admin-rest/latest/resources/abandoned-checkouts +type AbandonedCheckoutService interface { + List(interface{}) ([]AbandonedCheckout, error) +} + +// AbandonedCheckoutServiceOp handles communication with the checkout related methods of +// the Shopify API. +type AbandonedCheckoutServiceOp struct { + client *Client +} + +// Represents the result from the checkouts.json endpoint +type AbandonedCheckoutsResource struct { + AbandonedCheckouts []AbandonedCheckout `json:"checkouts,omitempty"` +} + +// AbandonedCheckout represents a Shopify abandoned checkout +type AbandonedCheckout struct { + ID int64 `json:"id,omitempty"` + Token string `json:"token,omitempty"` + CartToken string `json:"cart_token,omitempty"` + Email string `json:"email,omitempty"` + Gateway string `json:"gateway,omitempty"` + BuyerAcceptsMarketing bool `json:"buyer_accepts_marketing,omitempty"` + CreatedAt *time.Time `json:"created_at,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` + LandingSite string `json:"landing_site,omitempty"` + Note string `json:"note,omitempty"` + NoteAttributes []NoteAttribute `json:"note_attributes,omitempty"` + ReferringSite string `json:"referring_site,omitempty"` + ShippingLines []ShippingLines `json:"shipping_lines,omitempty"` + TaxesIncluded bool `json:"taxes_included,omitempty"` + TotalWeight int `json:"total_weight,omitempty"` + Currency string `json:"currency,omitempty"` + CompletedAt *time.Time `json:"completed_at,omitempty"` + ClosedAt *time.Time `json:"closed_at,omitempty"` + UserID int64 `json:"user_id,omitempty"` + SourceIdentifier string `json:"source_identifier,omitempty"` + SourceUrl string `json:"source_url,omitempty"` + DeviceID int64 `json:"device_id,omitempty"` + Phone string `json:"phone,omitempty"` + CustomerLocale string `json:"customer_locale,omitempty"` + Name string `json:"name,omitempty"` + Source string `json:"source,omitempty"` + AbandonedCheckoutUrl string `json:"abandoned_checkout_url,omitempty"` + DiscountCodes []DiscountCode `json:"discount_codes,omitempty"` + TaxLines []TaxLine `json:"tax_lines,omitempty"` + SourceName string `json:"source_name,omitempty"` + PresentmentCurrency string `json:"presentment_currency,omitempty"` + BuyerAcceptsSmsMarketing bool `json:"buyer_accepts_sms_marketing,omitempty"` + SmsMarketingPhone string `json:"sms_marketing_phone,omitempty"` + TotalDiscounts *decimal.Decimal `json:"total_discounts,omitempty"` + TotalLineItemsPrice *decimal.Decimal `json:"total_line_items_price,omitempty"` + TotalPrice *decimal.Decimal `json:"total_price,omitempty"` + SubtotalPrice *decimal.Decimal `json:"subtotal_price,omitempty"` + TotalDuties string `json:"total_duties,omitempty"` + BillingAddress *Address `json:"billing_address,omitempty"` + ShippingAddress *Address `json:"shipping_address,omitempty"` + Customer *Customer `json:"customer,omitempty"` + SmsMarketingConsent *SmsMarketingConsent `json:"sms_marketing_consent,omitempty"` + AdminGraphqlApiID string `json:"admin_graphql_api_id,omitempty"` + DefaultAddress *CustomerAddress `json:"default_address,omitempty"` +} + +type SmsMarketingConsent struct { + State string `json:"state,omitempty"` + OptInLevel string `json:"opt_in_level,omitempty"` + ConsentUpdatedAt *time.Time `json:"consent_updated_at,omitempty"` + ConsentCollectedFrom string `json:"consent_collected_from,omitempty"` +} + +// Get abandoned checkout list +func (s *AbandonedCheckoutServiceOp) List(options interface{}) ([]AbandonedCheckout, error) { + path := fmt.Sprintf("/%s.json", abandonedCheckoutsBasePath) + resource := new(AbandonedCheckoutsResource) + err := s.client.Get(path, resource, options) + return resource.AbandonedCheckouts, err +} diff --git a/abandoned_checkout_test.go b/abandoned_checkout_test.go new file mode 100644 index 00000000..f51cbe00 --- /dev/null +++ b/abandoned_checkout_test.go @@ -0,0 +1,34 @@ +package goshopify + +import ( + "fmt" + "reflect" + "testing" + + "github.com/jarcoal/httpmock" +) + +func TestAbandonedCheckoutList(t *testing.T) { + setup() + defer teardown() + + httpmock.RegisterResponder( + "GET", + fmt.Sprintf("https://fooshop.myshopify.com/%s/checkouts.json", client.pathPrefix), + httpmock.NewStringResponder( + 200, + `{"checkouts": [{"id":1},{"id":2}]}`, + ), + ) + + abandonedCheckouts, err := client.AbandonedCheckout.List(nil) + if err != nil { + t.Errorf("AbandonedCheckout.List returned error: %v", err) + } + + expected := []AbandonedCheckout{{ID: 1}, {ID: 2}} + if !reflect.DeepEqual(abandonedCheckouts, expected) { + t.Errorf("AbandonedCheckout.List returned %+v, expected %+v", abandonedCheckouts, expected) + } + +} diff --git a/access_scopes.go b/access_scopes.go index 14a0fc6b..e4194db7 100644 --- a/access_scopes.go +++ b/access_scopes.go @@ -16,7 +16,7 @@ type AccessScopesResource struct { // AccessScopesServiceOp handles communication with the Access Scopes // related methods of the Shopify API type AccessScopesServiceOp struct { - client *Client + client *Client } // List gets access scopes based on used oauth token diff --git a/access_scopes_test.go b/access_scopes_test.go index 1502f3b2..163c90f9 100644 --- a/access_scopes_test.go +++ b/access_scopes_test.go @@ -1,9 +1,9 @@ package goshopify import ( - "testing" "fmt" "reflect" + "testing" "github.com/jarcoal/httpmock" ) diff --git a/asset.go b/asset.go index fc6eea24..ec9ab89f 100644 --- a/asset.go +++ b/asset.go @@ -25,17 +25,17 @@ type AssetServiceOp struct { // Asset represents a Shopify asset type Asset struct { - Attachment string `json:"attachment"` - ContentType string `json:"content_type"` - Key string `json:"key"` - PublicURL string `json:"public_url"` - Size int `json:"size"` - SourceKey string `json:"source_key"` - Src string `json:"src"` - ThemeID int64 `json:"theme_id"` - Value string `json:"value"` - CreatedAt *time.Time `json:"created_at"` - UpdatedAt *time.Time `json:"updated_at"` + Attachment string `json:"attachment,omitempty"` + ContentType string `json:"content_type,omitempty"` + Key string `json:"key,omitempty"` + PublicURL string `json:"public_url,omitempty"` + Size int `json:"size,omitempty"` + SourceKey string `json:"source_key,omitempty"` + Src string `json:"src,omitempty"` + ThemeID int64 `json:"theme_id,omitempty"` + Value string `json:"value,omitempty"` + CreatedAt *time.Time `json:"created_at,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` } // AssetResource is the result from the themes/x/assets.json?asset[key]= endpoint diff --git a/blog.go b/blog.go index be9b9074..c2698cef 100644 --- a/blog.go +++ b/blog.go @@ -38,6 +38,7 @@ type Blog struct { TemplateSuffix string `json:"template_suffix"` CreatedAt *time.Time `json:"created_at"` UpdatedAt *time.Time `json:"updated_at"` + AdminGraphqlAPIID string `json:"admin_graphql_api_id,omitempty"` } // BlogsResource is the result from the blogs.json endpoint diff --git a/carrier.go b/carrier.go new file mode 100644 index 00000000..8b58e565 --- /dev/null +++ b/carrier.go @@ -0,0 +1,175 @@ +package goshopify + +import ( + "fmt" + "time" + + "github.com/shopspring/decimal" +) + +const carrierBasePath = "carrier_services" + +// CarrierServiceService is an interface for interfacing with the carrier service endpoints +// of the Shopify API. +// See: https://shopify.dev/docs/admin-api/rest/reference/shipping-and-fulfillment/carrierservice +type CarrierServiceService interface { + List() ([]CarrierService, error) + Get(int64) (*CarrierService, error) + Create(CarrierService) (*CarrierService, error) + Update(CarrierService) (*CarrierService, error) + Delete(int64) error +} + +// CarrierServiceOp handles communication with the product related methods of +// the Shopify API. +type CarrierServiceOp struct { + client *Client +} + +// CarrierService represents a Shopify carrier service +type CarrierService struct { + // Whether this carrier service is active. + Active bool `json:"active,omitempty"` + + // The URL endpoint that Shopify needs to retrieve shipping rates. This must be a public URL. + CallbackUrl string `json:"callback_url,omitempty"` + + // Distinguishes between API or legacy carrier services. + CarrierServiceType string `json:"carrier_service_type,omitempty"` + + // The Id of the carrier service. + Id int64 `json:"id,omitempty"` + + // The format of the data returned by the URL endpoint. Valid values: json and xml. Default value: json. + Format string `json:"format,omitempty"` + + // The name of the shipping service as seen by merchants and their customers. + Name string `json:"name,omitempty"` + + // Whether merchants are able to send dummy data to your service through the Shopify admin to see shipping rate examples. + ServiceDiscovery bool `json:"service_discovery,omitempty"` + + AdminGraphqlAPIID string `json:"admin_graphql_api_id,omitempty"` +} + +type SingleCarrierResource struct { + CarrierService *CarrierService `json:"carrier_service"` +} + +type ListCarrierResource struct { + CarrierServices []CarrierService `json:"carrier_services"` +} + +type ShippingRateRequest struct { + Rate ShippingRateQuery `json:"rate"` +} + +type ShippingRateQuery struct { + Origin ShippingRateAddress `json:"origin"` + Destination ShippingRateAddress `json:"destination"` + Items []LineItem `json:"items"` + Currency string `json:"currency"` + Locale string `json:"locale"` +} + +// The address3, fax, address_type, and company_name fields are returned by specific ActiveShipping providers. +// For API-created carrier services, you should use only the following shipping address fields: +// * address1 +// * address2 +// * city +// * zip +// * province +// * country +// Other values remain as null and are not sent to the callback URL. +type ShippingRateAddress struct { + Country string `json:"country"` + PostalCode string `json:"postal_code"` + Province string `json:"province"` + City string `json:"city"` + Name string `json:"name"` + Address1 string `json:"address1"` + Address2 string `json:"address2"` + Address3 string `json:"address3"` + Phone string `json:"phone"` + Fax string `json:"fax"` + Email string `json:"email"` + AddressType string `json:"address_type"` + CompanyName string `json:"company_name"` +} + +// When Shopify requests shipping rates using your callback URL, +// the response object rates must be a JSON array of objects with the following fields. +// Required fields must be included in the response for the carrier service integration to work properly. +type ShippingRateResponse struct { + Rates []ShippingRate `json:"rates"` +} + +type ShippingRate struct { + // The name of the rate, which customers see at checkout. For example: Expedited Mail. + ServiceName string `json:"service_name"` + + // A description of the rate, which customers see at checkout. For example: Includes tracking and insurance. + Description string `json:"description"` + + // A unique code associated with the rate. For example: expedited_mail. + ServiceCode string `json:"service_code"` + + // The currency of the shipping rate. + Currency string `json:"currency"` + + // The total price based on the shipping rate currency. + // In cents unit. See https://github.com/Shopify/shipping-fulfillment-app/issues/15#issuecomment-725996936 + TotalPrice decimal.Decimal `json:"total_price"` + + // Whether the customer must provide a phone number at checkout. + PhoneRequired bool `json:phone_required,omitempty"` + + // The earliest delivery date for the displayed rate. + MinDeliveryDate *time.Time `json:"min_delivery_date"` // "2013-04-12 14:48:45 -0400" + + // The latest delivery date for the displayed rate to still be valid. + MaxDeliveryDate *time.Time `json:"max_delivery_date"` // "2013-04-12 14:48:45 -0400" +} + +// List carrier services +func (s *CarrierServiceOp) List() ([]CarrierService, error) { + path := fmt.Sprintf("%s.json", carrierBasePath) + resource := new(ListCarrierResource) + err := s.client.Get(path, resource, nil) + return resource.CarrierServices, err +} + +// Get individual carrier resource by carrier resource ID +func (s *CarrierServiceOp) Get(id int64) (*CarrierService, error) { + path := fmt.Sprintf("%s/%d.json", carrierBasePath, id) + resource := new(SingleCarrierResource) + err := s.client.Get(path, resource, nil) + return resource.CarrierService, err +} + +// Create a carrier service +func (s *CarrierServiceOp) Create(carrier CarrierService) (*CarrierService, error) { + path := fmt.Sprintf("%s.json", carrierBasePath) + body := SingleCarrierResource{ + CarrierService: &carrier, + } + resource := new(SingleCarrierResource) + err := s.client.Post(path, body, resource) + return resource.CarrierService, err +} + +// Update a carrier service +func (s *CarrierServiceOp) Update(carrier CarrierService) (*CarrierService, error) { + path := fmt.Sprintf("%s/%d.json", carrierBasePath, carrier.Id) + body := SingleCarrierResource{ + CarrierService: &carrier, + } + resource := new(SingleCarrierResource) + err := s.client.Put(path, body, resource) + return resource.CarrierService, err +} + +// Delete a carrier service +func (s *CarrierServiceOp) Delete(id int64) error { + return s.client.Delete(fmt.Sprintf("%s/%d.json", carrierBasePath, id)) +} diff --git a/carrier_test.go b/carrier_test.go new file mode 100644 index 00000000..32315214 --- /dev/null +++ b/carrier_test.go @@ -0,0 +1,132 @@ +package goshopify + +import ( + "fmt" + "reflect" + "testing" + + "github.com/jarcoal/httpmock" +) + +func TestCarrierList(t *testing.T) { + setup() + defer teardown() + + httpmock.RegisterResponder("GET", fmt.Sprintf("https://fooshop.myshopify.com/%s/carrier_services.json", client.pathPrefix), + httpmock.NewBytesResponder(200, loadFixture("carrier_services.json"))) + + carriers, err := client.CarrierService.List() + if err != nil { + t.Errorf("Carrier.List returned error: %v", err) + } + + expected := []CarrierService{ + { + Id: 1, + Name: "Shipping Rate Provider", + Active: true, + ServiceDiscovery: true, + CarrierServiceType: "api", + AdminGraphqlAPIID: "gid://shopify/DeliveryCarrierService/1", + Format: "json", + CallbackUrl: "https://fooshop.example.com/shipping", + }, + } + if !reflect.DeepEqual(carriers, expected) { + t.Errorf("Carrier.List returned %+v, expected %+v", carriers, expected) + } +} + +func TestCarrierGet(t *testing.T) { + setup() + defer teardown() + + httpmock.RegisterResponder("GET", fmt.Sprintf("https://fooshop.myshopify.com/%s/carrier_services/1.json", client.pathPrefix), + httpmock.NewBytesResponder(200, loadFixture("carrier_service.json"))) + + carrier, err := client.CarrierService.Get(1) + if err != nil { + t.Errorf("Carrier.Get returned error: %v", err) + } + + expected := &CarrierService{ + Id: 1, + Name: "Shipping Rate Provider", + Active: true, + ServiceDiscovery: true, + CarrierServiceType: "api", + AdminGraphqlAPIID: "gid://shopify/DeliveryCarrierService/1", + Format: "json", + CallbackUrl: "https://fooshop.example.com/shipping", + } + if !reflect.DeepEqual(carrier, expected) { + t.Errorf("Carrier.Get returned %+v, expected %+v", carrier, expected) + } +} + +func TestCarrierCreate(t *testing.T) { + setup() + defer teardown() + + httpmock.RegisterResponder("POST", fmt.Sprintf("https://fooshop.myshopify.com/%s/carrier_services.json", client.pathPrefix), + httpmock.NewBytesResponder(200, loadFixture("carrier_service.json"))) + + carrier, err := client.CarrierService.Create(CarrierService{}) + if err != nil { + t.Errorf("Carrier.Create returned error: %v", err) + } + + expected := &CarrierService{ + Id: 1, + Name: "Shipping Rate Provider", + Active: true, + ServiceDiscovery: true, + CarrierServiceType: "api", + AdminGraphqlAPIID: "gid://shopify/DeliveryCarrierService/1", + Format: "json", + CallbackUrl: "https://fooshop.example.com/shipping", + } + if !reflect.DeepEqual(carrier, expected) { + t.Errorf("Carrier.Create returned %+v, expected %+v", carrier, expected) + } +} + +func TestCarrierUpdate(t *testing.T) { + setup() + defer teardown() + + httpmock.RegisterResponder("PUT", fmt.Sprintf("https://fooshop.myshopify.com/%s/carrier_services/1.json", client.pathPrefix), + httpmock.NewBytesResponder(200, loadFixture("carrier_service.json"))) + + carrier, err := client.CarrierService.Update(CarrierService{Id: 1}) + if err != nil { + t.Errorf("Carrier.Update returned error: %v", err) + } + + expected := &CarrierService{ + Id: 1, + Name: "Shipping Rate Provider", + Active: true, + ServiceDiscovery: true, + CarrierServiceType: "api", + AdminGraphqlAPIID: "gid://shopify/DeliveryCarrierService/1", + Format: "json", + CallbackUrl: "https://fooshop.example.com/shipping", + } + if !reflect.DeepEqual(carrier, expected) { + t.Errorf("Carrier.Update returned %+v, expected %+v", carrier, expected) + } +} + +func TestCarrierDelete(t *testing.T) { + setup() + defer teardown() + + httpmock.RegisterResponder("DELETE", fmt.Sprintf("https://fooshop.myshopify.com/%s/carrier_services/1.json", client.pathPrefix), + httpmock.NewStringResponder(200, `{}`)) + + err := client.CarrierService.Delete(1) + if err != nil { + t.Errorf("Carrier.Delete returned error: %v", err) + } +} diff --git a/collect.go b/collect.go index b1e87021..13e0bc26 100644 --- a/collect.go +++ b/collect.go @@ -13,6 +13,9 @@ const collectsBasePath = "collects" type CollectService interface { List(interface{}) ([]Collect, error) Count(interface{}) (int, error) + Get(int64, interface{}) (*Collect, error) + Create(Collect) (*Collect, error) + Delete(int64) error } // CollectServiceOp handles communication with the collect related methods of @@ -56,3 +59,25 @@ func (s *CollectServiceOp) Count(options interface{}) (int, error) { path := fmt.Sprintf("%s/count.json", collectsBasePath) return s.client.Count(path, options) } + +// Get individual collect +func (s *CollectServiceOp) Get(collectID int64, options interface{}) (*Collect, error) { + path := fmt.Sprintf("%s/%d.json", collectsBasePath, collectID) + resource := new(CollectResource) + err := s.client.Get(path, resource, options) + return resource.Collect, err +} + +// Create collects +func (s *CollectServiceOp) Create(collect Collect) (*Collect, error) { + path := fmt.Sprintf("%s.json", collectsBasePath) + wrappedData := CollectResource{Collect: &collect} + resource := new(CollectResource) + err := s.client.Post(path, wrappedData, resource) + return resource.Collect, err +} + +// Delete an existing collect +func (s *CollectServiceOp) Delete(collectID int64) error { + return s.client.Delete(fmt.Sprintf("%s/%d.json", collectsBasePath, collectID)) +} diff --git a/collect_test.go b/collect_test.go index 5759b45e..71a83cb7 100644 --- a/collect_test.go +++ b/collect_test.go @@ -16,11 +16,11 @@ func collectTests(t *testing.T, collect Collect) { expected interface{} actual interface{} }{ - {"ID", 18091352323, collect.ID}, - {"CollectionID", 241600835, collect.CollectionID}, - {"ProductID", 6654094787, collect.ProductID}, + {"ID", int64(18091352323), collect.ID}, + {"CollectionID", int64(241600835), collect.CollectionID}, + {"ProductID", int64(6654094787), collect.ProductID}, {"Featured", false, collect.Featured}, - {"SortValue", "0000000001", collect.SortValue}, + {"SortValue", "0000000002", collect.SortValue}, } for _, c := range cases { @@ -81,3 +81,54 @@ func TestCollectCount(t *testing.T) { t.Errorf("Collect.Count returned %d, expected %d", cnt, expected) } } + +func TestCollectGet(t *testing.T) { + setup() + defer teardown() + + httpmock.RegisterResponder("GET", fmt.Sprintf("https://fooshop.myshopify.com/%s/collects/1.json", client.pathPrefix), + httpmock.NewStringResponder(200, `{"collect": {"id":1}}`)) + + product, err := client.Collect.Get(1, nil) + if err != nil { + t.Errorf("Collect.Get returned error: %v", err) + } + + expected := &Collect{ID: 1} + if !reflect.DeepEqual(product, expected) { + t.Errorf("Collect.Get returned %+v, expected %+v", product, expected) + } +} + +func TestCollectCreate(t *testing.T) { + setup() + defer teardown() + + httpmock.RegisterResponder("POST", fmt.Sprintf("https://fooshop.myshopify.com/%s/collects.json", client.pathPrefix), + httpmock.NewBytesResponder(200, loadFixture("collect.json"))) + + collect := Collect{ + CollectionID: 241600835, + ProductID: 6654094787, + } + + returnedCollect, err := client.Collect.Create(collect) + if err != nil { + t.Errorf("Collect.Create returned error: %v", err) + } + + collectTests(t, *returnedCollect) +} + +func TestCollectDelete(t *testing.T) { + setup() + defer teardown() + + httpmock.RegisterResponder("DELETE", fmt.Sprintf("https://fooshop.myshopify.com/%s/collects/1.json", client.pathPrefix), + httpmock.NewStringResponder(200, "{}")) + + err := client.Collect.Delete(1) + if err != nil { + t.Errorf("Collect.Delete returned error: %v", err) + } +} diff --git a/collection.go b/collection.go index 279a253c..4c9e6fc7 100644 --- a/collection.go +++ b/collection.go @@ -2,7 +2,6 @@ package goshopify import ( "fmt" - "net/http" "time" ) @@ -14,7 +13,7 @@ const collectionsBasePath = "collections" type CollectionService interface { Get(collectionID int64, options interface{}) (*Collection, error) ListProducts(collectionID int64, options interface{}) ([]Product, error) - ListProductsWithPagination(collectionID int64,options interface{}) ([]Product, *Pagination, error) + ListProductsWithPagination(collectionID int64, options interface{}) ([]Product, *Pagination, error) } // CollectionServiceOp handles communication with the collection related methods of @@ -25,16 +24,16 @@ type CollectionServiceOp struct { // Collection represents a Shopify collection type Collection struct { - ID int64 `json:"id"` - Handle string `json:"handle"` - Title string `json:"title"` - UpdatedAt *time.Time `json:"updated_at"` - BodyHTML string `json:"body_html"` - SortOrder string `json:"sort_order"` - TemplateSuffix string `json:"template_suffix"` - Image Image `json:"image"` - PublishedAt *time.Time `json:"published_at"` - PublishedScope string `json:"published_scope"` + ID int64 `json:"id"` + Handle string `json:"handle"` + Title string `json:"title"` + UpdatedAt *time.Time `json:"updated_at"` + BodyHTML string `json:"body_html"` + SortOrder string `json:"sort_order"` + TemplateSuffix string `json:"template_suffix"` + Image Image `json:"image"` + PublishedAt *time.Time `json:"published_at"` + PublishedScope string `json:"published_scope"` } // Represents the result from the collections/X.json endpoint @@ -60,23 +59,14 @@ func (s *CollectionServiceOp) ListProducts(collectionID int64, options interface } // List products for a collection and return pagination to retrieve next/previous results. -func (s *CollectionServiceOp) ListProductsWithPagination(collectionID int64,options interface{}) ([]Product, *Pagination, error) { +func (s *CollectionServiceOp) ListProductsWithPagination(collectionID int64, options interface{}) ([]Product, *Pagination, error) { path := fmt.Sprintf("%s/%d/products.json", collectionsBasePath, collectionID) resource := new(ProductsResource) - headers := http.Header{} - headers, err := s.client.createAndDoGetHeaders("GET", path, nil, options, resource) - if err != nil { - return nil, nil, err - } - - // Extract pagination info from header - linkHeader := headers.Get("Link") - - pagination, err := extractPagination(linkHeader) + pagination, err := s.client.ListWithPagination(path, resource, options) if err != nil { return nil, nil, err } return resource.Products, pagination, nil -} \ No newline at end of file +} diff --git a/collection_test.go b/collection_test.go index a1bc10e4..e5df8934 100644 --- a/collection_test.go +++ b/collection_test.go @@ -46,7 +46,7 @@ func TestCollectionGet(t *testing.T) { updatedAt, _ := time.Parse(time.RFC3339, "2020-07-23T15:12:12-04:00") publishedAt, _ := time.Parse(time.RFC3339, "2020-06-23T14:22:47-04:00") - imageCreatedAt, _ :=time.Parse(time.RFC3339, "2020-02-27T15:01:45-05:00") + imageCreatedAt, _ := time.Parse(time.RFC3339, "2020-02-27T15:01:45-05:00") expected := &Collection{ ID: 25, Handle: "more-than-5", @@ -58,10 +58,10 @@ func TestCollectionGet(t *testing.T) { PublishedAt: &publishedAt, PublishedScope: "web", Image: Image{ - CreatedAt: &imageCreatedAt, - Width: 1920, - Height: 1279, - Src: "https://example/image.jpg", + CreatedAt: &imageCreatedAt, + Width: 1920, + Height: 1279, + Src: "https://example/image.jpg", }, } if !reflect.DeepEqual(collection, expected) { @@ -129,23 +129,23 @@ func TestCollectionListProducts(t *testing.T) { createdAt, _ := time.Parse(time.RFC3339, "2020-07-23T15:12:10-04:00") updatedAt, _ := time.Parse(time.RFC3339, "2020-07-23T15:13:26-04:00") publishedAt, _ := time.Parse(time.RFC3339, "2020-07-23T15:12:11-04:00") - imageCreatedAt, _ :=time.Parse(time.RFC3339, "2020-02-27T13:21:52-05:00") - imageUpdatedAt, _ :=time.Parse(time.RFC3339, "2020-02-27T13:21:52-05:00") + imageCreatedAt, _ := time.Parse(time.RFC3339, "2020-02-27T13:21:52-05:00") + imageUpdatedAt, _ := time.Parse(time.RFC3339, "2020-02-27T13:21:52-05:00") expected := []Product{ { - ID: 632910392, - Title: "The Best Product", - BodyHTML: "

The best product available

", - Vendor: "local-vendor", - ProductType: "Best Products", - Handle: "the-best-product", - CreatedAt: &createdAt, - UpdatedAt: &updatedAt, - PublishedAt: &publishedAt, - PublishedScope: "web", - Tags: "Best", - Options: []ProductOption{ + ID: 632910392, + Title: "The Best Product", + BodyHTML: "

The best product available

", + Vendor: "local-vendor", + ProductType: "Best Products", + Handle: "the-best-product", + CreatedAt: &createdAt, + UpdatedAt: &updatedAt, + PublishedAt: &publishedAt, + PublishedScope: "web", + Tags: "Best", + Options: []ProductOption{ { ID: 6519940513924, ProductID: 632910392, @@ -154,22 +154,22 @@ func TestCollectionListProducts(t *testing.T) { Values: nil, }, }, - Variants: nil, - Images: []Image{ + Variants: nil, + Images: []Image{ { ID: 14601766043780, ProductID: 632910392, Position: 1, CreatedAt: &imageCreatedAt, - UpdatedAt: &imageUpdatedAt, + UpdatedAt: &imageUpdatedAt, Width: 480, Height: 720, Src: "https://example/image.jpg", VariantIds: []int64{32434329944196, 32434531893380}, }, }, - TemplateSuffix: "special", - AdminGraphqlAPIID: "gid://shopify/Location/4688969785", + TemplateSuffix: "special", + AdminGraphqlAPIID: "gid://shopify/Location/4688969785", }, } if !reflect.DeepEqual(products, expected) { @@ -264,23 +264,23 @@ func TestListProductsWithPagination(t *testing.T) { createdAt, _ := time.Parse(time.RFC3339, "2020-07-23T15:12:10-04:00") updatedAt, _ := time.Parse(time.RFC3339, "2020-07-23T15:13:26-04:00") publishedAt, _ := time.Parse(time.RFC3339, "2020-07-23T15:12:11-04:00") - imageCreatedAt, _ :=time.Parse(time.RFC3339, "2020-02-27T13:21:52-05:00") - imageUpdatedAt, _ :=time.Parse(time.RFC3339, "2020-02-27T13:21:52-05:00") + imageCreatedAt, _ := time.Parse(time.RFC3339, "2020-02-27T13:21:52-05:00") + imageUpdatedAt, _ := time.Parse(time.RFC3339, "2020-02-27T13:21:52-05:00") expectedProducts := []Product{ { - ID: 632910392, - Title: "The Best Product", - BodyHTML: "

The best product available

", - Vendor: "local-vendor", - ProductType: "Best Products", - Handle: "the-best-product", - CreatedAt: &createdAt, - UpdatedAt: &updatedAt, - PublishedAt: &publishedAt, - PublishedScope: "web", - Tags: "Best", - Options: []ProductOption{ + ID: 632910392, + Title: "The Best Product", + BodyHTML: "

The best product available

", + Vendor: "local-vendor", + ProductType: "Best Products", + Handle: "the-best-product", + CreatedAt: &createdAt, + UpdatedAt: &updatedAt, + PublishedAt: &publishedAt, + PublishedScope: "web", + Tags: "Best", + Options: []ProductOption{ { ID: 6519940513924, ProductID: 632910392, @@ -289,22 +289,22 @@ func TestListProductsWithPagination(t *testing.T) { Values: nil, }, }, - Variants: nil, - Images: []Image{ + Variants: nil, + Images: []Image{ { ID: 14601766043780, ProductID: 632910392, Position: 1, CreatedAt: &imageCreatedAt, - UpdatedAt: &imageUpdatedAt, + UpdatedAt: &imageUpdatedAt, Width: 480, Height: 720, Src: "https://example/image.jpg", VariantIds: []int64{32434329944196, 32434531893380}, }, }, - TemplateSuffix: "special", - AdminGraphqlAPIID: "gid://shopify/Location/4688969785", + TemplateSuffix: "special", + AdminGraphqlAPIID: "gid://shopify/Location/4688969785", }, } if !reflect.DeepEqual(products, expectedProducts) { @@ -312,7 +312,7 @@ func TestListProductsWithPagination(t *testing.T) { } expectedPage := &Pagination{ - NextPageOptions: &ListOptions{ + NextPageOptions: &ListOptions{ PageInfo: "pageInfoCode", Page: 0, Limit: 1, diff --git a/customer.go b/customer.go index 47cd36ff..cb373065 100644 --- a/customer.go +++ b/customer.go @@ -15,6 +15,7 @@ const customersResourceName = "customers" // See: https://help.shopify.com/api/reference/customer type CustomerService interface { List(interface{}) ([]Customer, error) + ListWithPagination(options interface{}) ([]Customer, *Pagination, error) Count(interface{}) (int, error) Get(int64, interface{}) (*Customer, error) Search(interface{}) ([]Customer, error) @@ -91,6 +92,19 @@ func (s *CustomerServiceOp) List(options interface{}) ([]Customer, error) { return resource.Customers, err } +// ListWithPagination lists customers and return pagination to retrieve next/previous results. +func (s *CustomerServiceOp) ListWithPagination(options interface{}) ([]Customer, *Pagination, error) { + path := fmt.Sprintf("%s.json", customersBasePath) + resource := new(CustomersResource) + + pagination, err := s.client.ListWithPagination(path, resource, options) + if err != nil { + return nil, nil, err + } + + return resource.Customers, pagination, nil +} + // Count customers func (s *CustomerServiceOp) Count(options interface{}) (int, error) { path := fmt.Sprintf("%s/count.json", customersBasePath) diff --git a/customer_test.go b/customer_test.go index 85d8177d..df655479 100644 --- a/customer_test.go +++ b/customer_test.go @@ -1,8 +1,11 @@ package goshopify import ( + "errors" "fmt" + "net/http" "reflect" + "runtime" "testing" "time" @@ -28,6 +31,126 @@ func TestCustomerList(t *testing.T) { } } +func TestCustomerListWithPagination(t *testing.T) { + setup() + defer teardown() + + listURL := fmt.Sprintf("https://fooshop.myshopify.com/%s/customers.json", client.pathPrefix) + + // The strconv.Atoi error changed in go 1.8, 1.7 is still being tested/supported. + limitConversionErrorMessage := `strconv.Atoi: parsing "invalid": invalid syntax` + if runtime.Version()[2:5] == "1.7" { + limitConversionErrorMessage = `strconv.ParseInt: parsing "invalid": invalid syntax` + } + + cases := []struct { + body string + linkHeader string + expectedCustomers []Customer + expectedPagination *Pagination + expectedErr error + }{ + // Expect empty pagination when there is no link header + { + `{"customers": [{"id":1},{"id":2}]}`, + "", + []Customer{{ID: 1}, {ID: 2}}, + new(Pagination), + nil, + }, + // Invalid link header responses + { + "{}", + "invalid link", + []Customer(nil), + nil, + ResponseDecodingError{Message: "could not extract pagination link header"}, + }, + { + "{}", + `<:invalid.url>; rel="next"`, + []Customer(nil), + nil, + ResponseDecodingError{Message: "pagination does not contain a valid URL"}, + }, + { + "{}", + `; rel="next"`, + []Customer(nil), + nil, + errors.New(`invalid URL escape "%in"`), + }, + { + "{}", + `; rel="next"`, + []Customer(nil), + nil, + ResponseDecodingError{Message: "page_info is missing"}, + }, + { + "{}", + `; rel="next"`, + []Customer(nil), + nil, + errors.New(limitConversionErrorMessage), + }, + // Valid link header responses + { + `{"customers": [{"id":1}]}`, + `; rel="next"`, + []Customer{{ID: 1}}, + &Pagination{ + NextPageOptions: &ListOptions{PageInfo: "foo", Limit: 2}, + }, + nil, + }, + { + `{"customers": [{"id":2}]}`, + `; rel="next", ; rel="previous"`, + []Customer{{ID: 2}}, + &Pagination{ + NextPageOptions: &ListOptions{PageInfo: "foo"}, + PreviousPageOptions: &ListOptions{PageInfo: "bar"}, + }, + nil, + }, + } + for i, c := range cases { + response := &http.Response{ + StatusCode: 200, + Body: httpmock.NewRespBodyFromString(c.body), + Header: http.Header{ + "Link": {c.linkHeader}, + }, + } + + httpmock.RegisterResponder("GET", listURL, httpmock.ResponderFromResponse(response)) + + customers, pagination, err := client.Customer.ListWithPagination(nil) + if !reflect.DeepEqual(customers, c.expectedCustomers) { + t.Errorf("test %d Customer.ListWithPagination customers returned %+v, expected %+v", i, customers, c.expectedCustomers) + } + + if !reflect.DeepEqual(pagination, c.expectedPagination) { + t.Errorf( + "test %d Customer.ListWithPagination pagination returned %+v, expected %+v", + i, + pagination, + c.expectedPagination, + ) + } + + if (c.expectedErr != nil || err != nil) && err.Error() != c.expectedErr.Error() { + t.Errorf( + "test %d Customer.ListWithPagination err returned %+v, expected %+v", + i, + err, + c.expectedErr, + ) + } + } +} + func TestCustomerCount(t *testing.T) { setup() defer teardown() diff --git a/draft_order.go b/draft_order.go index 91735b40..e4e34e3a 100644 --- a/draft_order.go +++ b/draft_order.go @@ -44,7 +44,7 @@ type DraftOrder struct { ShippingAddress *Address `json:"shipping_address,omitempty"` BillingAddress *Address `json:"billing_address,omitempty"` Note string `json:"note,omitempty"` - NoteAttributes []NoteAttribute `json:"note_attribute,omitempty"` + NoteAttributes []NoteAttribute `json:"note_attributes,omitempty"` Email string `json:"email,omitempty"` Currency string `json:"currency,omitempty"` InvoiceSentAt *time.Time `json:"invoice_sent_at,omitempty"` @@ -56,6 +56,7 @@ type DraftOrder struct { AppliedDiscount *AppliedDiscount `json:"applied_discount,omitempty"` TaxesIncluded bool `json:"taxes_included,omitempty"` TotalTax string `json:"total_tax,omitempty"` + TaxExempt *bool `json:"tax_exempt,omitempty"` TotalPrice string `json:"total_price,omitempty"` SubtotalPrice *decimal.Decimal `json:"subtotal_price,omitempty"` CompletedAt *time.Time `json:"completed_at,omitempty"` @@ -68,7 +69,7 @@ type DraftOrder struct { // AppliedDiscount is the discount applied to the line item or the draft order object. type AppliedDiscount struct { - Title string `json:"applied_discount,omitempty"` + Title string `json:"title,omitempty"` Description string `json:"description,omitempty"` Value string `json:"value,omitempty"` ValueType string `json:"value_type,omitempty"` diff --git a/fixtures/carrier_service.json b/fixtures/carrier_service.json new file mode 100644 index 00000000..06994006 --- /dev/null +++ b/fixtures/carrier_service.json @@ -0,0 +1,12 @@ +{ + "carrier_service": { + "id": 1, + "name": "Shipping Rate Provider", + "active": true, + "service_discovery": true, + "carrier_service_type": "api", + "admin_graphql_api_id": "gid://shopify/DeliveryCarrierService/1", + "format": "json", + "callback_url": "https://fooshop.example.com/shipping" + } +} diff --git a/fixtures/carrier_services.json b/fixtures/carrier_services.json new file mode 100644 index 00000000..3960ea68 --- /dev/null +++ b/fixtures/carrier_services.json @@ -0,0 +1,14 @@ +{ + "carrier_services": [ + { + "id": 1, + "name": "Shipping Rate Provider", + "active": true, + "service_discovery": true, + "carrier_service_type": "api", + "admin_graphql_api_id": "gid://shopify/DeliveryCarrierService/1", + "format": "json", + "callback_url": "https://fooshop.example.com/shipping" + } + ] +} \ No newline at end of file diff --git a/fixtures/collect.json b/fixtures/collect.json new file mode 100644 index 00000000..879bb4db --- /dev/null +++ b/fixtures/collect.json @@ -0,0 +1,11 @@ +{ + "collect": { + "id": 18091352323, + "collection_id": 241600835, + "product_id": 6654094787, + "created_at": "2021-02-05T20:39:25-05:00", + "updated_at": "2021-02-05T20:39:25-05:00", + "position": 2, + "sort_value": "0000000002" + } + } \ No newline at end of file diff --git a/fixtures/fulfillment_service.json b/fixtures/fulfillment_service.json new file mode 100644 index 00000000..3da7e740 --- /dev/null +++ b/fixtures/fulfillment_service.json @@ -0,0 +1,18 @@ +{ + "fulfillment_service": { + "id": 1061774487, + "name": "Jupiter Fulfillment", + "email": "aaa@gmail.com", + "service_name": "Jupiter Fulfillment", + "handle": "jupiter-fulfillment", + "fulfillment_orders_opt_in": false, + "include_pending_stock": false, + "provider_id": 1234, + "location_id": 1072404542, + "callback_url": "https://google.com/", + "tracking_support": false, + "inventory_management": false, + "admin_graphql_api_id": "gid://shopify/ApiFulfillmentService/1061774487", + "permits_sku_sharing": false + } +} \ No newline at end of file diff --git a/fixtures/fulfillment_services.json b/fixtures/fulfillment_services.json new file mode 100644 index 00000000..1dde61c8 --- /dev/null +++ b/fixtures/fulfillment_services.json @@ -0,0 +1,20 @@ +{ + "fulfillment_services": [ + { + "id": 1061774487, + "name": "Jupiter Fulfillment", + "email": "aaa@gmail.com", + "service_name": "Jupiter Fulfillment", + "handle": "jupiter-fulfillment", + "fulfillment_orders_opt_in": false, + "include_pending_stock": false, + "provider_id": 1234, + "location_id": 1072404542, + "callback_url": "https://google.com/", + "tracking_support": false, + "inventory_management": false, + "admin_graphql_api_id": "gid://shopify/ApiFulfillmentService/1061774487", + "permits_sku_sharing": false + } + ] +} \ No newline at end of file diff --git a/fixtures/gift_card/get.json b/fixtures/gift_card/get.json new file mode 100644 index 00000000..361b3070 --- /dev/null +++ b/fixtures/gift_card/get.json @@ -0,0 +1,20 @@ +{ + "gift_card": { + "disabled_at": "2023-04-06T06:39:31-04:00", + "template_suffix": null, + "initial_value": "100.00", + "balance": "100.00", + "customer_id": null, + "id": 1, + "created_at": "2023-04-06T06:34:03-04:00", + "updated_at": "2023-04-06T06:39:31-04:00", + "currency": "USD", + "line_item_id": null, + "api_client_id": null, + "user_id": null, + "note": null, + "expires_on": null, + "last_characters": "0d0d", + "order_id": null + } +} \ No newline at end of file diff --git a/fixtures/gift_card/list.json b/fixtures/gift_card/list.json new file mode 100644 index 00000000..22f592fd --- /dev/null +++ b/fixtures/gift_card/list.json @@ -0,0 +1,22 @@ +{ + "gift_cards": [ + { + "id": 1, + "balance": "25.00", + "created_at": "2023-04-06T06:34:03-04:00", + "updated_at": "2023-04-06T06:34:03-04:00", + "currency": "USD", + "initial_value": "50.00", + "disabled_at": null, + "line_item_id": null, + "api_client_id": null, + "user_id": null, + "customer_id": null, + "note": null, + "expires_on": "2022-04-06", + "template_suffix": null, + "last_characters": "0e0e", + "order_id": null + } + ] +} \ No newline at end of file diff --git a/fixtures/inventory_item.json b/fixtures/inventory_item.json index 2b92a6d8..0c473fce 100644 --- a/fixtures/inventory_item.json +++ b/fixtures/inventory_item.json @@ -6,6 +6,10 @@ "updated_at": "2018-10-29T06:05:58-04:00", "cost": "25.00", "tracked": true, - "admin_graphql_api_id": "gid://shopify/InventoryItem/808950810" + "admin_graphql_api_id": "gid://shopify/InventoryItem/808950810", + "country_code_of_origin": "US", + "country_harmonized_system_codes": ["8471.70.40.35", "8471.70.50.35"], + "harmonized_system_code": "8471.70.40.35", + "province_code_of_origin": "ON" } } \ No newline at end of file diff --git a/fixtures/inventory_level.json b/fixtures/inventory_level.json new file mode 100644 index 00000000..1eab793a --- /dev/null +++ b/fixtures/inventory_level.json @@ -0,0 +1,9 @@ +{ + "inventory_level": { + "inventory_item_id": 808950810, + "location_id": 905684977, + "available": 6, + "updated_at": "2020-04-06T10:51:56-04:00", + "admin_graphql_api_id": "gid://shopify/InventoryLevel/905684977?inventory_item_id=808950810" + } +} diff --git a/fixtures/inventory_levels.json b/fixtures/inventory_levels.json new file mode 100644 index 00000000..cd2f46d1 --- /dev/null +++ b/fixtures/inventory_levels.json @@ -0,0 +1,32 @@ +{ + "inventory_levels": [ + { + "inventory_item_id": 808950810, + "location_id": 487838322, + "available": 9, + "updated_at": "2020-04-06T10:51:36-04:00", + "admin_graphql_api_id": "gid://shopify/InventoryLevel/690933842?inventory_item_id=808950810" + }, + { + "inventory_item_id": 39072856, + "location_id": 487838322, + "available": 27, + "updated_at": "2020-04-06T10:51:36-04:00", + "admin_graphql_api_id": "gid://shopify/InventoryLevel/690933842?inventory_item_id=39072856" + }, + { + "inventory_item_id": 808950810, + "location_id": 905684977, + "available": 1, + "updated_at": "2020-04-06T10:51:36-04:00", + "admin_graphql_api_id": "gid://shopify/InventoryLevel/905684977?inventory_item_id=808950810" + }, + { + "inventory_item_id": 39072856, + "location_id": 905684977, + "available": 3, + "updated_at": "2020-04-06T10:51:36-04:00", + "admin_graphql_api_id": "gid://shopify/InventoryLevel/905684977?inventory_item_id=39072856" + } + ] +} diff --git a/fixtures/order.json b/fixtures/order.json index fea9ac7a..bc7daacd 100644 --- a/fixtures/order.json +++ b/fixtures/order.json @@ -1 +1 @@ -{"order":{"id":123456,"email":"jon@doe.ca","closed_at":null,"created_at":"2016-05-17T04:14:36-00:00","updated_at":"2016-05-17T04:14:36-04:00","number":234,"note":null,"token":null,"gateway":null,"test":true,"total_price":"10.00","subtotal_price":"0.00","total_weight":0,"total_tax":null,"taxes_included":false,"currency":"USD","financial_status":"voided","confirmed":false,"total_discounts":"5.00","total_line_items_price":"5.00","cart_token":null,"buyer_accepts_marketing":true,"name":"#9999","referring_site":null,"landing_site":null,"cancelled_at":"2016-05-17T04:14:36-04:00","cancel_reason":"customer","total_price_usd":null,"checkout_token":null,"reference":null,"user_id":null,"location_id":null,"source_identifier":null,"source_url":null,"processed_at":null,"device_id":null,"browser_ip":null,"landing_site_ref":null,"order_number":1234,"discount_codes":[],"note_attributes":[],"payment_gateway_names":["visa","bogus"],"processing_method":"","checkout_id":null,"source_name":"web","fulfillment_status":"pending","tax_lines":[],"tags":"","contact_email":"jon@doe.ca","order_status_url":null,"line_items":[{"id":254721536,"variant_id":null,"title":"Soda","quantity":1,"price":"0.00","grams":0,"sku":"","variant_title":null,"vendor":null,"fulfillment_service":"manual","product_id":111475476,"requires_shipping":true,"taxable":true,"gift_card":false,"pre_tax_price":"9.00","name":"Soda","variant_inventory_management":null,"properties":[],"product_exists":true,"fulfillable_quantity":1,"total_discount":"0.00","fulfillment_status":null,"tax_lines":[]},{"id":5,"variant_id":null,"title":"Another Beer For Good Times","quantity":1,"price":"5.00","grams":500,"sku":"","variant_title":null,"vendor":null,"fulfillment_service":"manual","product_id":5410685889,"requires_shipping":true,"taxable":true,"gift_card":false,"name":"Another Beer For Good Times","variant_inventory_management":null,"properties":[],"product_exists":true,"fulfillable_quantity":1,"total_discount":"5.00","fulfillment_status":null,"tax_lines":[]}],"shipping_lines":[{"id":null,"title":"Generic Shipping","price":"10.00","code":null,"source":"shopify","phone":null,"carrier_identifier":null,"tax_lines":[]}],"billing_address":{"first_name":"Bob","address1":"123 Billing Street","phone":"555-555-BILL","city":"Billtown","zip":"K2P0B0","province":"Kentucky","country":"United States","last_name":"Biller","address2":null,"company":"My Company","latitude":null,"longitude":null,"name":"Bob Biller","country_code":"US","province_code":"KY"},"shipping_address":{"first_name":"Steve","address1":"123 Shipping Street","phone":"555-555-SHIP","city":"Shippington","zip":"K2P0S0","province":"Kentucky","country":"United States","last_name":"Shipper","address2":null,"company":"Shipping Company","latitude":null,"longitude":null,"name":"Steve Shipper","country_code":"US","province_code":"KY"},"fulfillments":[],"refunds":[],"customer":{"id":null,"email":"john@test.com","accepts_marketing":false,"created_at":null,"updated_at":null,"first_name":"John","last_name":"Smith","orders_count":0,"state":"disabled","total_spent":"0.00","last_order_id":null,"note":null,"verified_email":true,"multipass_identifier":null,"tax_exempt":false,"tags":"","last_order_name":null,"default_address":{"id":null,"first_name":null,"last_name":null,"company":null,"address1":"123 Elm St.","address2":null,"city":"Ottawa","province":"Ontario","country":"Canada","zip":"K2H7A8","phone":"123-123-1234","name":"","province_code":"ON","country_code":"CA","country_name":"Canada","default":true}}}} +{"order":{"id":123456,"email":"jon@doe.ca","closed_at":null,"created_at":"2016-05-17T04:14:36-00:00","updated_at":"2016-05-17T04:14:36-04:00","number":234,"note":null,"token":null,"gateway":null,"test":true,"total_price":"10.00","current_total_price":"9.50","subtotal_price":"0.00","total_weight":0,"total_tax":null,"taxes_included":false,"currency":"USD","financial_status":"voided","confirmed":false,"total_discounts":"5.00","total_line_items_price":"5.00","cart_token":null,"buyer_accepts_marketing":true,"name":"#9999","referring_site":null,"landing_site":null,"cancelled_at":"2016-05-17T04:14:36-04:00","cancel_reason":"customer","total_price_usd":null,"checkout_token":null,"reference":null,"user_id":null,"location_id":null,"source_identifier":null,"source_url":null,"processed_at":null,"device_id":null,"browser_ip":null,"landing_site_ref":null,"order_number":1234,"discount_codes":[],"note_attributes":[],"payment_gateway_names":["visa","bogus"],"processing_method":"","checkout_id":null,"source_name":"web","fulfillment_status":"pending","tax_lines":[],"tags":"","contact_email":"jon@doe.ca","order_status_url":null,"line_items":[{"id":254721536,"variant_id":null,"title":"Soda","quantity":1,"price":"0.00","grams":0,"sku":"","variant_title":null,"vendor":null,"fulfillment_service":"manual","product_id":111475476,"requires_shipping":true,"taxable":true,"gift_card":false,"pre_tax_price":"9.00","name":"Soda","variant_inventory_management":null,"properties":[],"product_exists":true,"fulfillable_quantity":1,"total_discount":"0.00","fulfillment_status":null,"tax_lines":[]},{"id":5,"variant_id":null,"title":"Another Beer For Good Times","quantity":1,"price":"5.00","grams":500,"sku":"","variant_title":null,"vendor":null,"fulfillment_service":"manual","product_id":5410685889,"requires_shipping":true,"taxable":true,"gift_card":false,"name":"Another Beer For Good Times","variant_inventory_management":null,"properties":[],"product_exists":true,"fulfillable_quantity":1,"total_discount":"5.00","fulfillment_status":null,"tax_lines":[]}],"shipping_lines":[{"id":null,"title":"Generic Shipping","price":"10.00","code":null,"source":"shopify","phone":null,"carrier_identifier":null,"tax_lines":[]}],"billing_address":{"first_name":"Bob","address1":"123 Billing Street","phone":"555-555-BILL","city":"Billtown","zip":"K2P0B0","province":"Kentucky","country":"United States","last_name":"Biller","address2":null,"company":"My Company","latitude":null,"longitude":null,"name":"Bob Biller","country_code":"US","province_code":"KY"},"shipping_address":{"first_name":"Steve","address1":"123 Shipping Street","phone":"555-555-SHIP","city":"Shippington","zip":"K2P0S0","province":"Kentucky","country":"United States","last_name":"Shipper","address2":null,"company":"Shipping Company","latitude":null,"longitude":null,"name":"Steve Shipper","country_code":"US","province_code":"KY"},"fulfillments":[],"refunds":[],"customer":{"id":null,"email":"john@test.com","accepts_marketing":false,"created_at":null,"updated_at":null,"first_name":"John","last_name":"Smith","orders_count":0,"state":"disabled","total_spent":"0.00","last_order_id":null,"note":null,"verified_email":true,"multipass_identifier":null,"tax_exempt":false,"tags":"","last_order_name":null,"default_address":{"id":null,"first_name":null,"last_name":null,"company":null,"address1":"123 Elm St.","address2":null,"city":"Ottawa","province":"Ontario","country":"Canada","zip":"K2H7A8","phone":"123-123-1234","name":"","province_code":"ON","country_code":"CA","country_name":"Canada","default":true}}}} diff --git a/fixtures/order_with_transaction.json b/fixtures/order_with_transaction.json index 6a58ab93..c2e2bfe1 100644 --- a/fixtures/order_with_transaction.json +++ b/fixtures/order_with_transaction.json @@ -1 +1 @@ -{"order":{"id":123456,"email":"jon@doe.ca","closed_at":null,"created_at":"2016-05-17T04:14:36-00:00","updated_at":"2016-05-17T04:14:36-04:00","number":234,"note":null,"token":null,"gateway":null,"test":true,"total_price":"10.00","subtotal_price":"0.00","total_weight":0,"total_tax":null,"taxes_included":false,"currency":"USD","financial_status":"voided","confirmed":false,"total_discounts":"5.00","total_line_items_price":"5.00","cart_token":null,"buyer_accepts_marketing":true,"name":"#9999","referring_site":null,"landing_site":null,"cancelled_at":"2016-05-17T04:14:36-04:00","cancel_reason":"customer","total_price_usd":null,"checkout_token":null,"reference":null,"user_id":null,"location_id":null,"source_identifier":null,"source_url":null,"processed_at":null,"device_id":null,"browser_ip":null,"landing_site_ref":null,"order_number":1234,"discount_codes":[],"note_attributes":[],"payment_gateway_names":["visa","bogus"],"processing_method":"","checkout_id":null,"source_name":"web","fulfillment_status":"pending","tax_lines":[],"tags":"","contact_email":"jon@doe.ca","order_status_url":null,"line_items":[{"id":254721536,"variant_id":null,"title":"Soda","quantity":1,"price":"0.00","grams":0,"sku":"","variant_title":null,"vendor":null,"fulfillment_service":"manual","product_id":111475476,"requires_shipping":true,"taxable":true,"gift_card":false,"pre_tax_price":"9.00","name":"Soda","variant_inventory_management":null,"properties":[],"product_exists":true,"fulfillable_quantity":1,"total_discount":"0.00","fulfillment_status":null,"tax_lines":[]},{"id":5,"variant_id":null,"title":"Another Beer For Good Times","quantity":1,"price":"5.00","grams":500,"sku":"","variant_title":null,"vendor":null,"fulfillment_service":"manual","product_id":5410685889,"requires_shipping":true,"taxable":true,"gift_card":false,"name":"Another Beer For Good Times","variant_inventory_management":null,"properties":[],"product_exists":true,"fulfillable_quantity":1,"total_discount":"5.00","fulfillment_status":null,"tax_lines":[]}],"shipping_lines":[{"id":null,"title":"Generic Shipping","price":"10.00","code":null,"source":"shopify","phone":null,"carrier_identifier":null,"tax_lines":[]}],"billing_address":{"first_name":"Bob","address1":"123 Billing Street","phone":"555-555-BILL","city":"Billtown","zip":"K2P0B0","province":"Kentucky","country":"United States","last_name":"Biller","address2":null,"company":"My Company","latitude":null,"longitude":null,"name":"Bob Biller","country_code":"US","province_code":"KY"},"shipping_address":{"first_name":"Steve","address1":"123 Shipping Street","phone":"555-555-SHIP","city":"Shippington","zip":"K2P0S0","province":"Kentucky","country":"United States","last_name":"Shipper","address2":null,"company":"Shipping Company","latitude":null,"longitude":null,"name":"Steve Shipper","country_code":"US","province_code":"KY"},"fulfillments":[],"refunds":[],"customer":{"id":null,"email":"john@test.com","accepts_marketing":false,"created_at":null,"updated_at":null,"first_name":"John","last_name":"Smith","orders_count":0,"state":"disabled","total_spent":"0.00","last_order_id":null,"note":null,"verified_email":true,"multipass_identifier":null,"tax_exempt":false,"tags":"","last_order_name":null,"default_address":{"id":null,"first_name":null,"last_name":null,"company":null,"address1":"123 Elm St.","address2":null,"city":"Ottawa","province":"Ontario","country":"Canada","zip":"K2H7A8","phone":"123-123-1234","name":"","province_code":"ON","country_code":"CA","country_name":"Canada","default":true}},"transactions":[{"id":1,"order_id":123456,"amount":"79.60","kind":"sale","gateway":"mygateway","status":"success","message":"Approved","created_at":"2017-10-09T19:26:23+00:00","test":false,"authorization":"ABC123","currency":"AUD","location_id":null,"user_id":null,"parent_id":null,"device_id":null,"receipt":{"vendor":"myshop","partner":"paypal","result":"0","avs_result":"X","rrn":"abcd1234","message":"Approved","pn_ref":"AAAABBBB","transactionid":"abc1234"},"error_code":null,"source_name":"web","payment_details":{"credit_card_bin":"123456","avs_result_code":"X","cvv_result_code":null,"credit_card_number":"•••• •••• •••• 1234","credit_card_company":"Mastercard"}}]}} +{"order":{"id":123456,"email":"jon@doe.ca","closed_at":null,"created_at":"2016-05-17T04:14:36-00:00","updated_at":"2016-05-17T04:14:36-04:00","number":234,"note":null,"token":null,"gateway":null,"test":true,"total_price":"10.00","current_total_price":"9.50","subtotal_price":"0.00","total_weight":0,"total_tax":null,"taxes_included":false,"currency":"USD","financial_status":"voided","confirmed":false,"total_discounts":"5.00","total_line_items_price":"5.00","cart_token":null,"buyer_accepts_marketing":true,"name":"#9999","referring_site":null,"landing_site":null,"cancelled_at":"2016-05-17T04:14:36-04:00","cancel_reason":"customer","total_price_usd":null,"checkout_token":null,"reference":null,"user_id":null,"location_id":null,"source_identifier":null,"source_url":null,"processed_at":null,"device_id":null,"browser_ip":null,"landing_site_ref":null,"order_number":1234,"discount_codes":[],"note_attributes":[],"payment_gateway_names":["visa","bogus"],"processing_method":"","checkout_id":null,"source_name":"web","fulfillment_status":"pending","tax_lines":[],"tags":"","contact_email":"jon@doe.ca","order_status_url":null,"line_items":[{"id":254721536,"variant_id":null,"title":"Soda","quantity":1,"price":"0.00","grams":0,"sku":"","variant_title":null,"vendor":null,"fulfillment_service":"manual","product_id":111475476,"requires_shipping":true,"taxable":true,"gift_card":false,"pre_tax_price":"9.00","name":"Soda","variant_inventory_management":null,"properties":[],"product_exists":true,"fulfillable_quantity":1,"total_discount":"0.00","fulfillment_status":null,"tax_lines":[]},{"id":5,"variant_id":null,"title":"Another Beer For Good Times","quantity":1,"price":"5.00","grams":500,"sku":"","variant_title":null,"vendor":null,"fulfillment_service":"manual","product_id":5410685889,"requires_shipping":true,"taxable":true,"gift_card":false,"name":"Another Beer For Good Times","variant_inventory_management":null,"properties":[],"product_exists":true,"fulfillable_quantity":1,"total_discount":"5.00","fulfillment_status":null,"tax_lines":[]}],"shipping_lines":[{"id":null,"title":"Generic Shipping","price":"10.00","code":null,"source":"shopify","phone":null,"carrier_identifier":null,"tax_lines":[]}],"billing_address":{"first_name":"Bob","address1":"123 Billing Street","phone":"555-555-BILL","city":"Billtown","zip":"K2P0B0","province":"Kentucky","country":"United States","last_name":"Biller","address2":null,"company":"My Company","latitude":null,"longitude":null,"name":"Bob Biller","country_code":"US","province_code":"KY"},"shipping_address":{"first_name":"Steve","address1":"123 Shipping Street","phone":"555-555-SHIP","city":"Shippington","zip":"K2P0S0","province":"Kentucky","country":"United States","last_name":"Shipper","address2":null,"company":"Shipping Company","latitude":null,"longitude":null,"name":"Steve Shipper","country_code":"US","province_code":"KY"},"fulfillments":[],"refunds":[],"customer":{"id":null,"email":"john@test.com","accepts_marketing":false,"created_at":null,"updated_at":null,"first_name":"John","last_name":"Smith","orders_count":0,"state":"disabled","total_spent":"0.00","last_order_id":null,"note":null,"verified_email":true,"multipass_identifier":null,"tax_exempt":false,"tags":"","last_order_name":null,"default_address":{"id":null,"first_name":null,"last_name":null,"company":null,"address1":"123 Elm St.","address2":null,"city":"Ottawa","province":"Ontario","country":"Canada","zip":"K2H7A8","phone":"123-123-1234","name":"","province_code":"ON","country_code":"CA","country_name":"Canada","default":true}},"transactions":[{"id":1,"order_id":123456,"amount":"79.60","kind":"sale","gateway":"mygateway","status":"success","message":"Approved","created_at":"2017-10-09T19:26:23+00:00","test":false,"authorization":"ABC123","currency":"AUD","location_id":null,"user_id":null,"parent_id":null,"device_id":null,"receipt":{"vendor":"myshop","partner":"paypal","result":"0","avs_result":"X","rrn":"abcd1234","message":"Approved","pn_ref":"AAAABBBB","transactionid":"abc1234"},"error_code":null,"source_name":"web","payment_details":{"credit_card_bin":"123456","avs_result_code":"X","cvv_result_code":null,"credit_card_number":"•••• •••• •••• 1234","credit_card_company":"Mastercard"}}]}} diff --git a/fixtures/orderlineitems/valid.json b/fixtures/orderlineitems/valid.json index f27acc31..0cba294d 100644 --- a/fixtures/orderlineitems/valid.json +++ b/fixtures/orderlineitems/valid.json @@ -79,7 +79,7 @@ "zip": "R3Y 0L6" }, "applied_discount": { - "applied_discount": "test discount", + "title": "test discount", "description": "my test discount", "value": "0.05", "value_type": "percent", diff --git a/fixtures/orders.json b/fixtures/orders.json index 15d2dc23..55a8856e 100644 --- a/fixtures/orders.json +++ b/fixtures/orders.json @@ -1 +1 @@ -{"orders":[{"id":123456,"email":"jon@doe.ca","closed_at":null,"created_at":"2016-05-17T04:14:36-00:00","updated_at":"2016-05-17T04:14:36-04:00","number":234,"note":null,"token":null,"gateway":null,"test":true,"total_price":"10.00","subtotal_price":"0.00","total_weight":0,"total_tax":null,"taxes_included":false,"currency":"USD","financial_status":"voided","confirmed":false,"total_discounts":"5.00","total_line_items_price":"5.00","cart_token":null,"buyer_accepts_marketing":true,"name":"#9999","referring_site":null,"landing_site":null,"cancelled_at":"2016-05-17T04:14:36-04:00","cancel_reason":"customer","total_price_usd":null,"checkout_token":null,"reference":null,"user_id":null,"location_id":null,"source_identifier":null,"source_url":null,"processed_at":null,"device_id":null,"browser_ip":null,"landing_site_ref":null,"order_number":1234,"discount_codes":[],"note_attributes":[],"payment_gateway_names":["visa","bogus"],"processing_method":"","checkout_id":null,"source_name":"web","fulfillment_status":"pending","tax_lines":[],"tags":"","contact_email":"jon@doe.ca","order_status_url":null,"line_items":[{"id":254721536,"variant_id":null,"title":"Soda","quantity":1,"price":"0.00","grams":0,"sku":"","variant_title":null,"vendor":null,"fulfillment_service":"manual","product_id":111475476,"requires_shipping":true,"taxable":true,"gift_card":false,"pre_tax_price":"9.00","name":"Soda","variant_inventory_management":null,"properties":[],"product_exists":true,"fulfillable_quantity":1,"total_discount":"0.00","fulfillment_status":null,"tax_lines":[]},{"id":5,"variant_id":null,"title":"Another Beer For Good Times","quantity":1,"price":"5.00","grams":500,"sku":"","variant_title":null,"vendor":null,"fulfillment_service":"manual","product_id":5410685889,"requires_shipping":true,"taxable":true,"gift_card":false,"name":"Another Beer For Good Times","variant_inventory_management":null,"properties":[],"product_exists":true,"fulfillable_quantity":1,"total_discount":"5.00","fulfillment_status":null,"tax_lines":[]}],"shipping_lines":[{"id":null,"title":"Generic Shipping","price":"10.00","code":null,"source":"shopify","phone":null,"carrier_identifier":null,"tax_lines":[]}],"billing_address":{"first_name":"Bob","address1":"123 Billing Street","phone":"555-555-BILL","city":"Billtown","zip":"K2P0B0","province":"Kentucky","country":"United States","last_name":"Biller","address2":null,"company":"My Company","latitude":null,"longitude":null,"name":"Bob Biller","country_code":"US","province_code":"KY"},"shipping_address":{"first_name":"Steve","address1":"123 Shipping Street","phone":"555-555-SHIP","city":"Shippington","zip":"K2P0S0","province":"Kentucky","country":"United States","last_name":"Shipper","address2":null,"company":"Shipping Company","latitude":null,"longitude":null,"name":"Steve Shipper","country_code":"US","province_code":"KY"},"fulfillments":[],"refunds":[],"customer":{"id":null,"email":"john@test.com","accepts_marketing":false,"created_at":null,"updated_at":null,"first_name":"John","last_name":"Smith","orders_count":0,"state":"disabled","total_spent":"0.00","last_order_id":null,"note":null,"verified_email":true,"multipass_identifier":null,"tax_exempt":false,"tags":"","last_order_name":null,"default_address":{"id":null,"first_name":null,"last_name":null,"company":null,"address1":"123 Elm St.","address2":null,"city":"Ottawa","province":"Ontario","country":"Canada","zip":"K2H7A8","phone":"123-123-1234","name":"","province_code":"ON","country_code":"CA","country_name":"Canada","default":true}}}]} +{"orders":[{"id":123456,"email":"jon@doe.ca","closed_at":null,"created_at":"2016-05-17T04:14:36-00:00","updated_at":"2016-05-17T04:14:36-04:00","number":234,"note":null,"token":null,"gateway":null,"test":true,"total_price":"10.00","current_total_price":"9.50","subtotal_price":"0.00","total_weight":0,"total_tax":null,"taxes_included":false,"currency":"USD","financial_status":"voided","confirmed":false,"total_discounts":"5.00","total_line_items_price":"5.00","cart_token":null,"buyer_accepts_marketing":true,"name":"#9999","referring_site":null,"landing_site":null,"cancelled_at":"2016-05-17T04:14:36-04:00","cancel_reason":"customer","total_price_usd":null,"checkout_token":null,"reference":null,"user_id":null,"location_id":null,"source_identifier":null,"source_url":null,"processed_at":null,"device_id":null,"browser_ip":null,"landing_site_ref":null,"order_number":1234,"discount_codes":[],"note_attributes":[],"payment_gateway_names":["visa","bogus"],"processing_method":"","checkout_id":null,"source_name":"web","fulfillment_status":"pending","tax_lines":[],"tags":"","contact_email":"jon@doe.ca","order_status_url":null,"line_items":[{"id":254721536,"variant_id":null,"title":"Soda","quantity":1,"price":"0.00","grams":0,"sku":"","variant_title":null,"vendor":null,"fulfillment_service":"manual","product_id":111475476,"requires_shipping":true,"taxable":true,"gift_card":false,"pre_tax_price":"9.00","name":"Soda","variant_inventory_management":null,"properties":[],"product_exists":true,"fulfillable_quantity":1,"total_discount":"0.00","fulfillment_status":null,"tax_lines":[]},{"id":5,"variant_id":null,"title":"Another Beer For Good Times","quantity":1,"price":"5.00","grams":500,"sku":"","variant_title":null,"vendor":null,"fulfillment_service":"manual","product_id":5410685889,"requires_shipping":true,"taxable":true,"gift_card":false,"name":"Another Beer For Good Times","variant_inventory_management":null,"properties":[],"product_exists":true,"fulfillable_quantity":1,"total_discount":"5.00","fulfillment_status":null,"tax_lines":[]}],"shipping_lines":[{"id":null,"title":"Generic Shipping","price":"10.00","code":null,"source":"shopify","phone":null,"carrier_identifier":null,"tax_lines":[]}],"billing_address":{"first_name":"Bob","address1":"123 Billing Street","phone":"555-555-BILL","city":"Billtown","zip":"K2P0B0","province":"Kentucky","country":"United States","last_name":"Biller","address2":null,"company":"My Company","latitude":null,"longitude":null,"name":"Bob Biller","country_code":"US","province_code":"KY"},"shipping_address":{"first_name":"Steve","address1":"123 Shipping Street","phone":"555-555-SHIP","city":"Shippington","zip":"K2P0S0","province":"Kentucky","country":"United States","last_name":"Shipper","address2":null,"company":"Shipping Company","latitude":null,"longitude":null,"name":"Steve Shipper","country_code":"US","province_code":"KY"},"fulfillments":[],"refunds":[],"customer":{"id":null,"email":"john@test.com","accepts_marketing":false,"created_at":null,"updated_at":null,"first_name":"John","last_name":"Smith","orders_count":0,"state":"disabled","total_spent":"0.00","last_order_id":null,"note":null,"verified_email":true,"multipass_identifier":null,"tax_exempt":false,"tags":"","last_order_name":null,"default_address":{"id":null,"first_name":null,"last_name":null,"company":null,"address1":"123 Elm St.","address2":null,"city":"Ottawa","province":"Ontario","country":"Canada","zip":"K2H7A8","phone":"123-123-1234","name":"","province_code":"ON","country_code":"CA","country_name":"Canada","default":true}}}]} diff --git a/fixtures/payout.json b/fixtures/payout.json new file mode 100644 index 00000000..8cd8e700 --- /dev/null +++ b/fixtures/payout.json @@ -0,0 +1,9 @@ +{ + "payout": { + "id": 623721858, + "status": "paid", + "date": "2012-11-12", + "currency": "USD", + "amount": "41.90" + } +} diff --git a/fixtures/payouts.json b/fixtures/payouts.json new file mode 100644 index 00000000..00e0468c --- /dev/null +++ b/fixtures/payouts.json @@ -0,0 +1,18 @@ +{ + "payouts": [ + { + "id": 854088011, + "status": "scheduled", + "date": "2013-11-01", + "currency": "USD", + "amount": "43.12" + }, + { + "id": 512467833, + "status": "failed", + "date": "2013-11-01", + "currency": "USD", + "amount": "43.12" + } + ] +} diff --git a/fixtures/payouts_filtered.json b/fixtures/payouts_filtered.json new file mode 100644 index 00000000..32770f72 --- /dev/null +++ b/fixtures/payouts_filtered.json @@ -0,0 +1,11 @@ +{ + "payouts": [ + { + "id": 854088011, + "status": "scheduled", + "date": "2013-11-01", + "currency": "USD", + "amount": "43.12" + } + ] +} diff --git a/fixtures/webhook.json b/fixtures/webhook.json index 8a277434..d55108fc 100644 --- a/fixtures/webhook.json +++ b/fixtures/webhook.json @@ -11,6 +11,10 @@ ], "metafield_namespaces": [ "google", "inventory" - ] + ], + "private_metafield_namespaces": [ + "info-for", "my-app" + ], + "api_version": "2021-01" } } diff --git a/fixtures/webhooks.json b/fixtures/webhooks.json index 77815454..4dac4d52 100644 --- a/fixtures/webhooks.json +++ b/fixtures/webhooks.json @@ -12,7 +12,11 @@ ], "metafield_namespaces": [ "google", "inventory" - ] + ], + "private_metafield_namespaces": [ + "info-for", "my-app" + ], + "api_version": "2021-01" } ] } diff --git a/fulfillment_service.go b/fulfillment_service.go new file mode 100644 index 00000000..046b84d8 --- /dev/null +++ b/fulfillment_service.go @@ -0,0 +1,94 @@ +package goshopify + +import "fmt" + +const ( + fulfillmentServiceBasePath = "fulfillment_services" +) + +// FulfillmentServiceService is an interface for interfacing with the fulfillment service +// of the Shopify API. +// https://help.shopify.com/api/reference/fulfillmentservice +type FulfillmentServiceService interface { + List(interface{}) ([]FulfillmentServiceData, error) + Get(int64, interface{}) (*FulfillmentServiceData, error) + Create(FulfillmentServiceData) (*FulfillmentServiceData, error) + Update(FulfillmentServiceData) (*FulfillmentServiceData, error) + Delete(int64) error +} + +type FulfillmentServiceData struct { + Id int64 `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Email string `json:"email,omitempty"` + ServiceName string `json:"service_name,omitempty"` + Handle string `json:"handle,omitempty"` + FulfillmentOrdersOptIn bool `json:"fulfillment_orders_opt_in,omitempty"` + IncludePendingStock bool `json:"include_pending_stock,omitempty"` + ProviderId int64 `json:"provider_id,omitempty"` + LocationId int64 `json:"location_id,omitempty"` + CallbackURL string `json:"callback_url,omitempty"` + TrackingSupport bool `json:"tracking_support,omitempty"` + InventoryManagement bool `json:"inventory_management,omitempty"` + AdminGraphqlApiId string `json:"admin_graphql_api_id,omitempty"` + PermitsSkuSharing bool `json:"permits_sku_sharing,omitempty"` + RequiresShippingMethod bool `json:"requires_shipping_method,omitempty"` +} + +type FulfillmentServiceResource struct { + FulfillmentService *FulfillmentServiceData `json:"fulfillment_service,omitempty"` +} + +type FulfillmentServicesResource struct { + FulfillmentServices []FulfillmentServiceData `json:"fulfillment_services,omitempty"` +} + +type FulfillmentServiceOptions struct { + Scope string `url:"scope,omitempty"` +} + +// FulfillmentServiceServiceOp handles communication with the FulfillmentServices +// related methods of the Shopify API +type FulfillmentServiceServiceOp struct { + client *Client +} + +// List Receive a list of all FulfillmentServiceData +func (s *FulfillmentServiceServiceOp) List(options interface{}) ([]FulfillmentServiceData, error) { + path := fmt.Sprintf("%s.json", fulfillmentServiceBasePath) + resource := new(FulfillmentServicesResource) + err := s.client.Get(path, resource, options) + return resource.FulfillmentServices, err +} + +// Get Receive a single FulfillmentServiceData +func (s *FulfillmentServiceServiceOp) Get(fulfillmentServiceId int64, options interface{}) (*FulfillmentServiceData, error) { + path := fmt.Sprintf("%s/%d.json", fulfillmentServiceBasePath, fulfillmentServiceId) + resource := new(FulfillmentServiceResource) + err := s.client.Get(path, resource, options) + return resource.FulfillmentService, err +} + +// Create Create a new FulfillmentServiceData +func (s *FulfillmentServiceServiceOp) Create(fulfillmentService FulfillmentServiceData) (*FulfillmentServiceData, error) { + path := fmt.Sprintf("%s.json", fulfillmentServiceBasePath) + wrappedData := FulfillmentServiceResource{FulfillmentService: &fulfillmentService} + resource := new(FulfillmentServiceResource) + err := s.client.Post(path, wrappedData, resource) + return resource.FulfillmentService, err +} + +// Update Modify an existing FulfillmentServiceData +func (s *FulfillmentServiceServiceOp) Update(fulfillmentService FulfillmentServiceData) (*FulfillmentServiceData, error) { + path := fmt.Sprintf("%s/%d.json", fulfillmentServiceBasePath, fulfillmentService.Id) + wrappedData := FulfillmentServiceResource{FulfillmentService: &fulfillmentService} + resource := new(FulfillmentServiceResource) + err := s.client.Put(path, wrappedData, resource) + return resource.FulfillmentService, err +} + +// Delete Remove an existing FulfillmentServiceData +func (s *FulfillmentServiceServiceOp) Delete(fulfillmentServiceId int64) error { + path := fmt.Sprintf("%s/%d.json", fulfillmentServiceBasePath, fulfillmentServiceId) + return s.client.Delete(path) +} diff --git a/fulfillment_service_test.go b/fulfillment_service_test.go new file mode 100644 index 00000000..3767cdab --- /dev/null +++ b/fulfillment_service_test.go @@ -0,0 +1,163 @@ +package goshopify + +import ( + "fmt" + "net/http" + "reflect" + "testing" + + "github.com/jarcoal/httpmock" +) + +func TestFulfillmentServiceServiceOp_List(t *testing.T) { + setup() + defer teardown() + + httpmock.RegisterResponder( + http.MethodGet, + fmt.Sprintf("https://fooshop.myshopify.com/%s/fulfillment_services.json", client.pathPrefix), + httpmock.NewBytesResponder(200, loadFixture("fulfillment_services.json")), + ) + + options := FulfillmentServiceOptions{Scope: "all"} + + fulfillmentServices, err := client.FulfillmentService.List(options) + if err != nil { + t.Errorf("fulfillmentService.List returned error: %v", err) + } + + expected := []FulfillmentServiceData{ + { + Id: 1061774487, + Name: "Jupiter Fulfillment", + Email: "aaa@gmail.com", + ServiceName: "Jupiter Fulfillment", + Handle: "jupiter-fulfillment", + FulfillmentOrdersOptIn: false, + IncludePendingStock: false, + ProviderId: 1234, + LocationId: 1072404542, + CallbackURL: "https://google.com/", + TrackingSupport: false, + InventoryManagement: false, + AdminGraphqlApiId: "gid://shopify/ApiFulfillmentService/1061774487", + PermitsSkuSharing: false, + }, + } + if !reflect.DeepEqual(fulfillmentServices, expected) { + t.Errorf("fulfillmentService.List returned %+v, expected %+v", fulfillmentServices, expected) + } +} + +func TestFulfillmentServiceServiceOp_Get(t *testing.T) { + setup() + defer teardown() + + httpmock.RegisterResponder( + http.MethodGet, + fmt.Sprintf("https://fooshop.myshopify.com/%s/fulfillment_services/1061774487.json", client.pathPrefix), + httpmock.NewBytesResponder(200, loadFixture("fulfillment_service.json")), + ) + + fulfillmentService, err := client.FulfillmentService.Get(1061774487, nil) + if err != nil { + t.Errorf("FulfillmentService.Get returned error: %v", err) + } + + expected := &FulfillmentServiceData{ + Id: 1061774487, + Name: "Jupiter Fulfillment", + Email: "aaa@gmail.com", + ServiceName: "Jupiter Fulfillment", + Handle: "jupiter-fulfillment", + FulfillmentOrdersOptIn: false, + IncludePendingStock: false, + ProviderId: 1234, + LocationId: 1072404542, + CallbackURL: "https://google.com/", + TrackingSupport: false, + InventoryManagement: false, + AdminGraphqlApiId: "gid://shopify/ApiFulfillmentService/1061774487", + PermitsSkuSharing: false, + } + if !reflect.DeepEqual(fulfillmentService, expected) { + t.Errorf("FulfillmentService.Get returned %+v, expected %+v", fulfillmentService, expected) + } +} + +func TestFulfillmentServiceServiceOp_Create(t *testing.T) { + setup() + defer teardown() + + httpmock.RegisterResponder( + http.MethodPost, + fmt.Sprintf("https://fooshop.myshopify.com/%s/fulfillment_services.json", client.pathPrefix), + httpmock.NewBytesResponder(200, loadFixture("fulfillment_service.json")), + ) + + fulfillmentService, err := client.FulfillmentService.Create(FulfillmentServiceData{ + Name: "jupiter-fulfillment", + }) + if err != nil { + t.Errorf("FulfillmentService.Get returned error: %v", err) + } + + expectedFulfillmentServiceID := int64(1061774487) + if fulfillmentService.Id != expectedFulfillmentServiceID { + t.Errorf("FulfillmentService.Id returned %+v, expected %+v", fulfillmentService.Id, expectedFulfillmentServiceID) + } +} + +func TestFulfillmentServiceServiceOp_Update(t *testing.T) { + setup() + defer teardown() + + httpmock.RegisterResponder( + http.MethodPut, + fmt.Sprintf("https://fooshop.myshopify.com/%s/fulfillment_services/1061774487.json", client.pathPrefix), + httpmock.NewBytesResponder(200, loadFixture("fulfillment_service.json")), + ) + + fulfillmentService, err := client.FulfillmentService.Update(FulfillmentServiceData{ + Id: 1061774487, + Handle: "jupiter-fulfillment", + }) + if err != nil { + t.Errorf("FulfillmentService.Update returned error: %v", err) + } + + expected := &FulfillmentServiceData{ + Id: 1061774487, + Name: "Jupiter Fulfillment", + Email: "aaa@gmail.com", + ServiceName: "Jupiter Fulfillment", + Handle: "jupiter-fulfillment", + FulfillmentOrdersOptIn: false, + IncludePendingStock: false, + ProviderId: 1234, + LocationId: 1072404542, + CallbackURL: "https://google.com/", + TrackingSupport: false, + InventoryManagement: false, + AdminGraphqlApiId: "gid://shopify/ApiFulfillmentService/1061774487", + PermitsSkuSharing: false, + } + if !reflect.DeepEqual(fulfillmentService, expected) { + t.Errorf("FulfillmentService.Update returned %+v, expected %+v", fulfillmentService, expected) + } +} + +func TestFulfillmentServiceServiceOp_Delete(t *testing.T) { + setup() + defer teardown() + + httpmock.RegisterResponder( + http.MethodDelete, + fmt.Sprintf("https://fooshop.myshopify.com/%s/fulfillment_services/1061774487.json", client.pathPrefix), + httpmock.NewStringResponder(200, ""), + ) + + if err := client.FulfillmentService.Delete(1061774487); err != nil { + t.Errorf("FulfillmentService.Delete returned error: %v", err) + } +} diff --git a/gift_card.go b/gift_card.go new file mode 100644 index 00000000..1b370d5b --- /dev/null +++ b/gift_card.go @@ -0,0 +1,111 @@ +package goshopify + +import ( + "fmt" + "time" + + "github.com/shopspring/decimal" +) + +const giftCardsBasePath = "gift_cards" + +// giftCardService is an interface for interfacing with the gift card endpoints +// of the Shopify API. +// See: https://shopify.dev/docs/api/admin-rest/2023-04/resources/gift-card +type GiftCardService interface { + Get(int64) (*GiftCard, error) + Create(GiftCard) (*GiftCard, error) + Update(GiftCard) (*GiftCard, error) + List() ([]GiftCard, error) + Disable(int64) (*GiftCard, error) + Count(interface{}) (int, error) +} + +// giftCardServiceOp handles communication with the gift card related methods of the Shopify API. +type GiftCardServiceOp struct { + client *Client +} + +// giftCard represents a Shopify discount rule +type GiftCard struct { + ID int64 `json:"id,omitempty"` + ApiClientId int64 `json:"api_client_id,omitempty"` + Balance *decimal.Decimal `json:"balance,omitempty"` + InitalValue *decimal.Decimal `json:"initial_value,omitempty"` + Code string `json:"code,omitempty"` + Currency string `json:"currency,omitempty"` + CustomerID *CustomerID `json:"customer_id,omitempty"` + CreatedAt *time.Time `json:"created_at,omitempty"` + DisabledAt *time.Time `json:"disabled_at,omitempty"` + ExpiresOn string `json:"expires_on,omitempty"` + LastCharacters string `json:"last_characters,omitempty"` + LineItemID int64 `json:"line_item_id,omitempty"` + Note string `json:"note,omitempty"` + OrderID int64 `json:"order_id,omitempty"` + TemplateSuffix string `json:"template_suffix,omitempty"` + UserID int64 `json:"user_id,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` +} + +type CustomerID struct { + CustomerID int64 `json:"customer_id,omitempty"` +} + +// giftCardResource represents the result from the gift_cards/X.json endpoint +type GiftCardResource struct { + GiftCard *GiftCard `json:"gift_card"` +} + +// giftCardsResource represents the result from the gift_cards.json endpoint +type GiftCardsResource struct { + GiftCards []GiftCard `json:"gift_cards"` +} + +// Get retrieves a single gift cards +func (s *GiftCardServiceOp) Get(giftCardID int64) (*GiftCard, error) { + path := fmt.Sprintf("%s/%d.json", giftCardsBasePath, giftCardID) + resource := new(GiftCardResource) + err := s.client.Get(path, resource, nil) + return resource.GiftCard, err +} + +// List retrieves a list of gift cards +func (s *GiftCardServiceOp) List() ([]GiftCard, error) { + path := fmt.Sprintf("%s.json", giftCardsBasePath) + resource := new(GiftCardsResource) + err := s.client.Get(path, resource, nil) + return resource.GiftCards, err +} + +// Create creates a gift card +func (s *GiftCardServiceOp) Create(pr GiftCard) (*GiftCard, error) { + path := fmt.Sprintf("%s.json", giftCardsBasePath) + resource := new(GiftCardResource) + wrappedData := GiftCardResource{GiftCard: &pr} + err := s.client.Post(path, wrappedData, resource) + return resource.GiftCard, err +} + +// Update updates an existing a gift card +func (s *GiftCardServiceOp) Update(pr GiftCard) (*GiftCard, error) { + path := fmt.Sprintf("%s/%d.json", giftCardsBasePath, pr.ID) + resource := new(GiftCardResource) + wrappedData := GiftCardResource{GiftCard: &pr} + err := s.client.Put(path, wrappedData, resource) + return resource.GiftCard, err +} + +// Disable disables an existing a gift card +func (s *GiftCardServiceOp) Disable(giftCardID int64) (*GiftCard, error) { + path := fmt.Sprintf("%s/%d/disable.json", giftCardsBasePath, giftCardID) + resource := new(GiftCardResource) + wrappedData := GiftCardResource{GiftCard: &GiftCard{ID: giftCardID}} + err := s.client.Post(path, wrappedData, resource) + return resource.GiftCard, err +} + +// Count retrieves the number of gift cards +func (s *GiftCardServiceOp) Count(options interface{}) (int, error) { + path := fmt.Sprintf("%s/count.json", giftCardsBasePath) + return s.client.Count(path, options) +} diff --git a/gift_card_test.go b/gift_card_test.go new file mode 100644 index 00000000..fc999d86 --- /dev/null +++ b/gift_card_test.go @@ -0,0 +1,153 @@ +package goshopify + +import ( + "fmt" + "testing" + + "github.com/jarcoal/httpmock" +) + +func TestGiftCardGet(t *testing.T) { + setup() + defer teardown() + + httpmock.RegisterResponder( + "GET", + fmt.Sprintf("https://fooshop.myshopify.com/%s/gift_cards/1.json", client.pathPrefix), + httpmock.NewBytesResponder( + 200, + loadFixture("gift_card/get.json"), + ), + ) + + giftCard, err := client.GiftCard.Get(1) + if err != nil { + t.Errorf("GiftCard.Get returned error: %v", err) + } + + expected := GiftCard{ID: 1} + if expected.ID != giftCard.ID { + t.Errorf("GiftCard.Get returned %+v, expected %+v", giftCard, expected) + } +} + +func TestGiftCardList(t *testing.T) { + setup() + defer teardown() + + httpmock.RegisterResponder( + "GET", + fmt.Sprintf("https://fooshop.myshopify.com/%s/gift_cards.json", client.pathPrefix), + httpmock.NewBytesResponder( + 200, + loadFixture("gift_card/list.json"), + ), + ) + + giftCard, err := client.GiftCard.List() + if err != nil { + t.Errorf("GiftCard.List returned error: %v", err) + } + + expected := []GiftCard{{ID: 1}} + if expected[0].ID != giftCard[0].ID { + t.Errorf("GiftCard.List returned %+v, expected %+v", giftCard, expected) + } +} + +func TestGiftCardCreate(t *testing.T) { + setup() + defer teardown() + + httpmock.RegisterResponder( + "POST", + fmt.Sprintf("https://fooshop.myshopify.com/%s/gift_cards.json", client.pathPrefix), + httpmock.NewBytesResponder( + 200, + loadFixture("gift_card/get.json"), + ), + ) + + giftCard, err := client.GiftCard.Create(GiftCard{}) + if err != nil { + t.Errorf("GiftCard.Create returned error: %v", err) + } + + expected := GiftCard{ID: 1} + if expected.ID != giftCard.ID { + t.Errorf("GiftCard.Create returned %+v, expected %+v", giftCard, expected) + } +} + +func TestGiftCardUpdate(t *testing.T) { + setup() + defer teardown() + + httpmock.RegisterResponder( + "PUT", + fmt.Sprintf("https://fooshop.myshopify.com/%s/gift_cards/1.json", client.pathPrefix), + httpmock.NewBytesResponder( + 200, + loadFixture("gift_card/get.json"), + ), + ) + + giftCard, err := client.GiftCard.Update(GiftCard{ID: 1}) + if err != nil { + t.Errorf("GiftCard.Update returned error: %v", err) + } + + expected := GiftCard{ID: 1} + if expected.ID != giftCard.ID { + t.Errorf("GiftCard.Update returned %+v, expected %+v", giftCard, expected) + } +} + +func TestGiftCardDisable(t *testing.T) { + setup() + defer teardown() + + httpmock.RegisterResponder( + "POST", + fmt.Sprintf("https://fooshop.myshopify.com/%s/gift_cards/1/disable.json", client.pathPrefix), + httpmock.NewBytesResponder( + 200, + loadFixture("gift_card/get.json"), + ), + ) + + giftCard, err := client.GiftCard.Disable(1) + if err != nil { + t.Errorf("GiftCard.Disable returned error: %v", err) + } + + expected := []GiftCard{{ID: 1}} + if expected[0].ID != giftCard.ID { + t.Errorf("GiftCard.Disable returned %+v, expected %+v", giftCard, expected) + } +} + +func TestGiftCardCount(t *testing.T) { + setup() + defer teardown() + + httpmock.RegisterResponder( + "GET", + fmt.Sprintf("https://fooshop.myshopify.com/%s/gift_cards/count.json", client.pathPrefix), + httpmock.NewStringResponder( + 200, + `{"count": 5}`, + ), + ) + + cnt, err := client.GiftCard.Count(nil) + if err != nil { + t.Errorf("GiftCard.Count returned error: %v", err) + } + + expected := 5 + if cnt != expected { + t.Errorf("GiftCard.Count returned %d, expected %d", cnt, expected) + } + +} diff --git a/go.mod b/go.mod index 56dda001..b3f3b83d 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,6 @@ go 1.16 require ( github.com/google/go-querystring v1.0.0 - github.com/jarcoal/httpmock v1.0.4 + github.com/jarcoal/httpmock v1.3.0 github.com/shopspring/decimal v0.0.0-20200105231215-408a2507e114 ) diff --git a/go.sum b/go.sum index 217d1039..75b4a05a 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,10 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk= github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= -github.com/jarcoal/httpmock v1.0.4 h1:jp+dy/+nonJE4g4xbVtl9QdrUNbn6/3hDT5R4nDIZnA= -github.com/jarcoal/httpmock v1.0.4/go.mod h1:ATjnClrvW/3tijVmpL/va5Z3aAyGvqU3gCT8nX0Txik= +github.com/jarcoal/httpmock v1.3.0 h1:2RJ8GP0IIaWwcC9Fp2BmVi8Kog3v2Hn7VXM3fTd+nuc= +github.com/jarcoal/httpmock v1.3.0/go.mod h1:3yb8rc4BI7TCBhFY8ng0gjuLKJNquuDNiPaZjnENuYg= +github.com/maxatome/go-testdeep v1.12.0 h1:Ql7Go8Tg0C1D/uMMX59LAoYK7LffeJQ6X2T04nTH68g= +github.com/maxatome/go-testdeep v1.12.0/go.mod h1:lPZc/HAcJMP92l7yI6TRz1aZN5URwUBUAfUNvrclaNM= github.com/shopspring/decimal v0.0.0-20200105231215-408a2507e114 h1:Pm6R878vxWWWR+Sa3ppsLce/Zq+JNTs6aVvRu13jv9A= github.com/shopspring/decimal v0.0.0-20200105231215-408a2507e114/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= diff --git a/goshopify.go b/goshopify.go index 8c630ef0..0bb9ec15 100644 --- a/goshopify.go +++ b/goshopify.go @@ -91,6 +91,7 @@ type Client struct { Order OrderService Fulfillment FulfillmentService DraftOrder DraftOrderService + AbandonedCheckout AbandonedCheckoutService Shop ShopService Webhook WebhookService Variant VariantService @@ -115,7 +116,12 @@ type Client struct { InventoryItem InventoryItemService ShippingZone ShippingZoneService ProductListing ProductListingService + InventoryLevel InventoryLevelService AccessScopes AccessScopesService + FulfillmentService FulfillmentServiceService + CarrierService CarrierServiceService + Payouts PayoutsService + GiftCard GiftCardService } // A general response error that follows a similar layout to Shopify's response @@ -266,6 +272,7 @@ func NewClient(app App, shopName, token string, opts ...Option) *Client { c.Order = &OrderServiceOp{client: c} c.Fulfillment = &FulfillmentServiceOp{client: c} c.DraftOrder = &DraftOrderServiceOp{client: c} + c.AbandonedCheckout = &AbandonedCheckoutServiceOp{client: c} c.Shop = &ShopServiceOp{client: c} c.Webhook = &WebhookServiceOp{client: c} c.Variant = &VariantServiceOp{client: c} @@ -290,7 +297,12 @@ func NewClient(app App, shopName, token string, opts ...Option) *Client { c.InventoryItem = &InventoryItemServiceOp{client: c} c.ShippingZone = &ShippingZoneServiceOp{client: c} c.ProductListing = &ProductListingServiceOp{client: c} + c.InventoryLevel = &InventoryLevelServiceOp{client: c} c.AccessScopes = &AccessScopesServiceOp{client: c} + c.FulfillmentService = &FulfillmentServiceServiceOp{client: c} + c.CarrierService = &CarrierServiceOp{client: c} + c.Payouts = &PayoutsServiceOp{client: c} + c.GiftCard = &GiftCardServiceOp{client: c} // apply any options for _, opt := range opts { @@ -616,6 +628,90 @@ func (c *Client) Get(path string, resource, options interface{}) error { return c.CreateAndDo("GET", path, nil, options, resource) } +// ListWithPagination performs a GET request for the given path and saves the result in the +// given resource and returns the pagination. +func (c *Client) ListWithPagination(path string, resource, options interface{}) (*Pagination, error) { + headers, err := c.createAndDoGetHeaders("GET", path, nil, options, resource) + if err != nil { + return nil, err + } + + // Extract pagination info from header + linkHeader := headers.Get("Link") + + pagination, err := extractPagination(linkHeader) + if err != nil { + return nil, err + } + + return pagination, nil +} + +// extractPagination extracts pagination info from linkHeader. +// Details on the format are here: +// https://help.shopify.com/en/api/guides/paginated-rest-results +func extractPagination(linkHeader string) (*Pagination, error) { + pagination := new(Pagination) + + if linkHeader == "" { + return pagination, nil + } + + for _, link := range strings.Split(linkHeader, ",") { + match := linkRegex.FindStringSubmatch(link) + // Make sure the link is not empty or invalid + if len(match) != 3 { + // We expect 3 values: + // match[0] = full match + // match[1] is the URL and match[2] is either 'previous' or 'next' + err := ResponseDecodingError{ + Message: "could not extract pagination link header", + } + return nil, err + } + + rel, err := url.Parse(match[1]) + if err != nil { + err = ResponseDecodingError{ + Message: "pagination does not contain a valid URL", + } + return nil, err + } + + params, err := url.ParseQuery(rel.RawQuery) + if err != nil { + return nil, err + } + + paginationListOptions := ListOptions{} + + paginationListOptions.PageInfo = params.Get("page_info") + if paginationListOptions.PageInfo == "" { + err = ResponseDecodingError{ + Message: "page_info is missing", + } + return nil, err + } + + limit := params.Get("limit") + if limit != "" { + paginationListOptions.Limit, err = strconv.Atoi(params.Get("limit")) + if err != nil { + return nil, err + } + } + + // 'rel' is either next or previous + if match[2] == "next" { + pagination.NextPageOptions = &paginationListOptions + } else { + pagination.PreviousPageOptions = &paginationListOptions + } + } + + return pagination, nil +} + // Post performs a POST request for the given path and saves the result in the // given resource. func (c *Client) Post(path string, data, resource interface{}) error { @@ -630,5 +726,10 @@ func (c *Client) Put(path string, data, resource interface{}) error { // Delete performs a DELETE request for the given path func (c *Client) Delete(path string) error { - return c.CreateAndDo("DELETE", path, nil, nil, nil) + return c.DeleteWithOptions(path, nil) +} + +// DeleteWithOptions performs a DELETE request for the given path WithOptions +func (c *Client) DeleteWithOptions(path string, options interface{}) error { + return c.CreateAndDo("DELETE", path, nil, options, nil) } diff --git a/goshopify_test.go b/goshopify_test.go index 6aad0501..9c35d5fe 100644 --- a/goshopify_test.go +++ b/goshopify_test.go @@ -722,18 +722,6 @@ func TestCheckResponseError(t *testing.T) { httpmock.NewStringResponse(400, `{"error": "bad request"}`), ResponseError{Status: 400, Message: "bad request"}, }, - { - httpmock.NewStringResponse(500, `{"error": "terrible error"}`), - ResponseError{Status: 500, Message: "terrible error"}, - }, - { - httpmock.NewStringResponse(500, `{"errors": "This action requires read_customers scope"}`), - ResponseError{Status: 500, Message: "This action requires read_customers scope"}, - }, - { - httpmock.NewStringResponse(500, `{"errors": ["not", "very good"]}`), - ResponseError{Status: 500, Message: "not, very good", Errors: []string{"not", "very good"}}, - }, { httpmock.NewStringResponse(400, `{"errors": { "order": ["order is wrong"] }}`), ResponseError{Status: 400, Message: "order: order is wrong", Errors: []string{"order: order is wrong"}}, @@ -750,12 +738,28 @@ func TestCheckResponseError(t *testing.T) { &http.Response{StatusCode: 400, Body: errReader{}}, errors.New("test-error"), }, + { + httpmock.NewStringResponse(422, `{"error": "Unprocessable Entity - ok"}`), + ResponseError{Status: 422, Message: "Unprocessable Entity - ok"}, + }, + { + httpmock.NewStringResponse(500, `{"error": "terrible error"}`), + ResponseError{Status: 500, Message: "terrible error"}, + }, + { + httpmock.NewStringResponse(500, `{"errors": "This action requires read_customers scope"}`), + ResponseError{Status: 500, Message: "This action requires read_customers scope"}, + }, + { + httpmock.NewStringResponse(500, `{"errors": ["not", "very good"]}`), + ResponseError{Status: 500, Message: "not, very good", Errors: []string{"not", "very good"}}, + }, } for _, c := range cases { actual := CheckResponseError(c.resp) if fmt.Sprint(actual) != fmt.Sprint(c.expected) { - t.Errorf("CheckResponseError(): expected %v, actual %v", c.expected, actual) + t.Errorf("CheckResponseError(): expected [%v], actual [%v]", c.expected, actual) } } } @@ -898,3 +902,40 @@ func TestDoRateLimit(t *testing.T) { }) } } + +func TestListWithPagination(t *testing.T) { + setup() + defer teardown() + + httpmock.RegisterResponder("GET", + fmt.Sprintf("https://fooshop.myshopify.com/%s/locations", client.pathPrefix), + httpmock.NewBytesResponder(200, loadFixture("locations.json")). + HeaderSet(http.Header{ + "Link": { + fmt.Sprintf( + `; rel="next", ; rel="previous"`, + client.pathPrefix, + client.pathPrefix, + ), + }, + })) + + var locations LocationsResource + pagination, err := client.ListWithPagination("locations", &locations, nil) + if err != nil { + t.Fatalf("Client.ListWithPagination returned error: %v", err) + } + + if pagination == nil || pagination.NextPageOptions == nil || pagination.PreviousPageOptions == nil { + t.Fatalf("Expected pagination options but found at least one of them nil") + } + + t.Logf("b: %#v \n", *pagination.NextPageOptions) + + if pagination.NextPageOptions.PageInfo != "abc" { + t.Fatalf("Expected next page: %s got: %s", "abc", pagination.NextPageOptions.PageInfo) + } + if pagination.PreviousPageOptions.PageInfo != "123" { + t.Fatalf("Expected prev page: %s got: %s", "123", pagination.PreviousPageOptions.PageInfo) + } +} diff --git a/inventory_item.go b/inventory_item.go index 2e926189..3303e386 100644 --- a/inventory_item.go +++ b/inventory_item.go @@ -25,13 +25,17 @@ type InventoryItemServiceOp struct { // InventoryItem represents a Shopify inventory item type InventoryItem struct { - ID int64 `json:"id,omitempty"` - SKU string `json:"sku,omitempty"` - CreatedAt *time.Time `json:"created_at,omitempty"` - UpdatedAt *time.Time `json:"updated_at,omitempty"` - Cost *decimal.Decimal `json:"cost,omitempty"` - Tracked *bool `json:"tracked,omitempty"` - AdminGraphqlAPIID string `json:"admin_graphql_api_id,omitempty"` + ID int64 `json:"id,omitempty"` + SKU string `json:"sku,omitempty"` + CreatedAt *time.Time `json:"created_at,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` + Cost *decimal.Decimal `json:"cost,omitempty"` + Tracked *bool `json:"tracked,omitempty"` + AdminGraphqlAPIID string `json:"admin_graphql_api_id,omitempty"` + CountryCodeOfOrigin *string `json:"country_code_of_origin"` + CountryHarmonizedSystemCodes []string `json:"country_harmonized_system_codes"` + HarmonizedSystemCode *string `json:"harmonized_system_code"` + ProvinceCodeOfOrigin *string `json:"province_code_of_origin"` } // InventoryItemResource is used for handling single item requests and responses diff --git a/inventory_item_test.go b/inventory_item_test.go index 498eebd6..891f9728 100644 --- a/inventory_item_test.go +++ b/inventory_item_test.go @@ -2,6 +2,7 @@ package goshopify import ( "fmt" + "strings" "testing" "github.com/jarcoal/httpmock" @@ -33,6 +34,28 @@ func inventoryItemTests(t *testing.T, item *InventoryItem) { if costFloat != expectedCost { t.Errorf("InventoryItem.Cost (float) is %+v, expected %+v", costFloat, expectedCost) } + + expectedOrigin := "US" + if *item.CountryCodeOfOrigin != expectedOrigin { + t.Errorf("InventoryItem.CountryCodeOfOrigin returned %+v, expected %+v", item.CountryCodeOfOrigin, expectedOrigin) + } + + //strings.Join is used to compare slices since package's go.mod is set to 1.13 + //which predates the experimental slices package that has a Compare() func. + expectedCountryHSCodes := strings.Join([]string{"8471.70.40.35", "8471.70.50.35"}, ",") + if strings.Join(item.CountryHarmonizedSystemCodes, ",") != expectedCountryHSCodes { + t.Errorf("InventoryItem.CountryHarmonizedSystemCodes returned %+v, expected %+v", item.CountryHarmonizedSystemCodes, expectedCountryHSCodes) + } + + expectedHSCode := "8471.70.40.35" + if *item.HarmonizedSystemCode != expectedHSCode { + t.Errorf("InventoryItem.HarmonizedSystemCode returned %+v, expected %+v", item.CountryHarmonizedSystemCodes, expectedHSCode) + } + + expectedProvince := "ON" + if *item.ProvinceCodeOfOrigin != expectedProvince { + t.Errorf("InventoryItem.ProvinceCodeOfOrigin returned %+v, expected %+v", item.ProvinceCodeOfOrigin, expectedHSCode) + } } func inventoryItemsTests(t *testing.T, items []InventoryItem) { diff --git a/inventory_level.go b/inventory_level.go new file mode 100644 index 00000000..977b51ea --- /dev/null +++ b/inventory_level.go @@ -0,0 +1,95 @@ +package goshopify + +import ( + "fmt" + "time" +) + +const inventoryLevelsBasePath = "inventory_levels" + +// InventoryLevelService is an interface for interacting with the +// inventory items endpoints of the Shopify API +// See https://help.shopify.com/en/api/reference/inventory/inventorylevel +type InventoryLevelService interface { + List(interface{}) ([]InventoryLevel, error) + Adjust(interface{}) (*InventoryLevel, error) + Delete(int64, int64) error + Connect(InventoryLevel) (*InventoryLevel, error) + Set(InventoryLevel) (*InventoryLevel, error) +} + +// InventoryLevelServiceOp is the default implementation of the InventoryLevelService interface +type InventoryLevelServiceOp struct { + client *Client +} + +// InventoryLevel represents a Shopify inventory level +type InventoryLevel struct { + InventoryItemId int64 `json:"inventory_item_id,omitempty"` + LocationId int64 `json:"location_id,omitempty"` + Available int `json:"available"` + CreatedAt *time.Time `json:"created_at,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` + AdminGraphqlApiId string `json:"admin_graphql_api_id,omitempty"` +} + +// InventoryLevelResource is used for handling single level requests and responses +type InventoryLevelResource struct { + InventoryLevel *InventoryLevel `json:"inventory_level"` +} + +// InventoryLevelsResource is used for handling multiple item responsees +type InventoryLevelsResource struct { + InventoryLevels []InventoryLevel `json:"inventory_levels"` +} + +// InventoryLevelListOptions is used for get list +type InventoryLevelListOptions struct { + InventoryItemIds []int64 `url:"inventory_item_ids,omitempty,comma"` + LocationIds []int64 `url:"location_ids,omitempty,comma"` + Limit int `url:"limit,omitempty"` + UpdatedAtMin time.Time `url:"updated_at_min,omitempty"` +} + +// InventoryLevelAdjustOptions is used for Adjust inventory levels +type InventoryLevelAdjustOptions struct { + InventoryItemId int64 `json:"inventory_item_id"` + LocationId int64 `json:"location_id"` + Adjust int `json:"available_adjustment"` +} + +// List inventory levels +func (s *InventoryLevelServiceOp) List(options interface{}) ([]InventoryLevel, error) { + path := fmt.Sprintf("%s.json", inventoryLevelsBasePath) + resource := new(InventoryLevelsResource) + err := s.client.Get(path, resource, options) + return resource.InventoryLevels, err +} + +// Delete an inventory level +func (s *InventoryLevelServiceOp) Delete(itemId, locationId int64) error { + path := fmt.Sprintf("%s.json?inventory_item_id=%v&location_id=%v", + inventoryLevelsBasePath, itemId, locationId) + return s.client.Delete(path) +} + +// Connect an inventory level +func (s *InventoryLevelServiceOp) Connect(level InventoryLevel) (*InventoryLevel, error) { + return s.post(fmt.Sprintf("%s/connect.json", inventoryLevelsBasePath), level) +} + +// Set an inventory level +func (s *InventoryLevelServiceOp) Set(level InventoryLevel) (*InventoryLevel, error) { + return s.post(fmt.Sprintf("%s/set.json", inventoryLevelsBasePath), level) +} + +// Adjust the inventory level of an inventory item at a single location +func (s *InventoryLevelServiceOp) Adjust(options interface{}) (*InventoryLevel, error) { + return s.post(fmt.Sprintf("%s/adjust.json", inventoryLevelsBasePath), options) +} + +func (s *InventoryLevelServiceOp) post(path string, options interface{}) (*InventoryLevel, error) { + resource := new(InventoryLevelResource) + err := s.client.Post(path, options, resource) + return resource.InventoryLevel, err +} diff --git a/inventory_level_test.go b/inventory_level_test.go new file mode 100644 index 00000000..8ba3b9aa --- /dev/null +++ b/inventory_level_test.go @@ -0,0 +1,214 @@ +package goshopify + +import ( + "fmt" + "testing" + + "github.com/jarcoal/httpmock" +) + +func inventoryLevelTests(t *testing.T, item *InventoryLevel) { + if item == nil { + t.Errorf("InventoryItem is nil") + return + } + + expectedInt := int64(808950810) + if item.InventoryItemId != expectedInt { + t.Errorf("InventoryLevel.InventoryItemId returned %+v, expected %+v", + item.InventoryItemId, expectedInt) + } + + expectedInt = int64(905684977) + if item.LocationId != expectedInt { + t.Errorf("InventoryLevel.LocationId is %+v, expected %+v", + item.LocationId, expectedInt) + } + + expectedAvailable := 6 + if item.Available != expectedAvailable { + t.Errorf("InventoryLevel.Available is %+v, expected %+v", + item.Available, expectedInt) + } +} + +func inventoryLevelsTests(t *testing.T, levels []InventoryLevel) { + expectedLen := 4 + if len(levels) != expectedLen { + t.Errorf("InventoryLevels list lenth is %+v, expected %+v", len(levels), expectedLen) + } +} + +func TestInventoryLevelsList(t *testing.T) { + setup() + defer teardown() + + httpmock.RegisterResponder("GET", + fmt.Sprintf("https://fooshop.myshopify.com/%s/inventory_levels.json", client.pathPrefix), + httpmock.NewBytesResponder(200, loadFixture("inventory_levels.json"))) + + levels, err := client.InventoryLevel.List(nil) + if err != nil { + t.Errorf("InventoryLevels.List returned error: %v", err) + } + + inventoryLevelsTests(t, levels) +} + +func TestInventoryLevelListWithItemId(t *testing.T) { + setup() + defer teardown() + + params := map[string]string{ + "inventory_item_ids": "1,2", + } + httpmock.RegisterResponderWithQuery( + "GET", + fmt.Sprintf("https://fooshop.myshopify.com/%s/inventory_levels.json", client.pathPrefix), + params, + httpmock.NewBytesResponder(200, loadFixture("inventory_levels.json")), + ) + + options := InventoryLevelListOptions{ + InventoryItemIds: []int64{1, 2}, + } + + levels, err := client.InventoryLevel.List(options) + if err != nil { + t.Errorf("InventoryLevels.List returned error: %v", err) + } + + inventoryLevelsTests(t, levels) +} + +func TestInventoryLevelListWithLocationId(t *testing.T) { + setup() + defer teardown() + + params := map[string]string{ + "location_ids": "1,2", + } + httpmock.RegisterResponderWithQuery( + "GET", + fmt.Sprintf("https://fooshop.myshopify.com/%s/inventory_levels.json", client.pathPrefix), + params, + httpmock.NewBytesResponder(200, loadFixture("inventory_levels.json")), + ) + + options := InventoryLevelListOptions{ + LocationIds: []int64{1, 2}, + } + + levels, err := client.InventoryLevel.List(options) + if err != nil { + t.Errorf("InventoryLevels.List returned error: %v", err) + } + + inventoryLevelsTests(t, levels) +} + +func TestInventoryLevelAdjust(t *testing.T) { + setup() + defer teardown() + + httpmock.RegisterResponder("POST", + fmt.Sprintf("https://fooshop.myshopify.com/%s/inventory_levels/adjust.json", client.pathPrefix), + httpmock.NewBytesResponder(200, loadFixture("inventory_level.json"))) + + option := InventoryLevelAdjustOptions{ + InventoryItemId: 808950810, + LocationId: 905684977, + Adjust: 6, + } + + adjItem, err := client.InventoryLevel.Adjust(option) + if err != nil { + t.Errorf("InventoryLevel.Adjust returned error: %v", err) + } + + inventoryLevelTests(t, adjItem) +} + +func TestInventoryLevelDelete(t *testing.T) { + setup() + defer teardown() + + httpmock.RegisterResponder("DELETE", + fmt.Sprintf("https://fooshop.myshopify.com/%s/inventory_levels.json", client.pathPrefix), + httpmock.NewStringResponder(200, "{}")) + + err := client.InventoryLevel.Delete(1, 1) + if err != nil { + t.Errorf("InventoryLevel.Delete returned error: %v", err) + } +} + +func TestInventoryLevelConnect(t *testing.T) { + setup() + defer teardown() + + httpmock.RegisterResponder( + "POST", + fmt.Sprintf("https://fooshop.myshopify.com/%s/inventory_levels/connect.json", client.pathPrefix), + httpmock.NewBytesResponder(200, loadFixture("inventory_level.json")), + ) + + options := InventoryLevel{ + InventoryItemId: 1, + LocationId: 1, + } + + level, err := client.InventoryLevel.Connect(options) + if err != nil { + t.Errorf("InventoryLevels.Connect returned error: %v", err) + } + + inventoryLevelTests(t, level) +} + +func TestInventoryLevelSet(t *testing.T) { + setup() + defer teardown() + + httpmock.RegisterResponder( + "POST", + fmt.Sprintf("https://fooshop.myshopify.com/%s/inventory_levels/set.json", client.pathPrefix), + httpmock.NewBytesResponder(200, loadFixture("inventory_level.json")), + ) + + options := InventoryLevel{ + InventoryItemId: 1, + LocationId: 1, + } + + level, err := client.InventoryLevel.Set(options) + if err != nil { + t.Errorf("InventoryLevels.Set returned error: %v", err) + } + + inventoryLevelTests(t, level) +} + +func TestInventoryLevelSetZero(t *testing.T) { + setup() + defer teardown() + + httpmock.RegisterResponder( + "POST", + fmt.Sprintf("https://fooshop.myshopify.com/%s/inventory_levels/set.json", client.pathPrefix), + httpmock.NewBytesResponder(200, loadFixture("inventory_level.json")), + ) + + options := InventoryLevel{ + InventoryItemId: 1, + LocationId: 1, + Available: 0, + } + + level, err := client.InventoryLevel.Set(options) + if err != nil { + t.Errorf("InventoryLevels.Set returned error: %v", err) + } + + inventoryLevelTests(t, level) +} diff --git a/oauth.go b/oauth.go index 6e0bf4c1..5d44928c 100644 --- a/oauth.go +++ b/oauth.go @@ -11,6 +11,8 @@ import ( "io/ioutil" "net/http" "net/url" + "sort" + "strings" ) const shopifyChecksumHeader = "X-Shopify-Hmac-Sha256" @@ -148,3 +150,34 @@ func (app App) VerifyWebhookRequestVerbose(httpRequest *http.Request) (bool, err return HMACSame, nil } + +// Verifies an app proxy request, sent by Shopify. +// When Shopify proxies HTTP requests to the proxy URL, +// Shopify adds a signature paramter that is used to verify that the request was sent by Shopify. +// https://shopify.dev/tutorials/display-dynamic-store-data-with-app-proxies +func (app App) VerifySignature(u *url.URL) bool { + val := u.Query() + sig := val.Get("signature") + val.Del("signature") + + keys := []string{} + for k, v := range val { + keys = append(keys, fmt.Sprintf("%s=%s", k, strings.Join(v, ","))) + } + sort.Strings(keys) + + joined := strings.Join(keys, "") + + return hmacSHA256([]byte(app.ApiSecret), []byte(joined), []byte(sig)) +} + +func hmacSHA256(key, body, expected []byte) bool { + mac := hmac.New(sha256.New, key) + mac.Write(body) + result := mac.Sum(nil) + + dst := make([]byte, hex.EncodedLen(len(result))) + hex.Encode(dst, result) + + return hmac.Equal(dst, expected) +} diff --git a/oauth_test.go b/oauth_test.go index e2eddd5f..d61784f0 100644 --- a/oauth_test.go +++ b/oauth_test.go @@ -57,7 +57,7 @@ func TestAppGetAccessTokenError(t *testing.T) { defer teardown() // app.Client isn't specified so NewClient called - expectedError := errors.New("invalid_request") + expectedError := errors.New("application_cannot_be_found") token, err := app.GetAccessToken("fooshop", "") @@ -106,6 +106,32 @@ func TestAppVerifyAuthorizationURL(t *testing.T) { } } +func TestSignature(t *testing.T) { + setup() + defer teardown() + + // https://shopify.dev/tutorials/display-data-on-an-online-store-with-an-application-proxy-app-extension + queryString := "extra=1&extra=2&shop=shop-name.myshopify.com&path_prefix=%2Fapps%2Fawesome_reviews×tamp=1317327555&signature=a9718877bea71c2484f91608a7eaea1532bdf71f5c56825065fa4ccabe549ef3" + + urlOk, _ := url.Parse(fmt.Sprintf("http://example.com/proxied?%s", queryString)) + urlNotOk, _ := url.Parse(fmt.Sprintf("http://example.com/proxied?%s¬ok=true", queryString)) + + cases := []struct { + u *url.URL + expected bool + }{ + {urlOk, true}, + {urlNotOk, false}, + } + + for _, c := range cases { + ok := app.VerifySignature(c.u) + if ok != c.expected { + t.Errorf("VerifySignature expected: |%v| but got: |%v|", c.expected, ok) + } + } +} + func TestVerifyWebhookRequest(t *testing.T) { setup() defer teardown() diff --git a/order.go b/order.go index fbc39abb..bf4e545d 100644 --- a/order.go +++ b/order.go @@ -3,7 +3,6 @@ package goshopify import ( "encoding/json" "fmt" - "net/http" "time" "github.com/shopspring/decimal" @@ -25,6 +24,7 @@ type OrderService interface { Cancel(int64, interface{}) (*Order, error) Close(int64) (*Order, error) Open(int64) (*Order, error) + Delete(int64) error // MetafieldsService used for Order resource to communicate with Metafields resource MetafieldsService @@ -80,69 +80,75 @@ type OrderCancelOptions struct { // Order represents a Shopify order type Order struct { - ID int64 `json:"id,omitempty"` - Name string `json:"name,omitempty"` - Email string `json:"email,omitempty"` - CreatedAt *time.Time `json:"created_at,omitempty"` - UpdatedAt *time.Time `json:"updated_at,omitempty"` - CancelledAt *time.Time `json:"cancelled_at,omitempty"` - ClosedAt *time.Time `json:"closed_at,omitempty"` - ProcessedAt *time.Time `json:"processed_at,omitempty"` - Customer *Customer `json:"customer,omitempty"` - BillingAddress *Address `json:"billing_address,omitempty"` - ShippingAddress *Address `json:"shipping_address,omitempty"` - Currency string `json:"currency,omitempty"` - TotalPrice *decimal.Decimal `json:"total_price,omitempty"` - SubtotalPrice *decimal.Decimal `json:"subtotal_price,omitempty"` - TotalDiscounts *decimal.Decimal `json:"total_discounts,omitempty"` - TotalLineItemsPrice *decimal.Decimal `json:"total_line_items_price,omitempty"` - TaxesIncluded bool `json:"taxes_included,omitempty"` - TotalTax *decimal.Decimal `json:"total_tax,omitempty"` - TaxLines []TaxLine `json:"tax_lines,omitempty"` - TotalWeight int `json:"total_weight,omitempty"` - FinancialStatus string `json:"financial_status,omitempty"` - Fulfillments []Fulfillment `json:"fulfillments,omitempty"` - FulfillmentStatus string `json:"fulfillment_status,omitempty"` - Token string `json:"token,omitempty"` - CartToken string `json:"cart_token,omitempty"` - Number int `json:"number,omitempty"` - OrderNumber int `json:"order_number,omitempty"` - Note string `json:"note,omitempty"` - Test bool `json:"test,omitempty"` - BrowserIp string `json:"browser_ip,omitempty"` - BuyerAcceptsMarketing bool `json:"buyer_accepts_marketing,omitempty"` - CancelReason string `json:"cancel_reason,omitempty"` - NoteAttributes []NoteAttribute `json:"note_attributes,omitempty"` - DiscountCodes []DiscountCode `json:"discount_codes,omitempty"` - LineItems []LineItem `json:"line_items,omitempty"` - ShippingLines []ShippingLines `json:"shipping_lines,omitempty"` - Transactions []Transaction `json:"transactions,omitempty"` - AppID int `json:"app_id,omitempty"` - CustomerLocale string `json:"customer_locale,omitempty"` - LandingSite string `json:"landing_site,omitempty"` - ReferringSite string `json:"referring_site,omitempty"` - SourceName string `json:"source_name,omitempty"` - ClientDetails *ClientDetails `json:"client_details,omitempty"` - Tags string `json:"tags,omitempty"` - LocationId int64 `json:"location_id,omitempty"` - PaymentGatewayNames []string `json:"payment_gateway_names,omitempty"` - ProcessingMethod string `json:"processing_method,omitempty"` - Refunds []Refund `json:"refunds,omitempty"` - UserId int64 `json:"user_id,omitempty"` - OrderStatusUrl string `json:"order_status_url,omitempty"` - Gateway string `json:"gateway,omitempty"` - Confirmed bool `json:"confirmed,omitempty"` - TotalPriceUSD *decimal.Decimal `json:"total_price_usd,omitempty"` - CheckoutToken string `json:"checkout_token,omitempty"` - Reference string `json:"reference,omitempty"` - SourceIdentifier string `json:"source_identifier,omitempty"` - SourceURL string `json:"source_url,omitempty"` - DeviceID int64 `json:"device_id,omitempty"` - Phone string `json:"phone,omitempty"` - LandingSiteRef string `json:"landing_site_ref,omitempty"` - CheckoutID int64 `json:"checkout_id,omitempty"` - ContactEmail string `json:"contact_email,omitempty"` - Metafields []Metafield `json:"metafields,omitempty"` + ID int64 `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Email string `json:"email,omitempty"` + CreatedAt *time.Time `json:"created_at,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` + CancelledAt *time.Time `json:"cancelled_at,omitempty"` + ClosedAt *time.Time `json:"closed_at,omitempty"` + ProcessedAt *time.Time `json:"processed_at,omitempty"` + Customer *Customer `json:"customer,omitempty"` + BillingAddress *Address `json:"billing_address,omitempty"` + ShippingAddress *Address `json:"shipping_address,omitempty"` + Currency string `json:"currency,omitempty"` + TotalPrice *decimal.Decimal `json:"total_price,omitempty"` + CurrentTotalPrice *decimal.Decimal `json:"current_total_price,omitempty"` + SubtotalPrice *decimal.Decimal `json:"subtotal_price,omitempty"` + CurrentSubtotalPrice *decimal.Decimal `json:"current_subtotal_price,omitempty"` + TotalDiscounts *decimal.Decimal `json:"total_discounts,omitempty"` + CurrentTotalDiscounts *decimal.Decimal `json:"current_total_discounts,omitempty"` + TotalLineItemsPrice *decimal.Decimal `json:"total_line_items_price,omitempty"` + TaxesIncluded bool `json:"taxes_included,omitempty"` + TotalTax *decimal.Decimal `json:"total_tax,omitempty"` + CurrentTotalTax *decimal.Decimal `json:"current_total_tax,omitempty"` + TaxLines []TaxLine `json:"tax_lines,omitempty"` + TotalWeight int `json:"total_weight,omitempty"` + FinancialStatus string `json:"financial_status,omitempty"` + Fulfillments []Fulfillment `json:"fulfillments,omitempty"` + FulfillmentStatus string `json:"fulfillment_status,omitempty"` + Token string `json:"token,omitempty"` + CartToken string `json:"cart_token,omitempty"` + Number int `json:"number,omitempty"` + OrderNumber int `json:"order_number,omitempty"` + Note string `json:"note,omitempty"` + Test bool `json:"test,omitempty"` + BrowserIp string `json:"browser_ip,omitempty"` + BuyerAcceptsMarketing bool `json:"buyer_accepts_marketing,omitempty"` + CancelReason string `json:"cancel_reason,omitempty"` + NoteAttributes []NoteAttribute `json:"note_attributes,omitempty"` + DiscountCodes []DiscountCode `json:"discount_codes,omitempty"` + LineItems []LineItem `json:"line_items,omitempty"` + ShippingLines []ShippingLines `json:"shipping_lines,omitempty"` + Transactions []Transaction `json:"transactions,omitempty"` + AppID int `json:"app_id,omitempty"` + CustomerLocale string `json:"customer_locale,omitempty"` + LandingSite string `json:"landing_site,omitempty"` + ReferringSite string `json:"referring_site,omitempty"` + SourceName string `json:"source_name,omitempty"` + ClientDetails *ClientDetails `json:"client_details,omitempty"` + Tags string `json:"tags,omitempty"` + LocationId int64 `json:"location_id,omitempty"` + PaymentGatewayNames []string `json:"payment_gateway_names,omitempty"` + ProcessingMethod string `json:"processing_method,omitempty"` + Refunds []Refund `json:"refunds,omitempty"` + UserId int64 `json:"user_id,omitempty"` + OrderStatusUrl string `json:"order_status_url,omitempty"` + Gateway string `json:"gateway,omitempty"` + Confirmed bool `json:"confirmed,omitempty"` + TotalPriceUSD *decimal.Decimal `json:"total_price_usd,omitempty"` + CheckoutToken string `json:"checkout_token,omitempty"` + Reference string `json:"reference,omitempty"` + SourceIdentifier string `json:"source_identifier,omitempty"` + SourceURL string `json:"source_url,omitempty"` + DeviceID int64 `json:"device_id,omitempty"` + Phone string `json:"phone,omitempty"` + LandingSiteRef string `json:"landing_site_ref,omitempty"` + CheckoutID int64 `json:"checkout_id,omitempty"` + ContactEmail string `json:"contact_email,omitempty"` + Metafields []Metafield `json:"metafields,omitempty"` + SendReceipt bool `json:"send_receipt,omitempty"` + SendFulfillmentReceipt bool `json:"send_fulfillment_receipt,omitempty"` } type Address struct { @@ -203,7 +209,7 @@ type LineItem struct { type DiscountAllocations struct { Amount *decimal.Decimal `json:"amount,omitempty"` DiscountApplicationIndex int `json:"discount_application_index,omitempty"` - AmountSet AmountSet `json:"amount_set,omitempty"` + AmountSet *AmountSet `json:"amount_set,omitempty"` } type AmountSet struct { @@ -286,6 +292,9 @@ type ShippingLines struct { ID int64 `json:"id,omitempty"` Title string `json:"title,omitempty"` Price *decimal.Decimal `json:"price,omitempty"` + PriceSet *AmountSet `json:"price_set,omitempty"` + DiscountedPrice *decimal.Decimal `json:"discounted_price,omitempty"` + DiscountedPriceSet *AmountSet `json:"discounted_price_set,omitempty"` Code string `json:"code,omitempty"` Source string `json:"source,omitempty"` Phone string `json:"phone,omitempty"` @@ -388,17 +397,8 @@ func (s *OrderServiceOp) List(options interface{}) ([]Order, error) { func (s *OrderServiceOp) ListWithPagination(options interface{}) ([]Order, *Pagination, error) { path := fmt.Sprintf("%s.json", ordersBasePath) resource := new(OrdersResource) - headers := http.Header{} - headers, err := s.client.createAndDoGetHeaders("GET", path, nil, options, resource) - if err != nil { - return nil, nil, err - } - - // Extract pagination info from header - linkHeader := headers.Get("Link") - - pagination, err := extractPagination(linkHeader) + pagination, err := s.client.ListWithPagination(path, resource, options) if err != nil { return nil, nil, err } @@ -462,6 +462,13 @@ func (s *OrderServiceOp) Open(orderID int64) (*Order, error) { return resource.Order, err } +// Delete order +func (s *OrderServiceOp) Delete(orderID int64) error { + path := fmt.Sprintf("%s/%d.json", ordersBasePath, orderID) + err := s.client.Delete(path) + return err +} + // List metafields for an order func (s *OrderServiceOp) ListMetafields(orderID int64, options interface{}) ([]Metafield, error) { metafieldService := &MetafieldServiceOp{client: s.client, resource: ordersResourceName, resourceID: orderID} diff --git a/order_test.go b/order_test.go index 26526ad2..86efaee2 100644 --- a/order_test.go +++ b/order_test.go @@ -171,6 +171,11 @@ func orderTests(t *testing.T, order Order) { t.Errorf("Order.TotalPrice returned %+v, expected %+v", order.TotalPrice, p) } + ctp := decimal.NewFromFloat(9.5) + if !ctp.Equals(*order.CurrentTotalPrice) { + t.Errorf("Order.CurrentTotalPrice returned %+v, expected %+v", order.CurrentTotalPrice, ctp) + } + // Check null prices, notice that prices are usually not empty. if order.TotalTax != nil { t.Errorf("Order.TotalTax returned %+v, expected %+v", order.TotalTax, nil) @@ -759,6 +764,19 @@ func TestOrderCancelFulfillment(t *testing.T) { FulfillmentTests(t, *returnedFulfillment) } +func TestOrderDelete(t *testing.T) { + setup() + defer teardown() + + httpmock.RegisterResponder("DELETE", fmt.Sprintf("https://fooshop.myshopify.com/%s/orders/1.json", client.pathPrefix), + httpmock.NewStringResponder(200, "{}")) + + err := client.Order.Delete(1) + if err != nil { + t.Errorf("Order.Delete returned error: %v", err) + } +} + // TestLineItemUnmarshalJSON tests unmarsalling a LineItem from json func TestLineItemUnmarshalJSON(t *testing.T) { setup() @@ -1264,7 +1282,7 @@ func validLineItem() LineItem { DiscountAllocations: []DiscountAllocations{ { Amount: &discountAllocationAmount, - AmountSet: AmountSet{ + AmountSet: &AmountSet{ ShopMoney: AmountSetEntry{ Amount: &discountAllocationAmount, CurrencyCode: "EUR", @@ -1281,15 +1299,37 @@ func validLineItem() LineItem { func validShippingLines() ShippingLines { price := decimal.New(400, -2) + eurPrice := decimal.New(317, -2) tl1Price := decimal.New(1350, -2) tl1Rate := decimal.New(6, -2) tl2Price := decimal.New(1250, -2) tl2Rate := decimal.New(5, -2) return ShippingLines{ - ID: int64(254721542), - Title: "Small Packet International Air", - Price: &price, + ID: int64(254721542), + Title: "Small Packet International Air", + Price: &price, + PriceSet: &AmountSet{ + ShopMoney: AmountSetEntry{ + Amount: &price, + CurrencyCode: "USD", + }, + PresentmentMoney: AmountSetEntry{ + Amount: &eurPrice, + CurrencyCode: "EUR", + }, + }, + DiscountedPrice: &price, + DiscountedPriceSet: &AmountSet{ + ShopMoney: AmountSetEntry{ + Amount: &price, + CurrencyCode: "USD", + }, + PresentmentMoney: AmountSetEntry{ + Amount: &eurPrice, + CurrencyCode: "EUR", + }, + }, Code: "INT.TP", Source: "canada_post", Phone: "", diff --git a/payouts.go b/payouts.go new file mode 100644 index 00000000..c9e404fc --- /dev/null +++ b/payouts.go @@ -0,0 +1,95 @@ +package goshopify + +import ( + "fmt" + + "github.com/shopspring/decimal" +) + +const payoutsBasePath = "shopify_payments/payouts" + +// PayoutsService is an interface for interfacing with the payouts endpoints of +// the Shopify API. +// See: https://shopify.dev/docs/api/admin-rest/2023-01/resources/payouts +type PayoutsService interface { + List(interface{}) ([]Payout, error) + ListWithPagination(interface{}) ([]Payout, *Pagination, error) + Get(int64, interface{}) (*Payout, error) +} + +// PayoutsServiceOp handles communication with the payout related methods of the +// Shopify API. +type PayoutsServiceOp struct { + client *Client +} + +// A struct for all available payout list options +type PayoutsListOptions struct { + PageInfo string `url:"page_info,omitempty"` + Limit int `url:"limit,omitempty"` + Fields string `url:"fields,omitempty"` + LastId int64 `url:"last_id,omitempty"` + SinceId int64 `url:"since_id,omitempty"` + Status PayoutStatus `url:"status,omitempty"` + DateMin *OnlyDate `url:"date_min,omitempty"` + DateMax *OnlyDate `url:"date_max,omitempty"` + Date *OnlyDate `url:"date,omitempty"` +} + +// Payout represents a Shopify payout +type Payout struct { + Id int64 `json:"id,omitempty"` + Date OnlyDate `json:"date,omitempty"` + Currency string `json:"currency,omitempty"` + Amount decimal.Decimal `json:"amount,omitempty"` + Status PayoutStatus `json:"status,omitempty"` +} + +type PayoutStatus string + +const ( + PayoutStatusScheduled PayoutStatus = "scheduled" + PayoutStatusInTransit PayoutStatus = "in_transit" + PayoutStatusPaid PayoutStatus = "paid" + PayoutStatusFailed PayoutStatus = "failed" + PayoutStatusCancelled PayoutStatus = "canceled" +) + +// Represents the result from the payouts/X.json endpoint +type PayoutResource struct { + Payout *Payout `json:"payout"` +} + +// Represents the result from the payouts.json endpoint +type PayoutsResource struct { + Payouts []Payout `json:"payouts"` +} + +// List payouts +func (s *PayoutsServiceOp) List(options interface{}) ([]Payout, error) { + payouts, _, err := s.ListWithPagination(options) + if err != nil { + return nil, err + } + return payouts, nil +} + +func (s *PayoutsServiceOp) ListWithPagination(options interface{}) ([]Payout, *Pagination, error) { + path := fmt.Sprintf("%s.json", payoutsBasePath) + resource := new(PayoutsResource) + + pagination, err := s.client.ListWithPagination(path, resource, options) + if err != nil { + return nil, nil, err + } + + return resource.Payouts, pagination, nil +} + +// Get individual payout +func (s *PayoutsServiceOp) Get(id int64, options interface{}) (*Payout, error) { + path := fmt.Sprintf("%s/%d.json", payoutsBasePath, id) + resource := new(PayoutResource) + err := s.client.Get(path, resource, options) + return resource.Payout, err +} diff --git a/payouts_test.go b/payouts_test.go new file mode 100644 index 00000000..464c8e54 --- /dev/null +++ b/payouts_test.go @@ -0,0 +1,205 @@ +package goshopify + +import ( + "errors" + "fmt" + "net/http" + "reflect" + "testing" + "time" + + "github.com/jarcoal/httpmock" + "github.com/shopspring/decimal" +) + +func TestPayoutsList(t *testing.T) { + setup() + defer teardown() + + httpmock.RegisterResponder("GET", fmt.Sprintf("https://fooshop.myshopify.com/%s/shopify_payments/payouts.json", client.pathPrefix), + httpmock.NewBytesResponder(200, loadFixture("payouts_filtered.json"))) + + date1 := OnlyDate{time.Date(2013, 11, 01, 0, 0, 0, 0, time.UTC)} + payouts, err := client.Payouts.List(PayoutsListOptions{Date: &date1}) + if err != nil { + t.Errorf("Payouts.List returned error: %v", err) + } + + expected := []Payout{{Id: 854088011, Date: date1, Currency: "USD", Amount: decimal.NewFromFloat(43.12), Status: PayoutStatusScheduled}} + if !reflect.DeepEqual(payouts, expected) { + t.Errorf("Payouts.List returned %+v, expected %+v", payouts, expected) + } +} + +func TestPayoutsListIncorrectDate(t *testing.T) { + setup() + defer teardown() + + httpmock.RegisterResponder("GET", fmt.Sprintf("https://fooshop.myshopify.com/%s/shopify_payments/payouts.json", client.pathPrefix), + httpmock.NewStringResponder(200, `{"payouts": [{"id":1, "date":"20-02-2"}]}`)) + + date1 := OnlyDate{time.Date(2022, 02, 03, 0, 0, 0, 0, time.Local)} + _, err := client.Payouts.List(PayoutsListOptions{Date: &date1}) + if err == nil { + t.Errorf("Payouts.List returned success, expected error: %v", err) + } +} + +func TestPayoutsListError(t *testing.T) { + setup() + defer teardown() + + httpmock.RegisterResponder("GET", fmt.Sprintf("https://fooshop.myshopify.com/%s/shopify_payments/payouts.json", client.pathPrefix), + httpmock.NewStringResponder(500, "")) + + expectedErrMessage := "Unknown Error" + + payouts, err := client.Payouts.List(nil) + if payouts != nil { + t.Errorf("Payouts.List returned payouts, expected nil: %v", err) + } + + if err == nil || err.Error() != expectedErrMessage { + t.Errorf("Payouts.List err returned %+v, expected %+v", err, expectedErrMessage) + } +} + +func TestPayoutsListWithPagination(t *testing.T) { + setup() + defer teardown() + + listURL := fmt.Sprintf("https://fooshop.myshopify.com/%s/shopify_payments/payouts.json", client.pathPrefix) + + cases := []struct { + body string + linkHeader string + expectedPayouts []Payout + expectedPagination *Pagination + expectedErr error + }{ + // Expect empty pagination when there is no link header + { + string(loadFixture("payouts.json")), + "", + []Payout{ + {Id: 854088011, Date: OnlyDate{time.Date(2013, 11, 1, 0, 0, 0, 0, time.UTC)}, Currency: "USD", Amount: decimal.NewFromFloat(43.12), Status: PayoutStatusScheduled}, + {Id: 512467833, Date: OnlyDate{time.Date(2013, 11, 1, 0, 0, 0, 0, time.UTC)}, Currency: "USD", Amount: decimal.NewFromFloat(43.12), Status: PayoutStatusFailed}, + }, + new(Pagination), + nil, + }, + // Invalid link header responses + { + "{}", + "invalid link", + []Payout(nil), + nil, + ResponseDecodingError{Message: "could not extract pagination link header"}, + }, + { + "{}", + `<:invalid.url>; rel="next"`, + []Payout(nil), + nil, + ResponseDecodingError{Message: "pagination does not contain a valid URL"}, + }, + { + "{}", + `; rel="next"`, + []Payout(nil), + nil, + errors.New(`invalid URL escape "%in"`), + }, + { + "{}", + `; rel="next"`, + []Payout(nil), + nil, + ResponseDecodingError{Message: "page_info is missing"}, + }, + { + "{}", + `; rel="next"`, + []Payout(nil), + nil, + errors.New(`strconv.Atoi: parsing "invalid": invalid syntax`), + }, + // Valid link header responses + { + `{"payouts": [{"id":1}]}`, + `; rel="next"`, + []Payout{{Id: 1}}, + &Pagination{ + NextPageOptions: &ListOptions{PageInfo: "foo", Limit: 2}, + }, + nil, + }, + { + `{"payouts": [{"id":2}]}`, + `; rel="next", ; rel="previous"`, + []Payout{{Id: 2}}, + &Pagination{ + NextPageOptions: &ListOptions{PageInfo: "foo"}, + PreviousPageOptions: &ListOptions{PageInfo: "bar"}, + }, + nil, + }, + } + for i, c := range cases { + response := &http.Response{ + StatusCode: 200, + Body: httpmock.NewRespBodyFromString(c.body), + Header: http.Header{ + "Link": {c.linkHeader}, + }, + } + + httpmock.RegisterResponder("GET", listURL, httpmock.ResponderFromResponse(response)) + + payouts, pagination, err := client.Payouts.ListWithPagination(nil) + if !reflect.DeepEqual(payouts, c.expectedPayouts) { + t.Errorf("test %d Payouts.ListWithPagination payouts returned %+v, expected %+v", i, payouts, c.expectedPayouts) + } + + if !reflect.DeepEqual(pagination, c.expectedPagination) { + t.Errorf( + "test %d Payouts.ListWithPagination pagination returned %+v, expected %+v", + i, + pagination, + c.expectedPagination, + ) + } + + if (c.expectedErr != nil || err != nil) && err.Error() != c.expectedErr.Error() { + t.Errorf( + "test %d Payouts.ListWithPagination err returned %+v, expected %+v", + i, + err, + c.expectedErr, + ) + } + } +} + +func TestPayoutsGet(t *testing.T) { + setup() + defer teardown() + + httpmock.RegisterResponder("GET", fmt.Sprintf("https://fooshop.myshopify.com/%s/shopify_payments/payouts/623721858.json", client.pathPrefix), + httpmock.NewBytesResponder(200, loadFixture("payout.json"))) + + payout, err := client.Payouts.Get(623721858, nil) + if err != nil { + t.Errorf("Payouts.Get returned error: %v", err) + } + + expected := &Payout{Id: 623721858, + Date: OnlyDate{time.Date(2012, 11, 12, 0, 0, 0, 0, time.UTC)}, + Status: PayoutStatusPaid, + Currency: "USD", + Amount: decimal.NewFromFloat(41.9), + } + if !reflect.DeepEqual(payout, expected) { + t.Errorf("Payouts.Get returned %+v, expected %+v", payout, expected) + } +} diff --git a/price_rule.go b/price_rule.go index c10a7288..7e71b8a1 100644 --- a/price_rule.go +++ b/price_rule.go @@ -90,7 +90,7 @@ func (pr *PriceRule) SetPrerequisiteSubtotalRange(greaterThanOrEqualTo *string) pr.PrerequisiteSubtotalRange = nil } else { if !validateMoney(*greaterThanOrEqualTo) { - return fmt.Errorf("failed to parse value as Decimal, invalid value") + return fmt.Errorf("failed to parse value as Decimal, invalid prerequisite subtotal range") } pr.PrerequisiteSubtotalRange = &prerequisiteSubtotalRange{ @@ -118,7 +118,7 @@ func (pr *PriceRule) SetPrerequisiteShippingPriceRange(lessThanOrEqualTo *string pr.PrerequisiteShippingPriceRange = nil } else { if !validateMoney(*lessThanOrEqualTo) { - return fmt.Errorf("failed to parse value as Decimal, invalid value") + return fmt.Errorf("failed to parse value as Decimal, invalid prerequisite shipping price range") } pr.PrerequisiteShippingPriceRange = &prerequisiteShippingPriceRange{ @@ -147,7 +147,7 @@ func (pr *PriceRule) SetPrerequisiteToEntitlementQuantityRatio(prerequisiteQuant pr.PrerequisiteToEntitlementQuantityRatio = &prerequisiteToEntitlementQuantityRatio{ PrerequisiteQuantity: pQuant, - EntitledQuantity: eQuant, + EntitledQuantity: eQuant, } } diff --git a/product.go b/product.go index 8729a964..6af894ce 100644 --- a/product.go +++ b/product.go @@ -2,11 +2,7 @@ package goshopify import ( "fmt" - "net/http" - "net/url" "regexp" - "strconv" - "strings" "time" ) @@ -51,6 +47,7 @@ type Product struct { PublishedAt *time.Time `json:"published_at,omitempty"` PublishedScope string `json:"published_scope,omitempty"` Tags string `json:"tags,omitempty"` + Status string `json:"status,omitempty"` Options []ProductOption `json:"options,omitempty"` Variants []Variant `json:"variants,omitempty"` Image Image `json:"image,omitempty"` @@ -112,17 +109,8 @@ func (s *ProductServiceOp) List(options interface{}) ([]Product, error) { func (s *ProductServiceOp) ListWithPagination(options interface{}) ([]Product, *Pagination, error) { path := fmt.Sprintf("%s.json", productsBasePath) resource := new(ProductsResource) - headers := http.Header{} - headers, err := s.client.createAndDoGetHeaders("GET", path, nil, options, resource) - if err != nil { - return nil, nil, err - } - - // Extract pagination info from header - linkHeader := headers.Get("Link") - - pagination, err := extractPagination(linkHeader) + pagination, err := s.client.ListWithPagination(path, resource, options) if err != nil { return nil, nil, err } @@ -130,71 +118,6 @@ func (s *ProductServiceOp) ListWithPagination(options interface{}) ([]Product, * return resource.Products, pagination, nil } -// extractPagination extracts pagination info from linkHeader. -// Details on the format are here: -// https://help.shopify.com/en/api/guides/paginated-rest-results -func extractPagination(linkHeader string) (*Pagination, error) { - pagination := new(Pagination) - - if linkHeader == "" { - return pagination, nil - } - - for _, link := range strings.Split(linkHeader, ",") { - match := linkRegex.FindStringSubmatch(link) - // Make sure the link is not empty or invalid - if len(match) != 3 { - // We expect 3 values: - // match[0] = full match - // match[1] is the URL and match[2] is either 'previous' or 'next' - err := ResponseDecodingError{ - Message: "could not extract pagination link header", - } - return nil, err - } - - rel, err := url.Parse(match[1]) - if err != nil { - err = ResponseDecodingError{ - Message: "pagination does not contain a valid URL", - } - return nil, err - } - - params, err := url.ParseQuery(rel.RawQuery) - if err != nil { - return nil, err - } - - paginationListOptions := ListOptions{} - - paginationListOptions.PageInfo = params.Get("page_info") - if paginationListOptions.PageInfo == "" { - err = ResponseDecodingError{ - Message: "page_info is missing", - } - return nil, err - } - - limit := params.Get("limit") - if limit != "" { - paginationListOptions.Limit, err = strconv.Atoi(params.Get("limit")) - if err != nil { - return nil, err - } - } - - // 'rel' is either next or previous - if match[2] == "next" { - pagination.NextPageOptions = &paginationListOptions - } else { - pagination.PreviousPageOptions = &paginationListOptions - } - } - - return pagination, nil -} - // Count products func (s *ProductServiceOp) Count(options interface{}) (int, error) { path := fmt.Sprintf("%s/count.json", productsBasePath) diff --git a/product_listing.go b/product_listing.go index 33a05064..d14093f0 100644 --- a/product_listing.go +++ b/product_listing.go @@ -2,7 +2,6 @@ package goshopify import ( "fmt" - "net/http" "time" ) @@ -63,11 +62,12 @@ type ProductListingIDsResource struct { // Resource which create product_listing endpoint expects in request body // e.g. // PUT /admin/api/2020-07/product_listings/921728736.json -// { -// "product_listing": { -// "product_id": 921728736 -// } -// } +// +// { +// "product_listing": { +// "product_id": 921728736 +// } +// } type ProductListingPublishResource struct { ProductListing struct { ProductID int64 `json:"product_id"` @@ -87,17 +87,8 @@ func (s *ProductListingServiceOp) List(options interface{}) ([]ProductListing, e func (s *ProductListingServiceOp) ListWithPagination(options interface{}) ([]ProductListing, *Pagination, error) { path := fmt.Sprintf("%s.json", productListingBasePath) resource := new(ProductsListingsResource) - headers := http.Header{} - headers, err := s.client.createAndDoGetHeaders("GET", path, nil, options, resource) - if err != nil { - return nil, nil, err - } - - // Extract pagination info from header - linkHeader := headers.Get("Link") - - pagination, err := extractPagination(linkHeader) + pagination, err := s.client.ListWithPagination(path, resource, options) if err != nil { return nil, nil, err } diff --git a/util.go b/util.go index 4a305612..e58c0c2b 100644 --- a/util.go +++ b/util.go @@ -2,7 +2,9 @@ package goshopify import ( "fmt" + "net/url" "strings" + "time" ) // Return the full shop name, including .myshopify.com @@ -46,3 +48,36 @@ func FulfillmentPathPrefix(resource string, resourceID int64) string { } return prefix } + +type OnlyDate struct { + time.Time +} + +func (c *OnlyDate) UnmarshalJSON(b []byte) error { + value := strings.Trim(string(b), `"`) + if value == "" || value == "null" { + *c = OnlyDate{time.Time{}} + return nil + } + + t, err := time.Parse("2006-01-02", value) + if err != nil { + return err + } + *c = OnlyDate{t} + return nil +} + +func (c *OnlyDate) MarshalJSON() ([]byte, error) { + return []byte(c.String()), nil +} + +// It seems shopify accepts both the date with double-quotes and without them, so we just stick to the double-quotes for now. +func (c *OnlyDate) EncodeValues(key string, v *url.Values) error { + v.Add(key, c.String()) + return nil +} + +func (c *OnlyDate) String() string { + return `"` + c.Format("2006-01-02") + `"` +} diff --git a/util_test.go b/util_test.go index b226bbde..12247519 100644 --- a/util_test.go +++ b/util_test.go @@ -1,7 +1,9 @@ package goshopify import ( + "net/url" "testing" + "time" ) func TestShopFullName(t *testing.T) { @@ -100,3 +102,57 @@ func TestFulfillmentPathPrefix(t *testing.T) { } } } + +func TestOnlyDateMarshal(t *testing.T) { + cases := []struct { + in OnlyDate + expected string + }{ + {OnlyDate{time.Date(2023, 03, 31, 0, 0, 0, 0, time.Local)}, "\"2023-03-31\""}, + {OnlyDate{}, "\"0001-01-01\""}, + } + + for _, c := range cases { + actual, _ := c.in.MarshalJSON() + if string(actual) != c.expected { + t.Errorf("MarshalJSON(%s): expected %s, actual %s", c.in.String(), c.expected, string(actual)) + } + } +} + +func TestOnlyDateUnmarshal(t *testing.T) { + cases := []struct { + in string + expected OnlyDate + }{ + {"\"2023-03-31\"", OnlyDate{time.Date(2023, 03, 31, 0, 0, 0, 0, time.Local)}}, + {"\"0001-01-01\"", OnlyDate{}}, + {"\"\"", OnlyDate{}}, + } + + for _, c := range cases { + newDate := OnlyDate{} + _ = newDate.UnmarshalJSON([]byte(c.in)) + if newDate.String() != c.expected.String() { + t.Errorf("UnmarshalJSON(%s): expected %s, actual %s", c.in, newDate.String(), c.expected.String()) + } + } +} + +func TestOnlyDateEncode(t *testing.T) { + cases := []struct { + in OnlyDate + expected string + }{ + {OnlyDate{time.Date(2023, 03, 31, 0, 0, 0, 0, time.Local)}, "\"2023-03-31\""}, + {OnlyDate{}, "\"0001-01-01\""}, + } + + for _, c := range cases { + urlVal := url.Values{} + _ = c.in.EncodeValues("date", &urlVal) + if urlVal.Get("date") != c.expected { + t.Errorf("EncodeValues(%s): expected %s, actual %s", c.in.String(), c.expected, urlVal.Get("date")) + } + } +} diff --git a/variant.go b/variant.go index 12e2e31a..41368eb6 100644 --- a/variant.go +++ b/variant.go @@ -58,7 +58,7 @@ type Variant struct { Weight *decimal.Decimal `json:"weight,omitempty"` WeightUnit string `json:"weight_unit,omitempty"` OldInventoryQuantity int `json:"old_inventory_quantity,omitempty"` - RequireShipping bool `json:"requires_shipping,omitempty"` + RequireShipping bool `json:"requires_shipping"` AdminGraphqlAPIID string `json:"admin_graphql_api_id,omitempty"` Metafields []Metafield `json:"metafields,omitempty"` } diff --git a/webhook.go b/webhook.go index 05f0a7c2..8aa81fa9 100644 --- a/webhook.go +++ b/webhook.go @@ -27,14 +27,16 @@ type WebhookServiceOp struct { // Webhook represents a Shopify webhook type Webhook struct { - ID int64 `json:"id"` - Address string `json:"address"` - Topic string `json:"topic"` - Format string `json:"format"` - CreatedAt *time.Time `json:"created_at,omitempty"` - UpdatedAt *time.Time `json:"updated_at,omitempty"` - Fields []string `json:"fields"` - MetafieldNamespaces []string `json:"metafield_namespaces"` + ID int64 `json:"id"` + Address string `json:"address"` + Topic string `json:"topic"` + Format string `json:"format"` + CreatedAt *time.Time `json:"created_at,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` + Fields []string `json:"fields"` + MetafieldNamespaces []string `json:"metafield_namespaces"` + PrivateMetafieldNamespaces []string `json:"private_metafield_namespaces"` + ApiVersion string `json:"api_version,omitempty"` } // WebhookOptions can be used for filtering webhooks on a List request. diff --git a/webhook_test.go b/webhook_test.go index 5635eaa1..d8c14e95 100644 --- a/webhook_test.go +++ b/webhook_test.go @@ -33,7 +33,17 @@ func webhookTests(t *testing.T, webhook Webhook) { expectedArr = []string{"google", "inventory"} if !reflect.DeepEqual(webhook.MetafieldNamespaces, expectedArr) { - t.Errorf("Webhook.Fields returned %+v, expected %+v", webhook.MetafieldNamespaces, expectedArr) + t.Errorf("Webhook.MetafieldNamespaces returned %+v, expected %+v", webhook.MetafieldNamespaces, expectedArr) + } + + expectedArr = []string{"info-for", "my-app"} + if !reflect.DeepEqual(webhook.PrivateMetafieldNamespaces, expectedArr) { + t.Errorf("Webhook.PrivateMetafieldNamespaces returned %+v, expected %+v", webhook.PrivateMetafieldNamespaces, expectedArr) + } + + expectedStr = "2021-01" + if webhook.ApiVersion != expectedStr { + t.Errorf("Webhook.ApiVersion returned %+v, expected %+v", webhook.ApiVersion, expectedStr) } }