Passed
Push — main ( 0d6b3e...2fb5ac )
by Acho
01:51
created

smobilpay.*Client.CollectSync   B

Complexity

Conditions 8

Size

Total Lines 20
Code Lines 14

Duplication

Lines 0
Ratio 0 %

Importance

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