rails-shopee-qris 0.2.0 → 0.2.1

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: 5f301fa2107316c8aa94a3ab8946fc318bbbb73d0306c9d0b08290648437e2c1
4
- data.tar.gz: d35eb2223fdad00dfc593ec82eb4c926652aa97a15ccea640177f1d9b6c31b3b
3
+ metadata.gz: c540bf182092132cfb2ffc55a0b47e1a5e83591e99ef57d1592717ac0b9aeeb7
4
+ data.tar.gz: bff1c88608517ed8258c727d3913693aea35b8b14006520545bf1552bacb02c8
5
5
  SHA512:
6
- metadata.gz: e4a3757eb7bd2e386a1774bafdb50b8486a51dd3265c6513b75c2039bac9d3e4ec6a8c0760794de5cbdf6fdba45b0cefee97e16aeb988dcb1e2ae8b11180f443
7
- data.tar.gz: 84a529743925b2494c02b9cd9c5a322f7efda75dc83c630e258a7833a6c59fb9a7dbfc1f04b31f45d743f1d265032da7f39c3c5d0d28fd960c903dcb636b11b3
6
+ metadata.gz: 11943a5f4d2f2398b6fa00a1973b479752cd1a3a49be0364529a1fedf7795fa392bcaa3b7f2f03a2d1fcf2e4bd89cd61ff46299d025c3d5bc747ef9e1c56f0f3
7
+ data.tar.gz: 65bbab7fc0cf7fd5ccf73800f3deb0fe4bf8766566e0e5e16920bcecb3caf339ad444316c1f7366a634a1496d526a508925985edf11ee6f0b756531a32750376
data/CHANGELOG.md CHANGED
@@ -1,14 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.1
4
+
5
+ - Do not infer merchant ID from JWT staff `userid`.
6
+ - Reject malformed credentials, invalid payment envelopes, and invalid time ranges.
7
+ - Do not suppress unknown password-auth failures or captcha requirements.
8
+ - Preserve provider rejection codes without claiming an undocumented expiry reason.
9
+ - Expose optional OTP `seed` as opaque response data, not delivery evidence.
10
+ - Reject malformed transaction/store lists rather than returning misleading empty history.
11
+ - Preserve selected merchant scope when constructing the OTP data client.
12
+ - Reject fractional provider result codes and malformed OTP channel settings.
13
+ - Correct prior verification claims: no successful OTP login or payment settlement observed.
14
+
3
15
  ## 0.2.0
4
16
 
5
- Verified against a live ShopeePay Partner merchant account (store listing, transaction
6
- feed, transaction detail, device-risk, OTP request).
17
+ Observed store listing and an empty transaction feed with a merchant token. OTP
18
+ requests returned success without recipient-confirmed delivery. Transaction detail,
19
+ successful OTP login and payment settlement were not verified live.
7
20
 
8
21
  ### Added
9
22
  - `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`.
23
+ `__shopee_partner_website_x_token_live` JWT cookie, extracting the inner token
24
+ and advisory expiry.
12
25
  - `Client#refreshable?` tells callers whether a session can silently renew.
13
26
  - Provider error codes are translated into actionable text
14
27
  (`10002`, `200020`, `2010000`, `48401003`, `48401102`, `48401103`, `48500102`).
@@ -20,14 +33,14 @@ feed, transaction detail, device-risk, OTP request).
20
33
  - **Payment envelope shape.** Request fields were nested under `data.metadata`; Shopee
21
34
  expects them as siblings of `metadata`. Store listing and the transaction feed never
22
35
  worked before this.
23
- - **`10002` aborted every non-password account.** It means "no password set" — the OTP
24
- flow now continues instead of raising.
36
+ - **`10002` compatibility.** Empty-password responses were allowed to continue to the
37
+ OTP step. Its meaning was not independently established.
25
38
  - **Wrong refresh error.** A rejected manual token raised "session cannot be refreshed";
26
39
  it now reports the real rejection and its code. Proactive refresh only runs when the
27
40
  session actually has renewal credentials.
28
41
  - **Duplicate-key crash.** `Client` symbolized sessions via `JSON.generate`, which raised
29
42
  on hashes carrying both `"token"` and `:token`.
30
- - **`48401003`** is now reported as a wrong/expired OTP rather than a bare code.
43
+ - **`48401003`** is reported as OTP rejection rather than a bare code; reason unspecified.
31
44
  - QRIS generation rejects fractional and oversized amounts instead of truncating them,
