rails-shopee-qris 0.1.0 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c4abd75453a929debadba31df32047103e52839b892f4cd410446bec2175a292
4
- data.tar.gz: 8cf2072749c406cfec500aedd4f0e8d1cb1dd895e589e360e5a86f8cf9ad64d7
3
+ metadata.gz: 5f301fa2107316c8aa94a3ab8946fc318bbbb73d0306c9d0b08290648437e2c1
4
+ data.tar.gz: d35eb2223fdad00dfc593ec82eb4c926652aa97a15ccea640177f1d9b6c31b3b
5
5
  SHA512:
6
- metadata.gz: 2a5a549cadef0c93f502b9ce8cf40ea7105db57292819887b4200cea58e1e225c10a83527a75f489e4963560f837fa4bfa3dd4f197e818c626c075951f4b12db
7
- data.tar.gz: a3be425bb15d15f483612622b5c1995e05bfe9d2451f48c4f938a9a4ebe257b7482d9573a6b81d931d463456ad4b6382914988ecf007e6aea00f35e62542c577
6
+ metadata.gz: e4a3757eb7bd2e386a1774bafdb50b8486a51dd3265c6513b75c2039bac9d3e4ec6a8c0760794de5cbdf6fdba45b0cefee97e16aeb988dcb1e2ae8b11180f443
7
+ data.tar.gz: 84a529743925b2494c02b9cd9c5a322f7efda75dc83c630e258a7833a6c59fb9a7dbfc1f04b31f45d743f1d265032da7f39c3c5d0d28fd960c903dcb636b11b3
data/CHANGELOG.md ADDED
@@ -0,0 +1,37 @@
1
+ # Changelog
2
+
3
+ ## 0.2.0
4
+
5
+ Verified against a live ShopeePay Partner merchant account (store listing, transaction
6
+ feed, transaction detail, device-risk, OTP request).
7
+
8
+ ### Added
9
+ - `Client` accepts either the raw `B:...` merchant token or the whole
10
+ `__shopee_partner_website_x_token_live` JWT cookie, extracting the inner token,
11
+ `userid` and `exp`.
12
+ - `Client#refreshable?` tells callers whether a session can silently renew.
13
+ - Provider error codes are translated into actionable text
14
+ (`10002`, `200020`, `2010000`, `48401003`, `48401102`, `48401103`, `48500102`).
15
+ - `Setup` exposes `CHANNEL_NAMES`; unavailable OTP channels are reported with the
16
+ available list.
17
+ - `test/response_test.rb` coverage for the error mapping.
18
+
19
+ ### Fixed
20
+ - **Payment envelope shape.** Request fields were nested under `data.metadata`; Shopee
21
+ expects them as siblings of `metadata`. Store listing and the transaction feed never
22
+ worked before this.
23
+ - **`10002` aborted every non-password account.** It means "no password set" — the OTP
24
+ flow now continues instead of raising.
25
+ - **Wrong refresh error.** A rejected manual token raised "session cannot be refreshed";
26
+ it now reports the real rejection and its code. Proactive refresh only runs when the
27
+ session actually has renewal credentials.
28
+ - **Duplicate-key crash.** `Client` symbolized sessions via `JSON.generate`, which raised
29
+ on hashes carrying both `"token"` and `:token`.
30
+ - **`48401003`** is now reported as a wrong/expired OTP rather than a bare code.
31
+ - QRIS generation rejects fractional and oversized amounts instead of truncating them,
32
+ and parsing rejects non-decimal lengths and duplicate tags.
33
+
34
+ ## 0.1.0
35
+
36
+ Initial release: dynamic QRIS generation, static QRIS validation, CRC-16, store listing,
37
+ transaction feed, payment matching, OTP login flow.
data/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
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
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.
5
+ Use only with merchant authorization. Internal Shopee APIs can change. Read paths (`get-store-list`, `get-transaction-list`, `get-transaction-detail`, device-risk, OTP request/verify) have been exercised against a live merchant account; actual money settlement has not. Never log tokens, passwords, device reports, session cookies, or raw payment data.
6
6
 
7
7
  ## Installation
8
8
 
