TapPay Ruby Gem
A Ruby library for integrating with TapPay payment services. This gem provides a simple and elegant way to process payments, refunds, and handle instalments through TapPay's payment gateway.
Features
- Multiple payment methods:
- Credit card payments (one-time and tokenized)
- Instalment payments (3, 6, 12, 18, 24, 30 months)
- Line Pay
- JKO Pay
- Apple Pay
- iPass Money
- Flexible merchant identification:
- Support for both
merchant_idandmerchant_group_id - Automatic fallback handling
- Priority-based merchant ID resolution
- Support for both
- Universal refund processing for all payment methods
- Transaction status queries
- Comprehensive error handling
- Configurable sandbox/production environments
- Environment-based endpoints management
- Card holder information management
Installation
Add this line to your application's Gemfile:
gem 'tappay_ruby'
And then execute:
$ bundle install
Or install it yourself as:
$ gem install tappay_ruby
Upgrading from 1.x to 2.0
Four changes need action. Each replaces a silent wrong answer with a loud one.
Transaction query timestamps are milliseconds. 1.x documented seconds.
TapPay accepts a seconds-magnitude range but matches nothing against it, so
Transaction::Query returned an empty list for every query. Multiply by 1000:
# before - always returned []
time: { start_time: 30.days.ago.to_i, end_time: Time.now.to_i }
# after
time: { start_time: 30.days.ago.to_i * 1000, end_time: Time.now.to_i * 1000 }
Out-of-scale values now raise Tappay::ValidationError rather than returning
nothing.
Response#success? reflects TapPay's status, not the HTTP code. TapPay
answers a declined card with HTTP 200 and a non-zero status, so 1.x reported
failed payments as successful. If you were already checking
result['status'] == 0 yourself, nothing changes; you can now use success?
instead. See Checking the result for what it does and
does not promise.
Trade records carry every field TapPay returns. 1.x kept 12 of roughly 32.
transaction_time and tsp are gone - they matched nothing in the response
and were always nil. The transaction timestamp is time, in milliseconds.
Fields such as refunded_amount, is_captured, original_amount,
bank_result_code and bank_result_msg are now available.
Some public constants are gone. Grep before upgrading:
rg 'Tappay::(PaymentError|RefundError|QueryError)|api_version|Endpoints::Bind|trade_history_url|cap_url'
Tappay::PaymentError, Tappay::RefundError and Tappay::QueryError were
never raised by the gem, so they never caught anything - use success?.
Removing them matters because rescue Tappay::PaymentError now raises
NameError when the rescue clause is evaluated, which happens only once some
other exception is already in flight.
Configuration
There are several ways to configure the gem:
Option 1: Using merchant_id
# Using merchant_id with optional payment-specific merchant IDs
Tappay.configure do |config|
config.partner_key = 'YOUR_PARTNER_KEY'
config.merchant_id = 'YOUR_MERCHANT_ID'
config.jko_pay_merchant_id = 'YOUR_JKO_PAY_MERCHANT_ID' # Optional, falls back to merchant_id if not set
config.line_pay_merchant_id = 'YOUR_LINE_PAY_MERCHANT_ID' # Optional, falls back to merchant_id if not set
config.currency = 'TWD' # Optional, default for payments that do not pass :currency
end
Option 2: Using merchant_group_id
# Using merchant_group_id (takes precedence over all other merchant IDs)
Tappay.configure do |config|
config.partner_key = 'YOUR_PARTNER_KEY'
config.merchant_group_id = 'YOUR_MERCHANT_GROUP_ID'
# When merchant_group_id is set, all other merchant IDs will be ignored
end
Merchant ID Resolution
The gem uses the following priority order when resolving merchant IDs:
-
If
merchant_group_idis set (either in configuration or options):- Uses
merchant_group_idfor all payment types - Ignores all other merchant IDs (including specific ones for Line Pay, JKO Pay, etc.)
- Uses
-
If
merchant_group_idis not set:- For Line Pay: Uses
line_pay_merchant_idif set, otherwise falls back tomerchant_id - For JKO Pay: Uses
jko_pay_merchant_idif set, otherwise falls back tomerchant_id - For iPass Money: Uses
ipass_money_merchant_idif set, otherwise falls back tomerchant_id - For Instalments: Uses
instalment_merchant_idif set, otherwise falls back tomerchant_id - For other payment types: Uses
merchant_id
- For Line Pay: Uses
Usage
Credit Card Payment
# One-time payment with prime
result = Tappay::CreditCard::Pay.by_prime(
prime: 'prime_from_tappay_sdk',
amount: 1000,
details: 'Order Details',
cardholder: {
phone_number: '0912345678',
name: 'Test User',
email: '[email protected]'
}
).execute
# Payment with saved card token
result = Tappay::CreditCard::Pay.by_token(
card_key: 'card_key_from_tappay',
card_token: 'card_token_from_tappay',
amount: 1000,
currency: 'TWD',
details: 'Order Details',
ccv_prime: 'ccv_prime_from_tappay' # Optional: CVV verification
).execute
# Instalment payment (3-30 months)
result = Tappay::CreditCard::Instalment.by_prime(
prime: 'prime_from_tappay_sdk',
amount: 1000,
instalment: 12, # 12 months instalment
details: 'Order Details',
cardholder: {
phone_number: '0912345678',
name: 'Test User',
email: '[email protected]'
}
).execute
# Instalment payment with saved card token
result = Tappay::CreditCard::Instalment.by_token(
card_key: 'card_key_from_tappay',
card_token: 'card_token_from_tappay',
amount: 1000,
instalment: 12,
details: 'Order Details',
ccv_prime: 'ccv_prime_from_tappay' # Optional: CVV verification
).execute
Line Pay
# Create Line Pay payment
result = Tappay::LinePay::Pay.new(
prime: 'line_pay_prime',
amount: 1000,
details: 'Order Details',
frontend_redirect_url: 'https://example.com/line_pay/result',
backend_notify_url: 'https://example.com/line_pay/notify',
cardholder: {
phone_number: '0912345678',
name: 'Test User',
email: '[email protected]'
}
).execute
Refund Processing
Process refunds for any payment method:
# Process a full refund
result = Tappay::Refund.new(
rec_trade_id: 'TRANSACTION_ID'
).execute
# Process a partial refund
result = Tappay::Refund.new(
rec_trade_id: 'TRANSACTION_ID',
amount: 1000 # Optional: specify amount for partial refund
).execute
JKO Pay
Configuration
Tappay.configure do |config|
config.partner_key = 'YOUR_PARTNER_KEY'
config.merchant_id = 'YOUR_MERCHANT_ID'
config.merchant_group_id = 'YOUR_MERCHANT_GROUP_ID' # Optional, mutually exclusive with merchant_id
config.jko_pay_merchant_id = 'YOUR_JKO_PAY_MERCHANT_ID' # Optional, falls back to merchant_id if not set
config.sandbox = true # Set to false for production
end
Processing a JKO Pay Payment
= {
prime: 'jko_pay_prime',
amount: 1000,
details: 'Some item',
frontend_redirect_url: 'https://your-site.com/jko_pay/result',
backend_notify_url: 'https://your-site.com/jko_pay/notify',
cardholder: {
phone_number: '0912345678',
name: 'Test User',
email: '[email protected]'
}
}
payment = Tappay::JkoPay::Pay.new()
result = payment.execute
iPass Money
Configuration
Tappay.configure do |config|
config.partner_key = 'YOUR_PARTNER_KEY'
config.merchant_id = 'YOUR_MERCHANT_ID'
config.merchant_group_id = 'YOUR_MERCHANT_GROUP_ID' # Optional, mutually exclusive with merchant_id
config.ipass_money_merchant_id = 'YOUR_IPASS_MONEY_MERCHANT_ID' # Optional, falls back to merchant_id if not set
config.sandbox = true # Set to false for production
end
Processing an iPass Money Payment
= {
prime: 'ipass_money_prime',
amount: 1000,
details: 'Some item',
frontend_redirect_url: 'https://your-site.com/ipass_money/result',
backend_notify_url: 'https://your-site.com/ipass_money/notify',
cardholder: {
phone_number: '0912345678',
name: 'Test User',
email: '[email protected]'
}
}
payment = Tappay::IPassMoney::Pay.new()
result = payment.execute
Transaction Query
Query transaction records with required time range:
# time is required; TapPay caps the range at 90 days
result = Tappay::Transaction::Query.new(
time: {
start_time: 1706198400000, # Unix timestamp in MILLISECONDS
end_time: 1706284800000 # Unix timestamp in MILLISECONDS
},
order_number: 'ORDER123', # Optional: filter by order number
records_per_page: 50, # Optional: default is 50
page: 0, # Optional: default is 0
order_by: { # Optional: sort results
attribute: 'time',
is_descending: true
}
).execute
# Access the results
result[:trade_records].each do |record|
puts "Transaction ID: #{record[:rec_trade_id]}"
puts "Amount: #{record[:amount]}"
puts "Status: #{record[:record_status]}"
end
Every field TapPay returns for a trade record is passed through verbatim, with
keys symbolized (recursively, so nested objects like refund_info and
card_info are symbol-keyed too). The gem does not whitelist fields, so
refunded_amount, is_captured, bank_result_code, bank_result_msg,
original_amount, time and the various *_millis timestamps are all
available, and fields TapPay adds later work without a gem upgrade. The exact
key set varies by payment method. See
spec/fixtures/transaction_query_response.json for a real response.
Two things worth knowing about the records:
amountis what remains after refunds. A fully refunded transaction reportsamount: 0withoriginal_amountandrefunded_amountboth set to the original charge, so comparerefunded_amountagainstoriginal_amountto tell a partial refund from a full one.- The transaction timestamp is
time(milliseconds).transaction_complete_millisis0on records that have not completed.
status in the result is TapPay's, not an HTTP code, and 2 means "End of
list", not "nothing found" - it is what you get on the last page, records
included. Verified against the sandbox: a query returning 33 records answered
status: 2. Treat 0 and 2 alike and read trade_records; checking
status == 0 before looking drops the final page. Response#success? is
status == 0, so it is the wrong question for a query - Transaction::Query
does not use it.
Note: time is required and its timestamps are in milliseconds, not
seconds. Seconds are accepted by TapPay but match nothing, so the gem rejects
them with a ValidationError rather than returning an empty list. TapPay caps
the range at 90 days.
Checking the result
Pay.by_prime and friends return an unexecuted payment object - call
execute to send the request. TapPay reports business failures (declined
card, insufficient funds, expired card) as HTTP 200 with a non-zero
status, so success? checks that status rather than the HTTP code:
result = Tappay::CreditCard::Pay.by_prime(...).execute
if result.success? # status == 0
result['rec_trade_id']
else
result['status'] # e.g. 10003
result['msg'] # e.g. 'Card is declined'
end
success? does not mean the money has moved. It means TapPay accepted and
processed the request. What that implies depends on the payment method:
- Credit card - the transaction was authorised. Capture is asynchronous;
confirm it with
is_capturedfrom aTransaction::Query. - LINE Pay / JKO Pay / iPass Money - only that a
payment_urlwas created. The customer has not paid yet. Redirect them toresult['payment_url'], and treat yourbackend_notify_urlcallback plus aTransaction::Queryas the authoritative answer. TapPay's own guidance is to query the Record API before showing a result page to the customer.
Error Handling
The gem provides comprehensive error handling:
begin
result = Tappay::CreditCard::Pay.by_prime(
prime: 'prime_from_tappay_sdk',
amount: 100,
details: 'Order Details',
order_number: 'ORDER-123'
).execute
# TapPay reports declined cards as HTTP 200 with a non-zero status.
raise "Payment failed: #{result['msg']}" unless result.success?
rescue Tappay::ValidationError => e
# Handle validation errors (e.g., missing required fields)
puts "Validation error: #{e.}"
rescue Tappay::Error => e
# Handle other TapPay errors
puts "TapPay error: #{e.}"
end
The gem raises Tappay::ValidationError (bad or missing options),
Tappay::ConfigurationError (authentication rejected) and
Tappay::ConnectionError (timeout, unreachable endpoint, unparseable
response), all of which inherit from Tappay::Error. Business failures are
not exceptions - check success?.
Development
After checking out the repo, run bin/setup to install dependencies. Then run
bundle exec rspec for the tests, or bin/console for an interactive prompt.
Mutation testing
Line coverage says a line ran, not that anything checked what it did. Both bugs fixed in 2.0.0 - a field name that never matched the API and a time filter in the wrong unit - sat under 100% line coverage for the life of the gem.
Mutant changes the source in small ways (flips a boolean, drops a call, swaps an operator) and reruns the suite. A mutation that survives marks behaviour nothing asserts.
bundle config set --local with mutant
bundle install
bundle exec mutant run
It needs Ruby >= 3.3, so it lives in an optional bundle group rather than the
gemspec - the gem itself supports >= 2.7. Scope is config/mutant.yml:
Transaction::Query, Response, and the two payment base classes that five
payment methods now share.
The current score is 95.34% (676 of 709). Mutant exits non-zero whenever
anything survives, and the 33 survivors here are equivalent mutations that no
test can distinguish - inside module Tappay, Client.new and
Tappay::Client.new are the same call, and is_a?(Hash) and
instance_of?(Hash) differ only for a Hash subclass nothing passes. Each one
is listed in config/mutant.yml with its reason, and CI gates on the score
rather than the exit code.
If the score drops, an assertion went missing. Read the new survivor rather than lowering the floor: the first run here scored 77.90%, and every one of those gaps was genuine - including two tests that passed whether or not the code they covered was there at all.
Contract tests against the sandbox
Stubbed tests assert the gem against its own assumptions, so they cannot catch the gem being wrong about TapPay. Every bug fixed in 2.0.0 was exactly that - a field name that did not exist, a time filter in the wrong unit - and all of them survived a suite with 100% line coverage. One real call catches the lot.
export TAPPAY_SANDBOX_PARTNER_KEY=...
bundle exec rspec spec/contract --tag contract
The specs are excluded from the default run and skip with an explanation if the key is unset, so a clone without credentials behaves normally. Nothing is written: the Record API is read-only and needs only a partner key.
They check that a millisecond window is accepted, that every field
spec/fixtures/transaction_query_response.json claims still exists in the live
response, that the transaction timestamp is time in milliseconds, and that
the amounts and capture state refunds reconcile against are all present. New
fields TapPay has added are reported rather than failed - the gem passes
everything through, so they break nothing; the fixture just wants re-capturing.
Only Transaction::Query is covered. Charging needs a prime, which the
frontend SDK mints from a test card - it is single-use, expires in seconds, and
there is no server-side way to obtain one, so payment and refund flows cannot
be driven from a test suite without automating a browser.
Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/7a6163/tappay.
License
The gem is available as open source under the terms of the MIT License.