invonetwork 2.1.2__tar.gz → 2.2.0__tar.gz

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.
Files changed (32) hide show
  1. {invonetwork-2.1.2 → invonetwork-2.2.0}/CHANGELOG.md +29 -0
  2. {invonetwork-2.1.2 → invonetwork-2.2.0}/PKG-INFO +30 -34
  3. {invonetwork-2.1.2 → invonetwork-2.2.0}/README.md +29 -33
  4. {invonetwork-2.1.2 → invonetwork-2.2.0}/pyproject.toml +1 -1
  5. {invonetwork-2.1.2 → invonetwork-2.2.0}/src/invonetwork/errors.py +9 -0
  6. {invonetwork-2.1.2 → invonetwork-2.2.0}/src/invonetwork/server.py +71 -56
  7. {invonetwork-2.1.2 → invonetwork-2.2.0}/tests/test_balance_identities.py +6 -4
  8. {invonetwork-2.1.2 → invonetwork-2.2.0}/tests/test_reads.py +12 -2
  9. {invonetwork-2.1.2 → invonetwork-2.2.0}/tests/test_server.py +16 -0
  10. {invonetwork-2.1.2 → invonetwork-2.2.0}/.gitignore +0 -0
  11. {invonetwork-2.1.2 → invonetwork-2.2.0}/LICENSE +0 -0
  12. {invonetwork-2.1.2 → invonetwork-2.2.0}/src/invonetwork/__init__.py +0 -0
  13. {invonetwork-2.1.2 → invonetwork-2.2.0}/src/invonetwork/hooks.py +0 -0
  14. {invonetwork-2.1.2 → invonetwork-2.2.0}/src/invonetwork/http.py +0 -0
  15. {invonetwork-2.1.2 → invonetwork-2.2.0}/src/invonetwork/py.typed +0 -0
  16. {invonetwork-2.1.2 → invonetwork-2.2.0}/src/invonetwork/types.py +0 -0
  17. {invonetwork-2.1.2 → invonetwork-2.2.0}/src/invonetwork/webhooks.py +0 -0
  18. {invonetwork-2.1.2 → invonetwork-2.2.0}/tests/helpers.py +0 -0
  19. {invonetwork-2.1.2 → invonetwork-2.2.0}/tests/test_audit_fixes.py +0 -0
  20. {invonetwork-2.1.2 → invonetwork-2.2.0}/tests/test_claim.py +0 -0
  21. {invonetwork-2.1.2 → invonetwork-2.2.0}/tests/test_errors.py +0 -0
  22. {invonetwork-2.1.2 → invonetwork-2.2.0}/tests/test_guardian_approval.py +0 -0
  23. {invonetwork-2.1.2 → invonetwork-2.2.0}/tests/test_hooks.py +0 -0
  24. {invonetwork-2.1.2 → invonetwork-2.2.0}/tests/test_inbound_pending.py +0 -0
  25. {invonetwork-2.1.2 → invonetwork-2.2.0}/tests/test_item_purchase.py +0 -0
  26. {invonetwork-2.1.2 → invonetwork-2.2.0}/tests/test_phone_share.py +0 -0
  27. {invonetwork-2.1.2 → invonetwork-2.2.0}/tests/test_recovery.py +0 -0
  28. {invonetwork-2.1.2 → invonetwork-2.2.0}/tests/test_retry.py +0 -0
  29. {invonetwork-2.1.2 → invonetwork-2.2.0}/tests/test_server_destinations.py +0 -0
  30. {invonetwork-2.1.2 → invonetwork-2.2.0}/tests/test_sms_verify.py +0 -0
  31. {invonetwork-2.1.2 → invonetwork-2.2.0}/tests/test_transaction_status.py +0 -0
  32. {invonetwork-2.1.2 → invonetwork-2.2.0}/tests/test_webhooks.py +0 -0
@@ -3,6 +3,35 @@
3
3
  All notable changes to this project are documented here. This project adheres to