@@ -24,7 +24,9 @@ end
24
24
 
25
25
  ## Direct merchant token
26
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.
27
+ Pass either the inner `B:...` token, or the whole `__shopee_partner_website_x_token_live` cookie value — the client decodes that JWT and pulls out the inner `token` (plus `userid` and `exp`). It does not verify the JWT signature; Shopee validates credentials on each request.
28
+
29
+ Get a fresh token: log in at `partner.shopee.co.id` → DevTools → Network → open the ShopeePay transaction history page → click `get-transaction-list` → Request Payload → `data.metadata.token`.
28
30
 
29
31
  ```ruby
30
32
  client = Rails::Shopee::Qris::Client.new(
@@ -33,7 +35,7 @@ client = Rails::Shopee::Qris::Client.new(
33
35
  stores = client.list_stores
34
36
  ```
35
37
 
36
- A manually supplied token cannot silently renew. Reconnect when rejected.
38
+ Tokens rotate when the portal session refreshes; `200020` means paste a new one. A manually supplied token cannot silently renew — only an OTP session carries `switch_credential` and can call `refresh!`.
37
39
 
38
40
  ## OTP login
39
41
 
@@ -58,7 +60,7 @@ else
58
60
  end
59
61
  ```
60
62
 
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.
63
+ Channels: `1` SMS, `2` voice, `3` WhatsApp, `4` email, `5` Zalo. Omit `channel:` to use the channel Shopee recommends for that account; asking for an unavailable one raises with the available list. `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
64
 
63
65
  Persist **entire** session encrypted, including `cookies`, `merchant`, `merchants`, and `switch_credential`. These are credentials, not safe client-side session data.
64
66
 
@@ -130,7 +132,17 @@ rescue Rails::Shopee::Qris::Error => error
130
132
  end
131
133
  ```
132
134
 
133
- Invalid provider envelopes, HTTP failures, expired credentials, malformed inputs, and incomplete pagination fail explicitly. Dead account session needs fresh OTP.
135
+ Invalid provider envelopes, HTTP failures, expired credentials, malformed inputs, and incomplete pagination fail explicitly. Shopee often answers with a bare numeric code and no message; observed codes are translated:
136
+
137
+ | Code | Meaning |
138
+ | --- | --- |
139
+ | `10002` | Account has no password set (not an error — the OTP flow continues) |
140
+ | `200020` | Token invalid or expired — paste a fresh one |
141
+ | `2010000` | Request carried no token |
142
+ | `48401003` | OTP code wrong or expired |
143
+ | `48401102` | Password required before an OTP is sent |
144
+ | `48401103` | OTP channel unavailable for this account |
145
+ | `48500102` | Account session expired; log in again with an OTP |
134
146
 
135
147
  ## Verification
136
148
 
@@ -141,12 +153,12 @@ gem build rails-shopee-qris.gemspec
141
153
 
142
154
  ## RubyGems release
143
155
 
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:
156
+ Package with `gem build rails-shopee-qris.gemspec`. Authenticate via `gem signin` (or configure a scoped `GEM_HOST_API_KEY` with push permission), then:
145
157
 
146
158
  ```sh
