rails-shopee-qris 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +152 -0
- data/lib/rails/shopee/qris/client.rb +381 -0
- data/lib/rails/shopee/qris/error.rb +18 -0
- data/lib/rails/shopee/qris/qris.rb +69 -0
- data/lib/rails/shopee/qris/response.rb +60 -0
- data/lib/rails/shopee/qris/setup.rb +726 -0
- data/lib/rails/shopee/qris.rb +35 -0
- data/lib/rails-shopee-qris.rb +3 -0
- metadata +49 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: c4abd75453a929debadba31df32047103e52839b892f4cd410446bec2175a292
|
|
4
|
+
data.tar.gz: 8cf2072749c406cfec500aedd4f0e8d1cb1dd895e589e360e5a86f8cf9ad64d7
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 2a5a549cadef0c93f502b9ce8cf40ea7105db57292819887b4200cea58e1e225c10a83527a75f489e4963560f837fa4bfa3dd4f197e818c626c075951f4b12db
|
|
7
|
+
data.tar.gz: a3be425bb15d15f483612622b5c1995e05bfe9d2451f48c4f938a9a4ebe257b7482d9573a6b81d931d463456ad4b6382914988ecf007e6aea00f35e62542c577
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Azmi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# rails-shopee-qris
|
|
2
|
+
|
|
3
|
+
Unofficial Ruby/Rails ShopeePay Partner client, based on `QrisMerchantID` Shopee API logic and `rails-gopay-unofficial` gem structure. Ruby >= 3.1; no runtime dependencies beyond stdlib.
|
|
4
|
+
|
|
5
|
+
Use only with merchant authorization. Internal Shopee APIs can change. Live Shopee login/payment acceptance has not been verified here; offline regressions and local HTTP smoke checks do not prove provider acceptance. Never log tokens, passwords, device reports, session cookies, or raw payment data.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
Until published, use a local path:
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
gem "rails-shopee-qris", path: "../rails-shopee-qris"
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
require "rails/shopee/qris"
|
|
17
|
+
|
|
18
|
+
Rails::Shopee::Qris.configure do |config|
|
|
19
|
+
config.static_qris = Rails.application.credentials.dig(:shopee, :static_qris)
|
|
20
|
+
config.phone_number = Rails.application.credentials.dig(:shopee, :phone_number)
|
|
21
|
+
config.device_report = Rails.application.credentials.dig(:shopee, :device_report)
|
|
22
|
+
end
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Direct merchant token
|
|
26
|
+
|
|
27
|
+
Use the inner `token` value (`B:...`), not the entire JWT from the `__shopee_partner_website_x_token_live` cookie. `Setup#read_merchant_credential` decodes that cookie payload; it does not verify the JWT signature. Provider validates credentials on requests.
|
|
28
|
+
|
|
29
|
+
```ruby
|
|
30
|
+
client = Rails::Shopee::Qris::Client.new(
|
|
31
|
+
token: "B:...", store_id: "12345", merchant_id: "67890"
|
|
32
|
+
)
|
|
33
|
+
stores = client.list_stores
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
A manually supplied token cannot silently renew. Reconnect when rejected.
|
|
37
|
+
|
|
38
|
+
## OTP login
|
|
39
|
+
|
|
40
|
+
Shopee device-risk telemetry must come from your own authenticated browser's request to `https://df.infra.sz.shopee.co.id/v2/shpsec/web/report`. Supply the captured request body as `device_report`; an empty report can result in silently suppressed OTP delivery. Password required for password-protected accounts. Captcha challenges need browser resolution; library does not bypass them.
|
|
41
|
+
|
|
42
|
+
```ruby
|
|
43
|
+
setup = Rails::Shopee::Qris::Setup.new
|
|
44
|
+
challenge = setup.request_otp(
|
|
45
|
+
"081234567890", password: "account password", channel: 3,
|
|
46
|
+
device_report: Rails.application.credentials.dig(:shopee, :device_report)
|
|
47
|
+
)
|
|
48
|
+
# Encrypt/persist entire challenge between requests.
|
|
49
|
+
outcome = setup.login_with_otp(challenge, "123456")
|
|
50
|
+
|
|
51
|
+
if outcome[:status] == "merchant-selection-required"
|
|
52
|
+
verification = outcome[:verification]
|
|
53
|
+
merchant_id = outcome[:merchants].find { |m| m[:is_active] && !m[:is_banned] }.fetch(:id)
|
|
54
|
+
# In production, let merchant choose instead of taking first available.
|
|
55
|
+
shopee_session = setup.complete_login(verification, merchant_id: merchant_id)
|
|
56
|
+
else
|
|
57
|
+
shopee_session = outcome.fetch(:session)
|
|
58
|
+
end
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Channels: `1` SMS, `2` voice, `3` WhatsApp, `4` email, `5` Zalo; availability comes from provider. `verify_otp(challenge, otp)` also returns verification separately, reusable by `complete_login(verification, merchant_id:, store_id:)`. Requested store must belong to selected merchant; multiple stores can leave `store_id` unset, requiring explicit selection before transaction reconciliation.
|
|
62
|
+
|
|
63
|
+
Persist **entire** session encrypted, including `cookies`, `merchant`, `merchants`, and `switch_credential`. These are credentials, not safe client-side session data.
|
|
64
|
+
|
|
65
|
+
```ruby
|
|
66
|
+
serialized = JSON.generate(shopee_session)
|
|
67
|
+
restored = JSON.parse(serialized, symbolize_names: true)
|
|
68
|
+
client = Rails::Shopee::Qris::Client.new(session: restored)
|
|
69
|
+
client.refresh! # Account session must still be alive; otherwise new OTP needed.
|
|
70
|
+
restored = client.session # Persist after renewal.
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Session hashes use symbol keys. Parse stored JSON with `symbolize_names: true`. Expiry is advisory; provider login-status determines account session validity.
|
|
74
|
+
|
|
75
|
+
## Dynamic QRIS
|
|
76
|
+
|
|
77
|
+
```ruby
|
|
78
|
+
payment = client.create_qris(amount: 50_321, reference: "ORDER-1002")
|
|
79
|
+
# { qris_id:, qris_code:, amount:, reference: }
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Static payload must have valid CRC, Indonesian country tag, merchant tag, and static initiation tag `01=11`. Generation sets `01=12`, injects whole-rupiah tag `54`, recalculates CRC-16/CCITT-FALSE. Amount must be positive integer or digit-only string; fractional amounts are rejected, never truncated. QR rendering is caller-owned (for example `rqrcode`).
|
|
83
|
+
|
|
84
|
+
`qris_id` and `reference` are local metadata, not provider-created order IDs and not transmitted in QRIS. Amount-only reconciliation cannot distinguish simultaneous equal-amount invoices. Assign distinct amounts/time windows, exclude already-consumed transaction IDs, and enforce DB uniqueness/atomic settlement in application.
|
|
85
|
+
|
|
86
|
+
```ruby
|
|
87
|
+
qris = Rails::Shopee::Qris::Qris
|
|
88
|
+
qris.valid_static?(static_payload)
|
|
89
|
+
qris.parse(static_payload)
|
|
90
|
+
qris.generate(static_payload, 75_000)
|
|
91
|
+
qris.crc16("123456789") # "29B1"
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Transactions and reconciliation
|
|
95
|
+
|
|
96
|
+
```ruby
|
|
97
|
+
transactions = client.transactions_between(
|
|
98
|
+
start_time: invoice.created_at - 60, end_time: Time.now
|
|
99
|
+
)
|
|
100
|
+
matched = transactions.filter_map do |transaction|
|
|
101
|
+
client.match_transaction(
|
|
102
|
+
transaction, amount: invoice.amount,
|
|
103
|
+
created_at: invoice.created_at, expires_at: invoice.expires_at
|
|
104
|
+
)
|
|
105
|
+
end.first
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Wire amounts are whole rupiah: `"409.662"` means `409662`, **not cents**. Feed requests use epoch seconds, services `[1, 3]`, page size at most 10. Rows must have proven store scope and configured merchant scope. Only numeric status `3` completes payment; missing ID/scope, malformed amount/time, wrong status, or out-of-window payment cannot match. Window is `[created_at - 60 seconds, expires_at]`.
|
|
109
|
+
|
|
110
|
+
Cursor cycles and pagination ceilings raise `Rails::Shopee::Qris::Error`, rather than silently treating incomplete history as complete. Defaults: 20 transaction pages, 10 store pages; increase `max_pages` when needed. Store discovery retries without service filter only when filtered result is empty.
|
|
111
|
+
|
|
112
|
+
Issuer comes from detail endpoint, not transaction feed:
|
|
113
|
+
|
|
114
|
+
```ruby
|
|
115
|
+
transaction = transactions.first
|
|
116
|
+
order_sn = transaction.fetch(:raw)[:displayTransactionId] || transaction.fetch(:id)
|
|
117
|
+
detail = client.transaction_detail(order_sn)
|
|
118
|
+
issuer = detail[:issuer]
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Persist `client.session` if token renews during polling. Background jobs, scheduling, transaction deduplication, and invoice settlement remain caller-owned.
|
|
122
|
+
|
|
123
|
+
## Errors
|
|
124
|
+
|
|
125
|
+
```ruby
|
|
126
|
+
begin
|
|
127
|
+
client.list_stores
|
|
128
|
+
rescue Rails::Shopee::Qris::Error => error
|
|
129
|
+
# Inspect error.status / error.code. Do not log error.payload (may contain credentials).
|
|
130
|
+
end
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Invalid provider envelopes, HTTP failures, expired credentials, malformed inputs, and incomplete pagination fail explicitly. Dead account session needs fresh OTP.
|
|
134
|
+
|
|
135
|
+
## Verification
|
|
136
|
+
|
|
137
|
+
```sh
|
|
138
|
+
ruby -Ilib:test -e 'Dir["test/**/*_test.rb"].each { |f| require_relative f }'
|
|
139
|
+
gem build rails-shopee-qris.gemspec
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## RubyGems release
|
|
143
|
+
|
|
144
|
+
Package `0.1.0` with `gem build rails-shopee-qris.gemspec`. Authenticate via `gem signin` (or configure a scoped `GEM_HOST_API_KEY` with push permission), then:
|
|
145
|
+
|
|
146
|
+
```sh
|
|
147
|
+
gem push rails-shopee-qris-0.1.0.gem --host https://rubygems.org
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
RubyGems versions are immutable. Bump gemspec version before subsequent releases. Built `.gem` files and credentials must not be committed.
|
|
151
|
+
|
|
152
|
+
MIT license; see [LICENSE.txt](LICENSE.txt).
|
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rails
|
|
4
|
+
module Shopee
|
|
5
|
+
module Qris
|
|
6
|
+
class Client
|
|
7
|
+
PAY_BASE_URL = "https://shopeepay.shopee.co.id"
|
|
8
|
+
PARTNER_BASE_URL = "https://partner.shopee.co.id"
|
|
9
|
+
TRANSACTIONS_URL = "#{PAY_BASE_URL}/merchant/v1/partner-web/get-transaction-list"
|
|
10
|
+
STORES_URL = "#{PAY_BASE_URL}/merchant/v1/partner-web/get-store-list"
|
|
11
|
+
TRANSACTION_DETAIL_URL = "#{PAY_BASE_URL}/merchant/v1/partner-web/get-transaction-detail"
|
|
12
|
+
USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:153.0) Gecko/20100101 Firefox/153.0"
|
|
13
|
+
COMPLETED_STATUS = 3
|
|
14
|
+
STATUS_NAMES = { 1 => "pending", 2 => "failed", 3 => "success", 4 => "refunded", 5 => "expired" }.freeze
|
|
15
|
+
INVALID_TOKEN_CODES = %w[200020 2010000].freeze
|
|
16
|
+
|
|
17
|
+
def initialize(
|
|
18
|
+
token: nil,
|
|
19
|
+
store_id: nil,
|
|
20
|
+
merchant_id: nil,
|
|
21
|
+
session: nil,
|
|
22
|
+
phone_number: nil,
|
|
23
|
+
device_id: nil,
|
|
24
|
+
expires_at: nil
|
|
25
|
+
)
|
|
26
|
+
config = Rails::Shopee::Qris.configuration
|
|
27
|
+
raise Error, "Shopee session must be an object" if session && !session.is_a?(Hash)
|
|
28
|
+
@session = session && JSON.parse(JSON.generate(session), symbolize_names: true)
|
|
29
|
+
@token = token || @session&.dig(:token) || config.token
|
|
30
|
+
@store_id = (store_id || @session&.dig(:store_id) || config.store_id)&.to_s
|
|
31
|
+
@merchant_id = (merchant_id || @session&.dig(:merchant, :id) || @session&.dig(:merchant_id) || config.merchant_id)&.to_s
|
|
32
|
+
@phone_number = phone_number || @session&.dig(:phone_number) || config.phone_number
|
|
33
|
+
@device_id = device_id || @session&.dig(:switch_credential, :spc_clientid)
|
|
34
|
+
expiry = expires_at || @session&.dig(:expires_at)
|
|
35
|
+
@expires_at = extract_time(expiry)
|
|
36
|
+
raise Error, "Invalid Shopee session expiry" if expiry && !@expires_at
|
|
37
|
+
@session[:expires_at] = @expires_at if @session && expiry
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
attr_reader :token, :store_id, :merchant_id, :session, :phone_number, :device_id, :expires_at
|
|
41
|
+
def create_qris(amount:, reference: nil, static_qris: Rails::Shopee::Qris.configuration.static_qris)
|
|
42
|
+
raise Error, "Shopee static QRIS is not configured" if static_qris.to_s.empty?
|
|
43
|
+
|
|
44
|
+
code = Qris.generate(static_qris, amount)
|
|
45
|
+
amount = Integer(amount)
|
|
46
|
+
{
|
|
47
|
+
qris_id: SecureRandom.hex(16),
|
|
48
|
+
qris_code: code,
|
|
49
|
+
amount: amount,
|
|
50
|
+
reference: reference
|
|
51
|
+
}
|
|
52
|
+
rescue ArgumentError, TypeError
|
|
53
|
+
raise Error, "Invalid payment amount"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def refresh!
|
|
57
|
+
unless session.is_a?(Hash) && session[:switch_credential].is_a?(Hash)
|
|
58
|
+
raise Error, "Shopee session cannot be refreshed without full session credentials; reconnect with OTP"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
updated = Setup.new.refresh_session(session)
|
|
62
|
+
@session = updated
|
|
63
|
+
@token = updated[:token]
|
|
64
|
+
@expires_at = extract_time(updated[:expires_at])
|
|
65
|
+
self
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def transactions_between(start_time:, end_time:, store_id: @store_id, page_size: 10, max_pages: 20)
|
|
69
|
+
refresh! if expires_at && expires_at <= Time.now + 900 && session
|
|
70
|
+
|
|
71
|
+
start_ts = start_time.respond_to?(:to_i) ? start_time.to_i : Integer(start_time)
|
|
72
|
+
end_ts = end_time.respond_to?(:to_i) ? end_time.to_i : Integer(end_time)
|
|
73
|
+
raise Error, "Shopee transaction time range is invalid" if start_ts > end_ts
|
|
74
|
+
|
|
75
|
+
size = [[1, page_size.to_i].max, 10].min
|
|
76
|
+
pages_limit = [1, max_pages.to_i].max
|
|
77
|
+
want_store = store_id&.to_s
|
|
78
|
+
raise Error, "Shopee store ID is missing" if want_store.to_s.strip.empty?
|
|
79
|
+
|
|
80
|
+
all = []
|
|
81
|
+
seen_ids = {}
|
|
82
|
+
seen_cursors = {}
|
|
83
|
+
next_position = ""
|
|
84
|
+
|
|
85
|
+
pages_limit.times do
|
|
86
|
+
body = {
|
|
87
|
+
pageSize: size,
|
|
88
|
+
filter: {
|
|
89
|
+
startTime: start_ts,
|
|
90
|
+
endTime: end_ts,
|
|
91
|
+
serviceList: [1, 3]
|
|
92
|
+
},
|
|
93
|
+
sorter: { field: "createTime", order: "descend" },
|
|
94
|
+
next_position: next_position
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
resp = request_payment(TRANSACTIONS_URL, body)
|
|
98
|
+
data = Response.data(resp)
|
|
99
|
+
list = data[:list].is_a?(Array) ? data[:list] : []
|
|
100
|
+
|
|
101
|
+
list.each do |raw|
|
|
102
|
+
tx = normalize_transaction(raw, want_store)
|
|
103
|
+
next if tx.nil? || seen_ids[tx[:id]]
|
|
104
|
+
|
|
105
|
+
seen_ids[tx[:id]] = true
|
|
106
|
+
all << tx
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
cursor = data[:next_position]
|
|
110
|
+
raise Error, "Shopee transaction cursor is invalid" unless cursor.nil? || cursor.is_a?(String)
|
|
111
|
+
return all if cursor.to_s.empty?
|
|
112
|
+
raise Error, "Shopee transaction cursor did not advance" if cursor == next_position || seen_cursors[cursor]
|
|
113
|
+
|
|
114
|
+
seen_cursors[cursor] = true
|
|
115
|
+
next_position = cursor
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
raise Error, "Shopee transaction pagination limit was reached"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def recent_transactions(minutes: 60, store_id: @store_id)
|
|
122
|
+
now = Time.now
|
|
123
|
+
transactions_between(start_time: now - (minutes * 60), end_time: now, store_id: store_id)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def transaction_detail(order_sn)
|
|
127
|
+
order_sn = order_sn.to_s.strip
|
|
128
|
+
raise Error, "Shopee transaction_detail needs a non-blank order_sn" if order_sn.empty?
|
|
129
|
+
|
|
130
|
+
resp = request_payment(TRANSACTION_DETAIL_URL, { order_sn: order_sn })
|
|
131
|
+
data = Response.data(resp)
|
|
132
|
+
{ order_sn: order_sn, issuer: data[:issuer], raw: data }
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def list_stores(max_pages: 10)
|
|
136
|
+
stores = fetch_stores(max_pages, [1, 10])
|
|
137
|
+
return stores if stores && !stores.empty?
|
|
138
|
+
|
|
139
|
+
fetch_stores(max_pages, nil) || []
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def match_transaction(transaction, amount:, created_at:, expires_at:, store_id: @store_id)
|
|
143
|
+
return unless transaction.is_a?(Hash)
|
|
144
|
+
|
|
145
|
+
return unless transaction[:status].is_a?(Integer) && transaction[:status] == COMPLETED_STATUS
|
|
146
|
+
|
|
147
|
+
tx_store = transaction[:store_id] || transaction[:storeId]
|
|
148
|
+
return if store_id.to_s.strip.empty? || scope_id(tx_store) != store_id.to_s
|
|
149
|
+
tx_merchant = transaction[:merchant_id] || transaction[:merchantId]
|
|
150
|
+
return if merchant_id && scope_id(tx_merchant) != merchant_id
|
|
151
|
+
|
|
152
|
+
target_amount = parse_amount(amount)
|
|
153
|
+
return unless target_amount && target_amount.positive?
|
|
154
|
+
return unless amount_matches?(transaction, target_amount)
|
|
155
|
+
|
|
156
|
+
raw_time = transaction[:create_time] || transaction[:create_time_iso] || transaction[:transaction_time] || transaction[:createTime]
|
|
157
|
+
paid_at = extract_time(raw_time)
|
|
158
|
+
return if paid_at.nil?
|
|
159
|
+
|
|
160
|
+
start_window = extract_time(created_at)
|
|
161
|
+
end_window = extract_time(expires_at)
|
|
162
|
+
return unless start_window && end_window && start_window <= end_window
|
|
163
|
+
return if paid_at < start_window - 60 || paid_at > end_window
|
|
164
|
+
|
|
165
|
+
id = transaction[:id] || transaction[:transaction_id] || transaction[:transactionId]
|
|
166
|
+
return unless id.is_a?(String) && !id.strip.empty?
|
|
167
|
+
order_id = transaction[:order_id] || transaction[:externalTransactionId] || transaction[:displayTransactionId] || id
|
|
168
|
+
issuer = transaction[:issuer] || transaction[:payer_issuer] || "ShopeePay / QRIS"
|
|
169
|
+
|
|
170
|
+
{
|
|
171
|
+
transaction_id: id.strip,
|
|
172
|
+
order_id: order_id.to_s,
|
|
173
|
+
payer_issuer: issuer,
|
|
174
|
+
transaction_time: paid_at,
|
|
175
|
+
amount: target_amount
|
|
176
|
+
}
|
|
177
|
+
rescue ArgumentError, TypeError, RangeError
|
|
178
|
+
nil
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def parse_amount(value)
|
|
182
|
+
return nil if value.nil?
|
|
183
|
+
|
|
184
|
+
text = value.to_s.strip
|
|
185
|
+
return nil unless text.match?(/\A\d+\z/) || text.match?(/\A\d{1,3}(?:\.\d{3})+\z/)
|
|
186
|
+
|
|
187
|
+
amount = text.delete(".").to_i
|
|
188
|
+
amount.positive? ? amount : nil
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
private
|
|
192
|
+
|
|
193
|
+
def fetch_stores(max_pages, service_list)
|
|
194
|
+
pages = [1, max_pages.to_i].max
|
|
195
|
+
stores = {}
|
|
196
|
+
seen_cursors = { 0 => true }
|
|
197
|
+
last_store_id = 0
|
|
198
|
+
|
|
199
|
+
pages.times do
|
|
200
|
+
body = {
|
|
201
|
+
storeName: "",
|
|
202
|
+
lastStoreId: last_store_id,
|
|
203
|
+
pageSize: 30
|
|
204
|
+
}
|
|
205
|
+
body[:serviceList] = service_list if service_list
|
|
206
|
+
|
|
207
|
+
data = Response.data(request_payment(STORES_URL, body))
|
|
208
|
+
list = data[:list].is_a?(Array) ? data[:list] : []
|
|
209
|
+
|
|
210
|
+
list.each do |raw|
|
|
211
|
+
store = normalize_store(raw)
|
|
212
|
+
stores[store[:id]] = store if store
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
total = data[:storeCount]
|
|
216
|
+
total_reached = total.is_a?(Integer) && total >= 0 && stores.size >= total
|
|
217
|
+
return (stores.values.empty? ? nil : stores.values) if list.empty? || list.size < 30 || total_reached
|
|
218
|
+
|
|
219
|
+
last_raw = list.last
|
|
220
|
+
cursor_id = last_raw.is_a?(Hash) ? scope_id(last_raw[:storeId]) : nil
|
|
221
|
+
next_cursor = cursor_id&.match?(/\A\d+\z/) ? cursor_id.to_i : nil
|
|
222
|
+
raise Error, "Shopee store cursor did not advance" if next_cursor.nil? || seen_cursors[next_cursor]
|
|
223
|
+
|
|
224
|
+
seen_cursors[next_cursor] = true
|
|
225
|
+
last_store_id = next_cursor
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
raise Error, "Shopee store pagination limit was reached"
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def normalize_store(raw)
|
|
232
|
+
return nil unless raw.is_a?(Hash)
|
|
233
|
+
|
|
234
|
+
store_id = scope_id(raw[:storeId])
|
|
235
|
+
return nil unless store_id
|
|
236
|
+
|
|
237
|
+
{
|
|
238
|
+
id: store_id.to_s,
|
|
239
|
+
name: raw[:storeName].to_s,
|
|
240
|
+
status: raw[:status].is_a?(Integer) ? raw[:status] : 0
|
|
241
|
+
}
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def normalize_transaction(raw, want_store)
|
|
245
|
+
return nil unless raw.is_a?(Hash)
|
|
246
|
+
|
|
247
|
+
tx_id = raw[:transactionId] || raw[:id]
|
|
248
|
+
return nil unless tx_id.is_a?(String) && !tx_id.strip.empty?
|
|
249
|
+
tx_id = tx_id.strip
|
|
250
|
+
|
|
251
|
+
amount = parse_amount(raw[:amount] || raw[:amount_idr])
|
|
252
|
+
return nil if amount.nil?
|
|
253
|
+
|
|
254
|
+
create_time = raw[:createTime] || raw[:create_time]
|
|
255
|
+
return nil unless create_time.is_a?(Numeric) && create_time.finite?
|
|
256
|
+
moment = extract_time(create_time)
|
|
257
|
+
return nil unless moment
|
|
258
|
+
|
|
259
|
+
ts = moment.to_i
|
|
260
|
+
tx_store = scope_id(raw[:storeId] || raw[:store_id])
|
|
261
|
+
return nil if tx_store != want_store
|
|
262
|
+
tx_merchant = scope_id(raw[:merchantId] || raw[:merchant_id])
|
|
263
|
+
return nil if merchant_id && tx_merchant != merchant_id
|
|
264
|
+
|
|
265
|
+
status = raw[:status].is_a?(Integer) ? raw[:status] : -1
|
|
266
|
+
completed = status == COMPLETED_STATUS
|
|
267
|
+
order_id = raw[:externalTransactionId] || raw[:displayTransactionId] || tx_id
|
|
268
|
+
service = raw[:service] || raw[:transactionType] || "1"
|
|
269
|
+
|
|
270
|
+
{
|
|
271
|
+
id: tx_id,
|
|
272
|
+
order_id: order_id.to_s,
|
|
273
|
+
amount_idr: amount,
|
|
274
|
+
create_time: ts,
|
|
275
|
+
create_time_iso: Time.at(ts).utc.strftime("%Y-%m-%dT%H:%M:%S.000Z"),
|
|
276
|
+
store_id: tx_store&.to_s,
|
|
277
|
+
merchant_id: tx_merchant,
|
|
278
|
+
status: status,
|
|
279
|
+
status_name: STATUS_NAMES.fetch(status, "unknown_#{status}"),
|
|
280
|
+
completed: completed,
|
|
281
|
+
payment_type: "shopee:#{service}",
|
|
282
|
+
raw: raw
|
|
283
|
+
}
|
|
284
|
+
rescue ArgumentError, TypeError, RangeError
|
|
285
|
+
nil
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def amount_matches?(transaction, target)
|
|
289
|
+
amount = parse_amount(transaction[:amount_idr] || transaction[:amount])
|
|
290
|
+
amount == target
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def scope_id(value)
|
|
294
|
+
return unless value.is_a?(String) || value.is_a?(Integer)
|
|
295
|
+
|
|
296
|
+
value = value.to_s.strip
|
|
297
|
+
value unless value.empty?
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def extract_time(value)
|
|
301
|
+
return nil if value.nil?
|
|
302
|
+
return value if value.is_a?(Time)
|
|
303
|
+
return Time.at(value) if value.is_a?(Numeric) && value.finite?
|
|
304
|
+
return nil unless value.is_a?(String) && !value.strip.empty?
|
|
305
|
+
|
|
306
|
+
Time.parse(value)
|
|
307
|
+
rescue ArgumentError, TypeError, RangeError
|
|
308
|
+
nil
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def request_payment(url, inner_data, retried: false)
|
|
312
|
+
raise Error, "Shopee merchant token is missing; configure token or login with OTP" if token.to_s.empty?
|
|
313
|
+
|
|
314
|
+
payload = {
|
|
315
|
+
data: {
|
|
316
|
+
metadata: {
|
|
317
|
+
token: token,
|
|
318
|
+
language: "id",
|
|
319
|
+
timezone: "Asia/Jakarta"
|
|
320
|
+
},
|
|
321
|
+
**inner_data
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
resp = request(:post, url, headers: payment_headers, body: payload)
|
|
326
|
+
raise Error, "Shopee returned a non-object response" unless resp.is_a?(Hash)
|
|
327
|
+
code = resp[:code].to_s
|
|
328
|
+
|
|
329
|
+
if (code != "0" && INVALID_TOKEN_CODES.include?(code)) && session && !retried
|
|
330
|
+
refresh!
|
|
331
|
+
return request_payment(url, inner_data, retried: true)
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
err = Response.error(resp)
|
|
335
|
+
raise Error.new(err || "ShopeePay request failed", nil, resp[:code], resp) if err
|
|
336
|
+
Response.data(resp)
|
|
337
|
+
|
|
338
|
+
resp
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def payment_headers
|
|
342
|
+
{
|
|
343
|
+
"Accept" => "application/json",
|
|
344
|
+
"Accept-Language" => "id,en-US;q=0.9,en;q=0.8",
|
|
345
|
+
"Content-Type" => "application/json",
|
|
346
|
+
"Origin" => PARTNER_BASE_URL,
|
|
347
|
+
"Referer" => "#{PARTNER_BASE_URL}/",
|
|
348
|
+
"User-Agent" => USER_AGENT,
|
|
349
|
+
"X-Timestamp-Ms" => (Time.now.to_f * 1000).to_i.to_s,
|
|
350
|
+
"X-Token" => ""
|
|
351
|
+
}
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def request(method, url, headers:, body: nil)
|
|
355
|
+
uri = URI(url)
|
|
356
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
357
|
+
http.use_ssl = (uri.scheme == "https")
|
|
358
|
+
http.open_timeout = 10
|
|
359
|
+
http.read_timeout = 15
|
|
360
|
+
|
|
361
|
+
req = Net::HTTP.const_get(method.to_s.capitalize).new(uri)
|
|
362
|
+
headers.each { |k, v| req[k] = v }
|
|
363
|
+
req.body = JSON.generate(body) if body
|
|
364
|
+
|
|
365
|
+
response = http.request(req)
|
|
366
|
+
parsed = JSON.parse(response.body, symbolize_names: true)
|
|
367
|
+
|
|
368
|
+
unless response.code.to_i.between?(200, 299)
|
|
369
|
+
raise Error.new(Response.error(parsed) || "Shopee request failed (HTTP #{response.code})", response.code.to_i, parsed.is_a?(Hash) ? parsed[:code] : nil, parsed)
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
parsed
|
|
373
|
+
rescue JSON::ParserError
|
|
374
|
+
raise Error, "Shopee returned an invalid response"
|
|
375
|
+
rescue Net::OpenTimeout, Net::ReadTimeout, SocketError, Errno::ECONNREFUSED => error
|
|
376
|
+
raise Error, "Shopee network error: #{error.message}"
|
|
377
|
+
end
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
end
|
|
381
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rails
|
|
4
|
+
module Shopee
|
|
5
|
+
module Qris
|
|
6
|
+
class Error < StandardError
|
|
7
|
+
attr_reader :status, :code, :payload
|
|
8
|
+
|
|
9
|
+
def initialize(message, status = nil, code = nil, payload = nil)
|
|
10
|
+
@status = status
|
|
11
|
+
@code = code
|
|
12
|
+
@payload = payload
|
|
13
|
+
super(message)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rails
|
|
4
|
+
module Shopee
|
|
5
|
+
module Qris
|
|
6
|
+
module Qris
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def generate(template, amount)
|
|
10
|
+
unless amount.is_a?(Integer) || (amount.is_a?(String) && amount.match?(/\A\d+\z/))
|
|
11
|
+
raise Error, "Invalid payment amount"
|
|
12
|
+
end
|
|
13
|
+
amount = Integer(amount)
|
|
14
|
+
raise Error, "Payment amount must be greater than zero" unless amount.positive?
|
|
15
|
+
raise Error, "Payment amount exceeds QRIS field length" if amount.to_s.bytesize > 99
|
|
16
|
+
|
|
17
|
+
tags = parse(template)
|
|
18
|
+
raise Error, "Invalid static QRIS" unless valid_static?(template, tags)
|
|
19
|
+
|
|
20
|
+
tags.map! { |tag, value| tag == "01" ? [tag, "12"] : [tag, value] }
|
|
21
|
+
tags.reject! { |tag,| tag == "54" }
|
|
22
|
+
tags.insert(tags.index { |tag,| tag == "58" } || tags.length, ["54", amount.to_s])
|
|
23
|
+
payload = tags.map { |tag, value| "#{tag}#{value.bytesize.to_s.rjust(2, "0")}#{value}" }.join + "6304"
|
|
24
|
+
payload + crc16(payload)
|
|
25
|
+
rescue ArgumentError, TypeError
|
|
26
|
+
raise Error, "Invalid payment amount"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def parse(payload)
|
|
30
|
+
source = payload.to_s.strip.sub(/6304[0-9A-Fa-f]{4}\z/, "")
|
|
31
|
+
tags = []
|
|
32
|
+
offset = 0
|
|
33
|
+
while offset < source.bytesize
|
|
34
|
+
tag = source.byteslice(offset, 2)
|
|
35
|
+
length_text = source.byteslice(offset + 2, 2)
|
|
36
|
+
return [] unless length_text&.match?(/\A\d{2}\z/)
|
|
37
|
+
|
|
38
|
+
length = Integer(length_text, 10)
|
|
39
|
+
value = source.byteslice(offset + 4, length)
|
|
40
|
+
return [] unless tag&.match?(/\A\d{2}\z/) && value&.bytesize == length
|
|
41
|
+
|
|
42
|
+
tags << [tag, value]
|
|
43
|
+
offset += 4 + length
|
|
44
|
+
end
|
|
45
|
+
tags
|
|
46
|
+
rescue ArgumentError, TypeError
|
|
47
|
+
[]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def crc16(payload)
|
|
51
|
+
crc = payload.each_byte.reduce(0xffff) do |checksum, byte|
|
|
52
|
+
checksum ^= byte << 8
|
|
53
|
+
8.times { checksum = (checksum & 0x8000).zero? ? checksum << 1 : (checksum << 1) ^ 0x1021; checksum &= 0xffff }
|
|
54
|
+
checksum
|
|
55
|
+
end
|
|
56
|
+
crc.to_s(16).upcase.rjust(4, "0")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def valid_static?(template, tags = parse(template))
|
|
60
|
+
source = template.to_s.strip
|
|
61
|
+
source.match?(/6304[0-9A-Fa-f]{4}\z/) && crc16(source[0...-4]) == source[-4, 4].upcase &&
|
|
62
|
+
tags.assoc("00")&.last == "01" && tags.assoc("01")&.last == "11" &&
|
|
63
|
+
tags.assoc("58")&.last == "ID" && tags.any? { |tag,| (26..51).cover?(tag.to_i) } &&
|
|
64
|
+
tags.map(&:first).uniq.length == tags.length && !tags.assoc("63")
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|