datatrans/client.go
Cyrill Schumacher 68d31d6e41 OptionMerchant.apply set also MerchantID as internal key
additionally needed as sometimes orders are coming in without a specific
 internalID and we must fall back to the MerchantID
2021-07-08 13:30:38 +02:00

597 lines
20 KiB
Go

package datatrans
import (
"bytes"
"context"
"crypto/tls"
"encoding/hex"
"encoding/json"
"fmt"
"hash/fnv"
"io"
"io/ioutil"
"net/http"
"strconv"
"time"
)
// https://docs.datatrans.ch/docs/api-endpoints
const (
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
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.
EnableIdempotency bool
DisableRawJSONBody bool
MerchantID string // basic auth user
Password string // basic auth pw
// Data contains merchant specific other IDs or configurations. Keys/Values
// from this map are not getting used in requests towards datatrans.
Data map[string]interface{}
}
func (m OptionMerchant) apply(c *Client) error {
if _, ok := c.merchants[m.InternalID]; ok {
return fmt.Errorf("InternalID %q already exists", m.InternalID)
}
c.merchants[m.InternalID] = m
if _, ok := c.merchants[m.MerchantID]; !ok {
c.merchants[m.MerchantID] = m
}
return nil
}
type OptionHTTPRequestFn func(req *http.Request) (*http.Response, error)
func (fn OptionHTTPRequestFn) apply(c *Client) error {
c.doFn = fn
return nil
}
type Client struct {
doFn OptionHTTPRequestFn
merchants map[string]OptionMerchant // string = your custom merchant ID
currentInternalID string
internalIDFound bool
}
type Option interface {
apply(*Client) error
}
func MakeClient(opts ...Option) (Client, error) {
c := Client{
merchants: make(map[string]OptionMerchant, 3),
}
for _, opt := range opts {
if err := opt.apply(&c); err != nil {
return Client{}, err
}
}
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
}
// see if we have a default one, otherwise you always have to call WithMerchant.
_, c.internalIDFound = c.merchants[""]
return c, nil
}
// WithMerchant sets an ID and returns a shallow clone of the client.
func (c *Client) WithMerchant(internalID string) *Client {
c2 := *c
c2.currentInternalID = internalID
_, c2.internalIDFound = c2.merchants[internalID]
return &c2
}
func (c *Client) do(req *http.Request, v interface{}) error {
internalID := c.currentInternalID
if !c.internalIDFound {
return fmt.Errorf("ClientID %q not found in list of merchants", internalID)
}
req.SetBasicAuth(c.merchants[internalID].MerchantID, c.merchants[internalID].Password)
resp, err := c.doFn(req)
defer closeResponse(resp)
if err != nil {
return fmt.Errorf("ClientID:%q: failed to execute HTTP request: %w", internalID, err)
}
var buf bytes.Buffer
body := io.TeeReader(resp.Body, &buf)
dec := json.NewDecoder(body)
if !c.isSuccess(resp.StatusCode) {
var errResp ErrorResponse
if err := dec.Decode(&errResp); err != nil {
return fmt.Errorf("ClientID:%q: failed to unmarshal HTTP error response: %w", internalID, err)
}
errResp.HTTPStatusCode = resp.StatusCode
return errResp
}
if v != nil {
if err := dec.Decode(v); err != nil {
return fmt.Errorf("ClientID:%q: failed to unmarshal HTTP error response: %w", internalID, err)
}
}
if ri, ok := v.(*ResponseInitialize); ok {
if loc := resp.Header.Get("Location"); loc != "" {
ri.Location = loc
}
}
if set, ok := v.(rawJSONBodySetter); !c.merchants[internalID].DisableRawJSONBody && ok {
set.setJSONRawBody(buf.Bytes())
}
return nil
}
func (c *Client) isSuccess(statusCode int) bool {
return statusCode >= http.StatusOK && statusCode < http.StatusMultipleChoices
}
func closeResponse(r *http.Response) {
if r == nil || r.Body == nil {
return
}
_, _ = io.Copy(ioutil.Discard, r.Body)
_ = r.Body.Close()
}
// MarshalJSON encodes the postData struct to json but also can merge custom
// settings into the final JSON. This function is called before sending the
// request to datatrans. Function exported for debug reasons.
func MarshalJSON(postData interface{}) ([]byte, error) {
jsonBytes, err := json.Marshal(postData)
if err != nil {
return nil, fmt.Errorf("failed to marshal postData: %w", err)
}
// this steps merges two different Go types into one JS object.
if cfg, ok := postData.(customFieldsGetter); ok {
custFields := cfg.getCustomFields()
if len(custFields) == 0 {
return jsonBytes, nil
}
postDataMap := map[string]interface{}{}
if err := json.Unmarshal(jsonBytes, &postDataMap); err != nil {
return nil, fmt.Errorf("failed to Unmarshal postData raw bytes: %w", err)
}
for k, v := range custFields {
postDataMap[k] = v // overwrites existing data from postData struct
}
jsonBytes, err = json.Marshal(postDataMap)
if err != nil {
return nil, fmt.Errorf("failed to marshal postDataMap: %w", err)
}
}
return jsonBytes, nil
}
func (c *Client) prepareJSONReq(ctx context.Context, method, path string, postData interface{}) (*http.Request, error) {
internalID := c.currentInternalID
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.NewRequestWithContext(ctx, method, host+path, r)
if err != nil {
return nil, fmt.Errorf("ClientID:%q: failed to create HTTP request: %w", internalID, err)
}
if postData != nil {
req.Header.Set("Content-Type", "application/json")
}
if method == http.MethodPost && c.merchants[internalID].EnableIdempotency {
// 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
}
// Status allows once a transactionId has been received the status can be checked
// with the Status API.
func (c *Client) Status(ctx context.Context, transactionID string) (*ResponseStatus, error) {
if transactionID == "" {
return nil, fmt.Errorf("transactionID cannot be empty")
}
internalID := c.currentInternalID
host := endpointURLSandBox
if c.merchants[internalID].EnableProduction {
host = endpointURLProduction
}
req, err := http.NewRequestWithContext(ctx, 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)
}
var respStatus ResponseStatus
if err := c.do(req, &respStatus); err != nil {
return nil, fmt.Errorf("ClientID:%q: failed to execute HTTP request: %w", internalID, err)
}
return &respStatus, nil
}
// Credit uses the credit API to credit a transaction which is in status settled.
// The previously settled amount must not be exceeded.
func (c *Client) Credit(ctx context.Context, transactionID string, rc RequestCredit) (*ResponseCardMasked, error) {
if transactionID == "" || rc.Currency == "" || rc.RefNo == "" {
return nil, fmt.Errorf("neither currency nor refno nor transactionID can be empty")
}
req, err := c.prepareJSONReq(ctx, http.MethodPost, fmt.Sprintf(pathCredit, transactionID), rc)
if err != nil {
return nil, err
}
var respRefund ResponseCardMasked
if err := c.do(req, &respRefund); err != nil {
return nil, fmt.Errorf("ClientID:%q: failed to execute HTTP request: %w", c.currentInternalID, err)
}
return &respRefund, nil
}
// CreditAuthorize allows to use this API to make a credit without referring to a
// previous authorization. This can be useful if you want to credit a cardholder
// when there was no debit.
func (c *Client) CreditAuthorize(ctx context.Context, rca RequestCreditAuthorize) (*ResponseCardMasked, error) {
if rca.Currency == "" || rca.RefNo == "" || rca.Amount == 0 {
return nil, fmt.Errorf("neither currency nor refno nor amount can be empty")
}
req, err := c.prepareJSONReq(ctx, http.MethodPost, pathCreditAuthorize, rca)
if err != nil {
return nil, err
}
var respRefund ResponseCardMasked
if err := c.do(req, &respRefund); err != nil {
return nil, fmt.Errorf("ClientID:%q: failed to execute HTTP request: %w", c.currentInternalID, err)
}
return &respRefund, nil
}
// Cancel API can be used to release the blocked amount from an authorization.
// The transaction must either be in status authorized or settled. The
// transactionId is needed to cancel an authorization.
// https://api-reference.datatrans.ch/#operation/cancel
func (c *Client) Cancel(ctx context.Context, transactionID string, refno string) error {
if transactionID == "" || refno == "" {
return fmt.Errorf("neither transactionID nor refno can be empty")
}
req, err := c.prepareJSONReq(ctx, http.MethodPost, fmt.Sprintf(pathCancel, transactionID), struct {
Refno string `json:"refno"`
}{
Refno: refno,
})
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
}
// Settle request is often also referred to as “Capture” or “Clearing”. It can be
// used for the settlement of previously authorized transactions. The
// transactionId is needed to settle an authorization. Note: This API call is not
// needed if "autoSettle": true was used when initializing a transaction.
// https://api-reference.datatrans.ch/#operation/settle
func (c *Client) Settle(ctx context.Context, 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.prepareJSONReq(ctx, http.MethodPost, fmt.Sprintf(pathSettle, transactionID), rs)
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
}
// ValidateAlias an existing alias can be validated at any time with the
// transaction validate API. No amount will be blocked on the customers account.
// Only credit cards (including Apple Pay and Google Pay), PFC, KLN and PAP
// support validation of an existing alias.
// https://api-reference.datatrans.ch/#operation/validate
func (c *Client) ValidateAlias(ctx context.Context, rva RequestValidateAlias) (*ResponseCardMasked, error) {
if rva.Currency == "" || rva.RefNo == "" {
return nil, fmt.Errorf("neither currency nor refno can be empty")
}
req, err := c.prepareJSONReq(ctx, http.MethodPost, pathValidate, rva)
if err != nil {
return nil, err
}
var rcm ResponseCardMasked
if err := c.do(req, &rcm); err != nil {
return nil, fmt.Errorf("ClientID:%q: failed to execute HTTP request: %w", c.currentInternalID, err)
}
return &rcm, nil
}
// AuthorizeTransaction an authenticated transaction. If during the initialization of a
// transaction the parameter option.authenticationOnly was set to true, this API
// can be used to authorize an already authenticated (3D) transaction.
// https://api-reference.datatrans.ch/#operation/authorize-split
func (c *Client) AuthorizeTransaction(ctx context.Context, transactionID string, rva RequestAuthorizeTransaction) (*ResponseAuthorize, error) {
if transactionID == "" || rva.RefNo == "" {
return nil, fmt.Errorf("neither transactionID nor refno can be empty")
}
req, err := c.prepareJSONReq(ctx, http.MethodPost, fmt.Sprintf(pathAuthorizeTransaction, transactionID), rva)
if err != nil {
return nil, err
}
var rcm ResponseAuthorize
if err := c.do(req, &rcm); err != nil {
return nil, fmt.Errorf("ClientID:%q: failed to execute HTTP request: %w", c.currentInternalID, err)
}
return &rcm, nil
}
// Authorize a transaction. Use this API to make an authorization without user
// interaction. (For example merchant initiated transactions with an alias)
// Depending on the payment method, different parameters are mandatory. Refer to
// the payment method specific objects (for example PAP) to see which parameters
// so send. For credit cards, the card object can be used.
// https://api-reference.datatrans.ch/#operation/authorize
func (c *Client) Authorize(ctx context.Context, 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.prepareJSONReq(ctx, http.MethodPost, pathAuthorize, rva)
if err != nil {
return nil, err
}
var rcm ResponseCardMasked
if err := c.do(req, &rcm); err != nil {
return nil, fmt.Errorf("ClientID:%q: failed to execute HTTP request: %w", c.currentInternalID, err)
}
return &rcm, nil
}
// Initialize a transaction. Securely send all the needed parameters to the
// transaction initialization API. The result of this API call is a HTTP 201
// status code with a transactionId in the response body and the Location header
// set. If you want to use the payment page redirect mode to collect the payment
// details, the browser needs to be redirected to this URL to continue with the
// transaction. Following the link provided in the Location header will raise the
// Datatrans Payment Page with all the payment methods available for the given
// merchantId. If you want to limit the number of payment methods, the
// paymentMethod array can be used.
func (c *Client) Initialize(ctx context.Context, rva RequestInitialize) (*ResponseInitialize, error) {
if rva.Amount == 0 || rva.Currency == "" || rva.RefNo == "" {
return nil, fmt.Errorf("neither amount nor currency nor refno can be empty")
}
req, err := c.prepareJSONReq(ctx, http.MethodPost, pathInitialize, 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
}
// 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(ctx context.Context, 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(ctx, 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(ctx context.Context, 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(ctx, 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(ctx context.Context, legacyAlias string) (string, error) {
if legacyAlias == "" {
return "", fmt.Errorf("legacyAlias cannot be empty")
}
req, err := c.prepareJSONReq(ctx, 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(ctx context.Context, alias string) error {
if alias == "" {
return fmt.Errorf("alias cannot be empty")
}
req, err := c.prepareJSONReq(ctx, 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(ctx context.Context, sale RequestReconciliationsSale) (*ResponseReconciliationsSale, error) {
req, err := c.prepareJSONReq(ctx, 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(ctx context.Context, sales RequestReconciliationsSales) (*ResponseReconciliationsSales, error) {
req, err := c.prepareJSONReq(ctx, 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
}
// GetDataInt returns the int value from the data map or false if not found or failed to convert.
func (c *Client) GetDataInt(key string) (int, bool) {
internalID := c.currentInternalID
if !c.internalIDFound {
return 0, false
}
raw, ok := c.merchants[internalID].Data[key]
if !ok {
return 0, false
}
switch t := raw.(type) {
case string:
i, err := strconv.Atoi(t)
return i, err == nil
case int:
return t, true
case int64:
return int(t), true
}
return 0, false
}
// GetDataString returns the string value from the data map or false if not found or failed to convert.
func (c *Client) GetDataString(key string) (string, bool) {
internalID := c.currentInternalID
if !c.internalIDFound {
return "", false
}
raw, ok := c.merchants[internalID].Data[key]
if !ok {
return "", false
}
switch t := raw.(type) {
case string:
return t, true
case []byte:
return string(t), true
default:
return fmt.Sprintf("%v", t), true
}
}
func (c *Client) GetDataRaw(key string) (interface{}, bool) {
internalID := c.currentInternalID
if !c.internalIDFound {
return nil, false
}
raw, ok := c.merchants[internalID].Data[key]
return raw, ok
}