Passed
Push — main ( 4648c5...0d6b3e )
by Acho
01:43
created

smobilpay.*Client.TransactionHistory   A

Complexity

Conditions 4

Size

Total Lines 22
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 4
eloc 15
nop 4
dl 0
loc 22
rs 9.65
c 0
b 0
f 0
1
package smobilpay
2
3
import (
4
	"bytes"
5
	"context"
6
	"crypto/hmac"
7
	"crypto/sha1"
8
	"encoding/base64"
9
	"encoding/json"
10
	"fmt"
11
	"io"
12
	"io/ioutil"
13
	"net/http"
14
	"net/url"
15
	"sort"
16
	"strings"
17
	"time"
18
)
19
20
type service struct {
21
	client *Client
22
}
23
24
// Client is the smobilpay API client.
25
// Do not instantiate this client with Client{}. Use the New method instead.
26
type Client struct {
27
	httpClient   *http.Client
28
	common       service
29
	baseURL      string
30
	accessToken  string
31
	accessSecret string
32
33
	Topup *topupService
34
	Bill  *billService
35
}
36
37
// New creates and returns a new *Client from a slice of Option.
38
func New(options ...Option) *Client {
39
	config := defaultClientConfig()
40
41
	for _, option := range options {
42
		option.apply(config)
43
	}
44
45
	client := &Client{
46
		httpClient:   config.httpClient,
47
		accessToken:  config.accessToken,
48
		accessSecret: config.accessSecret,
49
		baseURL:      config.baseURL,
50
	}
51
52
	client.common.client = client
53
	client.Topup = (*topupService)(&client.common)
54
	client.Bill = (*billService)(&client.common)
55
	return client
56
}
57
58
// Ping checks if the API is available
59
//
60
// https://apidocs.smobilpay.com/s3papi/API-Reference.2066448558.html
61
func (client *Client) Ping(ctx context.Context, options ...RequestOption) (*PingStatus, *Response, error) {
62
	request, err := client.newRequest(ctx, options, http.MethodGet, "/ping", nil)
63
	if err != nil {
64
		return nil, nil, err
65
	}
66
67
	response, err := client.do(request)
68
	if err != nil {
69
		return nil, response, err
70
	}
71
72
	status := new(PingStatus)
73
	if err = json.Unmarshal(*response.Body, status); err != nil {
74
		return nil, response, err
75
	}
76
77
	return status, response, nil
78
}
79
80
// Quote initializes a transaction
81
//
82
// https://apidocs.smobilpay.com/s3papi/API-Reference.2066448558.html
83
func (client *Client) Quote(ctx context.Context, params *QuoteParams, options ...RequestOption) (*Quote, *Response, error) {
84
	request, err := client.newRequest(ctx, options, http.MethodPost, "/quotestd", map[string]string{
85
		"payItemId": params.PayItemID,
86
		"amount":    params.Amount,
87
	})
88
	if err != nil {
89
		return nil, nil, err
90
	}
91
92
	response, err := client.do(request)
93
	if err != nil {
94
		return nil, response, err
95
	}
96
97
	packages := new(Quote)
98
	if err = json.Unmarshal(*response.Body, packages); err != nil {
99
		return nil, response, err
100
	}
101
102
	return packages, response, nil
103
}
104
105
// Collect confirms a transaction
106
//
107
// https://apidocs.smobilpay.com/s3papi/API-Reference.2066448558.html
108
func (client *Client) Collect(ctx context.Context, params *CollectParams, options ...RequestOption) (*Transaction, *Response, error) {
109
	request, err := client.newRequest(ctx, options, http.MethodPost, "/collectstd", params.toPayload())
110
	if err != nil {
111
		return nil, nil, err
112
	}
113
114
	response, err := client.do(request)
115
	if err != nil {
116
		return nil, response, err
117
	}
118
119
	transaction := new(Transaction)
120
	if err = json.Unmarshal(*response.Body, transaction); err != nil {
121
		return nil, response, err
122
	}
123
124
	return transaction, response, nil
125
}
126
127
// CollectSync confirms a transaction in sync by retrying every 15 seconds for 5 minutes
128
//
129
// https://apidocs.smobilpay.com/s3papi/API-Reference.2066448558.html
130
func (client *Client) CollectSync(ctx context.Context, params *CollectParams, options ...RequestOption) (*Transaction, *Response, error) {
131
	transaction, response, err := client.Collect(ctx, params, options...)
132
	if err != nil {
133
		return transaction, response, err
134
	}
135
136
	if !transaction.IsPending() {
137
		return transaction, response, err
138
	}
139
140
	// wait for completion in 5 minutes
141
	number := transaction.PaymentTransactionNumber
142
	counter := 1
143
	for {
144
		time.Sleep(15 * time.Second)
145
		transaction, response, err = client.Verify(ctx, number)
146
		if err != nil || !transaction.IsPending() || ctx.Err() != nil || counter == 20 {
147
			return transaction, response, err
148
		}
149
		counter++
150
	}
151
}
152
153
// Verify gets the current collection status
154
//
155
// https://apidocs.smobilpay.com/s3papi/API-Reference.2066448558.html
156
func (client *Client) Verify(ctx context.Context, paymentTransactionNumber string, options ...RequestOption) (*Transaction, *Response, error) {
157
	request, err := client.newRequest(ctx, options, http.MethodGet, fmt.Sprintf("/verifytx?ptn=%s", paymentTransactionNumber), nil)
158
	if err != nil {
159
		return nil, nil, err
160
	}
161
162
	response, err := client.do(request)
163
	if err != nil {
164
		return nil, response, err
165
	}
166
167
	var transactions []*Transaction
168
	if err = json.Unmarshal(*response.Body, &transactions); err != nil {
169
		return nil, response, err
170
	}
171
172
	if len(transactions) == 0 {
173
		return nil, response, fmt.Errorf("cannot verify transaction with payment transaction number [%s]", paymentTransactionNumber)
174
	}
175
176
	return transactions[0], response, nil
177
}
178
179
// TransactionHistory gets the history of transactions
180
//
181
// https://apidocs.smobilpay.com/s3papi/API-Reference.2066448558.html
182
func (client *Client) TransactionHistory(ctx context.Context, from time.Time, to time.Time, options ...RequestOption) ([]*Transaction, *Response, error) {
183
	request, err := client.newRequest(
184
		ctx,
185
		options,
186
		http.MethodGet, fmt.Sprintf("/historystd?timestamp_from=%s&timestamp_to=%s", from.Format("2006-01-02T15:04:05.999Z"), to.Format("2006-01-02T15:04:05.999Z")),
187
		nil,
188
	)
189
	if err != nil {
190
		return nil, nil, err
191
	}
192
193
	response, err := client.do(request)
194
	if err != nil {
195
		return nil, response, err
196
	}
197
198
	var transactions []*Transaction
199
	if err = json.Unmarshal(*response.Body, &transactions); err != nil {
200
		return nil, response, err
201
	}
202
203
	return transactions, response, nil
204
}
205
206
func (client *Client) makeRequestConfig(options []RequestOption) *requestConfig {
207
	config := defaultRequestConfig()
208
209
	for _, option := range options {
210
		option.apply(config)
211
	}
212
213
	return config
214
}
215
216
// newRequest creates an API request. A relative URL can be provided in uri,
217
// in which case it is resolved relative to the BaseURL of the Client.
218
// URI's should always be specified without a preceding slash.
219
func (client *Client) newRequest(ctx context.Context, options []RequestOption, method, uri string, body map[string]string) (*http.Request, error) {
220
	config := client.makeRequestConfig(options)
221
222
	var buf io.ReadWriter
223
	if body != nil {
224
		buf = &bytes.Buffer{}
225
		enc := json.NewEncoder(buf)
226
		enc.SetEscapeHTML(false)
227
		err := enc.Encode(body)
228
		if err != nil {
229
			return nil, err
230
		}
231
	}
232
233
	req, err := http.NewRequestWithContext(ctx, method, client.baseURL+uri, buf)
234
	if err != nil {
235
		return nil, err
236
	}
237
238
	req.Header.Set("Content-Type", "application/json")
239
	req.Header.Set("Accept", "application/json")
240
	req.Header.Set("Authorization", client.getAuthHeader(req, config, client.authPayload(req, body)))
241
242
	return req, nil
243
}
244
245
func (client *Client) authPayload(request *http.Request, body map[string]string) map[string]string {
246
	payload := map[string]string{}
247
	for key, value := range request.URL.Query() {
248
		payload[key] = value[0]
249
	}
250
251
	for key, value := range body {
252
		payload[key] = value
253
	}
254
255
	return payload
256
}
257
258
func (client *Client) getBaseHmacAuthString(request *http.Request) string {
259
	return fmt.Sprintf(
260
		"%s&%s",
261
		strings.ToUpper(request.Method),
262
		url.QueryEscape(
263
			fmt.Sprintf("%s://%s%s", request.URL.Scheme, request.URL.Host, request.URL.Path),
264
		),
265
	)
266
}
267
268
func (client *Client) urlEncode(params url.Values) string {
269
	keys := make([]string, 0, len(params))
270
	for k := range params {
271
		keys = append(keys, k)
272
	}
273
	sort.Strings(keys)
274
275
	var buf strings.Builder
276
	for _, k := range keys {
277
		if buf.Len() > 0 {
278
			buf.WriteByte('&')
279
		}
280
		buf.WriteString(k)
281
		buf.WriteByte('=')
282
		buf.WriteString(params[k][0])
283
	}
284
285
	return buf.String()
286
}
287
288
func (client *Client) getPayloadHmacAuthString(config *requestConfig, payload map[string]string) string {
289
	params := url.Values{}
290
	params.Add("s3pAuth_nonce", config.nonce)
291
	params.Add("s3pAuth_signature_method", "HMAC-SHA1")
292
	params.Add("s3pAuth_timestamp", config.timestampString())
293
	params.Add("s3pAuth_token", client.accessToken)
294
295
	for key, value := range payload {
296
		params.Add(key, strings.TrimSpace(value))
297
	}
298
299
	return client.urlEncode(params)
300
}
301
302
func (client *Client) getAuthHeader(request *http.Request, config *requestConfig, payload map[string]string) string {
303
	return fmt.Sprintf(
304
		"s3pAuth,s3pAuth_nonce=\"%s\",s3pAuth_signature=\"%s\",s3pAuth_signature_method=\"HMAC-SHA1\",s3pAuth_timestamp=\"%s\",s3pAuth_token=\"%s\"",
305
		config.nonce,
306
		client.computeHmac(client.getBaseHmacAuthString(request)+"&"+url.QueryEscape(client.getPayloadHmacAuthString(config, payload))),
307
		config.timestampString(),
308
		client.accessToken,
309
	)
310
}
311
312
func (client *Client) computeHmac(message string) string {
313
	key := []byte(client.accessSecret)
314
	h := hmac.New(sha1.New, key)
315
	_, _ = h.Write([]byte(message))
316
	return base64.StdEncoding.EncodeToString(h.Sum(nil))
317
}
318
319
// do carries out an HTTP request and returns a Response
320
func (client *Client) do(req *http.Request) (*Response, error) {
321
	if req == nil {
322
		return nil, fmt.Errorf("%T cannot be nil", req)
323
	}
324
325
	httpResponse, err := client.httpClient.Do(req)
326
	if err != nil {
327
		return nil, err
328
	}
329
330
	defer func() { _ = httpResponse.Body.Close() }()
331
332
	resp, err := client.newResponse(httpResponse)
333
	if err != nil {
334
		return resp, err
335
	}
336
337
	_, err = io.Copy(ioutil.Discard, httpResponse.Body)
338
	if err != nil {
339
		return resp, err
340
	}
341
342
	return resp, nil
343
}
344
345
// newResponse converts an *http.Response to *Response
346
func (client *Client) newResponse(httpResponse *http.Response) (*Response, error) {
347
	if httpResponse == nil {
348
		return nil, fmt.Errorf("%T cannot be nil", httpResponse)
349
	}
350
351
	resp := new(Response)
352
	resp.HTTPResponse = httpResponse
353
354
	buf, err := ioutil.ReadAll(resp.HTTPResponse.Body)
355
	if err != nil {
356
		return nil, err
357
	}
358
	resp.Body = &buf
359
360
	return resp, resp.Error()
361
}
362