32
45
  and parsing rejects non-decimal lengths and duplicate tags.
33
46
 
data/README.md CHANGED
@@ -2,16 +2,18 @@
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. 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.
5
+ Use only with merchant authorization. Internal Shopee APIs can change. Store listing and an empty transaction feed were observed with a merchant token. OTP endpoints accepted requests, but recipients reported no delivery; successful OTP login and money settlement remain unverified. Never log tokens, passwords, device reports, session cookies, or raw payment data.
6
6
 
7
7
  ## Installation
8
8
 
9
- Until published, use a local path:
9
+ Install published version from RubyGems:
10
10
 
11
11
  ```ruby
12
- gem "rails-shopee-qris", path: "../rails-shopee-qris"
12
+ gem "rails-shopee-qris", "~> 0.2.0"
13
13
  ```
14
14
 
15
+ Version 0.2.1 contains the reviewed validation and merchant-scope fixes; it must be published before RubyGems consumers can install it. To exercise this checkout locally, use `gem "rails-shopee-qris", path: "../rails-shopee-qris"`.
16
+
15
17
  ```ruby
16
18
  require "rails/shopee/qris"
17
19
 
@@ -24,7 +26,7 @@ end
24
26
 
25
27
  ## Direct merchant token
26
28
 
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.
29
+ Pass either the inner `B:...` token or the whole `__shopee_partner_website_x_token_live` cookie value. Client extracts the inner token and advisory expiry. JWT `userid` is a staff account ID, **not** merchant ID; provide merchant scope explicitly when known. JWT decoding does not verify its signature; Shopee validates credentials on each request.
28
30
 
29
31
  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`.
30
32
 
@@ -64,6 +66,10 @@ Channels: `1` SMS, `2` voice, `3` WhatsApp, `4` email, `5` Zalo. Omit `channel:`
64
66
 
65
67
  Persist **entire** session encrypted, including `cookies`, `merchant`, `merchants`, and `switch_credential`. These are credentials, not safe client-side session data.
66
68
 
69
+ OTP request success and optional `seed` do not prove SMS/WhatsApp delivery. `device_report` means the raw browser SDK report request body, not the `riskToken` returned by its response. No reliable report-format or delivery-status check is available here. Do not retry repeatedly or switch channels without merchant consent.
70
+
71
+ Rails cookie sessions cannot hold full Shopee state (4 KB limit). Use server-side sessions; memory-backed development sessions disappear on restart. Never persist passwords for resend—prompt again when required.
72
+
67
73
  ```ruby
68
74
  serialized = JSON.generate(shopee_session)
69
75
  restored = JSON.parse(serialized, symbolize_names: true)
@@ -136,10 +142,10 @@ Invalid provider envelopes, HTTP failures, expired credentials, malformed inputs
136
142
 
137
143
  | Code | Meaning |
138
144
  | --- | --- |
139
- | `10002` | Account has no password set (not an error — the OTP flow continues) |
145
+ | `10002` | Undocumented password-auth response; empty-password probe may continue to OTP, but it does not prove account existence or password state |
140
146
  | `200020` | Token invalid or expired — paste a fresh one |
141
147
  | `2010000` | Request carried no token |
142
- | `48401003` | OTP code wrong or expired |
148
+ | `48401003` | Credential rejected; when returned by OTP verification, the OTP was rejected (reason unspecified) |
143
149
  | `48401102` | Password required before an OTP is sent |
144
150
  | `48401103` | OTP channel unavailable for this account |
145
151
  | `48500102` | Account session expired; log in again with an OTP |
@@ -156,7 +162,7 @@ gem build rails-shopee-qris.gemspec
156
162
  Package with `gem build rails-shopee-qris.gemspec`. Authenticate via `gem signin` (or configure a scoped `GEM_HOST_API_KEY` with push permission), then:
157
163
 
158
164
  ```sh
