datatrans/client.go
2021-03-03 15:23:20 +01:00

370 lines
12 KiB
Go

package datatrans
import (
"bytes"
"encoding/json"
"fmt"
"io"
"io/ioutil"
"net/http"
)
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
)
type OptionMerchant struct {
InternalID string
Server string
MerchantID string // basic auth user
Password string // basic auth pw
}
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
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 {
copyRawResponseBody bool
doFn OptionHTTPRequestFn
merchants map[string]OptionMerchant // string = your custom merchant ID
currentInternalID string
}
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")
}
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
return &c2
}
func (c *Client) do(req *http.Request, v interface{}) error {
internalID := c.currentInternalID
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); 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) preparePostJSONReq(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)
}
req, err := http.NewRequest(http.MethodPost, c.merchants[internalID].Server+path, bytes.NewReader(jsonBytes))
if err != nil {
return nil, fmt.Errorf("ClientID:%q: failed to create HTTP request: %w", internalID, err)
}
req.Header.Set("Content-Type", "application/json")
return req, nil
}
// Status allows once a transactionId has been received the status can be checked
// with the Status API.
func (c *Client) Status(transactionID string) (*ResponseStatus, error) {
if transactionID == "" {
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)
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(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.preparePostJSONReq(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(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.preparePostJSONReq(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(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 {
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(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)
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(rva RequestValidateAlias) (*ResponseCardMasked, error) {
if rva.Currency == "" || rva.RefNo == "" {
return nil, fmt.Errorf("neither currency nor refno can be empty")
}
req, err := c.preparePostJSONReq(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(transactionID string, rva RequestAuthorizeTransaction) (*ResponseAuthorize, error) {
if transactionID == "" || rva.RefNo == "" {
return nil, fmt.Errorf("neither transactionID nor refno can be empty")
}
req, err := c.preparePostJSONReq(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(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)
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(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")
}
req, err := c.preparePostJSONReq(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
}