4
4
  [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
5
5
 
6
+ ## [2.2.0] - 2026-07-07
7
+
8
+ ### Changed
9
+
10
+ **Backend reconciliation** — an audit of the SDK against the live backend surfaced three places
11
+ the SDK didn't match reality. All changes are backward-compatible (relaxing required inputs /
12
+ adding surface); no working call breaks.
13
+
14
+ - **`mint_player_token` no longer requires `player_phone`.** `/api/sdk/player-token` is a
15
+ session-token mint for an **existing** player (looked up by email), not a registration call —
16
+ it reads only `player_email` and ignores `player_phone`. The 2.0.0 hard E.164 requirement was
17
+ a bug: it could **block a valid token mint for an existing email-only player**. `player_phone`
18
+ is now optional (keyword) and validated only if provided. Phone stays enforced on the money
19
+ calls and at player creation.
20
+ - **Send by phone alone.** `receiver_player_email` (send) and `target_player_email` (transfer)
21
+ are now **optional** keyword args — send to a recipient's phone; they supply their email at
22
+ claim (claim still requires it).
23
+ - **`get_player_balance` is by email only.** There is no by-id balance route; passing
24
+ `player_id` now raises a clear `INVALID_INPUT` pointing to the browser `getBalance()` or
25
+ `player_email`, instead of hitting a 404.
26
+ - **New `InvoError.is_user_verification_required`** for the `WEBAUTHN_UV_REQUIRED` (401) approve
27
+ failure.
28
+ - `iterate_item_purchase_history` reads the backend's `total_count` pagination key (was `total`;
29
+ behavior unchanged — the short-page guard already terminated the loop).
30
+
31
+ **Migration:** none required. If you passed `player_phone` to `mint_player_token` it still works;
32
+ you can now omit it. Replace any `get_player_balance(player_id=…)` with an email lookup or the
33
+ browser `getBalance()`.
34
+
6
35
  ## [2.1.2] - 2026-07-07
7
36
 
8
37
  ### Added / Changed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: invonetwork
3
- Version: 2.1.2
3
+ Version: 2.2.0
4
4
  Summary: INVO Python server SDK -- currency purchase, item purchase, sends/transfers, and webhook verification for partner platforms.
5
5
  Project-URL: Homepage, https://docs.invo.network
6
6
  Project-URL: Documentation, https://docs.invo.network
@@ -54,8 +54,10 @@ same live backend interchangeably.
54
54
  > wraps is **live** on sandbox + production, so you can build and test against sandbox today.
55
55
  > **Since 2.0.0 (breaking):** `mint_player_token` requires `player_phone` (E.164) — see the
56
56
  > [CHANGELOG](CHANGELOG.md) migration note. **2.1.0 adds** the passkey-recovery relay
