From 68981748dbaa5a776711e7795696fd8c7c166df3 Mon Sep 17 00:00:00 2001 From: AlBaraa-mohamed <84770862+AlBaraa-mohamed@users.noreply.github.com> Date: Mon, 8 Jan 2024 07:50:57 +0200 Subject: [PATCH] Add endpoint for Shopify Payments transactions && Order Risk (#254) --- fixtures/order_risk.json | 14 ++ fixtures/order_risks.json | 28 +++ fixtures/payments_transaction.json | 18 ++ fixtures/payments_transactions.json | 52 +++++ goshopify.go | 4 + order_risk.go | 122 ++++++++++ order_risk_test.go | 340 ++++++++++++++++++++++++++++ payments_transactions.go | 108 +++++++++ payments_transactions_test.go | 310 +++++++++++++++++++++++++ 9 files changed, 996 insertions(+) create mode 100644 fixtures/order_risk.json create mode 100644 fixtures/order_risks.json create mode 100644 fixtures/payments_transaction.json create mode 100644 fixtures/payments_transactions.json create mode 100644 order_risk.go create mode 100644 order_risk_test.go create mode 100644 payments_transactions.go create mode 100644 payments_transactions_test.go diff --git a/fixtures/order_risk.json b/fixtures/order_risk.json new file mode 100644 index 00000000..db88e0c0 --- /dev/null +++ b/fixtures/order_risk.json @@ -0,0 +1,14 @@ +{ + "risk": { + "id": 284138680, + "order_id": 450789469, + "checkout_id": 0, + "source": "External", + "score": "1.0", + "recommendation": "cancel", + "display": true, + "cause_cancel": true, + "message": "This order was placed from a proxy IP", + "merchant_message": "This order was placed from a proxy IP" + } +} \ No newline at end of file diff --git a/fixtures/order_risks.json b/fixtures/order_risks.json new file mode 100644 index 00000000..4f931f3e --- /dev/null +++ b/fixtures/order_risks.json @@ -0,0 +1,28 @@ +{ + "risks": [ + { + "id": 284138680, + "order_id": 450789469, + "checkout_id": 0, + "source": "External", + "score": "1.0", + "recommendation": "cancel", + "display": true, + "cause_cancel": true, + "message": "This order was placed from a proxy IP", + "merchant_message": "This order was placed from a proxy IP" + }, + { + "id": 1029151489, + "order_id": 450789469, + "checkout_id": 901414060, + "source": "External", + "score": "1.0", + "recommendation": "cancel", + "display": true, + "cause_cancel": true, + "message": "This order came from an anonymous proxy", + "merchant_message": "This order came from an anonymous proxy" + } + ] +} \ No newline at end of file diff --git a/fixtures/payments_transaction.json b/fixtures/payments_transaction.json new file mode 100644 index 00000000..c7a1483c --- /dev/null +++ b/fixtures/payments_transaction.json @@ -0,0 +1,18 @@ +{ + "transaction": { + "id": 699519475, + "type": "debit", + "test": false, + "payout_id": 623721858, + "payout_status": "paid", + "currency": "USD", + "amount": "-50.00", + "fee": "0.00", + "net": "-50.00", + "source_id": 460709370, + "source_type": "adjustment", + "source_order_id": 0, + "source_order_transaction_id": 0, + "processed_at": "2013-11-01" + } +} \ No newline at end of file diff --git a/fixtures/payments_transactions.json b/fixtures/payments_transactions.json new file mode 100644 index 00000000..041cf26c --- /dev/null +++ b/fixtures/payments_transactions.json @@ -0,0 +1,52 @@ +{ + "transactions": [ + { + "id": 699519475, + "type": "debit", + "test": false, + "payout_id": 623721858, + "payout_status": "paid", + "currency": "USD", + "amount": "-50.00", + "fee": "0.00", + "net": "-50.00", + "source_id": 460709370, + "source_type": "adjustment", + "source_order_id": null, + "source_order_transaction_id": null, + "processed_at": "2013-11-01" + }, + { + "id": 77412310, + "type": "credit", + "test": false, + "payout_id": 623721858, + "payout_status": "paid", + "currency": "USD", + "amount": "50.00", + "fee": "0.00", + "net": "50.00", + "source_id": 374511569, + "source_type": "Payments::Balance::AdjustmentReversal", + "source_order_id": null, + "source_order_transaction_id": null, + "processed_at": "2013-11-01" + }, + { + "id": 1006917261, + "type": "refund", + "test": false, + "payout_id": 623721858, + "payout_status": "paid", + "currency": "USD", + "amount": "-3.45", + "fee": "0.00", + "net": "-3.45", + "source_id": 1006917261, + "source_type": "Payments::Refund", + "source_order_id": 217130470, + "source_order_transaction_id": 1006917261, + "processed_at": "2013-11-01" + } + ] +} \ No newline at end of file diff --git a/goshopify.go b/goshopify.go index 07626039..0ed9382c 100644 --- a/goshopify.go +++ b/goshopify.go @@ -128,6 +128,8 @@ type Client struct { AssignedFulfillmentOrder AssignedFulfillmentOrderService FulfillmentEvent FulfillmentEventService FulfillmentRequest FulfillmentRequestService + PaymentsTransactions PaymentsTransactionsService + OrderRisk OrderRiskService } // A general response error that follows a similar layout to Shopify's response @@ -314,6 +316,8 @@ func NewClient(app App, shopName, token string, opts ...Option) *Client { c.AssignedFulfillmentOrder = &AssignedFulfillmentOrderServiceOp{client: c} c.FulfillmentEvent = &FulfillmentEventServiceOp{client: c} c.FulfillmentRequest = &FulfillmentRequestServiceOp{client: c} + c.PaymentsTransactions = &PaymentsTransactionsServiceOp{client: c} + c.OrderRisk = &OrderRiskServiceOp{client: c} // apply any options for _, opt := range opts { diff --git a/order_risk.go b/order_risk.go new file mode 100644 index 00000000..050d66ab --- /dev/null +++ b/order_risk.go @@ -0,0 +1,122 @@ +package goshopify + +import ( + "fmt" +) + +const ordersRiskBasePath = "orders" +const ordersRiskResourceName = "risks" + +// OrderRiskService is an interface for interfacing with the orders Risk endpoints of +// the Shopify API. +// See: https://shopify.dev/docs/api/admin-rest/2023-10/resources/order-risk +type OrderRiskService interface { + List(int64, interface{}) ([]OrderRisk, error) + ListWithPagination(int64, interface{}) ([]OrderRisk, *Pagination, error) + Get(int64, int64, interface{}) (*OrderRisk, error) + Create(int64, OrderRisk) (*OrderRisk, error) + Update(int64, int64, OrderRisk) (*OrderRisk, error) + Delete(int64, int64) error +} + +// OrderRiskServiceOp handles communication with the order related methods of the +// Shopify API. +type OrderRiskServiceOp struct { + client *Client +} + +// Represents the result from the orders-risk/X.json endpoint +type OrderRiskResource struct { + OrderRisk *OrderRisk `json:"risk"` +} + +// Represents the result from the orders-risk.json endpoint +type OrdersRisksResource struct { + OrderRisk []OrderRisk `json:"risks"` +} +type orderRiskRecommendation string + +const ( + //order is fraudulent. + OrderRecommendationCancel orderRiskRecommendation = "cancel" + + //medium level of risk that this order is fraudulent. + OrderRecommendationInvestigate orderRiskRecommendation = "investigate" + + //level of risk that this order is fraudulent. + OrderRecommendationAccept orderRiskRecommendation = "accept" +) + +// A struct for all available order Risk list options. +// See: https://shopify.dev/docs/api/admin-rest/2023-10/resources/order-risk#index +type OrderRiskListOptions struct { + ListOptions +} + +// OrderRisk represents a Shopify order risk +type OrderRisk struct { + Id int64 `json:"id,omitempty"` + CheckoutId int64 `json:"checkout_id,omitempty"` + OrderId int64 `json:"order_id,omitempty"` + CauseCancel bool `json:"cause_cancel,omitempty"` + Display bool `json:"display,omitempty"` + MerchantMessage string `json:"merchant_message,omitempty"` + Message string `json:"message,omitempty"` + Score string `json:"score,omitempty"` + Source string `json:"source,omitempty"` + Recommendation orderRiskRecommendation `json:"recommendation,omitempty"` +} + +// List OrderRisk +func (s *OrderRiskServiceOp) List(orderId int64, options interface{}) ([]OrderRisk, error) { + orders, _, err := s.ListWithPagination(orderId, options) + if err != nil { + return nil, err + } + return orders, nil +} + +func (s *OrderRiskServiceOp) ListWithPagination(orderId int64, options interface{}) ([]OrderRisk, *Pagination, error) { + path := fmt.Sprintf("%s/%d/%s.json", ordersRiskBasePath, orderId, ordersRiskResourceName) + resource := new(OrdersRisksResource) + + pagination, err := s.client.ListWithPagination(path, resource, options) + if err != nil { + return nil, nil, err + } + + return resource.OrderRisk, pagination, nil +} + +// Get individual order +func (s *OrderRiskServiceOp) Get(orderID int64, riskID int64, options interface{}) (*OrderRisk, error) { + path := fmt.Sprintf("%s/%d/%s/%d.json", ordersRiskBasePath, orderID, ordersRiskResourceName, riskID) + resource := new(OrderRiskResource) + err := s.client.Get(path, resource, options) + return resource.OrderRisk, err +} + +// Create order +func (s *OrderRiskServiceOp) Create(orderID int64, orderRisk OrderRisk) (*OrderRisk, error) { + path := fmt.Sprintf("%s/%d/%s.json", ordersRiskBasePath, orderID, ordersRiskResourceName) + wrappedData := OrderRiskResource{OrderRisk: &orderRisk} + resource := new(OrderRiskResource) + err := s.client.Post(path, wrappedData, resource) + return resource.OrderRisk, err +} + +// Update order +func (s *OrderRiskServiceOp) Update(orderID int64, riskID int64, orderRisk OrderRisk) (*OrderRisk, error) { + path := fmt.Sprintf("%s/%d/%s/%d.json", ordersRiskBasePath, orderID, ordersRiskResourceName, riskID) + wrappedData := OrderRiskResource{OrderRisk: &orderRisk} + resource := new(OrderRiskResource) + err := s.client.Put(path, wrappedData, resource) + return resource.OrderRisk, err +} + +// Delete order +func (s *OrderRiskServiceOp) Delete(orderID int64, riskID int64) error { + path := fmt.Sprintf("%s/%d/%s/%d.json", ordersRiskBasePath, orderID, ordersRiskResourceName, riskID) + err := s.client.Delete(path) + return err +} diff --git a/order_risk_test.go b/order_risk_test.go new file mode 100644 index 00000000..2c358096 --- /dev/null +++ b/order_risk_test.go @@ -0,0 +1,340 @@ +package goshopify + +import ( + "errors" + "fmt" + "net/http" + "reflect" + "runtime" + "testing" + + "github.com/jarcoal/httpmock" +) + +func TestOrderRiskListError(t *testing.T) { + setup() + defer teardown() + + httpmock.RegisterResponder("GET", fmt.Sprintf("https://fooshop.myshopify.com/%s/orders/450789469/risks.json", client.pathPrefix), + httpmock.NewStringResponder(500, "")) + + expectedErrMessage := "Unknown Error" + + orders, err := client.OrderRisk.List(450789469, nil) + if orders != nil { + t.Errorf("OrderRisk.List returned orders, expected nil: %v", err) + } + + if err == nil || err.Error() != expectedErrMessage { + t.Errorf("OrderRisk.List err returned %+v, expected %+v", err, expectedErrMessage) + } +} + +func TestOrderRiskListWithPagination(t *testing.T) { + setup() + defer teardown() + + listURL := fmt.Sprintf("https://fooshop.myshopify.com/%s/orders/450789469/risks.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 + expectedOrders []OrderRisk + expectedPagination *Pagination + expectedErr error + }{ + // Expect empty pagination when there is no link header + { + `{"risks": [{"id":1},{"id":2}]}`, + "", + []OrderRisk{{Id: 1}, {Id: 2}}, + new(Pagination), + nil, + }, + // Invalid link header responses + { + "{}", + "invalid link", + []OrderRisk(nil), + nil, + ResponseDecodingError{Message: "could not extract pagination link header"}, + }, + { + "{}", + `<:invalid.url>; rel="next"`, + []OrderRisk(nil), + nil, + ResponseDecodingError{Message: "pagination does not contain a valid URL"}, + }, + { + "{}", + `; rel="next"`, + []OrderRisk(nil), + nil, + errors.New(`invalid URL escape "%in"`), + }, + { + "{}", + `; rel="next"`, + []OrderRisk(nil), + nil, + ResponseDecodingError{Message: "page_info is missing"}, + }, + { + "{}", + `; rel="next"`, + []OrderRisk(nil), + nil, + errors.New(limitConversionErrorMessage), + }, + // Valid link header responses + { + `{"risks": [{"id":1}]}`, + `; rel="next"`, + []OrderRisk{{Id: 1}}, + &Pagination{ + NextPageOptions: &ListOptions{PageInfo: "foo", Limit: 2}, + }, + nil, + }, + { + `{"risks": [{"id":2}]}`, + `; rel="next", ; rel="previous"`, + []OrderRisk{{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)) + + orderRisks, pagination, err := client.OrderRisk.ListWithPagination(450789469, nil) + if !reflect.DeepEqual(orderRisks, c.expectedOrders) { + t.Errorf("test %d OrderRisk.ListWithPagination OrderRisk returned %+v, expected %+v", i, orderRisks, c.expectedOrders) + } + + if !reflect.DeepEqual(pagination, c.expectedPagination) { + t.Errorf( + "test %d OrderRisk.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 OrderRisk.ListWithPagination err returned %+v, expected %+v", + i, + err, + c.expectedErr, + ) + } + } +} + +func TestOrderRiskList(t *testing.T) { + setup() + defer teardown() + + httpmock.RegisterResponder("GET", fmt.Sprintf("https://fooshop.myshopify.com/%s/orders/450789469/risks.json", client.pathPrefix), + httpmock.NewBytesResponder(200, loadFixture("order_risks.json"))) + + orderRisks, err := client.OrderRisk.List(450789469, nil) + if err != nil { + t.Errorf("OrderRisk.List returned error: %v", err) + } + + expected := []OrderRisk{ + { + Id: 284138680, + CheckoutId: 0, + OrderId: 450789469, + CauseCancel: true, + Display: true, + MerchantMessage: "This order was placed from a proxy IP", + Message: "This order was placed from a proxy IP", + Score: "1.0", + Source: "External", + Recommendation: OrderRecommendationCancel, + }, + { + Id: 1029151489, + CheckoutId: 901414060, + OrderId: 450789469, + CauseCancel: true, + Display: true, + MerchantMessage: "This order came from an anonymous proxy", + Message: "This order came from an anonymous proxy", + Score: "1.0", + Source: "External", + Recommendation: OrderRecommendationCancel, + }, + } + + if !reflect.DeepEqual(orderRisks, expected) { + t.Errorf("OrderRisks.List returned %+v, expected %+v", orderRisks, expected) + } +} + +func TestOrderRiskListOptions(t *testing.T) { + setup() + defer teardown() + params := map[string]string{ + "fields": "id", + "limit": "250", + "page": "10", + } + httpmock.RegisterResponderWithQuery( + "GET", + fmt.Sprintf("https://fooshop.myshopify.com/%s/orders/450789469/risks.json", client.pathPrefix), + params, + httpmock.NewBytesResponder(200, loadFixture("order_risks.json"))) + + options := OrderRiskListOptions{ + ListOptions: ListOptions{ + Page: 10, + Limit: 250, + Fields: "id", + }, + } + + orderRisks, err := client.OrderRisk.List(450789469, options) + if err != nil { + t.Errorf("OrderRisk.List returned error: %v", err) + } + expected := []OrderRisk{ + { + Id: 284138680, + CheckoutId: 0, + OrderId: 450789469, + CauseCancel: true, + Display: true, + MerchantMessage: "This order was placed from a proxy IP", + Message: "This order was placed from a proxy IP", + Score: "1.0", + Source: "External", + Recommendation: OrderRecommendationCancel, + }, + { + Id: 1029151489, + CheckoutId: 901414060, + OrderId: 450789469, + CauseCancel: true, + Display: true, + MerchantMessage: "This order came from an anonymous proxy", + Message: "This order came from an anonymous proxy", + Score: "1.0", + Source: "External", + Recommendation: OrderRecommendationCancel, + }, + } + + if !reflect.DeepEqual(orderRisks, expected) { + t.Errorf("OrderRisks.List returned %+v, expected %+v", orderRisks, expected) + } +} + +func TestOrderRiskGet(t *testing.T) { + setup() + defer teardown() + + httpmock.RegisterResponder("GET", fmt.Sprintf("https://fooshop.myshopify.com/%s/orders/450789469/risks/284138680.json", client.pathPrefix), + httpmock.NewBytesResponder(200, loadFixture("order_risk.json"))) + + orderRisk, err := client.OrderRisk.Get(450789469, 284138680, nil) + if err != nil { + t.Errorf("OrderRisk.List returned error: %v", err) + } + expected := &OrderRisk{ + Id: 284138680, + CheckoutId: 0, + OrderId: 450789469, + CauseCancel: true, + Display: true, + MerchantMessage: "This order was placed from a proxy IP", + Message: "This order was placed from a proxy IP", + Score: "1.0", + Source: "External", + Recommendation: OrderRecommendationCancel, + } + if !reflect.DeepEqual(orderRisk, expected) { + t.Errorf("OrderRisks.Get returned %+v, expected %+v", orderRisk, expected) + } +} + +func TestOrderRiskCreate(t *testing.T) { + setup() + defer teardown() + + httpmock.RegisterResponder("POST", fmt.Sprintf("https://fooshop.myshopify.com/%s/orders/450789469/risks.json", client.pathPrefix), + httpmock.NewStringResponder(201, `{"risk":{"id": 1}}`)) + + orderRisk := OrderRisk{ + Id: 1, + } + + o, err := client.OrderRisk.Create(450789469, orderRisk) + if err != nil { + t.Errorf("OrderRisk.Create returned error: %v", err) + } + + expected := OrderRisk{Id: 1} + if o.Id != expected.Id { + t.Errorf("OrderRisk.Create returned id %d, expected %d", o.Id, expected.Id) + } +} + +func TestOrderRiskUpdate(t *testing.T) { + setup() + defer teardown() + + httpmock.RegisterResponder("PUT", fmt.Sprintf("https://fooshop.myshopify.com/%s/orders/1/risks/2.json", client.pathPrefix), + httpmock.NewStringResponder(201, `{"risk":{"id": 1,"order_id": 2}}`)) + + orderRisk := OrderRisk{ + Id: 1, + OrderId: 2, + Recommendation: OrderRecommendationAccept, + } + + o, err := client.OrderRisk.Update(1, 2, orderRisk) + if err != nil { + t.Errorf("Order.Update returned error: %v", err) + } + + expected := OrderRisk{Id: 1, OrderId: 2, Recommendation: OrderRecommendationAccept} + if o.Id != expected.Id && o.OrderId != expected.OrderId && o.Recommendation == expected.Recommendation { + t.Errorf("Order.Update returned id %d, expected %d, expected %d", o.Id, expected.Id, expected.OrderId) + } +} + +func TestOrderRiskDelete(t *testing.T) { + setup() + defer teardown() + + httpmock.RegisterResponder("DELETE", fmt.Sprintf("https://fooshop.myshopify.com/%s/orders/1/risks/2.json", client.pathPrefix), + httpmock.NewStringResponder(200, "{}")) + + err := client.OrderRisk.Delete(1, 2) + if err != nil { + t.Errorf("Order.Delete returned error: %v", err) + } +} diff --git a/payments_transactions.go b/payments_transactions.go new file mode 100644 index 00000000..7b370eb5 --- /dev/null +++ b/payments_transactions.go @@ -0,0 +1,108 @@ +package goshopify + +import ( + "fmt" +) + +const paymentsTransactionsBasePath = "shopify_payments/balance/transactions" + +// PaymentsTransactionsService is an interface for interfacing with the Transactions endpoints of +// the Shopify API. +// See: https://shopify.dev/docs/api/admin-rest/2023-01/resources/transactions +type PaymentsTransactionsService interface { + List(interface{}) ([]PaymentsTransactions, error) + ListWithPagination(interface{}) ([]PaymentsTransactions, *Pagination, error) + Get(int64, interface{}) (*PaymentsTransactions, error) +} + +// PaymentsTransactionsServiceOp handles communication with the transactions related methods of +// the Payment methods of Shopify API. +type PaymentsTransactionsServiceOp struct { + client *Client +} + +// A struct for all available PaymentsTransactions list options +type PaymentsTransactionsListOptions 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"` + PayoutId int64 `url:"payout_id,omitempty"` + PayoutStatus PayoutStatus `url:"payout_status,omitempty"` + DateMin *OnlyDate `url:"date_min,omitempty"` + DateMax *OnlyDate `url:"date_max,omitempty"` + ProcessedAt *OnlyDate `json:"processed_at,omitempty"` +} + +// PaymentsTransactions represents a Shopify Transactions +type PaymentsTransactions struct { + Id int64 `json:"id,omitempty"` + Type PaymentsTransactionsTypes `json:"type,omitempty"` + Test bool `json:"test,omitempty"` + PayoutId int `json:"payout_id,omitempty"` + PayoutStatus PayoutStatus `json:"payout_status,omitempty"` + Currency string `json:"currency,omitempty"` + Amount string `json:"amount,omitempty"` + Fee string `json:"fee,omitempty"` + Net string `json:"net,omitempty"` + SourceId int `json:"source_id,omitempty"` + SourceType string `json:"source_type,omitempty"` + SourceOrderTransactionId int `json:"source_order_transaction_id,omitempty"` + SourceOrderId int `json:"source_order_id,omitempty"` + ProcessedAt OnlyDate `json:"processed_at,omitempty"` +} + +type PaymentsTransactionsTypes string + +const ( + PaymentsTransactionsCharge PaymentsTransactionsTypes = "charge" + PaymentsTransactionsRefund PaymentsTransactionsTypes = "refund" + PaymentsTransactionsDispute PaymentsTransactionsTypes = "dispute" + PaymentsTransactionsReserve PaymentsTransactionsTypes = "reserve" + PaymentsTransactionsAdjustment PaymentsTransactionsTypes = "adjustment" + PaymentsTransactionsCredit PaymentsTransactionsTypes = "credit" + PaymentsTransactionsDebit PaymentsTransactionsTypes = "debit" + PaymentsTransactionsPayout PaymentsTransactionsTypes = "payout" + PaymentsTransactionsPayoutFailure PaymentsTransactionsTypes = "payout_failure" + PaymentsTransactionsPayoutCancellation PaymentsTransactionsTypes = "payout_cancellation" +) + +// Represents the result from the PaymentsTransactions/X.json endpoint +type PaymentsTransactionResource struct { + PaymentsTransaction *PaymentsTransactions `json:"transaction"` +} + +// Represents the result from the PaymentsTransactions.json endpoint +type PaymentsTransactionsResource struct { + PaymentsTransactions []PaymentsTransactions `json:"transactions"` +} + +// List PaymentsTransactions +func (s *PaymentsTransactionsServiceOp) List(options interface{}) ([]PaymentsTransactions, error) { + PaymentsTransactions, _, err := s.ListWithPagination(options) + if err != nil { + return nil, err + } + return PaymentsTransactions, nil +} + +func (s *PaymentsTransactionsServiceOp) ListWithPagination(options interface{}) ([]PaymentsTransactions, *Pagination, error) { + path := fmt.Sprintf("%s.json", paymentsTransactionsBasePath) + resource := new(PaymentsTransactionsResource) + + pagination, err := s.client.ListWithPagination(path, resource, options) + if err != nil { + return nil, nil, err + } + + return resource.PaymentsTransactions, pagination, nil +} + +// Get individual PaymentsTransactions +func (s *PaymentsTransactionsServiceOp) Get(payoutID int64, options interface{}) (*PaymentsTransactions, error) { + path := fmt.Sprintf("%s/%d.json", paymentsTransactionsBasePath, payoutID) + resource := new(PaymentsTransactionResource) + err := s.client.Get(path, resource, options) + return resource.PaymentsTransaction, err +} diff --git a/payments_transactions_test.go b/payments_transactions_test.go new file mode 100644 index 00000000..d81390ea --- /dev/null +++ b/payments_transactions_test.go @@ -0,0 +1,310 @@ +package goshopify + +import ( + "errors" + "fmt" + "net/http" + "reflect" + "testing" + "time" + + "github.com/jarcoal/httpmock" +) + +func TestPaymentsTransactionsList(t *testing.T) { + setup() + defer teardown() + + httpmock.RegisterResponder("GET", fmt.Sprintf("https://fooshop.myshopify.com/%s/shopify_payments/balance/transactions.json", client.pathPrefix), + httpmock.NewBytesResponder(200, loadFixture("payments_transactions.json"))) + date1 := OnlyDate{time.Date(2013, 11, 01, 0, 0, 0, 0, time.UTC)} + paymentsTransactions, err := client.PaymentsTransactions.List(PaymentsTransactionsListOptions{PayoutId: 623721858}) + if err != nil { + t.Errorf("PaymentsTransactions.List returned error: %v", err) + } + + expected := []PaymentsTransactions{ + { + Id: 699519475, + Type: PaymentsTransactionsDebit, + Test: false, + PayoutId: 623721858, + PayoutStatus: PayoutStatusPaid, + Currency: "USD", + Amount: "-50.00", + Fee: "0.00", + Net: "-50.00", + SourceId: 460709370, + SourceType: "adjustment", + SourceOrderId: 0, + SourceOrderTransactionId: 0, + ProcessedAt: date1, + }, + { + Id: 77412310, + Type: PaymentsTransactionsCredit, + Test: false, + PayoutId: 623721858, + PayoutStatus: PayoutStatusPaid, + Currency: "USD", + Amount: "50.00", + Fee: "0.00", + Net: "50.00", + SourceId: 374511569, + SourceType: "Payments::Balance::AdjustmentReversal", + SourceOrderId: 0, + SourceOrderTransactionId: 0, + ProcessedAt: date1, + }, + { + Id: 1006917261, + Type: PaymentsTransactionsRefund, + Test: false, + PayoutId: 623721858, + PayoutStatus: PayoutStatusPaid, + Currency: "USD", + Amount: "-3.45", + Fee: "0.00", + Net: "-3.45", + SourceId: 1006917261, + SourceType: "Payments::Refund", + SourceOrderId: 217130470, + SourceOrderTransactionId: 1006917261, + ProcessedAt: date1, + }, + } + if !reflect.DeepEqual(paymentsTransactions, expected) { + t.Errorf("PaymentsTransactions.List returned %+v, expected %+v", paymentsTransactions, expected) + } +} + +func TestPaymentsTransactionsListIncorrectDate(t *testing.T) { + setup() + defer teardown() + + httpmock.RegisterResponder("GET", fmt.Sprintf("https://fooshop.myshopify.com/%s/shopify_payments/balance/transactions.json", client.pathPrefix), + httpmock.NewStringResponder(200, `{"transactions": [{"id":1, "processed_at":"20-02-2"}]}`)) + + date1 := OnlyDate{time.Date(2022, 02, 03, 0, 0, 0, 0, time.Local)} + _, err := client.PaymentsTransactions.List(PaymentsTransactionsListOptions{ProcessedAt: &date1}) + if err == nil { + t.Errorf("PaymentsTransactions.List returned success, expected error: %v", err) + } +} + +func TestPaymentsTransactionsListError(t *testing.T) { + setup() + defer teardown() + + httpmock.RegisterResponder("GET", fmt.Sprintf("https://fooshop.myshopify.com/%s/shopify_payments/balance/transactions.json", client.pathPrefix), + httpmock.NewStringResponder(500, "")) + + expectedErrMessage := "Unknown Error" + + paymentsTransactions, err := client.PaymentsTransactions.List(nil) + if paymentsTransactions != nil { + t.Errorf("PaymentsTransactions.List returned transactions, expected nil: %v", err) + } + + if err == nil || err.Error() != expectedErrMessage { + t.Errorf("PaymentsTransactions.List err returned %+v, expected %+v", err, expectedErrMessage) + } +} + +func TestPaymentsTransactionsListWithPagination(t *testing.T) { + setup() + defer teardown() + + listURL := fmt.Sprintf("https://fooshop.myshopify.com/%s/shopify_payments/balance/transactions.json", client.pathPrefix) + date1 := OnlyDate{time.Date(2013, 11, 01, 0, 0, 0, 0, time.UTC)} + + cases := []struct { + body string + linkHeader string + expectedPaymentsTransactions []PaymentsTransactions + expectedPagination *Pagination + expectedErr error + }{ + // Expect empty pagination when there is no link header + { + string(loadFixture("payments_transactions.json")), + "", + []PaymentsTransactions{ + { + Id: 699519475, + Type: PaymentsTransactionsDebit, + Test: false, + PayoutId: 623721858, + PayoutStatus: PayoutStatusPaid, + Currency: "USD", + Amount: "-50.00", + Fee: "0.00", + Net: "-50.00", + SourceId: 460709370, + SourceType: "adjustment", + SourceOrderId: 0, + SourceOrderTransactionId: 0, + ProcessedAt: date1, + }, + { + Id: 77412310, + Type: PaymentsTransactionsCredit, + Test: false, + PayoutId: 623721858, + PayoutStatus: PayoutStatusPaid, + Currency: "USD", + Amount: "50.00", + Fee: "0.00", + Net: "50.00", + SourceId: 374511569, + SourceType: "Payments::Balance::AdjustmentReversal", + SourceOrderId: 0, + SourceOrderTransactionId: 0, + ProcessedAt: date1, + }, + { + Id: 1006917261, + Type: PaymentsTransactionsRefund, + Test: false, + PayoutId: 623721858, + PayoutStatus: PayoutStatusPaid, + Currency: "USD", + Amount: "-3.45", + Fee: "0.00", + Net: "-3.45", + SourceId: 1006917261, + SourceType: "Payments::Refund", + SourceOrderId: 217130470, + SourceOrderTransactionId: 1006917261, + ProcessedAt: date1, + }, + }, + new(Pagination), + nil, + }, + // Invalid link header responses + { + "{}", + "invalid link", + []PaymentsTransactions(nil), + nil, + ResponseDecodingError{Message: "could not extract pagination link header"}, + }, + { + "{}", + `<:invalid.url>; rel="next"`, + []PaymentsTransactions(nil), + nil, + ResponseDecodingError{Message: "pagination does not contain a valid URL"}, + }, + { + "{}", + `; rel="next"`, + []PaymentsTransactions(nil), + nil, + errors.New(`invalid URL escape "%in"`), + }, + { + "{}", + `; rel="next"`, + []PaymentsTransactions(nil), + nil, + ResponseDecodingError{Message: "page_info is missing"}, + }, + { + "{}", + `; rel="next"`, + []PaymentsTransactions(nil), + nil, + errors.New(`strconv.Atoi: parsing "invalid": invalid syntax`), + }, + // Valid link header responses + { + `{"transactions": [{"id":1}]}`, + `; rel="next"`, + []PaymentsTransactions{{Id: 1}}, + &Pagination{ + NextPageOptions: &ListOptions{PageInfo: "foo", Limit: 2}, + }, + nil, + }, + { + `{"transactions": [{"id":2}]}`, + `; rel="next", ; rel="previous"`, + []PaymentsTransactions{{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)) + + paymentsTransactions, pagination, err := client.PaymentsTransactions.ListWithPagination(nil) + if !reflect.DeepEqual(paymentsTransactions, c.expectedPaymentsTransactions) { + t.Errorf("test %d PaymentsTransactions.ListWithPagination transactions returned %+v, expected %+v", i, paymentsTransactions, c.expectedPaymentsTransactions) + } + + if !reflect.DeepEqual(pagination, c.expectedPagination) { + t.Errorf( + "test %d PaymentsTransactions.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 PaymentsTransactions.ListWithPagination err returned %+v, expected %+v", + i, + err, + c.expectedErr, + ) + } + } +} + +func TestPaymentsTransactionsGet(t *testing.T) { + setup() + defer teardown() + + httpmock.RegisterResponder("GET", fmt.Sprintf("https://fooshop.myshopify.com/%s/shopify_payments/balance/transactions/623721858.json", client.pathPrefix), + httpmock.NewBytesResponder(200, loadFixture("payments_transaction.json"))) + + paymentsTransactions, err := client.PaymentsTransactions.Get(623721858, nil) + if err != nil { + t.Errorf("PaymentsTransactions.Get returned error: %v", err) + } + date1 := OnlyDate{time.Date(2013, 11, 01, 0, 0, 0, 0, time.UTC)} + + expected := &PaymentsTransactions{ + Id: 699519475, + Type: PaymentsTransactionsDebit, + Test: false, + PayoutId: 623721858, + PayoutStatus: PayoutStatusPaid, + Currency: "USD", + Amount: "-50.00", + Fee: "0.00", + Net: "-50.00", + SourceId: 460709370, + SourceType: "adjustment", + SourceOrderId: 0, + SourceOrderTransactionId: 0, + ProcessedAt: date1, + } + if !reflect.DeepEqual(paymentsTransactions, expected) { + t.Errorf("PaymentsTransactions.Get returned %+v, expected %+v", paymentsTransactions, expected) + } +}