147
- gem push rails-shopee-qris-0.1.0.gem --host https://rubygems.org
159
+ gem push rails-shopee-qris-0.2.0.gem --host https://rubygems.org
148
160
  ```
149
161
 
150
- RubyGems versions are immutable. Bump gemspec version before subsequent releases. Built `.gem` files and credentials must not be committed.
162
+ RubyGems versions are immutable. Bump the gemspec version before subsequent releases. Built `.gem` files and credentials must not be committed.
151
163
 
152
164
  MIT license; see [LICENSE.txt](LICENSE.txt).
@@ -25,16 +25,22 @@ module Rails
25
25
  )
26
26
  config = Rails::Shopee::Qris.configuration
27
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
28
+ @session = session && deep_symbolize(session)
29
+ raw_token = token || @session&.dig(:token) || config.token
30
+ parsed_token, token_meta = parse_token_input(raw_token)
31
+ @token = parsed_token
30
32
  @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
33
+ @merchant_id = (merchant_id || @session&.dig(:merchant, :id) || @session&.dig(:merchant_id) || token_meta[:account_id] || config.merchant_id)&.to_s
32
34
  @phone_number = phone_number || @session&.dig(:phone_number) || config.phone_number
33
35
  @device_id = device_id || @session&.dig(:switch_credential, :spc_clientid)
34
- expiry = expires_at || @session&.dig(:expires_at)
36
+ expiry = expires_at || @session&.dig(:expires_at) || token_meta[:expires_at]
35
37
  @expires_at = extract_time(expiry)
36
38
  raise Error, "Invalid Shopee session expiry" if expiry && !@expires_at
37
- @session[:expires_at] = @expires_at if @session && expiry
39
+ if @session
40
+ @session[:token] = @token if @token
41
+ @session[:merchant_id] ||= @merchant_id if @merchant_id
42
+ @session[:expires_at] = @expires_at if expiry
43
+ end
38
44
  end
39
45
 
40
46
  attr_reader :token, :store_id, :merchant_id, :session, :phone_number, :device_id, :expires_at
@@ -53,9 +59,14 @@ module Rails
53
59
  raise Error, "Invalid payment amount"
54
60
  end
55
61
 
62
+ def refreshable?
63
+ session.is_a?(Hash) && session[:switch_credential].is_a?(Hash)
64
+ end
65
+
56
66
  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"
67
+ unless refreshable?
68
+ raise Error, "Shopee merchant token is invalid or expired and this session has no renewal " \
69
+ "credentials; log in again with OTP or paste a fresh B:... token"
59
70
  end
60
71
 
61
72
  updated = Setup.new.refresh_session(session)
@@ -66,7 +77,7 @@ module Rails
66
77
  end
67
78
 
68
79
  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
80
+ refresh! if refreshable? && expires_at && expires_at <= Time.now + 900
70
81
 
71
82
  start_ts = start_time.respond_to?(:to_i) ? start_time.to_i : Integer(start_time)
72
83
  end_ts = end_time.respond_to?(:to_i) ? end_time.to_i : Integer(end_time)
@@ -307,6 +318,43 @@ module Rails
307
318
  rescue ArgumentError, TypeError, RangeError
308
319
  nil
309
320
  end
321
+ def deep_symbolize(obj)
322
+ case obj
323
+ when Hash
324
+ obj.each_with_object({}) do |(k, v), acc|
325
+ acc[k.to_sym] = deep_symbolize(v)
326
+ end
327
+ when Array
328
+ obj.map { |item| deep_symbolize(item) }
329
+ else
330
+ obj
331
+ end
332
+ end
333
+
334
+ def parse_token_input(val)
335
+ return [nil, {}] if val.nil?
336
+
337
+ str = val.to_s.strip.gsub(/[\r\n\t ]/, "")
338
+ return [str, {}] if str.start_with?("B:")
339
+
340
+ if str.count(".") == 2
341
+ segments = str.split(".")
342
+ padded = segments[1] + ("=" * (-segments[1].bytesize % 4))
343
+ payload = JSON.parse(Base64.urlsafe_decode64(padded), symbolize_names: true)
344
+ if payload.is_a?(Hash) && payload[:token].to_s.start_with?("B:")
345
+ meta = {
346
+ account_id: payload[:userid]&.to_s,
347
+ expires_at: payload[:exp] ? Time.at(payload[:exp]) : nil
348
+ }
349
+ return [payload[:token].to_s, meta]
350
+ end
351
+ end
352
+
353
+ [str, {}]
354
+ rescue StandardError
355
+ [str, {}]
356
+ end
357
+
310
358
 
311
359
  def request_payment(url, inner_data, retried: false)
312
360
  raise Error, "Shopee merchant token is missing; configure token or login with OTP" if token.to_s.empty?
@@ -326,14 +374,21 @@ module Rails
326
374
  raise Error, "Shopee returned a non-object response" unless resp.is_a?(Hash)
327
375
  code = resp[:code].to_s
328
376
 
329
- if (code != "0" && INVALID_TOKEN_CODES.include?(code)) && session && !retried
330
- refresh!
331
- return request_payment(url, inner_data, retried: true)
377
+ if code != "0" && INVALID_TOKEN_CODES.include?(code) && !retried
378
+ if refreshable?
379
+ refresh!
380
+ return request_payment(url, inner_data, retried: true)
381
+ end
382
+
383
+ raise Error.new(
384
+ "Shopee rejected the merchant token (code #{code}); it is invalid or expired. " \
385
+ "Log in again with OTP or paste a fresh B:... token.",
386
+ nil, resp[:code], resp
387
+ )
332
388
  end
333
389
 
334
390
  err = Response.error(resp)
335
391
  raise Error.new(err || "ShopeePay request failed", nil, resp[:code], resp) if err
336
- Response.data(resp)
337
392
 
338
393
  resp
339
394
  end
@@ -6,6 +6,18 @@ module Rails
6
6
  module Response
7
7
  module_function
8
8
 
9
+ # Shopee answers many failures with a bare numeric code and no message.
10
+ # Map the ones we have observed so callers get something actionable.
11
+ CODE_HINTS = {
12
+ 10002 => "The Shopee account has no password set",
13
+ 200020 => "Shopee rejected the token as invalid or expired",
14
+ 2010000 => "Shopee request carried no token",
15
+ 48401003 => "Shopee rejected the OTP code as wrong or expired",
16
+ 48401102 => "Shopee requires the account password before sending an OTP",
17
+ 48401103 => "Shopee refused to send the OTP on the requested channel",
18
+ 48500102 => "The Shopee account session has expired; log in again with an OTP"
19
+ }.freeze
20
+
9
21
  def data(payload)
10
22
  code = payload[:code] if payload.is_a?(Hash)
11
23
  success = (code.is_a?(Integer) && code.zero?) || code == "0"
@@ -38,7 +50,7 @@ module Rails
38
50
 
39
51
  messages << msg.to_s unless msg.to_s.empty?
40
52
  messages << description.to_s unless description.to_s.empty?
41
- messages << "Shopee error (code #{code})" if messages.empty? && !code.nil?
53
+ messages << (CODE_HINTS[code_i] || "Shopee error (code #{code})") if messages.empty? && !code.nil?
42
54
  end
43
55
  return nil if messages.empty?
44
56
 
@@ -18,8 +18,13 @@ module Rails
18
18
  OTP_CHANNELS = [1, 2, 3, 5].freeze
19
19
  SEND_OTP_CHANNELS = [1, 2, 3, 5, 4].freeze
20
20
  DEFAULT_OTP_CHANNEL = 3
21
+ CHANNEL_NAMES = { 1 => "SMS", 2 => "voice", 3 => "WhatsApp", 4 => "email", 5 => "Zalo" }.freeze
21
22
  NEED_OTP_CODE = 48401102
22
23
  NOT_LOGIN_CODE = 48500102
24
+ NO_PASSWORD_CODE = 10002
25
+ OTP_CHANNEL_UNAVAILABLE_CODE = 48401103
26
+ WRONG_OTP_CODE = 48401003
27
+ PASSWORD_REQUIRED = "This Shopee account is password-protected; supply the password to receive an OTP"
23
28
 
24
29
  LIVE_TOKEN_COOKIE = "__shopee_partner_website_x_token_live"
25
30
  CLIENT_ID_COOKIE = "SPC_CLIENTID"
@@ -60,22 +65,32 @@ module Rails
60
65
  default_ch = settings[:default_channel]
61
66
  resolved = channel || default_ch || DEFAULT_OTP_CHANNEL
62
67
  if !available.empty? && !available.include?(resolved)
63
- raise Error, "Requested Shopee OTP channel is unavailable"
68
+ names = available.map { |c| "#{c} (#{CHANNEL_NAMES[c] || 'unknown'})" }.join(", ")
69
+ raise Error, "Shopee OTP channel #{resolved} is unavailable for this account; available: #{names}"
64
70
  end
65
71
 
66
- account_request(
67
- "/api/v4/account/business/send_otp",
68
- {
69
- operation: OTP_OPERATION,
70
- phone: phone,
71
- security_device_fingerprint: fingerprint,
72
- support_session: false,
73
- supported_channels: SEND_OTP_CHANNELS,
74
- channel: resolved,
75
- captcha_signature: ""
76
- },
77
- fingerprint
78
- )
72
+ begin
73
+ account_request(
74
+ "/api/v4/account/business/send_otp",
75
+ {
76
+ operation: OTP_OPERATION,
77
+ phone: phone,
78
+ security_device_fingerprint: fingerprint,
79
+ support_session: false,
80
+ supported_channels: SEND_OTP_CHANNELS,
81
+ channel: resolved,
82
+ captcha_signature: ""
83
+ },
84
+ fingerprint
85
+ )
86
+ rescue Error => e
87
+ raise Error.new(
88
+ e.code == OTP_CHANNEL_UNAVAILABLE_CODE ?
89
+ "Shopee refused to send the OTP on channel #{resolved}; choose another available channel" :
90
+ e.message,
91
+ e.status, e.code, e.payload
92
+ )
93
+ end
79
94
 
80
95
  {
81
96
  version: 1,
@@ -105,17 +120,26 @@ module Rails
105
120
  fingerprint = challenge[:device_fingerprint].to_s
106
121
 
107
122
  formatted_phone = format_phone_for_verification(phone)
108
- verified = account_request(
109
- "/api/v4/account/business/verify_otp",
110
- {
111
- operation: OTP_OPERATION,
112
- otp: code.to_s,
113
- phone: formatted_phone,
114
- security_device_fingerprint: fingerprint,
115
- support_session: false
116
- },
117
- fingerprint
118
- )
123
+ begin
124
+ verified = account_request(
125
+ "/api/v4/account/business/verify_otp",
126
+ {
127
+ operation: OTP_OPERATION,
128
+ otp: code.to_s,
129
+ phone: formatted_phone,
130
+ security_device_fingerprint: fingerprint,
131
+ support_session: false
132
+ },
133
+ fingerprint
134
+ )
135
+ rescue Error => e
136
+ raise e unless e.code.to_s == WRONG_OTP_CODE.to_s
137
+
138
+ raise Error.new(
139
+ "Shopee rejected the OTP code (wrong or expired); request a new one and try again",
140
+ e.status, e.code, e.payload
141
+ )
142
+ end
119
143
 
120
144
  token = verified[:otp_token]
121
145
  raise Error, "Shopee OTP verification returned no token" unless token.is_a?(String) && !token.empty?
@@ -374,20 +398,23 @@ module Rails
374
398
  parsed = parse_json(resp)
375
399
 
376
400
  if parsed[:error] == NEED_OTP_CODE
377
- raise Error, "This Shopee account is password-protected; supply the password to receive an OTP" if password.to_s.empty?
401
+ raise Error, PASSWORD_REQUIRED if password.to_s.empty?
378
402
 
379
403
  return true
380
404
  end
381
405
 
382
- return false if parsed[:error] == 0 && parsed[:data].is_a?(Hash)
406
+ return false if parsed[:error] == 0
407
+ # 10002: the account exists but has no password set — nothing to verify.
408
+ return false if parsed[:error] == NO_PASSWORD_CODE
383
409
 
384
410
  account = parsed[:data].is_a?(Hash) ? parsed[:data][:toc_account] : nil
385
411
  if account.is_a?(Hash) && account[:has_password] == true
386
- raise Error, "This Shopee account is password-protected; supply the password to receive an OTP"
412
+ raise Error, PASSWORD_REQUIRED
387
413
  end
388
414
 
389
- err = Response.error(parsed)
390
- raise Error.new(err || "Shopee authentication failed", resp.code.to_i, parsed[:error], parsed)
415
+ # Any other code means this account does not authenticate by password;
416
+ # OTP delivery is validated by send_otp afterwards.
417
+ false
391
418
  end
392
419
 
393
420
  def login_status
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails-shopee-qris
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Azmi
@@ -14,6 +14,7 @@ executables: []
14
14
  extensions: []
15
15
  extra_rdoc_files: []
16
16
  files:
17
+ - CHANGELOG.md
17
18
  - LICENSE.txt
18
19
  - README.md
19
20
  - lib/rails-shopee-qris.rb