159
- gem push rails-shopee-qris-0.2.0.gem --host https://rubygems.org
165
+ gem push rails-shopee-qris-0.2.1.gem --host https://rubygems.org
160
166
  ```
161
167
 
162
168
  RubyGems versions are immutable. Bump the gemspec version before subsequent releases. Built `.gem` files and credentials must not be committed.
@@ -30,9 +30,9 @@ module Rails
30
30
  parsed_token, token_meta = parse_token_input(raw_token)
31
31
  @token = parsed_token
32
32
  @store_id = (store_id || @session&.dig(:store_id) || config.store_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
33
+ @merchant_id = (merchant_id || (@session[:merchant][:id] if @session&.dig(:merchant).is_a?(Hash)) || @session&.dig(:merchant_id) || config.merchant_id)&.to_s
34
34
  @phone_number = phone_number || @session&.dig(:phone_number) || config.phone_number
35
- @device_id = device_id || @session&.dig(:switch_credential, :spc_clientid)
35
+ @device_id = device_id || (@session[:switch_credential][:spc_clientid] if @session&.dig(:switch_credential).is_a?(Hash))
36
36
  expiry = expires_at || @session&.dig(:expires_at) || token_meta[:expires_at]
37
37
  @expires_at = extract_time(expiry)
38
38
  raise Error, "Invalid Shopee session expiry" if expiry && !@expires_at
@@ -48,7 +48,7 @@ module Rails
48
48
  raise Error, "Shopee static QRIS is not configured" if static_qris.to_s.empty?
49
49
 
50
50
  code = Qris.generate(static_qris, amount)
51
- amount = Integer(amount)
51
+ amount = amount.is_a?(String) ? Integer(amount, 10) : Integer(amount)
52
52
  {
53
53
  qris_id: SecureRandom.hex(16),
54
54
  qris_code: code,
@@ -65,8 +65,7 @@ module Rails
65
65
 
66
66
  def refresh!
67
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"
68
+ raise Error, "Shopee session has no renewal credentials; log in again with OTP or paste a fresh B:... token"
70
69
  end
71
70
 
72
71
  updated = Setup.new.refresh_session(session)
@@ -77,12 +76,12 @@ module Rails
77
76
  end
78
77
 
79
78
  def transactions_between(start_time:, end_time:, store_id: @store_id, page_size: 10, max_pages: 20)
80
- refresh! if refreshable? && expires_at && expires_at <= Time.now + 900
81
-
82
- start_ts = start_time.respond_to?(:to_i) ? start_time.to_i : Integer(start_time)
83
- end_ts = end_time.respond_to?(:to_i) ? end_time.to_i : Integer(end_time)
79
+ start_ts = transaction_timestamp(start_time)
80
+ end_ts = transaction_timestamp(end_time)
84
81
  raise Error, "Shopee transaction time range is invalid" if start_ts > end_ts
85
82
 
83
+ refresh! if refreshable? && expires_at && expires_at <= Time.now + 900
84
+
86
85
  size = [[1, page_size.to_i].max, 10].min
87
86
  pages_limit = [1, max_pages.to_i].max
88
87
  want_store = store_id&.to_s
@@ -107,7 +106,8 @@ module Rails
107
106
 
108
107
  resp = request_payment(TRANSACTIONS_URL, body)
109
108
  data = Response.data(resp)
110
- list = data[:list].is_a?(Array) ? data[:list] : []
109
+ list = data[:list]
110
+ raise Error, "Shopee transaction list is missing or invalid" unless list.is_a?(Array)
111
111
 
112
112
  list.each do |raw|
113
113
  tx = normalize_transaction(raw, want_store)
@@ -216,7 +216,8 @@ module Rails
216
216
  body[:serviceList] = service_list if service_list
217
217
 
218
218
  data = Response.data(request_payment(STORES_URL, body))
219
- list = data[:list].is_a?(Array) ? data[:list] : []
219
+ list = data[:list]
220
+ raise Error, "Shopee store list is missing or invalid" unless list.is_a?(Array)
220
221
 
221
222
  list.each do |raw|
222
223
  store = normalize_store(raw)
@@ -322,7 +323,7 @@ module Rails
322
323
  case obj
323
324
  when Hash
324
325
  obj.each_with_object({}) do |(k, v), acc|
325
- acc[k.to_sym] = deep_symbolize(v)
326
+ acc[k.is_a?(String) ? k.to_sym : k] = deep_symbolize(v)
326
327
  end
327
328
  when Array
328
329
  obj.map { |item| deep_symbolize(item) }
@@ -331,28 +332,25 @@ module Rails
331
332
  end
332
333
  end
333
334
 
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
335
+ def transaction_timestamp(value)
336
+ return value.to_i if value.is_a?(Time) || (value.is_a?(Numeric) && value.finite?)
337
+ return Integer(value, 10) if value.is_a?(String) && value.match?(/\A[+-]?\d+\z/)
338
+
339
+ raise Error, "Shopee transaction time range is invalid"
340
+ rescue ArgumentError, TypeError, RangeError
341
+ raise Error, "Shopee transaction time range is invalid"
342
+ end
343
+
344
+ def parse_token_input(value)
345
+ return [nil, {}] if value.nil?
346
+ raise Error, "Shopee merchant token must be a string" unless value.is_a?(String)
347
+ return [value, {}] if value.start_with?("B:")
352
348
 
353
- [str, {}]
354
- rescue StandardError
355
- [str, {}]
349
+ credential = Setup.new.read_merchant_credential([
350
+ { name: Setup::LIVE_TOKEN_COOKIE, value: value, domain: "partner.shopee.co.id", path: "/" }
351
+ ])
352
+ expiry = credential[:expires_at]
353
+ [credential[:token], { expires_at: expiry && Time.at(expiry / 1000.0) }]
356
354
  end
357
355
 
358
356
 
@@ -381,7 +379,7 @@ module Rails
381
379
  end
382
380
 
383
381
  raise Error.new(
384
- "Shopee rejected the merchant token (code #{code}); it is invalid or expired. " \
382
+ "Shopee rejected the merchant token (code #{code}). " \
385
383
  "Log in again with OTP or paste a fresh B:... token.",
386
384
  nil, resp[:code], resp
387
385
  )
@@ -389,6 +387,7 @@ module Rails
389
387
 
390
388
  err = Response.error(resp)
391
389
  raise Error.new(err || "ShopeePay request failed", nil, resp[:code], resp) if err
390
+ Response.data(resp)
392
391
 
393
392
  resp
394
393
  end
@@ -10,7 +10,7 @@ module Rails
10
10
  unless amount.is_a?(Integer) || (amount.is_a?(String) && amount.match?(/\A\d+\z/))
11
11
  raise Error, "Invalid payment amount"
12
12
  end
13
- amount = Integer(amount)
13
+ amount = amount.is_a?(String) ? Integer(amount, 10) : amount
14
14
  raise Error, "Payment amount must be greater than zero" unless amount.positive?
15
15
  raise Error, "Payment amount exceeds QRIS field length" if amount.to_s.bytesize > 99
16
16
 
@@ -9,10 +9,8 @@ module Rails
9
9
  # Shopee answers many failures with a bare numeric code and no message.
10
10
  # Map the ones we have observed so callers get something actionable.
11
11
  CODE_HINTS = {
12
- 10002 => "The Shopee account has no password set",
13
12
  200020 => "Shopee rejected the token as invalid or expired",
14
13
  2010000 => "Shopee request carried no token",
15
- 48401003 => "Shopee rejected the OTP code as wrong or expired",
16
14
  48401102 => "Shopee requires the account password before sending an OTP",
17
15
  48401103 => "Shopee refused to send the OTP on the requested channel",
18
16
  48500102 => "The Shopee account session has expired; log in again with an OTP"
@@ -37,7 +35,11 @@ module Rails
37
35
  end
38
36
 
39
37
  code = payload[:code] || payload[:errorCode] || payload[:error]
40
- code_i = Integer(code) rescue nil
38
+ code_i = if code.is_a?(Integer)
39
+ code
40
+ elsif code.is_a?(String) && code.match?(/\A-?\d+\z/)
41
+ Integer(code, 10)
42
+ end
41
43
  is_success_code = code_i == 0
42
44
 
43
45
  errors = payload[:errors] || inner[:errors]
@@ -21,9 +21,9 @@ module Rails
21
21
  CHANNEL_NAMES = { 1 => "SMS", 2 => "voice", 3 => "WhatsApp", 4 => "email", 5 => "Zalo" }.freeze
22
22
  NEED_OTP_CODE = 48401102
23
23
  NOT_LOGIN_CODE = 48500102
24
- NO_PASSWORD_CODE = 10002
24
+ EMPTY_PASSWORD_CONTINUATION_CODE = 10002
25
25
  OTP_CHANNEL_UNAVAILABLE_CODE = 48401103
26
- WRONG_OTP_CODE = 48401003
26
+ OTP_REJECTED_CODE = 48401003
27
27
  PASSWORD_REQUIRED = "This Shopee account is password-protected; supply the password to receive an OTP"
28
28
 
29
29
  LIVE_TOKEN_COOKIE = "__shopee_partner_website_x_token_live"
@@ -39,6 +39,9 @@ module Rails
39
39
  def request_otp(phone_number = Rails::Shopee::Qris.configuration.phone_number, password: nil, channel: nil, device_report: Rails::Shopee::Qris.configuration.device_report)
40
40
  phone_info = parse_id_mobile(phone_number)
41
41
  raise Error, "Supply a captured Shopee device report to request an OTP" unless device_report.is_a?(String) && !device_report.strip.empty?
42
+ if !channel.nil? && (!channel.is_a?(Integer) || !SEND_OTP_CHANNELS.include?(channel))
43
+ raise Error, "Invalid Shopee OTP channel"
44
+ end
42
45
  phone = phone_info[:e164]
43
46
  @cookies.clear
44
47
 
@@ -61,15 +64,21 @@ module Rails
61
64
  fingerprint
62
65
  )
63
66
 
64
- available = (settings[:available_channel_list] || []).select { |c| c.is_a?(Integer) }
67
+ available = settings[:available_channel_list].nil? ? [] : settings[:available_channel_list]
68
+ unless available.is_a?(Array) && available.all? { |c| c.is_a?(Integer) && SEND_OTP_CHANNELS.include?(c) }
69
+ raise Error, "Shopee returned invalid available OTP channels"
70
+ end
65
71
  default_ch = settings[:default_channel]
66
72
  resolved = channel || default_ch || DEFAULT_OTP_CHANNEL
73
+ unless resolved.is_a?(Integer) && SEND_OTP_CHANNELS.include?(resolved)
74
+ raise Error, "Shopee returned an invalid OTP channel"
75
+ end
67
76
  if !available.empty? && !available.include?(resolved)
68
77
  names = available.map { |c| "#{c} (#{CHANNEL_NAMES[c] || 'unknown'})" }.join(", ")
69
78
  raise Error, "Shopee OTP channel #{resolved} is unavailable for this account; available: #{names}"
70
79
  end
71
80
 
72
- begin
81
+ sent = begin
73
82
  account_request(
74
83
  "/api/v4/account/business/send_otp",
75
84
  {
@@ -86,7 +95,7 @@ module Rails
86
95
  rescue Error => e
87
96
  raise Error.new(
88
97
  e.code == OTP_CHANNEL_UNAVAILABLE_CODE ?
89
- "Shopee refused to send the OTP on channel #{resolved}; choose another available channel" :
98
+ "Shopee refused OTP at /api/v4/account/business/send_otp on channel #{resolved}; choose another available channel" :
90
99
  e.message,
91
100
  e.status, e.code, e.payload
92
101
  )
@@ -101,6 +110,8 @@ module Rails
101
110
  risk_token: fingerprint,
102
111
  has_password: has_password,
103
112
  cookies: snapshot_cookies,
113
+ # Opaque provider field; neither its meaning nor OTP delivery is established.
114
+ seed: sent[:seed],
104
115
  requested_at: (Time.now.to_f * 1000).to_i
105
116
  }
106
117
  end
@@ -133,10 +144,10 @@ module Rails
133
144
  fingerprint
134
145
  )
135
146
  rescue Error => e
136
- raise e unless e.code.to_s == WRONG_OTP_CODE.to_s
147
+ raise e unless e.code.to_s == OTP_REJECTED_CODE.to_s
137
148
 
138
149
  raise Error.new(
139
- "Shopee rejected the OTP code (wrong or expired); request a new one and try again",
150
+ "Shopee rejected the OTP at /api/v4/account/business/verify_otp; cause is not established",
140
151
  e.status, e.code, e.payload
141
152
  )
142
153
  end
@@ -198,7 +209,7 @@ module Rails
198
209
  merchant, credential = complete_base(verification, merchant_id)
199
210
  profile = get_profile(credential[:token], merchant[:id])
200
211
 
201
- data_client = client || Client.new(token: credential[:token])
212
+ data_client = client || Client.new(token: credential[:token], merchant_id: merchant[:id])
202
213
  stores = data_client.list_stores
203
214
  chosen_store = choose_store_id(stores, store_id, profile[:store_id])
204
215
 
@@ -220,7 +231,7 @@ module Rails
220
231
  },
221
232
  profile: profile,
222
233
  created_at: (Time.now.to_f * 1000).to_i,
223
- expires_at: credential[:expires_at] ? Time.at(credential[:expires_at] / 1000) : nil
234
+ expires_at: credential[:expires_at] ? Time.at(credential[:expires_at] / 1000.0) : nil
224
235
  }
225
236
  end
226
237
 
@@ -246,9 +257,9 @@ module Rails
246
257
  validate_strings(switch_cred, :toc_nonce, :spc_clientid, :device_fingerprint)
247
258
 
248
259
  restore_cookies(session[:cookies] || [])
249
- alive, payload = login_status
260
+ alive, payload, status = login_status
250
261
  unless alive
251
- raise Error.new("The Shopee account session has expired; log in again with an OTP", nil, NOT_LOGIN_CODE, payload)
262
+ raise Error.new("The Shopee account session has expired; log in again with an OTP", status, NOT_LOGIN_CODE, payload)
252
263
  end
253
264
 
254
265
  verification = {
@@ -268,7 +279,7 @@ module Rails
268
279
  session.merge(
269
280
  cookies: snapshot_cookies,
270
281
  token: credential[:token],
271
- expires_at: credential[:expires_at] ? Time.at(credential[:expires_at] / 1000) : session[:expires_at]
282
+ expires_at: credential[:expires_at] ? Time.at(credential[:expires_at] / 1000.0) : session[:expires_at]
272
283
  )
273
284
  end
274
285
 
@@ -303,8 +314,8 @@ module Rails
303
314
  jwt = cookie ? cookie[:value] : nil
304
315
  raise Error, "Shopee login did not return a merchant session token" unless jwt.is_a?(String) && !jwt.empty?
305
316
 
306
- segments = jwt.split(".")
307
- raise Error, "Invalid JWT format" unless segments.length == 3 && !segments[1].empty?
317
+ segments = jwt.split(".", -1)
318
+ raise Error, "Invalid JWT format" unless segments.length == 3 && segments.all? { |segment| segment.match?(/\A[A-Za-z0-9_-]+\z/) }
308
319
 
309
320
  padded = segments[1] + ("=" * (-segments[1].bytesize % 4))
310
321
  payload = JSON.parse(Base64.urlsafe_decode64(padded), symbolize_names: true)
@@ -327,8 +338,12 @@ module Rails
327
338
 
328
339
  def symbolize_state(value)
329
340
  case value
330
- when Hash then value.to_h { |key, item| [key.to_sym, symbolize_state(item)] }
341
+ when Hash
342
+ raise Error, "Invalid Shopee state keys" unless value.keys.all? { |key| key.is_a?(String) || key.is_a?(Symbol) }
343
+
344
+ value.to_h { |key, item| [key.to_sym, symbolize_state(item)] }
331
345
  when Array then value.map { |item| symbolize_state(item) }
346
+ when String then value.dup
332
347
  else value
333
348
  end
334
349
  end
@@ -382,6 +397,10 @@ module Rails
382
397
  body = { phone: phone, password: hash_shopee_password(password) }
383
398
  account_request("/api/v4/account/business/check_account_exist_by_password", body, fingerprint)
384
399
  rescue Error => e
400
+ if e.payload.is_a?(Hash)
401
+ data = e.payload[:data]
402
+ raise if e.payload[:captcha_required] == true || data.is_a?(Hash) && data[:captcha_required] == true
403
+ end
385
404
  raise unless e.status && (200..299).cover?(e.status) && e.code.is_a?(Integer) && e.code != 0
386
405
 
387
406
  nil
@@ -398,31 +417,35 @@ module Rails
398
417
  parsed = parse_json(resp)
399
418
 
400
419
  if parsed[:error] == NEED_OTP_CODE
401
- raise Error, PASSWORD_REQUIRED if password.to_s.empty?
420
+ if password.to_s.empty?
421
+ raise Error.new("#{PASSWORD_REQUIRED} (at /api/v4/account/business/authenticate_toc_by_password)", resp.code.to_i, parsed[:error], parsed)
422
+ end
402
423
 
403
424
  return true
404
425
  end
405
426
 
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
427
+ return false if parsed[:error] == 0 && parsed[:data].is_a?(Hash)
428
+ # Only this empty-password response was observed to permit OTP requests.
429
+ # Its provider meaning remains unknown; never bypass a supplied password.
430
+ return nil if password.to_s.empty? && parsed[:error] == EMPTY_PASSWORD_CONTINUATION_CODE
409
431
 
410
- account = parsed[:data].is_a?(Hash) ? parsed[:data][:toc_account] : nil
411
- if account.is_a?(Hash) && account[:has_password] == true
412
- raise Error, PASSWORD_REQUIRED
413
- end
414
-
415
- # Any other code means this account does not authenticate by password;
416
- # OTP delivery is validated by send_otp afterwards.
417
- false
432
+ raise Error.new(
433
+ "Shopee rejected password authentication at /api/v4/account/business/authenticate_toc_by_password (error #{parsed[:error]})",
434
+ resp.code.to_i, parsed[:error], parsed
435
+ )
418
436
  end
419
437
 
420
438
  def login_status
421
439
  headers = account_headers.merge("Content-Type" => "application/json")
422
440
  resp = send_request(:post, "#{ACCOUNT_BASE_URL}/api/v4/account/business/login_status", headers: headers, body: "{}")
423
441
  parsed = parse_json(resp)
424
- raise Error, "Shopee returned a malformed login status" unless parsed[:error].is_a?(Integer)
425
- [parsed[:error] == 0, parsed]
442
+ unless parsed[:error].is_a?(Integer)
443
+ raise Error.new("Shopee returned a malformed login status at /api/v4/account/business/login_status", resp.code.to_i, parsed[:error], parsed)
444
+ end
445
+ unless [0, NOT_LOGIN_CODE].include?(parsed[:error])
446
+ raise Error.new("Shopee login status failed at /api/v4/account/business/login_status", resp.code.to_i, parsed[:error], parsed)
447
+ end
448
+ [parsed[:error] == 0, parsed, resp.code.to_i]
426
449
  end
427
450
 
428
451
  def complete_base(verification, merchant_id)
@@ -546,7 +569,7 @@ module Rails
546
569
 
547
570
  if parsed[:error] != 0 || !parsed[:data].is_a?(Hash)
548
571
  err = Response.error(parsed)
549
- raise Error.new(err || "Shopee account request failed at #{path}", resp.code.to_i, parsed[:error], parsed)
572
+ raise Error.new("Shopee account request failed at #{path}: #{err || 'invalid response'}", resp.code.to_i, parsed[:error], parsed)
550
573
  end
551
574
 
552
575
  parsed[:data].is_a?(Hash) ? parsed[:data] : {}
@@ -639,6 +662,9 @@ module Rails
639
662
  end
640
663
  parsed = JSON.parse(response.body.to_s, symbolize_names: true)
641
664
  raise Error.new("Shopee returned a malformed JSON envelope", response.code.to_i) unless parsed.is_a?(Hash)
665
+ if parsed[:captcha_required] == true || parsed[:data].is_a?(Hash) && parsed[:data][:captcha_required] == true
666
+ raise Error.new(Response.error(parsed), response.code.to_i, parsed[:error] || parsed[:errorCode] || parsed[:code], parsed)
667
+ end
642
668
 
643
669
  parsed
644
670
  rescue JSON::ParserError
@@ -726,7 +752,7 @@ module Rails
726
752
 
727
753
  def snapshot_cookies
728
754
  @cookies.delete_if { |_, cookie| cookie[:expires] && cookie[:expires] <= Time.now.to_f }
729
- @cookies.values.map(&:dup)
755
+ symbolize_state(@cookies.values)
730
756
  end
731
757
 
732
758
  def restore_cookies(cookie_list)
@@ -744,7 +770,7 @@ module Rails
744
770
  next if name.match?(/[\s,;]/) || cookie[:value].to_s.match?(/[\r\n;]/)
745
771
  next if cookie[:expires] && (!cookie[:expires].is_a?(Numeric) || !cookie[:expires].finite?)
746
772
 
747
- @cookies[[name, domain, path]] = cookie.merge(name: name, value: cookie[:value].to_s, domain: domain, path: path)
773
+ @cookies[[name, domain, path]] = symbolize_state(cookie.merge(name: name, value: cookie[:value].to_s, domain: domain, path: path))
748
774
  end
749
775
  end
750
776
  end
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.2.0
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Azmi