add more endpoints

This commit is contained in:
Cyrill Schumacher 2021-03-04 11:15:55 +01:00
parent bc81e833f0
commit 07189b5892
4 changed files with 282 additions and 45 deletions

228
client.go
View File

@ -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
}

View File

@ -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")
}
}

46
dto.go
View File

@ -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"`
}

View File

@ -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
}