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 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