57
- > (`recovery_begin`/`recovery_complete`); **2.1.1** is a security/correctness hardening patch;
58
- > **2.1.2** surfaces `expires_in_seconds` on `create_checkout`.
57
+ > (`recovery_begin`/`recovery_complete`); **2.1.1** security hardening; **2.1.2** `expires_in_seconds`
58
+ > on `create_checkout`. **2.2.0** reconciles with the backend: `mint_player_token` no longer
59
+ > requires `player_phone` (it's a session mint, not registration), send-by-phone-alone, and
60
+ > `get_player_balance` is email-only.
59
61
  > Canonical partner reference: **https://docs.invo.network**.
60
62
 
61
63
  ## Highlights
@@ -166,37 +168,29 @@ INVO integrations split across two trust boundaries. This package is the **serve
166
168
  The **game secret authenticates every call here and must never reach a browser.** Mint a
167
169
  short-lived player token server-side with `mint_player_token` and hand *that* to the browser SDK.
168
170
 
169
- ### Player registration (email + phone, required)
171
+ ### Player token (session mint for an existing player)
170
172
 
171
- `mint_player_token` is the player **registration** call, and it requires **both**
172
- `player_email` and `player_phone`:
173
+ `mint_player_token` mints a short-lived, game-scoped **session token** for a player who
174
+ **already exists** on your game — it is *not* a registration call. The backend looks the player
175
+ up by `player_email` and returns a token for their existing identity (or `404` if unknown). It
176
+ only needs `player_email`:
173
177
 
174
178
  ```python
175
- token = server.mint_player_token(
176
- player_email="player@example.com",
177
- player_phone="+15555550100", # REQUIRED -- E.164: leading "+", country code, digits only
178
- )
179
+ token = server.mint_player_token(player_email="player@example.com")
179
180
  ```
180
181
 
181
- Why phone is not optional on the INVO network:
182
-
183
- - **Identity is derived from email + phone together.** Registering email-only and adding the
184
- phone later **changes the identity and forces device re-enrollment** collect the phone in
185
- your enrollment UI *before* the first mint.
186
- - **Incoming cross-game transfers are matched by phone.** An account without a phone can never
187
- see money sent to it.
188
- - **The account-linking consent SMS is triggered by phone.** Without one, the "add this email
189
- to your account?" flow can't run.
190
- - **Format is E.164** (`+15555550100`) the SDK rejects anything else before it reaches the
191
- wire. Don't send national/local formats.
192
-
193
- **Same person, new email, same phone is expected and supported.** When the phone already
194
- belongs to another INVO account, the mint raises an `InvoError` with
195
- `is_phone_share_approval_required` (409) — this is **not a failure**: INVO sent the phone
196
- holder a consent SMS. Show the player a "pending approval" state, then re-issue the same mint
197
- once approved. Poll `phone_share_status(phone=..., email=...)`, or use
198
- `phone_share_initiate` / `phone_share_approve` (the error's `phone_share_approval_id` carries
199
- the id) for the in-app OTP fallback.
182
+ `player_phone` is **optional** here (validated as E.164 only if you pass it, and ignored by this
183
+ endpoint) — an existing email-only player still mints a token.
184
+
185
+ **Where phone actually matters.** A player's INVO identity encodes their phone, and cross-game
186
+ money routing keys off it — but that's enforced on the money calls, not the token mint:
187
+
188
+ - **`initiate_send` / `initiate_transfer`** require the **sender's** phone (E.164), and take the
189
+ **recipient's** phone. You can send by the recipient's **phone alone** they supply their
190
+ email when they claim.
191
+ - An account with no phone can't receive cross-game money or take part in the account-linking
192
+ consent SMS, so make sure players have a phone at **creation / enrollment** (in your own
193
+ player system), before they transact.
200
194
 
201
195
  ### Not in this SDK (by design): the browser WebAuthn ceremonies
202
196
 
@@ -246,8 +240,9 @@ INVO enables each flow for your tenant in the [console](#get-your-account--game-
246
240
 
247
241
  - **Store the game secret server-side** (env var / secret manager) and expose a small endpoint
248
242
  that calls `mint_player_token` so your front-end can fetch/refresh a player token.
249
- - **Collect a phone number (E.164) in your enrollment flow** — `mint_player_token` requires it
250
- alongside the email (see [Player registration](#player-registration-email--phone-required)).
243
+ - **Make sure players have a phone (E.164) at creation/enrollment** — it's required on the money
244
+ calls (`initiate_send`/`initiate_transfer`) and for cross-game receive, though not on the token
245
+ mint itself (see [Player token](#player-token-session-mint-for-an-existing-player)).
251
246
  - **Set your webhook signing secret** and verify every delivery with `verify_webhook` — grant
252
247
  currency/items off webhooks, not synchronous responses.
253
248
  - **For currency purchase:** hosted checkout works out of the box; ask INVO to enable the
@@ -394,7 +389,8 @@ for row in server.iterate_item_purchase_history(player_email="p@example.com"):
394
389
 
395
390
  ```python
396
391
  result = server.get_player_balance(player_email="p@example.com")
397
- # or: server.get_player_balance(player_id=12345)
392
+ # Lookup is by EMAIL only — there is no by-id balance route (player_id is a per-game internal
393
+ # id). For a client-side read, use the browser InvoClient.getBalance() (identity from the token).
398
394
  for b in result.balances:
399
395
  print(b.currency_name, b.available_balance, b.total_balance)
400
396
  ```
@@ -624,7 +620,7 @@ Construct: `InvoServer(game_secret, base_url, *, timeout=30, max_retries=2, retr
624
620
 
625
621
  | Method | Returns |
626
622
  |---|---|
627
- | `mint_player_token(player_email, player_phone)` | `PlayerToken(token, expires_at, identity_id, raw)` — **both required**; `player_phone` must be E.164 (see [Player registration](#player-registration-email--phone-required)) |
623
+ | `mint_player_token(player_email, player_phone?)` | `PlayerToken(token, expires_at, identity_id, raw)` — session mint for an **existing** player (404 if unknown); `player_phone` optional/validated-if-present (see [Player token](#player-token-session-mint-for-an-existing-player)) |
628
624
  | `initiate_send(...)` | `InitiateResult(transaction_id, verification_method, guardian_approval, raw)` |
629
625
  | `initiate_transfer(...)` | `InitiateResult` |
630
626
  | `create_checkout(player_email, usd_amount, rail?, success_url?, cancel_url?, metadata?)` | `CreateCheckoutResult(session_id, checkout_url, expires_at, expires_in_seconds, raw)` |
@@ -635,7 +631,7 @@ Construct: `InvoServer(game_secret, base_url, *, timeout=30, max_retries=2, retr
635
631
  | `get_item_purchase_history(player_email, limit?, offset?)` | `ItemHistoryResult(history, pagination, raw)` |
636
632
  | `get_item_order_details(order_id? \| transaction_id? \| client_request_id?)` | `OrderDetailsResult` |
637
633
  | `iterate_item_purchase_history(player_email, page_size?)` | generator of history rows (`dict`) |
638
- | `get_player_balance(player_email? \| player_id?)` | `PlayerBalanceResult(player, balances, summary, raw)` |
634
+ | `get_player_balance(player_email)` | `PlayerBalanceResult(player, balances, summary, raw)` — **by email only** (no by-id route; use browser `InvoClient.getBalance()` client-side) |
639
635
  | `get_inbound_pending(player_email? \| player_phone?)` | `InboundPendingResult(inbound_pending, raw)` |
640
636
  | `get_linked_identities(player_email? \| player_phone?)` | `LinkedIdentitiesResult(wallet_user_id, primary_email, primary_phone, is_minor, emails, not_found, raw)` — **server-only (PII)** |
641
637
  | `verify_sms_transfer(transaction_id, sms_pin)` / `verify_sms_send(...)` | `SmsVerifyResult` — complete the SMS-PIN path when `verification_method == "sms"` |
@@ -9,8 +9,10 @@ same live backend interchangeably.
9
9
  > wraps is **live** on sandbox + production, so you can build and test against sandbox today.
10
10
  > **Since 2.0.0 (breaking):** `mint_player_token` requires `player_phone` (E.164) — see the
11
11
  > [CHANGELOG](CHANGELOG.md) migration note. **2.1.0 adds** the passkey-recovery relay
12
- > (`recovery_begin`/`recovery_complete`); **2.1.1** is a security/correctness hardening patch;
13
- > **2.1.2** surfaces `expires_in_seconds` on `create_checkout`.
12
+ > (`recovery_begin`/`recovery_complete`); **2.1.1** security hardening; **2.1.2** `expires_in_seconds`
13
+ > on `create_checkout`. **2.2.0** reconciles with the backend: `mint_player_token` no longer
14
+ > requires `player_phone` (it's a session mint, not registration), send-by-phone-alone, and
15
+ > `get_player_balance` is email-only.
14
16
  > Canonical partner reference: **https://docs.invo.network**.
15
17
 
16
18
  ## Highlights
@@ -121,37 +123,29 @@ INVO integrations split across two trust boundaries. This package is the **serve
121
123
  The **game secret authenticates every call here and must never reach a browser.** Mint a
122
124
  short-lived player token server-side with `mint_player_token` and hand *that* to the browser SDK.
123
125
 
124
- ### Player registration (email + phone, required)
126
+ ### Player token (session mint for an existing player)
125
127
 
126
- `mint_player_token` is the player **registration** call, and it requires **both**
127
- `player_email` and `player_phone`:
128
+ `mint_player_token` mints a short-lived, game-scoped **session token** for a player who
129
+ **already exists** on your game — it is *not* a registration call. The backend looks the player
130
+ up by `player_email` and returns a token for their existing identity (or `404` if unknown). It
131
+ only needs `player_email`:
128
132
 
129
133
  ```python
130
- token = server.mint_player_token(
131
- player_email="player@example.com",
132
- player_phone="+15555550100", # REQUIRED -- E.164: leading "+", country code, digits only
133
- )
134
+ token = server.mint_player_token(player_email="player@example.com")
134
135
  ```
135
136
 
136
- Why phone is not optional on the INVO network:
137
-
138
- - **Identity is derived from email + phone together.** Registering email-only and adding the
139
- phone later **changes the identity and forces device re-enrollment** collect the phone in
140
- your enrollment UI *before* the first mint.
141
- - **Incoming cross-game transfers are matched by phone.** An account without a phone can never
142
- see money sent to it.
143
- - **The account-linking consent SMS is triggered by phone.** Without one, the "add this email
144
- to your account?" flow can't run.
145
- - **Format is E.164** (`+15555550100`) the SDK rejects anything else before it reaches the
146
- wire. Don't send national/local formats.
147
-
148
- **Same person, new email, same phone is expected and supported.** When the phone already
149
- belongs to another INVO account, the mint raises an `InvoError` with
150
- `is_phone_share_approval_required` (409) — this is **not a failure**: INVO sent the phone
151
- holder a consent SMS. Show the player a "pending approval" state, then re-issue the same mint
152
- once approved. Poll `phone_share_status(phone=..., email=...)`, or use
153
- `phone_share_initiate` / `phone_share_approve` (the error's `phone_share_approval_id` carries
154
- the id) for the in-app OTP fallback.
137
+ `player_phone` is **optional** here (validated as E.164 only if you pass it, and ignored by this
138
+ endpoint) — an existing email-only player still mints a token.
139
+
140
+ **Where phone actually matters.** A player's INVO identity encodes their phone, and cross-game
141
+ money routing keys off it — but that's enforced on the money calls, not the token mint:
142
+
143
+ - **`initiate_send` / `initiate_transfer`** require the **sender's** phone (E.164), and take the
144
+ **recipient's** phone. You can send by the recipient's **phone alone** they supply their
145
+ email when they claim.
146
+ - An account with no phone can't receive cross-game money or take part in the account-linking
147
+ consent SMS, so make sure players have a phone at **creation / enrollment** (in your own
148
+ player system), before they transact.
155
149
 
156
150
  ### Not in this SDK (by design): the browser WebAuthn ceremonies
157
151
 
@@ -201,8 +195,9 @@ INVO enables each flow for your tenant in the [console](#get-your-account--game-
201
195
 
202
196
  - **Store the game secret server-side** (env var / secret manager) and expose a small endpoint
203
197
  that calls `mint_player_token` so your front-end can fetch/refresh a player token.
204
- - **Collect a phone number (E.164) in your enrollment flow** — `mint_player_token` requires it
205
- alongside the email (see [Player registration](#player-registration-email--phone-required)).
198
+ - **Make sure players have a phone (E.164) at creation/enrollment** — it's required on the money
199
+ calls (`initiate_send`/`initiate_transfer`) and for cross-game receive, though not on the token
200
+ mint itself (see [Player token](#player-token-session-mint-for-an-existing-player)).
206
201
  - **Set your webhook signing secret** and verify every delivery with `verify_webhook` — grant
207
202
  currency/items off webhooks, not synchronous responses.
208
203
  - **For currency purchase:** hosted checkout works out of the box; ask INVO to enable the
@@ -349,7 +344,8 @@ for row in server.iterate_item_purchase_history(player_email="p@example.com"):
349
344
 
350
345
  ```python
351
346
  result = server.get_player_balance(player_email="p@example.com")
352
- # or: server.get_player_balance(player_id=12345)
347
+ # Lookup is by EMAIL only — there is no by-id balance route (player_id is a per-game internal
348
+ # id). For a client-side read, use the browser InvoClient.getBalance() (identity from the token).
353
349
  for b in result.balances:
354
350
  print(b.currency_name, b.available_balance, b.total_balance)
355
351
  ```
@@ -579,7 +575,7 @@ Construct: `InvoServer(game_secret, base_url, *, timeout=30, max_retries=2, retr
579
575
 
580
576
  | Method | Returns |
581
577
  |---|---|
582
- | `mint_player_token(player_email, player_phone)` | `PlayerToken(token, expires_at, identity_id, raw)` — **both required**; `player_phone` must be E.164 (see [Player registration](#player-registration-email--phone-required)) |
578
+ | `mint_player_token(player_email, player_phone?)` | `PlayerToken(token, expires_at, identity_id, raw)` — session mint for an **existing** player (404 if unknown); `player_phone` optional/validated-if-present (see [Player token](#player-token-session-mint-for-an-existing-player)) |
583
579
  | `initiate_send(...)` | `InitiateResult(transaction_id, verification_method, guardian_approval, raw)` |
584
580
  | `initiate_transfer(...)` | `InitiateResult` |
585
581
  | `create_checkout(player_email, usd_amount, rail?, success_url?, cancel_url?, metadata?)` | `CreateCheckoutResult(session_id, checkout_url, expires_at, expires_in_seconds, raw)` |
@@ -590,7 +586,7 @@ Construct: `InvoServer(game_secret, base_url, *, timeout=30, max_retries=2, retr
590
586
  | `get_item_purchase_history(player_email, limit?, offset?)` | `ItemHistoryResult(history, pagination, raw)` |
591
587
  | `get_item_order_details(order_id? \| transaction_id? \| client_request_id?)` | `OrderDetailsResult` |
592
588
  | `iterate_item_purchase_history(player_email, page_size?)` | generator of history rows (`dict`) |
593
- | `get_player_balance(player_email? \| player_id?)` | `PlayerBalanceResult(player, balances, summary, raw)` |
589
+ | `get_player_balance(player_email)` | `PlayerBalanceResult(player, balances, summary, raw)` — **by email only** (no by-id route; use browser `InvoClient.getBalance()` client-side) |
594
590
  | `get_inbound_pending(player_email? \| player_phone?)` | `InboundPendingResult(inbound_pending, raw)` |
595
591
  | `get_linked_identities(player_email? \| player_phone?)` | `LinkedIdentitiesResult(wallet_user_id, primary_email, primary_phone, is_minor, emails, not_found, raw)` — **server-only (PII)** |
596
592
  | `verify_sms_transfer(transaction_id, sms_pin)` / `verify_sms_send(...)` | `SmsVerifyResult` — complete the SMS-PIN path when `verification_method == "sms"` |
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "invonetwork"
7
- version = "2.1.2"
7
+ version = "2.2.0"
8
8
  description = "INVO Python server SDK -- currency purchase, item purchase, sends/transfers, and webhook verification for partner platforms."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -85,6 +85,15 @@ class InvoError(Exception):
85
85
  """True if enrollment needs the OTP-grant flow (``enrollmentBegin``/``enrollmentVerify``)."""
86
86
  return self.code == "ENROLLMENT_REQUIRES_AUTHORIZATION"
87
87
 
88
+ @property
89
+ def is_user_verification_required(self) -> bool:
90
+ """True if a passkey approve/confirm was rejected for lacking user verification.
91
+
92
+ ``WEBAUTHN_UV_REQUIRED`` (401) -- re-run the ceremony requiring UV (biometric/PIN); the
93
+ backend treats a UV-satisfied assertion as the signed step-up.
94
+ """
95
+ return self.code == "WEBAUTHN_UV_REQUIRED"
96
+
88
97
  @property
89
98
  def is_enrollment_proof_required(self) -> bool:
90
99
  """True if enrolling is blocked because another method exists -- use device link."""
@@ -50,7 +50,7 @@ from .types import (
50
50
 
51
51
  __all__ = ["InvoServer"]
52
52
 
53
- DEFAULT_USER_AGENT = "invonetwork-python-sdk/2.1.2 (+https://invo.network)"
53
+ DEFAULT_USER_AGENT = "invonetwork-python-sdk/2.2.0 (+https://invo.network)"
54
54
  """Explicit, non-blocked UA. The backend 403-blocks UAs matching nikto|sqlmap|
55
55
  python-urllib|scanner|bot|^$, so we never use urllib's default UA."""
56
56
 
@@ -409,32 +409,33 @@ class InvoServer:
409
409
 
410
410
  # ---- Token ----
411
411
 
412
- def mint_player_token(self, *, player_email: str, player_phone: str) -> PlayerToken:
412
+ def mint_player_token(
413
+ self, *, player_email: str, player_phone: Optional[str] = None
414
+ ) -> PlayerToken:
413
415
  """Mint a short-lived, game-scoped player token to hand to the browser client.
414
416
 
415
- This is the player REGISTRATION call: the network identity is derived from
416
- ``email + phone`` TOGETHER, so both are required. Registering email-only and
417
- attaching the phone later changes the identity and forces device re-enrollment --
418
- collect the phone before the first mint. ``player_phone`` must be E.164
419
- (e.g. ``"+15555550100"``); incoming cross-game transfers and the account-linking
420
- consent SMS are both matched by phone, so an account without one can't receive
421
- either.
422
-
423
- If the phone already belongs to another INVO account (same person, new email),
424
- this raises an :class:`InvoError` with ``is_phone_share_approval_required`` --
425
- NOT a failure: the phone holder was sent a consent SMS. Surface "pending
426
- approval" to the user, then re-issue the same call once approved (poll
427
- ``phone_share_status``, or use ``phone_share_initiate`` / ``phone_share_approve``
428
- for the in-app OTP fallback).
417
+ This is a SESSION-TOKEN mint for a player who ALREADY EXISTS on the game -- not a
418
+ registration call. The backend looks the player up by ``player_email`` and returns a
419
+ token for their existing identity; if no such player exists it responds 404. The
420
+ network identity (which encodes the player's phone) is established at player creation
421
+ and on the money-routing calls, so phone is NOT required or read here.
422
+
423
+ ``player_phone`` is optional and only validated (E.164) if you pass it -- the backend
424
+ ignores it on this endpoint, so an existing email-only player can still mint a token.
425
+ Phone is enforced where it matters: ``initiate_send`` / ``initiate_transfer`` (the
426
+ sender's phone, and the recipient's when known) and at player creation.
429
427
 
430
428
  Idempotent: safe to retry (re-minting just issues a fresh token).
431
429
  """
432
430
  if not _is_present(player_email):
433
431
  raise _invalid_input("playerEmail", player_email, "is required")
434
- _assert_e164(player_phone, "playerPhone")
432
+ body: Dict[str, Any] = {"player_email": player_email}
433
+ if player_phone is not None:
434
+ _assert_e164(player_phone, "playerPhone") # validate only if provided
435
+ body["player_phone"] = player_phone
435
436
  raw = self._http.post(
436
437
  "/api/sdk/player-token",
437
- {"player_email": player_email, "player_phone": player_phone},
438
+ body,
438
439
  self._auth,
439
440
  idempotent=True,
440
441
  )
@@ -454,29 +455,30 @@ class InvoServer:
454
455
  sender_player_name: str,
455
456
  sender_player_email: str,
456
457
  sender_player_phone: str,
457
- receiver_player_email: str,
458
458
  receiver_player_phone: str,
459
459
  receiving_game_id: Any,
460
460
  amount: Money,
461
+ receiver_player_email: Optional[str] = None,
461
462
  ) -> InitiateResult:
462
463
  """Initiate a cross-game currency SEND. Inspect ``result.verification_method``.
463
464
 
464
- Idempotent on ``client_request_id`` (the backend dedupes), so it is safe to retry.
465
+ You can send by the recipient's PHONE alone -- ``receiver_player_email`` is optional
466
+ (they supply their own email when they claim). Idempotent on ``client_request_id``
467
+ (the backend dedupes), so it is safe to retry.
465
468
  """
469
+ body: Dict[str, Any] = {
470
+ "client_request_id": client_request_id,
471
+ "sender_player_name": sender_player_name,
472
+ "sender_player_email": sender_player_email,
473
+ "sender_player_phone": sender_player_phone,
474
+ "receiver_player_phone": receiver_player_phone,
475
+ "receiving_game_id": receiving_game_id,
476
+ "amount": amount,
477
+ }
478
+ if receiver_player_email is not None:
479
+ body["receiver_player_email"] = receiver_player_email
466
480
  raw = self._http.post(
467
- "/api/currency-sends/initiate-send",
468
- {
469
- "client_request_id": client_request_id,
470
- "sender_player_name": sender_player_name,
471
- "sender_player_email": sender_player_email,
472
- "sender_player_phone": sender_player_phone,
473
- "receiver_player_email": receiver_player_email,
474
- "receiver_player_phone": receiver_player_phone,
475
- "receiving_game_id": receiving_game_id,
476
- "amount": amount,
477
- },
478
- self._auth,
479
- idempotent=True,
481
+ "/api/currency-sends/initiate-send", body, self._auth, idempotent=True
480
482
  )
481
483
  return self._to_initiate_result(raw)
482
484
 
@@ -487,29 +489,30 @@ class InvoServer:
487
489
  source_player_name: str,
488
490
  source_player_email: str,
489
491
  source_player_phone: str,
490
- target_player_email: str,
491
492
  target_player_phone: str,
492
493
  target_game_id: Any,
493
494
  amount: Money,
495
+ target_player_email: Optional[str] = None,
494
496
  ) -> InitiateResult:
495
497
  """Initiate a cross-game TRANSFER. Inspect ``result.verification_method``.
496
498
 
497
- Idempotent on ``client_request_id`` (the backend dedupes), so it is safe to retry.
499
+ You can transfer by the recipient's PHONE alone -- ``target_player_email`` is optional
500
+ (they supply their own email when they claim). Idempotent on ``client_request_id``
501
+ (the backend dedupes), so it is safe to retry.
498
502
  """
503
+ body: Dict[str, Any] = {
504
+ "client_request_id": client_request_id,
505
+ "source_player_name": source_player_name,
506
+ "source_player_email": source_player_email,
507
+ "source_player_phone": source_player_phone,
508
+ "target_player_phone": target_player_phone,
509
+ "target_game_id": target_game_id,
510
+ "amount": amount,
511
+ }
512
+ if target_player_email is not None:
513
+ body["target_player_email"] = target_player_email
499
514
  raw = self._http.post(
500
- "/api/transfers/initiate-transfer",
501
- {
502
- "client_request_id": client_request_id,
503
- "source_player_name": source_player_name,
504
- "source_player_email": source_player_email,
505
- "source_player_phone": source_player_phone,
506
- "target_player_email": target_player_email,
507
- "target_player_phone": target_player_phone,
508
- "target_game_id": target_game_id,
509
- "amount": amount,
510
- },
511
- self._auth,
512
- idempotent=True,
515
+ "/api/transfers/initiate-transfer", body, self._auth, idempotent=True
513
516
  )
514
517
  return self._to_initiate_result(raw)
515
518
 
@@ -831,9 +834,10 @@ class InvoServer:
831
834
  if len(page.history) < size:
832
835
  return # last (short) page
833
836
  # Guard against a backend that ignores `offset` and returns full pages forever:
834
- # stop once we've yielded the reported total. Coerce like JS `Number(total)`
835
- # so a string total (e.g. "100") still trips the guard.
836
- total = page.pagination.get("total")
837
+ # stop once we've yielded the reported total. The backend key is ``total_count``
838
+ # (``total`` kept as a fallback). Coerce like JS ``Number()`` so a string total
839
+ # (e.g. "100") still trips the guard.
840
+ total = page.pagination.get("total_count", page.pagination.get("total"))
837
841
  try:
838
842
  total_num = float(total) if total is not None else None
839
843
  except (TypeError, ValueError):
@@ -847,14 +851,25 @@ class InvoServer:
847
851
  def get_player_balance(
848
852
  self, *, player_email: Optional[str] = None, player_id: Any = None
849
853
  ) -> PlayerBalanceResult:
850
- """Read a player's currency balances, by email or player id (game-secret)."""
851
- if player_id is not None and str(player_id).strip():
852
- path = f"/api/player-balances/player/{quote(str(player_id), safe='')}"
853
- elif _is_present(player_email):
854
+ """Read a player's currency balances by EMAIL (game-secret).
855
+
856
+ Lookup is by email only -- there is no by-id balance route on the backend
857
+ (``player_id`` is a per-game internal id), so passing ``player_id`` raises. For a
858
+ client-side read use the browser ``InvoClient.getBalance()`` (identity from the token).
859
+ """
860
+ if _is_present(player_email):
854
861
  path = f"/api/player-balances/player/by-email/{quote(player_email, safe='')}" # type: ignore[arg-type]
862
+ elif player_id is not None and str(player_id).strip():
863
+ raise InvoError(
864
+ "get_player_balance is by player_email only -- there is no by-id balance "
865
+ "route. Use the browser InvoClient.getBalance() (player token), or pass "
866
+ "player_email here.",
867
+ code="INVALID_INPUT",
868
+ status=0,
869
+ )
855
870
  else:
856
871
  raise InvoError(
857
- "get_player_balance requires a player_email or player_id.",
872
+ "get_player_balance requires a player_email.",
858
873
  code="INVALID_INPUT",
859
874
  status=0,
860
875
  )
@@ -46,11 +46,13 @@ class TestPlayerBalance:
46
46
  assert b.available_balance == "1250.00"
47
47
  assert b.total_balance == "1250.00"
48
48
 
49
- def test_by_player_id_uses_the_id_path(self):
49
+ def test_by_player_id_is_rejected(self):
50
+ # there is no by-id balance route on the backend -> clear error, no network call
50
51
  server, calls = _server(lambda r, i: fake_response(200, {"balances": []}))
51
- r = server.get_player_balance(player_id=12345)
52
- assert calls[0].url == f"{BASE}/api/player-balances/player/12345"
53
- assert r.balances == []
52
+ with pytest.raises(InvoError) as ei:
53
+ server.get_player_balance(player_id=12345)
54
+ assert ei.value.code == "INVALID_INPUT"
55
+ assert len(calls) == 0
54
56
 
55
57
  def test_requires_email_or_id(self):
56
58
  server, calls = _server(lambda r, i: fake_response(200, {}))
@@ -99,15 +99,25 @@ class TestLightValidation:
99
99
 
100
100
  @pytest.mark.parametrize(
101
101
  "player_phone",
102
- ["", " ", "5555550100", "(555) 555-0100", "+0123", "+1 555 555 0100", None],
102
+ ["", " ", "5555550100", "(555) 555-0100", "+0123", "+1 555 555 0100"],
103
103
  )
104
- def test_mint_requires_e164_phone(self, player_phone):
104
+ def test_mint_validates_e164_phone_when_provided(self, player_phone):
105
+ # a provided-but-malformed phone still throws...
105
106
  server, calls = _server(lambda r, i: fake_response(200, {}))
106
107
  with pytest.raises(InvoError) as ei:
107
108
  server.mint_player_token(player_email="p@x.com", player_phone=player_phone)
108
109
  assert ei.value.code == "INVALID_INPUT"
109
110
  assert len(calls) == 0
110
111
 
112
+ def test_mint_email_only_omits_phone(self):
113
+ # ...but phone is optional: an existing email-only player mints, no player_phone sent
114
+ server, calls = _server(
115
+ lambda r, i: fake_response(200, {"token": "t", "expires_at": "e", "identity_id": "id"})
116
+ )
117
+ r = server.mint_player_token(player_email="p@x.com")
118
+ assert r.token == "t"
119
+ assert calls[0].body == {"player_email": "p@x.com"}
120
+
111
121
  def test_create_checkout_requires_email(self):
112
122
  server, calls = _server(lambda r, i: fake_response(200, {}))
113
123
  with pytest.raises(InvoError) as ei:
@@ -72,6 +72,22 @@ class TestRequestMapping:
72
72
  assert r.transaction_id == "txn_1"
73
73
  assert r.verification_method == "in_app"
74
74
 
75
+ def test_initiate_send_by_phone_alone_omits_receiver_email(self):
76
+ transport, calls = mock_transport(
77
+ lambda r, i: fake_response(201, {"transaction_id": "txn_p", "verification_method": "sms"})
78
+ )
79
+ make_server(transport).initiate_send(
80
+ client_request_id="req_p",
81
+ sender_player_name="S",
82
+ sender_player_email="s@x.com",
83
+ sender_player_phone="+15555550100",
84
+ receiver_player_phone="+15555550111", # no receiver_player_email
85
+ receiving_game_id=1,
86
+ amount="5",
87
+ )
88
+ assert calls[0].body["receiver_player_phone"] == "+15555550111"
89
+ assert "receiver_player_email" not in calls[0].body
90
+
75
91
  def test_initiate_transfer_maps_body(self):
76
92
  transport, calls = mock_transport(
77
93
  lambda r, i: fake_response(201, {"transaction_id": "txn_2", "verification_method": "sms"})
File without changes
File without changes