diff --git a/.api-sync/unmodeled.json b/.api-sync/unmodeled.json index ca4ff5c..6421283 100644 --- a/.api-sync/unmodeled.json +++ b/.api-sync/unmodeled.json @@ -230,13 +230,6 @@ "reason": "CreatePayinInput is dead code (allowlist.json: create_evm() takes a bare payin_quote_id str, never this TypedDict), so its one field is unmodeled by construction.", "owner": "eric@blindpay.com" }, - { - "kind": "property", - "schema": "CreatePayinOut", - "field": "billing_fee_amount", - "reason": "Not modeled on CreateEvmPayinResponse (Payin/GetPayinTrackResponse do model it).", - "owner": "eric@blindpay.com" - }, { "kind": "property", "schema": "CreatePayinOut", @@ -279,20 +272,6 @@ "reason": "Not modeled on CreatePayinQuoteResponse.", "owner": "eric@blindpay.com" }, - { - "kind": "property", - "schema": "CreateWalletIn", - "field": "external_id", - "reason": "Optional caller-supplied external reference; not modeled on CreateCustodialWalletInput.", - "owner": "eric@blindpay.com" - }, - { - "kind": "property", - "schema": "CreateWalletIn", - "field": "name", - "reason": "REQUIRED by the API (CreateWalletIn.required includes name) but CreateCustodialWalletInput (customer_id, network only) never sends it -- likely a functional bug, not just a documentation gap; flagging for priority triage.", - "owner": "eric@blindpay.com" - }, { "kind": "property", "schema": "CustomerOut", @@ -1017,15 +996,6 @@ "reason": "Modeled under a different name: CustodialWalletBalanceToken uses `token` where the wire's WalletTokenOut object uses `symbol`.", "owner": "eric@blindpay.com" }, - { - "kind": "enum", - "enum": "BankAccountType", - "missing_values": [ - "saving" - ], - "reason": "Live defect, not just typing: the wire's real enum is [checking, saving] (singular); the SDK Literal is [checking, savings] (plural). Confirmed against packages/api-contract in blindpay-v2 and the API's own payout code, which branches on `account_type === 'saving'`. Do not auto-append \"saving\" as a 3rd member; this needs a coordinated rename PR (node and go already use the correct singular spelling).", - "owner": "eric@blindpay.com" - }, { "kind": "enum", "enum": "KycStatus", diff --git a/src/blindpay/resources/custodial_wallets/custodial_wallets.py b/src/blindpay/resources/custodial_wallets/custodial_wallets.py index 9fb969e..719d3e2 100644 --- a/src/blindpay/resources/custodial_wallets/custodial_wallets.py +++ b/src/blindpay/resources/custodial_wallets/custodial_wallets.py @@ -1,6 +1,6 @@ from typing import List, Optional -from typing_extensions import TypedDict +from typing_extensions import NotRequired, TypedDict from ..._internal.api_client import InternalApiClient, InternalApiClientSync from ...types import BlindpayApiResponse, Network @@ -30,6 +30,8 @@ class CustodialWalletBalance(TypedDict): class CreateCustodialWalletInput(TypedDict): customer_id: str network: Network + name: str + external_id: NotRequired[Optional[str]] CreateCustodialWalletResponse = CustodialWallet diff --git a/src/blindpay/resources/payins/payins.py b/src/blindpay/resources/payins/payins.py index 4324c60..395d097 100644 --- a/src/blindpay/resources/payins/payins.py +++ b/src/blindpay/resources/payins/payins.py @@ -1,4 +1,4 @@ -from typing import List, Optional, TypedDict +from typing import List, NotRequired, Optional, TypedDict from urllib.parse import urlencode from ..._internal.api_client import InternalApiClient, InternalApiClientSync @@ -92,7 +92,7 @@ class Payin(TypedDict): network: Network blindpay_bank_details: BankDetails is_otc: Optional[bool] - billing_fee_amount: Optional[str] + billing_fee_amount: Optional[float] pse_document_type: Optional[str] pse_full_name: Optional[str] pse_payment_link: Optional[str] @@ -170,6 +170,7 @@ class GetPayinTrackResponse(TypedDict): network: Network blindpay_bank_details: BankDetails partner_fee_id: Optional[str] + billing_fee_amount: NotRequired[Optional[float]] class ExportPayinsInput(TypedDict): @@ -194,6 +195,7 @@ class CreateEvmPayinResponse(TypedDict): blindpay_bank_details: BankDetails customer_id: str receiver_amount: float + billing_fee_amount: NotRequired[Optional[float]] class PayinsResource: diff --git a/src/blindpay/types.py b/src/blindpay/types.py index 5110269..7eb8cea 100644 --- a/src/blindpay/types.py +++ b/src/blindpay/types.py @@ -1,4 +1,4 @@ -from typing import Generic, Literal, TypeVar, Union +from typing import Generic, Literal, Optional, TypeVar, Union from typing_extensions import TypedDict @@ -45,7 +45,7 @@ class BlindpaySuccessResponse(TypedDict, Generic[T]): "invoice", "purchase_order", "delivery_slip", "contract", "customs_declaration", "bill_of_lading", "others" ] -BankAccountType = Literal["checking", "savings"] +BankAccountType = Literal["checking", "saving"] Currency = Literal["USDC", "USDT", "USDB", "BRL", "USD", "MXN", "COP", "ARS"] @@ -334,8 +334,8 @@ class PaginationParams(TypedDict, total=False): class PaginationMetadata(TypedDict): has_more: bool - next_page: int - prev_page: int + next_page: Optional[str] + prev_page: Optional[str] class TrackingTransaction(TypedDict): diff --git a/tests/resources/test_custodial_wallets.py b/tests/resources/test_custodial_wallets.py index f1ac1ea..d671960 100644 --- a/tests/resources/test_custodial_wallets.py +++ b/tests/resources/test_custodial_wallets.py @@ -28,6 +28,7 @@ async def test_create_custodial_wallet(self): { "customer_id": "re_000000000000", "network": "solana", + "name": "My Solana Wallet", } ) @@ -36,10 +37,44 @@ async def test_create_custodial_wallet(self): assert response["data"]["id"] == "cw_000000000000" assert response["data"]["network"] == "solana" assert response["data"]["address"] == "So1ana1234567890" + # `name` is required on the wire (CreateWalletIn.required includes it); + # the create() body must actually carry it, not just the type. mock_request.assert_called_once_with( "POST", "/instances/in_000000000000/customers/re_000000000000/wallets", - {"network": "solana"}, + {"network": "solana", "name": "My Solana Wallet"}, + ) + + @pytest.mark.asyncio + async def test_create_custodial_wallet_with_external_id(self): + """external_id is optional on the wire; proves it flows through when + supplied and that name alone (without external_id) still type-checks.""" + mocked_wallet = { + "id": "cw_000000000000", + "customer_id": "re_000000000000", + "instance_id": "in_000000000000", + "network": "solana", + "address": "So1ana1234567890", + "created_at": "2025-01-01T00:00:00Z", + } + + with patch.object(self.blindpay._api, "_request") as mock_request: + mock_request.return_value = {"data": mocked_wallet, "error": None} + + response = await self.blindpay.wallets.custodial.create( + { + "customer_id": "re_000000000000", + "network": "solana", + "name": "My Solana Wallet", + "external_id": "your-database-id", + } + ) + + assert response["error"] is None + mock_request.assert_called_once_with( + "POST", + "/instances/in_000000000000/customers/re_000000000000/wallets", + {"network": "solana", "name": "My Solana Wallet", "external_id": "your-database-id"}, ) @pytest.mark.asyncio @@ -162,6 +197,7 @@ def test_create_custodial_wallet(self): { "customer_id": "re_000000000000", "network": "solana", + "name": "My Solana Wallet", } ) @@ -171,7 +207,36 @@ def test_create_custodial_wallet(self): mock_request.assert_called_once_with( "POST", "/instances/in_000000000000/customers/re_000000000000/wallets", - {"network": "solana"}, + {"network": "solana", "name": "My Solana Wallet"}, + ) + + def test_create_custodial_wallet_with_external_id(self): + mocked_wallet = { + "id": "cw_000000000000", + "customer_id": "re_000000000000", + "instance_id": "in_000000000000", + "network": "solana", + "address": "So1ana1234567890", + "created_at": "2025-01-01T00:00:00Z", + } + + with patch.object(self.blindpay._api, "_request") as mock_request: + mock_request.return_value = {"data": mocked_wallet, "error": None} + + response = self.blindpay.wallets.custodial.create( + { + "customer_id": "re_000000000000", + "network": "solana", + "name": "My Solana Wallet", + "external_id": "your-database-id", + } + ) + + assert response["error"] is None + mock_request.assert_called_once_with( + "POST", + "/instances/in_000000000000/customers/re_000000000000/wallets", + {"network": "solana", "name": "My Solana Wallet", "external_id": "your-database-id"}, ) def test_list_custodial_wallets(self): diff --git a/tests/resources/test_payins.py b/tests/resources/test_payins.py index e19aa35..9459e52 100644 --- a/tests/resources/test_payins.py +++ b/tests/resources/test_payins.py @@ -104,8 +104,8 @@ async def test_list_payins(self): ], "pagination": { "has_more": True, - "next_page": 3, - "prev_page": 1, + "next_page": "pi_123", + "prev_page": "pi_123", }, } @@ -501,8 +501,8 @@ def test_list_payins(self): ], "pagination": { "has_more": True, - "next_page": 3, - "prev_page": 1, + "next_page": "pi_123", + "prev_page": "pi_123", }, } diff --git a/tests/resources/test_payouts.py b/tests/resources/test_payouts.py index 2848990..a9b566c 100644 --- a/tests/resources/test_payouts.py +++ b/tests/resources/test_payouts.py @@ -109,8 +109,8 @@ async def test_list_payouts(self): ], "pagination": { "has_more": True, - "next_page": 3, - "prev_page": 1, + "next_page": "pi_123", + "prev_page": "pi_123", }, } @@ -694,8 +694,8 @@ def test_list_payouts(self): ], "pagination": { "has_more": True, - "next_page": 3, - "prev_page": 1, + "next_page": "pi_123", + "prev_page": "pi_123", }, } diff --git a/tests/test_types.py b/tests/test_types.py new file mode 100644 index 0000000..57e4219 --- /dev/null +++ b/tests/test_types.py @@ -0,0 +1,256 @@ +"""Type-surface regression tests: each of these constructs an explicitly +TypedDict-annotated literal, so pyright/mypy (both run over tests/) enforce +the shape at the assignment itself -- not just at runtime equality. This is +the proof that matters for a type-only fix: a wrong type here fails the type +checker, not just a test assertion. +""" + +from blindpay.resources.custodial_wallets.custodial_wallets import CreateCustodialWalletInput +from blindpay.resources.payins.payins import BankDetails, CreateEvmPayinResponse, GetPayinTrackResponse, Payin +from blindpay.types import BankAccountType, PaginationMetadata + +_BANK_DETAILS: BankDetails = { + "routing_number": "0", + "account_number": "0", + "account_type": "checking", + "swift_bic_code": "0", + "ach": {"routing_number": "0", "account_number": "0"}, + "wire": {"routing_number": "0", "account_number": "0"}, + "rtp": {"routing_number": "0", "account_number": "0"}, + "beneficiary": {"name": "0", "address_line_1": "0", "address_line_2": None}, + "receiving_bank": {"name": "0", "address_line_1": "0", "address_line_2": None}, +} + + +class TestPaginationMetadataAcceptsStringCursors: + def test_string_next_page_and_prev_page(self): + meta: PaginationMetadata = { + "has_more": True, + "next_page": "pi_123", + "prev_page": "pi_123", + } + assert meta["next_page"] == "pi_123" + assert meta["prev_page"] == "pi_123" + + def test_next_page_and_prev_page_are_nullable(self): + meta: PaginationMetadata = { + "has_more": False, + "next_page": None, + "prev_page": None, + } + assert meta["next_page"] is None + assert meta["prev_page"] is None + + +class TestBillingFeeAmountIsNumeric: + def test_payin_accepts_a_float(self): + payin: Payin = { + "customer_id": "cus_000000000000", + "id": "pi_000000000000", + "pix_code": None, + "memo_code": None, + "clabe": None, + "status": "completed", + "manual_execution_status": None, + "payin_quote_id": "pq_000000000000", + "instance_id": "in_000000000000", + "tracking_transaction": None, + "tracking_payment": None, + "tracking_complete": None, + "tracking_partner_fee": None, + "created_at": "2025-01-01T00:00:00Z", + "updated_at": "2025-01-01T00:00:00Z", + "image_url": None, + "first_name": None, + "last_name": None, + "legal_name": None, + "type": "ach", + "payment_method": "ach", + "sender_amount": 1000, + "receiver_amount": 1000, + "token": "USDC", + "partner_fee_amount": 0, + "total_fee_amount": 0, + "commercial_quotation": 1, + "blindpay_quotation": 1, + "currency": "USD", + "billing_fee": 0, + "name": "John Doe", + "address": "0x0", + "network": "base", + "blindpay_bank_details": _BANK_DETAILS, + "is_otc": None, + "billing_fee_amount": 50.0, + "pse_document_type": None, + "pse_full_name": None, + "pse_payment_link": None, + "pse_tax_id": None, + "partner_fee_id": None, + } + assert payin["billing_fee_amount"] == 50.0 + + def test_get_payin_track_response_billing_fee_amount_omitted_type_checks(self): + # NotRequired: omitting the key entirely must still type-check. + without: GetPayinTrackResponse = { + "customer_id": "cus_000000000000", + "id": "pi_000000000000", + "pix_code": "0", + "memo_code": "0", + "clabe": "0", + "status": "completed", + "manual_execution_status": None, + "payin_quote_id": "pq_000000000000", + "instance_id": "in_000000000000", + "tracking_transaction": { + "step": "completed", + "status": "completed", + "transaction_hash": "0", + "completed_at": "0", + }, + "tracking_payment": { + "step": "completed", + "provider_name": "0", + "provider_transaction_id": "0", + "provider_status": "0", + "estimated_time_of_arrival": "0", + "completed_at": "0", + }, + "tracking_complete": { + "step": "completed", + "status": "completed", + "transaction_hash": "0", + "completed_at": "0", + }, + "tracking_partner_fee": {"step": "completed", "transaction_hash": "0", "completed_at": "0"}, + "created_at": "2025-01-01T00:00:00Z", + "updated_at": "2025-01-01T00:00:00Z", + "image_url": "0", + "first_name": "0", + "last_name": "0", + "legal_name": "0", + "type": "ach", + "payment_method": "ach", + "sender_amount": 1000, + "receiver_amount": 1000, + "token": "USDC", + "partner_fee_amount": 0, + "total_fee_amount": 0, + "commercial_quotation": 1, + "blindpay_quotation": 1, + "currency": "USD", + "billing_fee": 0, + "name": "John Doe", + "address": "0x0", + "network": "base", + "blindpay_bank_details": _BANK_DETAILS, + "partner_fee_id": None, + } + assert without.get("billing_fee_amount") is None + + def test_get_payin_track_response_billing_fee_amount_is_numeric_when_present(self): + with_it: GetPayinTrackResponse = { + "customer_id": "cus_000000000000", + "id": "pi_000000000000", + "pix_code": "0", + "memo_code": "0", + "clabe": "0", + "status": "completed", + "manual_execution_status": None, + "payin_quote_id": "pq_000000000000", + "instance_id": "in_000000000000", + "tracking_transaction": { + "step": "completed", + "status": "completed", + "transaction_hash": "0", + "completed_at": "0", + }, + "tracking_payment": { + "step": "completed", + "provider_name": "0", + "provider_transaction_id": "0", + "provider_status": "0", + "estimated_time_of_arrival": "0", + "completed_at": "0", + }, + "tracking_complete": { + "step": "completed", + "status": "completed", + "transaction_hash": "0", + "completed_at": "0", + }, + "tracking_partner_fee": {"step": "completed", "transaction_hash": "0", "completed_at": "0"}, + "created_at": "2025-01-01T00:00:00Z", + "updated_at": "2025-01-01T00:00:00Z", + "image_url": "0", + "first_name": "0", + "last_name": "0", + "legal_name": "0", + "type": "ach", + "payment_method": "ach", + "sender_amount": 1000, + "receiver_amount": 1000, + "token": "USDC", + "partner_fee_amount": 0, + "total_fee_amount": 0, + "commercial_quotation": 1, + "blindpay_quotation": 1, + "currency": "USD", + "billing_fee": 0, + "name": "John Doe", + "address": "0x0", + "network": "base", + "blindpay_bank_details": _BANK_DETAILS, + "partner_fee_id": None, + "billing_fee_amount": 50.0, + } + assert with_it.get("billing_fee_amount") == 50.0 + + def test_create_evm_payin_response_billing_fee_amount_is_optional_and_numeric(self): + resp: CreateEvmPayinResponse = { + "id": "pi_000000000000", + "status": "completed", + "pix_code": None, + "memo_code": None, + "clabe": None, + "tracking_complete": None, + "tracking_payment": None, + "tracking_transaction": None, + "tracking_partner_fee": None, + "blindpay_bank_details": _BANK_DETAILS, + "customer_id": "cus_000000000000", + "receiver_amount": 1000, + "billing_fee_amount": 50.0, + } + assert resp.get("billing_fee_amount") == 50.0 + + +class TestBankAccountTypeUsesSingularSaving: + def test_saving_is_a_valid_value(self): + value: BankAccountType = "saving" + assert value == "saving" + + +class TestCreateCustodialWalletInputRequiresName: + def test_name_is_required_and_sent(self): + data: CreateCustodialWalletInput = { + "customer_id": "cus_000000000000", + "network": "base", + "name": "My Wallet", + } + assert data["name"] == "My Wallet" + + def test_external_id_is_optional(self): + data: CreateCustodialWalletInput = { + "customer_id": "cus_000000000000", + "network": "base", + "name": "My Wallet", + } + assert "external_id" not in data + + with_external_id: CreateCustodialWalletInput = { + "customer_id": "cus_000000000000", + "network": "base", + "name": "My Wallet", + "external_id": "your-database-id", + } + assert with_external_id.get("external_id") == "your-database-id" diff --git a/uv.lock b/uv.lock index 7a8c709..247fbcf 100644 --- a/uv.lock +++ b/uv.lock @@ -27,7 +27,7 @@ wheels = [ [[package]] name = "blindpay" -version = "3.0.0" +version = "3.1.1" source = { editable = "." } dependencies = [ { name = "httpx" },