From 07189b5892591ae76d2f742eda1d5549967e04a7 Mon Sep 17 00:00:00 2001 From: Cyrill Schumacher Date: Thu, 4 Mar 2021 11:15:55 +0100 Subject: [PATCH] add more endpoints --- client.go | 228 +++++++++++++++++++++++++++++++++++++++++-------- client_test.go | 45 +++++++++- dto.go | 46 +++++++++- webhook.go | 8 +- 4 files changed, 282 insertions(+), 45 deletions(-) diff --git a/client.go b/client.go index b2df318..0a8e9d1 100644 --- a/client.go +++ b/client.go @@ -2,31 +2,52 @@ package datatrans import ( "bytes" + "crypto/tls" + "encoding/hex" "encoding/json" "fmt" + "hash/fnv" "io" "io/ioutil" "net/http" + "time" ) +// https://docs.datatrans.ch/docs/api-endpoints const ( - pathBase = "/v1/transactions" - pathStatus = pathBase + "/%s" - pathCredit = pathBase + "/%s/credit" - pathCreditAuthorize = pathBase + "/credit" - pathCancel = pathBase + "/%s/cancel" - pathSettle = pathBase + "/%s/settle" - pathValidate = pathBase + "/validate" - pathAuthorizeTransaction = pathBase + "/%s/authorize" - pathAuthorize = pathBase + "/authorize" - pathInitialize = pathBase + endpointURLSandBox = `https://api.sandbox.datatrans.com` + endpointURLProduction = `https://api.datatrans.com` + + pathBase = "/v1/transactions" + pathStatus = pathBase + "/%s" + pathCredit = pathBase + "/%s/credit" + pathCreditAuthorize = pathBase + "/credit" + pathCancel = pathBase + "/%s/cancel" + pathSettle = pathBase + "/%s/settle" + pathValidate = pathBase + "/validate" + pathAuthorizeTransaction = pathBase + "/%s/authorize" + pathAuthorize = pathBase + "/authorize" + pathInitialize = pathBase + pathSecureFields = pathBase + "/secureFields" + pathSecureFieldsUpdate = pathBase + "/secureFields/%s" + pathAliases = pathBase + "/aliases" + pathAliasesDelete = pathBase + "/aliases/%s" + pathReconciliationsSales = "/v1/reconciliations/sales" + pathReconciliationsSalesBulk = "/v1/reconciliations/sales/bulk" ) type OptionMerchant struct { - InternalID string - Server string - MerchantID string // basic auth user - Password string // basic auth pw + InternalID string + EnableProduction bool + // https://docs.datatrans.ch/docs/api-endpoints#section-idempotency + // If your request failed to reach our servers, no idempotent result is saved + // because no API endpoint processed your request. In such cases, you can + // simply retry your operation safely. Idempotency keys remain stored for 3 + // minutes. After 3 minutes have passed, sending the same request together + // with the previous idempotency key will create a new operation. + UseIdempotency bool + MerchantID string // basic auth user + Password string // basic auth pw } func (m OptionMerchant) apply(c *Client) error { @@ -45,10 +66,9 @@ func (fn OptionHTTPRequestFn) apply(c *Client) error { } type Client struct { - copyRawResponseBody bool - doFn OptionHTTPRequestFn - merchants map[string]OptionMerchant // string = your custom merchant ID - currentInternalID string + doFn OptionHTTPRequestFn + merchants map[string]OptionMerchant // string = your custom merchant ID + currentInternalID string } type Option interface { @@ -67,6 +87,16 @@ func MakeClient(opts ...Option) (Client, error) { if len(c.merchants) == 0 { return Client{}, fmt.Errorf("no merchants applied") } + if c.doFn == nil { + c.doFn = (&http.Client{ + Timeout: 30 * time.Second, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + MinVersion: tls.VersionTLS12, + }, + }, + }).Do + } return c, nil } @@ -160,19 +190,39 @@ func MarshalJSON(postData interface{}) ([]byte, error) { return jsonBytes, nil } -func (c *Client) preparePostJSONReq(path string, postData interface{}) (*http.Request, error) { +func (c *Client) prepareJSONReq(method, path string, postData interface{}) (*http.Request, error) { internalID := c.currentInternalID - jsonBytes, err := MarshalJSON(postData) - if err != nil { - return nil, fmt.Errorf("ClientID:%q: failed to json marshal HTTP request: %w", internalID, err) + var r io.Reader + var jsonBytes []byte + if postData != nil { + var err error + jsonBytes, err = MarshalJSON(postData) + if err != nil { + return nil, fmt.Errorf("ClientID:%q: failed to json marshal HTTP request: %w", internalID, err) + } + r = bytes.NewReader(jsonBytes) + } + host := endpointURLSandBox + if c.merchants[internalID].EnableProduction { + host = endpointURLProduction } - req, err := http.NewRequest(http.MethodPost, c.merchants[internalID].Server+path, bytes.NewReader(jsonBytes)) + req, err := http.NewRequest(method, host+path, r) if err != nil { return nil, fmt.Errorf("ClientID:%q: failed to create HTTP request: %w", internalID, err) } - req.Header.Set("Content-Type", "application/json") + if postData != nil { + req.Header.Set("Content-Type", "application/json") + } + if method == http.MethodPost && c.merchants[internalID].UseIdempotency { + // not quite happy with this + // https://docs.datatrans.ch/docs/api-endpoints#section-idempotency + fh := fnv.New64a() + _, _ = fh.Write([]byte(internalID + host + path)) + _, _ = fh.Write(jsonBytes) + req.Header.Set("Idempotency-Key", hex.EncodeToString(fh.Sum(nil))) + } return req, nil } @@ -184,7 +234,11 @@ func (c *Client) Status(transactionID string) (*ResponseStatus, error) { return nil, fmt.Errorf("transactionID cannot be empty") } internalID := c.currentInternalID - req, err := http.NewRequest(http.MethodGet, fmt.Sprintf(c.merchants[internalID].Server+pathStatus, transactionID), nil) + host := endpointURLSandBox + if c.merchants[internalID].EnableProduction { + host = endpointURLProduction + } + req, err := http.NewRequest(http.MethodGet, fmt.Sprintf(host+pathStatus, transactionID), nil) if err != nil { return nil, fmt.Errorf("ClientID:%q: failed to create HTTP request: %w", internalID, err) } @@ -204,7 +258,7 @@ func (c *Client) Credit(transactionID string, rc RequestCredit) (*ResponseCardMa return nil, fmt.Errorf("neither currency nor refno nor transactionID can be empty") } - req, err := c.preparePostJSONReq(fmt.Sprintf(pathCredit, transactionID), rc) + req, err := c.prepareJSONReq(http.MethodPost, fmt.Sprintf(pathCredit, transactionID), rc) if err != nil { return nil, err } @@ -225,7 +279,7 @@ func (c *Client) CreditAuthorize(rca RequestCreditAuthorize) (*ResponseCardMaske return nil, fmt.Errorf("neither currency nor refno nor amount can be empty") } - req, err := c.preparePostJSONReq(pathCreditAuthorize, rca) + req, err := c.prepareJSONReq(http.MethodPost, pathCreditAuthorize, rca) if err != nil { return nil, err } @@ -245,7 +299,7 @@ func (c *Client) Cancel(transactionID string, refno string) error { if transactionID == "" || refno == "" { return fmt.Errorf("neither transactionID nor refno can be empty") } - req, err := c.preparePostJSONReq(fmt.Sprintf(pathCancel, transactionID), struct { + req, err := c.prepareJSONReq(http.MethodPost, fmt.Sprintf(pathCancel, transactionID), struct { Refno string `json:"refno"` }{ Refno: refno, @@ -269,7 +323,7 @@ func (c *Client) Settle(transactionID string, rs RequestSettle) error { if transactionID == "" || rs.Amount == 0 || rs.Currency == "" || rs.RefNo == "" { return fmt.Errorf("neither transactionID nor refno nor amount nor currency can be empty") } - req, err := c.preparePostJSONReq(fmt.Sprintf(pathSettle, transactionID), rs) + req, err := c.prepareJSONReq(http.MethodPost, fmt.Sprintf(pathSettle, transactionID), rs) if err != nil { return err } @@ -289,7 +343,7 @@ func (c *Client) ValidateAlias(rva RequestValidateAlias) (*ResponseCardMasked, e if rva.Currency == "" || rva.RefNo == "" { return nil, fmt.Errorf("neither currency nor refno can be empty") } - req, err := c.preparePostJSONReq(pathValidate, rva) + req, err := c.prepareJSONReq(http.MethodPost, pathValidate, rva) if err != nil { return nil, err } @@ -309,7 +363,7 @@ func (c *Client) AuthorizeTransaction(transactionID string, rva RequestAuthorize if transactionID == "" || rva.RefNo == "" { return nil, fmt.Errorf("neither transactionID nor refno can be empty") } - req, err := c.preparePostJSONReq(fmt.Sprintf(pathAuthorizeTransaction, transactionID), rva) + req, err := c.prepareJSONReq(http.MethodPost, fmt.Sprintf(pathAuthorizeTransaction, transactionID), rva) if err != nil { return nil, err } @@ -331,7 +385,7 @@ func (c *Client) Authorize(rva RequestAuthorize) (*ResponseCardMasked, error) { if rva.Amount == 0 || rva.Currency == "" || rva.RefNo == "" { return nil, fmt.Errorf("neither transactionID nor amount nor currency nor refno can be empty") } - req, err := c.preparePostJSONReq(pathAuthorize, rva) + req, err := c.prepareJSONReq(http.MethodPost, pathAuthorize, rva) if err != nil { return nil, err } @@ -354,9 +408,9 @@ func (c *Client) Authorize(rva RequestAuthorize) (*ResponseCardMasked, error) { // paymentMethod array can be used. func (c *Client) Initialize(rva RequestInitialize) (*ResponseInitialize, error) { if rva.Amount == 0 || rva.Currency == "" || rva.RefNo == "" { - return nil, fmt.Errorf("neither transactionID nor amount nor currency nor refno can be empty") + return nil, fmt.Errorf("neither amount nor currency nor refno can be empty") } - req, err := c.preparePostJSONReq(pathInitialize, rva) + req, err := c.prepareJSONReq(http.MethodPost, pathInitialize, rva) if err != nil { return nil, err } @@ -367,3 +421,109 @@ func (c *Client) Initialize(rva RequestInitialize) (*ResponseInitialize, error) } return &ri, nil } + +// InitializeSecureFields initializes a Secure Fields transaction. Proceed with +// the steps below to process Secure Fields payment transactions. +// https://api-reference.datatrans.ch/#operation/secureFieldsInit +func (c *Client) SecureFieldsInit(rva RequestSecureFieldsInit) (*ResponseInitialize, error) { + if rva.Amount == 0 || rva.Currency == "" || rva.ReturnUrl == "" { + return nil, fmt.Errorf("neither amount nor currency nor returnURL can be empty") + } + req, err := c.prepareJSONReq(http.MethodPost, pathSecureFields, rva) + if err != nil { + return nil, err + } + + var ri ResponseInitialize + if err := c.do(req, &ri); err != nil { + return nil, fmt.Errorf("ClientID:%q: failed to execute HTTP request: %w", c.currentInternalID, err) + } + return &ri, nil +} + +// SecureFieldsUpdate use this API to update the amount of a Secure Fields +// transaction. This action is only allowed before the 3D process. At least one +// property must be updated. +// https://api-reference.datatrans.ch/#operation/secure-fields-update +func (c *Client) SecureFieldsUpdate(transactionID string, rva RequestSecureFieldsUpdate) error { + if rva.Amount == 0 || rva.Currency == "" { + return fmt.Errorf("neither amount nor currency nor returnURL can be empty") + } + req, err := c.prepareJSONReq(http.MethodPatch, fmt.Sprintf(pathSecureFieldsUpdate, transactionID), rva) + if err != nil { + return err + } + + if err := c.do(req, nil); err != nil { + return fmt.Errorf("ClientID:%q: failed to execute HTTP request: %w", c.currentInternalID, err) + } + return nil +} + +// AliasConvert converts a legacy (numeric or masked) alias to the most recent +// alias format. +func (c *Client) AliasConvert(legacyAlias string) (string, error) { + if legacyAlias == "" { + return "", fmt.Errorf("legacyAlias cannot be empty") + } + req, err := c.prepareJSONReq(http.MethodPost, pathAliases, struct { + LegacyAlias string `json:"legacyAlias"` + }{ + LegacyAlias: legacyAlias, + }) + if err != nil { + return "", err + } + var resp struct { + Alias string `json:"alias"` + } + if err := c.do(req, &resp); err != nil { + return "", fmt.Errorf("ClientID:%q: failed to execute HTTP request: %w", c.currentInternalID, err) + } + return resp.Alias, nil +} + +// AliasDelete deletes an alias with immediate effect. The alias will no longer +// be recognized if used later with any API call. +func (c *Client) AliasDelete(alias string) error { + if alias == "" { + return fmt.Errorf("alias cannot be empty") + } + req, err := c.prepareJSONReq(http.MethodDelete, fmt.Sprintf(pathAliasesDelete, alias), nil) + if err != nil { + return err + } + if err := c.do(req, nil); err != nil { + return fmt.Errorf("ClientID:%q: failed to execute HTTP request: %w", c.currentInternalID, err) + } + return nil +} + +// ReconciliationsSales reports a sale. When using reconciliation, use this API +// to report a sale. The matching is based on the transactionId. +func (c *Client) ReconciliationsSales(sale RequestReconciliationsSale) (*ResponseReconciliationsSale, error) { + req, err := c.prepareJSONReq(http.MethodPost, pathReconciliationsSales, sale) + if err != nil { + return nil, err + } + var rrs ResponseReconciliationsSale + if err := c.do(req, &rrs); err != nil { + return nil, fmt.Errorf("ClientID:%q: failed to execute HTTP request: %w", c.currentInternalID, err) + } + return &rrs, nil +} + +// ReconciliationsSalesBulk reports bulk sales. When using reconciliation, use +// this API to report multiples sales with a single API call. The matching is +// based on the transactionId. +func (c *Client) ReconciliationsSalesBulk(sales RequestReconciliationsSales) (*ResponseReconciliationsSales, error) { + req, err := c.prepareJSONReq(http.MethodPost, pathReconciliationsSalesBulk, sales) + if err != nil { + return nil, err + } + var rrs ResponseReconciliationsSales + if err := c.do(req, &rrs); err != nil { + return nil, fmt.Errorf("ClientID:%q: failed to execute HTTP request: %w", c.currentInternalID, err) + } + return &rrs, nil +} diff --git a/client_test.go b/client_test.go index 082c92b..e78677d 100644 --- a/client_test.go +++ b/client_test.go @@ -2,6 +2,7 @@ package datatrans_test import ( "bytes" + "errors" "io/ioutil" "net/http" "os" @@ -50,7 +51,6 @@ func TestClient_Status(t *testing.T) { c, err := datatrans.MakeClient( datatrans.OptionHTTPRequestFn(mockResponse(t, 200, "testdata/status_response.json", nil)), datatrans.OptionMerchant{ - Server: "http://localhost", MerchantID: "322342", Password: "32168", }, @@ -73,6 +73,9 @@ func TestClient_Initialize(t *testing.T) { if req.Header.Get("Content-Type") != "application/json" { t.Error("invalid content type") } + if k := req.Header.Get("Idempotency-Key"); k != "c0476553a7e7da70" { + t.Errorf("invalid Idempotency-Key: %q", k) + } u, p, _ := req.BasicAuth() if u != "322342" { @@ -90,9 +93,9 @@ func TestClient_Initialize(t *testing.T) { } })), datatrans.OptionMerchant{ - Server: "http://localhost", - MerchantID: "322342", - Password: "sfdgsdfg", + UseIdempotency: true, + MerchantID: "322342", + Password: "sfdgsdfg", }, ) must(t, err) @@ -141,3 +144,37 @@ func TestMarshalJSON(t *testing.T) { t.Errorf("\nWant: %s\nHave: %s", wantJSON, data) } } + +func TestClient_AliasDelete_Error(t *testing.T) { + c, err := datatrans.MakeClient( + datatrans.OptionHTTPRequestFn(mockResponse(t, 400, `{"error": {"code": "ALIAS_NOT_FOUND"}}`, func(t *testing.T, req *http.Request) { + if req.Method != http.MethodDelete { + t.Error("not a delete request") + } + if req.Header.Get("Content-Type") == "application/json" { + t.Error("invalid content type") + } + + u, p, _ := req.BasicAuth() + if u != "322342" { + t.Error("invalid basic username") + } + if p != "sfdgsdfg" { + t.Error("invalid basic password") + } + })), + datatrans.OptionMerchant{ + MerchantID: "322342", + Password: "sfdgsdfg", + }, + ) + must(t, err) + + err = c.AliasDelete("3469efdbbdcb043e56b19ffca69a8be0c5524d89") + + var detailErr datatrans.ErrorResponse + errors.As(err, &detailErr) + if !reflect.DeepEqual(detailErr, datatrans.ErrorResponse{HTTPStatusCode: 400, ErrorDetail: datatrans.ErrorDetail{Code: "ALIAS_NOT_FOUND", Message: ""}}) { + t.Error("errors not equal") + } +} diff --git a/dto.go b/dto.go index 22a992c..15e2430 100644 --- a/dto.go +++ b/dto.go @@ -28,9 +28,19 @@ func (b *RawJSONBody) setJSONRawBody(p []byte) { *b = p } -type ResponseAuthorize struct { - AcquirerAuthorizationCode string `json:"acquirerAuthorizationCode"` - RawJSONBody `json:"raw,omitempty"` +// https://api-reference.datatrans.ch/#operation/secureFieldsInit +type RequestSecureFieldsInit struct { + Currency string `json:"currency"` + Amount int `json:"amount,omitempty"` + ReturnUrl string `json:"returnUrl"` + CustomFields `json:"-"` +} + +// https://api-reference.datatrans.ch/#operation/secure-fields-update +type RequestSecureFieldsUpdate struct { + Currency string `json:"currency"` + Amount int `json:"amount,omitempty"` + CustomFields `json:"-"` } // https://api-reference.datatrans.ch/#operation/init @@ -69,6 +79,11 @@ type RequestAuthorize struct { CustomFields `json:"-"` } +type ResponseAuthorize struct { + AcquirerAuthorizationCode string `json:"acquirerAuthorizationCode"` + RawJSONBody `json:"raw,omitempty"` +} + type RequestAuthorizeTransaction struct { RefNo string `json:"refno,omitempty"` Amount int `json:"amount,omitempty"` @@ -196,6 +211,7 @@ type Customer struct { type Theme struct { // Theme configuration options when using the default DT2015 theme + Name string `json:"name,omitempty"` // Theme name, e.g. DT2015 Configuration ThemeConfiguration `json:"configuration,omitempty"` } @@ -244,3 +260,27 @@ type InitializeOption struct { RememberMe string `json:"rememberMe"` // Enum: "true" "checked" Whether to show a checkbox on the payment page to let the customer choose if they want to save their card information. ReturnMobileToken bool `json:"returnMobileToken"` // Indicates that a mobile token should be created. This is needed when using our Mobile SDKs. } + +type RequestReconciliationsSale struct { + Date time.Time `json:"date"` + TransactionID string `json:"transactionId"` + Currency string `json:"currency"` + Amount int `json:"amount"` + Type string `json:"type"` + Refno string `json:"refno"` +} + +type ResponseReconciliationsSale struct { + TransactionID string `json:"transactionId"` + SaleDate time.Time `json:"saleDate"` + ReportedDate time.Time `json:"reportedDate"` + MatchResult string `json:"matchResult"` +} + +type RequestReconciliationsSales struct { + Sales []RequestReconciliationsSale `json:"sales"` +} + +type ResponseReconciliationsSales struct { + Sales []ResponseReconciliationsSale `json:"sales"` +} diff --git a/webhook.go b/webhook.go index 716e7ed..4e41acb 100644 --- a/webhook.go +++ b/webhook.go @@ -20,8 +20,8 @@ var ( // https://api-reference.datatrans.ch/#section/Webhook/Webhook-signing type WebhookOption struct { - Sign2HMACKey string - ErrorHandler func(error) http.Handler + Sign2HMACKey string // hex encoded + ErrorHandler func(error) http.Handler // optional custom error handler } // ValidateWebhook an HTTP middleware which checks that the signature in the header is valid. @@ -55,13 +55,13 @@ func ValidateWebhook(wo WebhookOption) (func(next http.Handler) http.Handler, er var buf bytes.Buffer if _, err := io.Copy(io.MultiWriter(&buf, hmv), r.Body); err != nil { _ = r.Body.Close() - wo.ErrorHandler(errors.New("ValidateWebhook: copy failed")).ServeHTTP(w, r) + wo.ErrorHandler(err).ServeHTTP(w, r) return } _ = r.Body.Close() r.Body = ioutil.NopCloser(&buf) - if !hmac.Equal(hmv.Sum(nil), []byte(s0)) { + if !hmac.Equal(hmv.Sum(nil), s0) { wo.ErrorHandler(ErrWebhookMismatchSignature).ServeHTTP(w, r) return }