Skip to content

Commit ca45b1b

Browse files
hudakhhuda-kh
andauthored
PinPayments: Add transaction_search method to search charges (#5541)
* PinPayments: Add transaction_search method and support paginated API responses * PinPayments: Add unit and remote tests * Update changelog and contributors --------- Co-authored-by: Huda <huda@haesemathematics.com.au>
1 parent 189fee0 commit ca45b1b

5 files changed

Lines changed: 290 additions & 0 deletions

File tree

‎CHANGELOG‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,7 @@
203203
* Stripe PI: Update API version [almalee24] #5360
204204
* Airwallex: Add optional products array field [yunnydang] #5530
205205
* Pin Payments: Add inquire method to get details of payment [hudakh] #5537
206+
* Pin Payments: Add transaction_search method and support paginated API responses [hudakh] #5541
206207

207208
== Version 1.137.0 (August 2, 2024)
208209
* Unlock dependency on `rexml` to allow fixing a CVE (#5181).

‎CONTRIBUTORS‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -374,6 +374,7 @@ Spreedly Core gateway (December 2012)
374374
Pin gateway (February 2013)
375375

376376
* Myles Eftos (madpilot)
377+
* Portions of the Pin Payments Gateway by Huda Kharrufa (hudakh)
377378

378379
Merchant Warrior (February 2013)
379380

‎lib/active_merchant/billing/gateways/pin.rb‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,29 @@ def inquire(token, options = {})
8989
commit(:get, "charges/#{CGI.escape(token)}", nil, options)
9090
end
9191

92+
# Returns a paginated list of charges matching the search criteria.
93+
# ==== Options:
94+
# * <tt>:query </tt> -- (Optional) Return only charges whose fields match the query. Fields covered include description, email, metadata, cardholder name, currency, amount (exact), charge token (exact), card token (exact), refund token (exact), and customer token (exact).
95+
# * <tt>:start_date </tt> -- (Optional) Return only charges created on or after this date (e.g. 2012/12/25).
96+
# * <tt>:end_date </tt> -- (Optional) Return only charges created before this date (e.g. 2013/12/25).
97+
# * <tt>:sort</tt> -- (Optional) The field used to sort the charges (created_at or amount). Default value is created_at.
98+
# * <tt>:direction </tt> -- (Optional) The direction in which to sort the charges (1 for ascending or -1 for descending). Default value is 1.
99+
#
100+
def transaction_search(options = {})
101+
params = {}
102+
params[:query] = options[:query] if options[:query]
103+
params[:start_date] = options[:start_date] if options[:start_date]
104+
params[:end_date] = options[:end_date] if options[:end_date]
105+
params[:sort] = options[:sort] if options[:sort]
106+
params[:direction] = options[:direction] if options[:direction]
107+
params[:page] = options[:page] if options[:page]
108+
109+
action = 'charges/search'
110+
action += '?' + params.to_query unless params.empty?
111+
112+
commit(:get, action, nil, options)
113+
end
114+
92115
# Verify a previously authorized charge.
93116
def verify_3ds(session_token, options = {})
94117
commit(:get, "/charges/verify?session_token=#{session_token}", nil, options)
@@ -257,6 +280,8 @@ def commit(method, action, params, options)
257280

258281
if body.nil?
259282
no_content_response
283+
elsif body['response'].is_a?(Array)
284+
paginated_response(body)
260285
elsif body['response']
261286
success_response(body)
262287
elsif body['error']
@@ -302,6 +327,15 @@ def unparsable_response(raw_response)
302327
return Response.new(false, message)
303328
end
304329

330+
def paginated_response(body)
331+
Response.new(
332+
true,
333+
'Search successful',
334+
body,
335+
test: test?
336+
)
337+
end
338+
305339
def token(response)
306340
if response['token'].start_with?('cus')
307341
"#{response.dig('card', 'token')};#{response['token']}"

‎test/remote/gateways/remote_pin_test.rb‎

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,99 @@ def test_successful_inquire
289289
assert_success inquire
290290
end
291291

292+
def test_successful_transaction_search
293+
response = @gateway.transaction_search
294+
assert_success response
295+
assert response.params['response'].is_a?(Array)
296+
assert response.params.key?('pagination')
297+
end
298+
299+
def test_transaction_search_with_query
300+
response = @gateway.transaction_search(query: @options[:email])
301+
assert_success response
302+
assert response.params['response'].is_a?(Array)
303+
end
304+
305+
def test_transaction_search_with_date_range
306+
today = Date.today
307+
start_date = (today - 30).strftime('%Y-%m-%d')
308+
end_date = today.strftime('%Y-%m-%d')
309+
310+
response = @gateway.transaction_search(start_date: start_date, end_date: end_date)
311+
assert_success response
312+
assert response.params['response'].is_a?(Array)
313+
314+
# Verify all returned transactions are within the date range
315+
response.params['response'].each do |charge|
316+
charge_date = Date.parse(charge['created_at'])
317+
assert charge_date >= Date.parse(start_date), "Charge date should be on or after start date"
318+
assert charge_date <= Date.parse(end_date), "Charge date should be on or before end date"
319+
end
320+
end
321+
322+
def test_transaction_search_with_pagination
323+
response = @gateway.transaction_search(page: 1)
324+
assert_success response
325+
assert_equal 1, response.params['pagination']['current']
326+
assert response.params['pagination'].key?('per_page')
327+
assert response.params['pagination'].key?('count')
328+
end
329+
330+
def test_transaction_search_with_sort_ascending
331+
response = @gateway.transaction_search(sort: 'created_at', direction: 1)
332+
assert_success response
333+
334+
if response.params['response'].length > 1
335+
# Verify ascending order
336+
dates = response.params['response'].map { |charge| Time.parse(charge['created_at']) }
337+
assert_equal dates, dates.sort, "Results should be sorted in ascending order by created_at"
338+
end
339+
end
340+
341+
def test_transaction_search_with_sort_descending
342+
response = @gateway.transaction_search(sort: 'created_at', direction: -1)
343+
assert_success response
344+
345+
if response.params['response'].length > 1
346+
# Verify descending order
347+
dates = response.params['response'].map { |charge| Time.parse(charge['created_at']) }
348+
assert_equal dates, dates.sort.reverse, "Results should be sorted in descending order by created_at"
349+
end
350+
end
351+
352+
def test_transaction_search_with_amount_sort
353+
response = @gateway.transaction_search(sort: 'amount', direction: -1)
354+
assert_success response
355+
356+
if response.params['response'].length > 1
357+
# Verify descending order by amount
358+
amounts = response.params['response'].map { |charge| charge['amount'] }
359+
assert_equal amounts, amounts.sort.reverse, "Results should be sorted in descending order by amount"
360+
end
361+
end
362+
363+
def test_transaction_search_with_multiple_filters
364+
today = Date.today
365+
start_date = (today - 7).strftime('%Y/%m/%d')
366+
367+
response = @gateway.transaction_search(
368+
query: @options[:email],
369+
start_date: start_date,
370+
sort: 'created_at',
371+
direction: -1
372+
)
373+
374+
assert_success response
375+
assert response.params['response'].is_a?(Array)
376+
end
377+
378+
def test_transaction_search_empty_results
379+
# Search with a very specific query that shouldn't match anything
380+
response = @gateway.transaction_search(query: 'nonexistent-email-xyz@test.invalid')
381+
assert_success response
382+
assert_equal 0, response.params['response'].length
383+
end
384+
292385
def test_invalid_login
293386
gateway = PinGateway.new(api_key: '')
294387
response = gateway.purchase(@amount, @credit_card, @options)

‎test/unit/gateways/pin_test.rb‎

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,77 @@ def test_successful_inquire
253253
assert response.test?
254254
end
255255

256+
def test_successful_transaction_search
257+
@gateway.expects(:ssl_request).with(:get, 'https://test-api.pinpayments.com/1/charges/search', nil, instance_of(Hash)).returns(successful_transaction_search_response)
258+
259+
assert response = @gateway.transaction_search
260+
assert_success response
261+
assert_equal 2, response.params['response'].length
262+
assert_equal 'ch_Kw_JxmVqMeSOQU19_krRdw', response.params['response'][0]['token']
263+
assert response.test?
264+
end
265+
266+
def test_transaction_search_with_query
267+
@gateway.expects(:ssl_request).with(:get, 'https://test-api.pinpayments.com/1/charges/search?query=roland%40pinpayments.com', nil, instance_of(Hash)).returns(successful_transaction_search_response)
268+
269+
assert response = @gateway.transaction_search(query: 'roland@pinpayments.com')
270+
assert_success response
271+
assert_equal 2, response.params['response'].length
272+
assert response.test?
273+
end
274+
275+
def test_transaction_search_with_date_range
276+
start_date = '2025-01-01'
277+
end_date = '2025-01-31'
278+
@gateway.expects(:ssl_request).with(:get, "https://test-api.pinpayments.com/1/charges/search?end_date=#{end_date}&start_date=#{start_date}", nil, instance_of(Hash)).returns(successful_transaction_search_response)
279+
280+
assert response = @gateway.transaction_search(start_date: start_date, end_date: end_date)
281+
assert_success response
282+
assert response.test?
283+
end
284+
285+
def test_transaction_search_with_pagination
286+
@gateway.expects(:ssl_request).with(:get, 'https://test-api.pinpayments.com/1/charges/search?page=2', nil, instance_of(Hash)).returns(successful_transaction_search_response)
287+
288+
assert response = @gateway.transaction_search(page: 2)
289+
assert_success response
290+
assert response.test?
291+
end
292+
293+
def test_transaction_search_with_sort_and_direction
294+
@gateway.expects(:ssl_request).with(:get, 'https://test-api.pinpayments.com/1/charges/search?direction=1&sort=created_at', nil, instance_of(Hash)).returns(successful_transaction_search_response)
295+
296+
assert response = @gateway.transaction_search(sort: 'created_at', direction: 1)
297+
assert_success response
298+
assert response.test?
299+
end
300+
301+
def test_transaction_search_with_amount_sort
302+
@gateway.expects(:ssl_request).with(:get, 'https://test-api.pinpayments.com/1/charges/search?direction=-1&sort=amount', nil, instance_of(Hash)).returns(successful_transaction_search_response)
303+
304+
assert response = @gateway.transaction_search(sort: 'amount', direction: -1)
305+
assert_success response
306+
assert response.test?
307+
end
308+
309+
def test_transaction_search_empty_results
310+
@gateway.expects(:ssl_request).with(:get, 'https://test-api.pinpayments.com/1/charges/search', nil, instance_of(Hash)).returns(empty_transaction_search_response)
311+
312+
assert response = @gateway.transaction_search
313+
assert_success response
314+
assert_equal 0, response.params['response'].length
315+
assert response.test?
316+
end
317+
318+
def test_unsuccessful_transaction_search
319+
@gateway.expects(:ssl_request).returns(failed_transaction_search_response)
320+
321+
assert response = @gateway.transaction_search
322+
assert_failure response
323+
assert_equal 'The requested resource could not be found.', response.message
324+
assert response.test?
325+
end
326+
256327
def test_successful_refund
257328
token = 'ch_encBuMDf17qTabmVjDsQlg'
258329
@gateway.expects(:ssl_request).with(:post, "https://test-api.pinpayments.com/1/charges/#{token}/refunds", { amount: '100' }.to_json, instance_of(Hash)).returns(successful_refund_response)
@@ -800,6 +871,96 @@ def successful_inquire_response
800871
}'
801872
end
802873

874+
def successful_transaction_search_response
875+
'{
876+
"response": [
877+
{
878+
"token": "ch_Kw_JxmVqMeSOQU19_krRdw",
879+
"success": true,
880+
"amount": 400,
881+
"currency": "AUD",
882+
"description": "test charge",
883+
"email": "roland@pinpayments.com",
884+
"ip_address": "203.192.1.172",
885+
"created_at": "2025-01-14T03:00:41Z",
886+
"status_message": "Success!",
887+
"error_message": null,
888+
"card": {
889+
"token": "card_0oG1hjachN7g8KsOnWlOcg",
890+
"display_number": "XXXX-XXXX-XXXX-0000",
891+
"scheme": "master",
892+
"address_line1": "42 Sevenoaks St",
893+
"address_line2": null,
894+
"address_city": "Lathlain",
895+
"address_postcode": "6454",
896+
"address_state": "WA",
897+
"address_country": "AU"
898+
},
899+
"transfer": [],
900+
"amount_refunded": 0,
901+
"total_fees": 62,
902+
"merchant_entitlement": 338,
903+
"refund_pending": false,
904+
"captured": true,
905+
"captured_at": "2025-01-14T03:00:41Z"
906+
},
907+
{
908+
"token": "ch_ABC123XYZ",
909+
"success": true,
910+
"amount": 200,
911+
"currency": "AUD",
912+
"description": "another test charge",
913+
"email": "roland@pinpayments.com",
914+
"ip_address": "203.192.1.173",
915+
"created_at": "2025-01-15T04:00:41Z",
916+
"status_message": "Success!",
917+
"error_message": null,
918+
"card": {
919+
"token": "card_ABC123",
920+
"display_number": "XXXX-XXXX-XXXX-1111",
921+
"scheme": "visa"
922+
},
923+
"transfer": [],
924+
"amount_refunded": 0,
925+
"total_fees": 32,
926+
"merchant_entitlement": 168,
927+
"refund_pending": false,
928+
"captured": true,
929+
"captured_at": "2025-01-15T04:00:41Z"
930+
}
931+
],
932+
"pagination": {
933+
"current": 1,
934+
"previous": null,
935+
"next": null,
936+
"per_page": 25,
937+
"pages": 1,
938+
"count": 2
939+
}
940+
}'
941+
end
942+
943+
def empty_transaction_search_response
944+
'{
945+
"response": [],
946+
"pagination": {
947+
"current": 1,
948+
"previous": null,
949+
"next": null,
950+
"per_page": 25,
951+
"pages": 0,
952+
"count": 0
953+
}
954+
}'
955+
end
956+
957+
def failed_transaction_search_response
958+
'{
959+
"error": "not_found",
960+
"error_description": "The requested resource could not be found."
961+
}'
962+
end
963+
803964
def successful_refund_response
804965
'{
805966
"response":{

0 commit comments

Comments
 (0)