From da65819cd8a68f283348328225c7c35ffac6e051 Mon Sep 17 00:00:00 2001 From: alvseven Date: Fri, 10 Apr 2026 17:38:22 -0300 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20add=20customers=20resource=20(recei?= =?UTF-8?q?vers=20=E2=86=92=20customers=20migration)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VERSIONING.md | 172 ++++++ src/blindpay/resources/customers/__init__.py | 66 +++ src/blindpay/resources/customers/receivers.py | 516 ++++++++++++++++++ 3 files changed, 754 insertions(+) create mode 100644 VERSIONING.md create mode 100644 src/blindpay/resources/customers/__init__.py create mode 100644 src/blindpay/resources/customers/receivers.py diff --git a/VERSIONING.md b/VERSIONING.md new file mode 100644 index 0000000..7ca068f --- /dev/null +++ b/VERSIONING.md @@ -0,0 +1,172 @@ +# Package Versioning Guide + +This project uses [release-please](https://github.com/googleapis/release-please) to automate versioning, changelog generation, and publishing to PyPI. Versions follow [Semantic Versioning (semver)](https://semver.org/) and are determined automatically based on [Conventional Commits](https://www.conventionalcommits.org/). + +## How It Works + +### The Release Pipeline + +1. You merge a PR into `main` with conventional commit messages. +2. **release-please** (running in `.github/workflows/publish.yaml`) automatically opens or updates a **Release PR** titled `chore(main): release X.Y.Z`. +3. That Release PR bumps the version in all tracked files and updates `CHANGELOG.md`. +4. When the Release PR is merged, release-please creates a GitHub Release and tag, which triggers the **publish** job to build and upload the package to PyPI. + +### What release-please Updates Automatically + +When the Release PR is merged, release-please bumps the version in these files (you should **never** bump them manually): + +- `pyproject.toml` (`version = "X.Y.Z"`) +- `src/blindpay/__init__.py` (`__version__ = "X.Y.Z"`) +- `.release-please-manifest.json` (`".": "X.Y.Z"`) +- `CHANGELOG.md` (appends the new version entry) + +These are configured in `release-please-config.json` under `extra-files` and the default Python release type. + +## Conventional Commits + +The commit message format determines the version bump. **Only the PR title matters** when using squash merges (GitHub uses the PR title as the commit message). + +### Patch Release (`X.Y.Z` -> `X.Y.Z+1`) + +For bug fixes and minor corrections that don't add new functionality: + +``` +fix: correct receiver address validation +fix: handle empty response from virtual accounts endpoint +``` + +### Minor Release (`X.Y.Z` -> `X.Y+1.0`) + +For new features that are **backward-compatible**: + +``` +feat: add new tos and solana endpoints +feat: add swift code check endpoint +``` + +### Major Release (`X.Y.Z` -> `X+1.0.0`) + +For **breaking changes** — anything that could cause existing users' code to fail: + +``` +feat!: update receiver and virtual account types to match API reference +``` + +Or using a `BREAKING CHANGE` footer: + +``` +feat: update receiver and virtual account types + +BREAKING CHANGE: removed OwnerUpdate type, CreateVirtualAccountInput now requires banking_partner field +``` + +### Other Commit Types (No Release) + +These commit types do **not** trigger a version bump: + +``` +chore: update CI workflow +docs: update README +test: add unit tests for receivers +ci: fix lint workflow +refactor: reorganize internal utils +``` + +## What Counts as a Breaking Change? + +When working on this SDK, the following changes are **breaking** and require a `feat!:` prefix or `BREAKING CHANGE` footer: + +| Change | Breaking? | Why | +|--------|-----------|-----| +| Removing a previously exported type (e.g., `OwnerUpdate`) | Yes | Users importing it will get `ImportError` | +| Adding a new **required** field to an input `TypedDict` | Yes | Existing calls will fail type checking / miss the field | +| Removing a field from an input `TypedDict` | Yes | Users passing that field will get a type error | +| Changing a field's type (e.g., `str` -> `int`) | Yes | Users' existing values may become invalid | +| Changing `owners` from `List[OwnerUpdate]` to `List[Owner]` | Yes | Different type with different fields | +| Making a required field **optional** | No | Existing code still works, just less strict | +| Adding a new **optional** field to a `TypedDict` | No | Existing code ignores it | +| Adding a new exported type | No | Doesn't affect existing code | +| Adding new values to a `Literal` type | No | Existing values still valid | + +## Step-by-Step: Making a New Release + +### 1. Create Your Feature Branch + +```bash +git checkout -b eric/my-new-feature +``` + +### 2. Make Your Changes + +Edit the source code under `src/blindpay/`. + +### 3. Commit with Conventional Commit Messages + +Use the appropriate prefix based on the nature of your changes: + +```bash +# For a new non-breaking feature: +git commit -m "feat: add webhook retry configuration" + +# For a breaking change: +git commit -m "feat!: redesign receiver creation flow + +BREAKING CHANGE: CreateIndividualWithStandardKYCInput now requires account_purpose field" + +# For a bug fix: +git commit -m "fix: handle timeout in payout status polling" +``` + +### 4. Open a Pull Request + +Make sure the **PR title** follows conventional commits format, since this repo uses squash merges (the PR title becomes the final commit message on `main`). + +- Non-breaking feature: `feat: add webhook retry configuration` +- Breaking change: `feat!: redesign receiver creation flow` +- Bug fix: `fix: handle timeout in payout status polling` + +### 5. Merge to `main` + +After CI passes and the PR is approved, merge it. + +### 6. Release PR Is Created/Updated Automatically + +release-please will open (or update) a PR titled something like: + +``` +chore(main): release 1.3.0 +``` + +This PR contains: +- Version bumps in `pyproject.toml`, `src/blindpay/__init__.py`, `.release-please-manifest.json` +- Updated `CHANGELOG.md` with entries derived from commit messages + +### 7. Review and Merge the Release PR + +Once you're ready to publish the new version, merge the Release PR. This triggers: +1. A GitHub Release is created with the tag (e.g., `v1.3.0`) +2. The publish workflow builds and uploads the package to PyPI + +## Configuration Files + +| File | Purpose | +|------|---------| +| `release-please-config.json` | Configures release-please behavior (release type, changelog path, extra files to update) | +| `.release-please-manifest.json` | Tracks the current released version (updated by release-please, don't edit manually) | +| `.github/workflows/publish.yaml` | Workflow that runs release-please and publishes to PyPI | + +## Important Rules + +1. **Never manually bump versions** in `pyproject.toml`, `__init__.py`, or the manifest. release-please handles this. +2. **Always use conventional commit prefixes** in your PR title (for squash merges). +3. **Mark breaking changes explicitly** with `!` after the type or with a `BREAKING CHANGE:` footer. +4. **Don't merge the Release PR until you're ready** to publish to PyPI — merging it triggers the publish immediately. + +## Past Release History + +| Version | Type | Commit Prefix | Description | +|---------|------|---------------|-------------| +| `v0.1.0` | Initial | — | Initial release | +| `v1.0.0` | Major | `feat!:` | Stable release with breaking changes | +| `v1.1.0` | Minor | `feat:` | Added swift code check endpoint | +| `v1.2.0` | Minor | `feat:` | Added new TOS and Solana endpoints | diff --git a/src/blindpay/resources/customers/__init__.py b/src/blindpay/resources/customers/__init__.py new file mode 100644 index 0000000..cdc0f6b --- /dev/null +++ b/src/blindpay/resources/customers/__init__.py @@ -0,0 +1,66 @@ +from .customers import ( + BusinessType, + BusinessWithStandardKYB, + CreateBusinessWithStandardKYBInput, + CreateBusinessWithStandardKYBResponse, + CreateIndividualWithEnhancedKYCInput, + CreateIndividualWithEnhancedKYCResponse, + CreateIndividualWithStandardKYCInput, + CreateIndividualWithStandardKYCResponse, + EnhancedKycType, + GetCustomerLimitsResponse, + GetCustomerResponse, + IdentificationDocument, + IndividualType, + IndividualWithEnhancedKYC, + IndividualWithStandardKYC, + KycType, + KycWarning, + ListCustomersResponse, + Owner, + OwnerRole, + OwnerUpdate, + ProofOfAddressDocType, + PurposeOfTransactions, + CustomersResource, + CustomersResourceSync, + SourceOfFundsDocType, + TransactionLimit, + UpdateCustomerInput, + create_customers_resource, + create_customers_resource_sync, +) + +__all__ = [ + "create_customers_resource", + "create_customers_resource_sync", + "CustomersResource", + "CustomersResourceSync", + "IndividualWithStandardKYC", + "IndividualWithEnhancedKYC", + "BusinessWithStandardKYB", + "CreateIndividualWithStandardKYCInput", + "CreateIndividualWithEnhancedKYCInput", + "CreateBusinessWithStandardKYBInput", + "UpdateCustomerInput", + "GetCustomerLimitsResponse", + "KycType", + "BusinessType", + "IndividualType", + "EnhancedKycType", + "ProofOfAddressDocType", + "PurposeOfTransactions", + "SourceOfFundsDocType", + "IdentificationDocument", + "OwnerRole", + "KycWarning", + "TransactionLimit", + "Owner", + "CreateBusinessWithStandardKYBResponse", + "CreateIndividualWithEnhancedKYCResponse", + "CreateIndividualWithStandardKYCResponse", + "ListCustomersResponse", + "GetCustomerResponse", + "OwnerUpdate", + "UpdateCustomerInput", +] diff --git a/src/blindpay/resources/customers/receivers.py b/src/blindpay/resources/customers/receivers.py new file mode 100644 index 0000000..0a6577b --- /dev/null +++ b/src/blindpay/resources/customers/receivers.py @@ -0,0 +1,516 @@ +from typing import List, Optional, Union + +from typing_extensions import Literal, TypedDict + +from ..._internal.api_client import InternalApiClient, InternalApiClientSync +from ...types import ( + BlindpayApiResponse, + Country, +) + +IndividualType = Literal["individual"] +BusinessType = Literal["business"] +StandardKycType = Literal["standard"] +EnhancedKycType = Literal["enhanced"] +KycType = Literal["light", "standard", "enhanced"] + +ProofOfAddressDocType = Literal[ + "UTILITY_BILL", "BANK_STATEMENT", "RENTAL_AGREEMENT", "TAX_DOCUMENT", "GOVERNMENT_CORRESPONDENCE" +] + +PurposeOfTransactions = Literal[ + "business_transactions", + "charitable_donations", + "investment_purposes", + "payments_to_friends_or_family_abroad", + "personal_or_living_expenses", + "protect_wealth", + "purchase_good_and_services", + "receive_payment_for_freelancing", + "receive_salary", + "other", +] + +SourceOfFundsDocType = Literal[ + "business_income", + "gambling_proceeds", + "gifts", + "government_benefits", + "inheritance", + "investment_loans", + "pension_retirement", + "salary", + "sale_of_assets_real_estate", + "savings", + "esops", + "investment_proceeds", + "someone_else_funds", +] + +IdentificationDocument = Literal["PASSPORT", "ID_CARD", "DRIVERS"] + +OwnerRole = Literal["beneficial_controlling", "beneficial_owner", "controlling_person"] + +LimitIncreaseRequestStatus = Literal["in_review", "approved", "rejected"] + +LimitIncreaseRequestSupportingDocumentType = Literal[ + "individual_bank_statement", + "individual_tax_return", + "individual_proof_of_income", + "business_bank_statement", + "business_financial_statements", + "business_tax_return", +] + + +class KycWarning(TypedDict): + code: Optional[str] + message: Optional[str] + resolution_status: Optional[str] + warning_id: Optional[str] + + +class TransactionLimit(TypedDict): + per_transaction: float + daily: float + monthly: float + + +class Owner(TypedDict): + id: str + instance_id: str + customer_id: str + role: OwnerRole + first_name: str + last_name: str + date_of_birth: str + tax_id: str + address_line_1: str + address_line_2: Optional[str] + city: str + state_province_region: str + country: Country + postal_code: str + id_doc_country: Country + id_doc_type: IdentificationDocument + id_doc_front_file: str + id_doc_back_file: Optional[str] + proof_of_address_doc_type: ProofOfAddressDocType + proof_of_address_doc_file: str + + +class IndividualWithStandardKYC(TypedDict): + id: str + type: IndividualType + kyc_type: StandardKycType + kyc_status: str + kyc_warnings: Optional[List[KycWarning]] + email: str + tax_id: str + address_line_1: str + address_line_2: Optional[str] + city: str + state_province_region: str + country: Country + postal_code: str + ip_address: Optional[str] + image_url: Optional[str] + phone_number: str + proof_of_address_doc_type: ProofOfAddressDocType + proof_of_address_doc_file: str + first_name: str + last_name: str + date_of_birth: str + id_doc_country: Country + id_doc_type: IdentificationDocument + id_doc_front_file: str + id_doc_back_file: str + aiprise_validation_key: str + instance_id: str + tos_id: Optional[str] + created_at: str + updated_at: str + limit: TransactionLimit + + +class IndividualWithEnhancedKYC(TypedDict): + id: str + type: IndividualType + kyc_type: EnhancedKycType + kyc_status: str + kyc_warnings: Optional[List[KycWarning]] + email: str + tax_id: str + address_line_1: str + address_line_2: Optional[str] + city: str + state_province_region: str + country: Country + postal_code: str + ip_address: Optional[str] + image_url: Optional[str] + phone_number: Optional[str] + proof_of_address_doc_type: ProofOfAddressDocType + proof_of_address_doc_file: str + first_name: str + last_name: str + date_of_birth: str + id_doc_country: Country + id_doc_type: IdentificationDocument + id_doc_front_file: str + id_doc_back_file: Optional[str] + aiprise_validation_key: str + instance_id: str + source_of_funds_doc_type: str + source_of_funds_doc_file: str + individual_holding_doc_front_file: str + purpose_of_transactions: PurposeOfTransactions + purpose_of_transactions_explanation: Optional[str] + tos_id: Optional[str] + created_at: str + updated_at: str + limit: TransactionLimit + + +class BusinessWithStandardKYB(TypedDict): + id: str + type: BusinessType + kyc_type: StandardKycType + kyc_status: str + kyc_warnings: Optional[List[KycWarning]] + email: str + tax_id: str + address_line_1: str + address_line_2: Optional[str] + city: str + state_province_region: str + country: Country + postal_code: str + ip_address: Optional[str] + image_url: Optional[str] + phone_number: Optional[str] + proof_of_address_doc_type: ProofOfAddressDocType + proof_of_address_doc_file: str + legal_name: str + alternate_name: Optional[str] + formation_date: str + website: Optional[str] + owners: List[Owner] + incorporation_doc_file: str + proof_of_ownership_doc_file: str + external_id: Optional[str] + instance_id: str + tos_id: Optional[str] + aiprise_validation_key: str + created_at: str + updated_at: str + limit: TransactionLimit + + +class CreateIndividualWithStandardKYCInput(TypedDict): + external_id: Optional[str] + address_line_1: str + address_line_2: Optional[str] + city: str + country: Country + date_of_birth: str + email: str + first_name: str + phone_number: Optional[str] + id_doc_country: Country + id_doc_front_file: str + id_doc_type: IdentificationDocument + id_doc_back_file: Optional[str] + last_name: str + postal_code: str + proof_of_address_doc_file: str + proof_of_address_doc_type: ProofOfAddressDocType + state_province_region: str + tax_id: str + tos_id: str + + +class CreateIndividualWithStandardKYCResponse(TypedDict): + id: str + + +class CreateIndividualWithEnhancedKYCInput(TypedDict): + external_id: Optional[str] + address_line_1: str + address_line_2: Optional[str] + city: str + country: Country + date_of_birth: str + email: str + first_name: str + id_doc_country: Country + id_doc_front_file: str + id_doc_type: IdentificationDocument + id_doc_back_file: Optional[str] + individual_holding_doc_front_file: str + last_name: str + postal_code: str + phone_number: Optional[str] + proof_of_address_doc_file: str + proof_of_address_doc_type: ProofOfAddressDocType + purpose_of_transactions: PurposeOfTransactions + source_of_funds_doc_file: str + source_of_funds_doc_type: SourceOfFundsDocType + purpose_of_transactions_explanation: Optional[str] + state_province_region: str + tax_id: str + tos_id: str + + +class CreateIndividualWithEnhancedKYCResponse(TypedDict): + id: str + + +class CreateBusinessWithStandardKYBInput(TypedDict): + external_id: Optional[str] + address_line_1: str + address_line_2: Optional[str] + alternate_name: str + city: str + country: Country + email: str + formation_date: str + incorporation_doc_file: str + legal_name: str + owners: List[Owner] + postal_code: str + proof_of_address_doc_file: str + proof_of_address_doc_type: ProofOfAddressDocType + proof_of_ownership_doc_file: str + state_province_region: str + tax_id: str + tos_id: str + website: Optional[str] + + +class CreateBusinessWithStandardKYBResponse(TypedDict): + id: str + + +ListCustomersResponse = List[Union[IndividualWithStandardKYC, IndividualWithEnhancedKYC, BusinessWithStandardKYB]] + +GetCustomerResponse = Union[IndividualWithStandardKYC, IndividualWithEnhancedKYC, BusinessWithStandardKYB] + + +class OwnerUpdate(TypedDict): + id: str + first_name: str + last_name: str + role: OwnerRole + date_of_birth: str + tax_id: str + address_line_1: str + address_line_2: Optional[str] + city: str + state_province_region: str + country: Country + postal_code: str + id_doc_country: Country + id_doc_type: IdentificationDocument + id_doc_front_file: str + id_doc_back_file: Optional[str] + + +class UpdateCustomerInput(TypedDict): + customer_id: str + email: Optional[str] + tax_id: Optional[str] + address_line_1: Optional[str] + address_line_2: Optional[str] + city: Optional[str] + state_province_region: Optional[str] + country: Optional[Country] + postal_code: Optional[str] + ip_address: Optional[str] + image_url: Optional[str] + phone_number: Optional[str] + proof_of_address_doc_type: Optional[ProofOfAddressDocType] + proof_of_address_doc_file: Optional[str] + first_name: Optional[str] + last_name: Optional[str] + date_of_birth: Optional[str] + id_doc_country: Optional[Country] + id_doc_type: Optional[IdentificationDocument] + id_doc_front_file: Optional[str] + id_doc_back_file: Optional[str] + legal_name: Optional[str] + alternate_name: Optional[str] + formation_date: Optional[str] + website: Optional[str] + owners: Optional[List[OwnerUpdate]] + incorporation_doc_file: Optional[str] + proof_of_ownership_doc_file: Optional[str] + source_of_funds_doc_type: Optional[SourceOfFundsDocType] + source_of_funds_doc_file: Optional[str] + individual_holding_doc_front_file: Optional[str] + purpose_of_transactions: Optional[PurposeOfTransactions] + purpose_of_transactions_explanation: Optional[str] + external_id: Optional[str] + tos_id: Optional[str] + + +class PayinLimit(TypedDict): + daily: float + monthly: float + + +class PayoutLimit(TypedDict): + daily: float + monthly: float + + +class Limits(TypedDict): + payin: PayinLimit + payout: PayoutLimit + + +class GetCustomerLimitsResponse(TypedDict): + limits: Limits + + +class LimitIncreaseRequest(TypedDict): + id: str + customer_id: str + status: LimitIncreaseRequestStatus + daily: float + monthly: float + per_transaction: float + supporting_document_file: str + supporting_document_type: LimitIncreaseRequestSupportingDocumentType + created_at: str + updated_at: str + + +GetLimitIncreaseRequestsResponse = List[LimitIncreaseRequest] + + +class RequestLimitIncreaseInput(TypedDict): + customer_id: str + daily: float + monthly: float + per_transaction: float + supporting_document_file: str + supporting_document_type: LimitIncreaseRequestSupportingDocumentType + + +class RequestLimitIncreaseResponse(TypedDict): + id: str + + +class CustomersResource: + def __init__(self, instance_id: str, client: InternalApiClient): + self._instance_id = instance_id + self._client = client + + async def list(self) -> BlindpayApiResponse[ListCustomersResponse]: + return await self._client.get(f"/instances/{self._instance_id}/customers") + + async def create_individual_with_standard_kyc( + self, data: CreateIndividualWithStandardKYCInput + ) -> BlindpayApiResponse[CreateIndividualWithStandardKYCResponse]: + payload = {"kyc_type": "standard", "type": "individual", **data} + return await self._client.post(f"/instances/{self._instance_id}/customers", payload) + + async def create_individual_with_enhanced_kyc( + self, data: CreateIndividualWithEnhancedKYCInput + ) -> BlindpayApiResponse[CreateIndividualWithEnhancedKYCResponse]: + payload = {"kyc_type": "enhanced", "type": "individual", **data} + return await self._client.post(f"/instances/{self._instance_id}/customers", payload) + + async def create_business_with_standard_kyb( + self, data: CreateBusinessWithStandardKYBInput + ) -> BlindpayApiResponse[CreateBusinessWithStandardKYBResponse]: + payload = {"kyc_type": "standard", "type": "business", **data} + return await self._client.post(f"/instances/{self._instance_id}/customers", payload) + + async def get(self, customer_id: str) -> BlindpayApiResponse[GetCustomerResponse]: + return await self._client.get(f"/instances/{self._instance_id}/customers/{customer_id}") + + async def update(self, data: UpdateCustomerInput) -> BlindpayApiResponse[None]: + customer_id = data["customer_id"] + payload = {k: v for k, v in data.items() if k != "customer_id"} + return await self._client.put(f"/instances/{self._instance_id}/customers/{customer_id}", payload) + + async def delete(self, customer_id: str) -> BlindpayApiResponse[None]: + return await self._client.delete(f"/instances/{self._instance_id}/customers/{customer_id}") + + async def get_limits(self, customer_id: str) -> BlindpayApiResponse[GetCustomerLimitsResponse]: + return await self._client.get(f"/instances/{self._instance_id}/limits/customers/{customer_id}") + + async def get_limit_increase_requests( + self, customer_id: str + ) -> BlindpayApiResponse[GetLimitIncreaseRequestsResponse]: + return await self._client.get(f"/instances/{self._instance_id}/customers/{customer_id}/limit-increase") + + async def request_limit_increase( + self, data: RequestLimitIncreaseInput + ) -> BlindpayApiResponse[RequestLimitIncreaseResponse]: + customer_id = data["customer_id"] + payload = {k: v for k, v in data.items() if k != "customer_id"} + return await self._client.post( + f"/instances/{self._instance_id}/customers/{customer_id}/limit-increase", payload + ) + + +class CustomersResourceSync: + def __init__(self, instance_id: str, client: InternalApiClientSync): + self._instance_id = instance_id + self._client = client + + def list(self) -> BlindpayApiResponse[ListCustomersResponse]: + return self._client.get(f"/instances/{self._instance_id}/customers") + + def create_individual_with_standard_kyc( + self, data: CreateIndividualWithStandardKYCInput + ) -> BlindpayApiResponse[CreateIndividualWithStandardKYCResponse]: + payload = {"kyc_type": "standard", "type": "individual", **data} + return self._client.post(f"/instances/{self._instance_id}/customers", payload) + + def create_individual_with_enhanced_kyc( + self, data: CreateIndividualWithEnhancedKYCInput + ) -> BlindpayApiResponse[CreateIndividualWithEnhancedKYCResponse]: + payload = {"kyc_type": "enhanced", "type": "individual", **data} + return self._client.post(f"/instances/{self._instance_id}/customers", payload) + + def create_business_with_standard_kyb( + self, data: CreateBusinessWithStandardKYBInput + ) -> BlindpayApiResponse[CreateBusinessWithStandardKYBResponse]: + payload = {"kyc_type": "standard", "type": "business", **data} + return self._client.post(f"/instances/{self._instance_id}/customers", payload) + + def get(self, customer_id: str) -> BlindpayApiResponse[GetCustomerResponse]: + return self._client.get(f"/instances/{self._instance_id}/customers/{customer_id}") + + def update(self, data: UpdateCustomerInput) -> BlindpayApiResponse[None]: + customer_id = data["customer_id"] + payload = {k: v for k, v in data.items() if k != "customer_id"} + return self._client.put(f"/instances/{self._instance_id}/customers/{customer_id}", payload) + + def delete(self, customer_id: str) -> BlindpayApiResponse[None]: + return self._client.delete(f"/instances/{self._instance_id}/customers/{customer_id}") + + def get_limits(self, customer_id: str) -> BlindpayApiResponse[GetCustomerLimitsResponse]: + return self._client.get(f"/instances/{self._instance_id}/limits/customers/{customer_id}") + + def get_limit_increase_requests(self, customer_id: str) -> BlindpayApiResponse[GetLimitIncreaseRequestsResponse]: + return self._client.get(f"/instances/{self._instance_id}/customers/{customer_id}/limit-increase") + + def request_limit_increase( + self, data: RequestLimitIncreaseInput + ) -> BlindpayApiResponse[RequestLimitIncreaseResponse]: + customer_id = data["customer_id"] + payload = {k: v for k, v in data.items() if k != "customer_id"} + return self._client.post(f"/instances/{self._instance_id}/customers/{customer_id}/limit-increase", payload) + + +def create_customers_resource(instance_id: str, client: InternalApiClient) -> CustomersResource: + return CustomersResource(instance_id, client) + + +def create_customers_resource_sync(instance_id: str, client: InternalApiClientSync) -> CustomersResourceSync: + return CustomersResourceSync(instance_id, client) From f24de978aaab30cc8dcef2e593249eac79d00366 Mon Sep 17 00:00:00 2001 From: alvseven Date: Fri, 10 Apr 2026 17:52:18 -0300 Subject: [PATCH 2/4] fix: rename customers module file --- src/blindpay/resources/customers/{receivers.py => customers.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/blindpay/resources/customers/{receivers.py => customers.py} (100%) diff --git a/src/blindpay/resources/customers/receivers.py b/src/blindpay/resources/customers/customers.py similarity index 100% rename from src/blindpay/resources/customers/receivers.py rename to src/blindpay/resources/customers/customers.py From ca525987413def160255ec5cce48a52e9a8a7e2b Mon Sep 17 00:00:00 2001 From: alvseven Date: Fri, 10 Apr 2026 17:56:19 -0300 Subject: [PATCH 3/4] chore: bump version to 2.0.0 for customers migration --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index cdaf9e8..eb23a88 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "blindpay" -version = "1.3.0" +version = "2.0.0" description = "Official Python SDK for the Blindpay API — Global payments infrastructure" readme = "README.md" authors = [{ name = "Blindpay", email = "alves@blindpay.com" }] From aaa9e4ae69eb1ee08d7a9ffd66e6b50abba1831a Mon Sep 17 00:00:00 2001 From: alvseven Date: Sun, 12 Apr 2026 01:03:26 -0300 Subject: [PATCH 4/4] feat: add CLAUDE.md and api-sync workflow for automated SDK updates CLAUDE.md: AI agent playbook with codebase patterns, conventions, and OpenAPI-to-SDK mapping rules. api-sync.yml: Workflow triggered by blindpay-v2 on API changes. Runs Claude Code to generate SDK updates following CLAUDE.md patterns. --- .github/workflows/api-sync.yml | 114 +++ CLAUDE.md | 737 ++++++++++++++++++ VERSIONING.md | 172 ---- pyproject.toml | 2 +- src/blindpay/resources/customers/__init__.py | 66 -- src/blindpay/resources/customers/customers.py | 516 ------------ 6 files changed, 852 insertions(+), 755 deletions(-) create mode 100644 .github/workflows/api-sync.yml create mode 100644 CLAUDE.md delete mode 100644 VERSIONING.md delete mode 100644 src/blindpay/resources/customers/__init__.py delete mode 100644 src/blindpay/resources/customers/customers.py diff --git a/.github/workflows/api-sync.yml b/.github/workflows/api-sync.yml new file mode 100644 index 0000000..5b923ef --- /dev/null +++ b/.github/workflows/api-sync.yml @@ -0,0 +1,114 @@ +name: API Sync + +on: + repository_dispatch: + types: [api-sync] + +permissions: + contents: write + pull-requests: write + +jobs: + sync: + name: Sync SDK with API changes + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Download OpenAPI spec and diff from source + env: + GH_TOKEN: ${{ secrets.SDK_SYNC_PAT }} + run: | + # Download the diff payload and openapi.json from the triggering workflow + RUN_URL="${{ github.event.client_payload.diff_url }}" + RUN_ID=$(echo "$RUN_URL" | grep -oP '\d+$') + + gh run download "$RUN_ID" \ + --repo blindpaylabs/blindpay-v2 \ + --name sdk-diff-payload \ + --dir /tmp/api-sync + + echo "Downloaded diff payload:" + cat /tmp/api-sync/sdk-diff-payload.json | head -100 + + - name: Check for existing api-sync PR + id: check-pr + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + PR_NUMBER=$(gh pr list --head api-sync --json number --jq '.[0].number // empty') + if [ -n "$PR_NUMBER" ]; then + echo "existing_pr=$PR_NUMBER" >> $GITHUB_OUTPUT + echo "Found existing api-sync PR: #$PR_NUMBER" + else + echo "existing_pr=" >> $GITHUB_OUTPUT + echo "No existing api-sync PR found" + fi + + - name: Create or checkout api-sync branch + run: | + git fetch origin api-sync 2>/dev/null || true + if git rev-parse --verify origin/api-sync >/dev/null 2>&1; then + git checkout api-sync + git reset --hard origin/main + else + git checkout -b api-sync + fi + + - name: Apply changes with Claude Code + uses: anthropics/claude-code-action@v1 + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + model: sonnet + prompt: | + You are updating this SDK to match API changes. Read CLAUDE.md for codebase standards. + + The OpenAPI diff is at /tmp/api-sync/sdk-diff-payload.json + The full current OpenAPI spec is at /tmp/api-sync/openapi.json + + Instructions: + 1. Read CLAUDE.md thoroughly — it contains all patterns and conventions for this SDK. + 2. Read the diff payload to understand what changed (new paths, modified schemas, removed paths). + 3. Only process paths where x-sdk is true in the OpenAPI spec. + 4. Apply the changes following CLAUDE.md patterns exactly. + 5. If adding a new resource: create the resource file, register in client, add exports. + 6. If modifying types: update the type definitions to match new schemas. + 7. If removing a resource: delete files, remove registrations. + 8. Bump the version following CLAUDE.md versioning rules and semver. + 9. Run lint and type checking. Fix any errors. + 10. Do NOT create commits — just modify the files. + + - name: Commit and push + run: | + git add -A + if git diff --staged --quiet; then + echo "No changes to commit" + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git commit -m "feat: sync SDK with API changes + + Source: ${{ github.event.client_payload.source_sha }}" + git push --force-with-lease origin api-sync + + - name: Create or update PR + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + EXISTING_PR="${{ steps.check-pr.outputs.existing_pr }}" + + if [ -n "$EXISTING_PR" ]; then + echo "Updating existing PR #$EXISTING_PR" + gh pr comment "$EXISTING_PR" --body "Updated with latest API changes from blindpay-v2@\`${{ github.event.client_payload.source_sha }}\`" + else + gh pr create \ + --title "feat: sync SDK with API changes" \ + --body "Automated SDK update from API changes in blindpay-v2. + + Source commit: blindpaylabs/blindpay-v2@\`${{ github.event.client_payload.source_sha }}\` + Workflow run: ${{ github.event.client_payload.diff_url }}" \ + --base main \ + --head api-sync \ + --label api-sync + fi diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..a4743c2 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,737 @@ +# CLAUDE.md -- blindpay-python SDK + +This document is the authoritative reference for an AI agent modifying this codebase. +Read it fully before making any change. + +--- + +## 1. Project structure + +``` +blindpay-python/ + pyproject.toml # Package metadata, deps, ruff/mypy config + release-please-config.json # release-please settings (Python type, v-tag) + .release-please-manifest.json # Current release-please version tracker + src/blindpay/ + __init__.py # SDK version, public re-exports (types + clients) + client.py # BlindPay (async) and BlindPaySync classes, + # ApiClientImpl / ApiClientImplSync (HTTP layer), + # _*Namespace / _*NamespaceSync classes (sub-resources) + types.py # Shared Literal types and TypedDicts used across resources + _internal/ + api_client.py # Protocol interfaces: InternalApiClient, InternalApiClientSync + exceptions.py # BlindPayError exception class + resources/ + __init__.py # Barrel re-exports of all create_*_resource factory functions + / + __init__.py # Barrel re-exports from the resource module(s) + .py # TypedDicts (input/output) + Resource classes (async+sync) + factory fns + tests/ + conftest.py # Adds src/ to sys.path + test_client.py # Tests for BlindPay / BlindPaySync (webhook verification) + resources/ + test_.py # Per-resource test files (async + sync test classes) +``` + +### Resource directory examples + +Standard resource (no sub-resources): +``` +resources/payouts/ + __init__.py # re-exports from payouts.py + payouts.py # types + PayoutsResource + PayoutsResourceSync + factory fns +``` + +Resource with sub-resources (e.g. payins has quotes): +``` +resources/payins/ + __init__.py # re-exports from BOTH payins.py and quotes.py + payins.py # types + PayinsResource + PayinsResourceSync + factory fns + quotes.py # types + PayinQuotesResource + PayinQuotesResourceSync + factory fns +``` + +Multi-module namespace (wallets -- no _base, purely a grouping): +``` +resources/wallets/ + __init__.py # re-exports from blockchain.py and offramp.py + blockchain.py # BlockchainWalletsResource + BlockchainWalletsResourceSync + factory fns + offramp.py # OfframpWalletsResource + OfframpWalletsResourceSync + factory fns +resources/custodial_wallets/ + __init__.py + custodial_wallets.py # CustodialWalletsResource + CustodialWalletsResourceSync + factory fns +``` + +--- + +## 2. Conventions + +### Naming + +| Concept | Convention | Example | +|---|---|---| +| Resource class (async) | `{Name}Resource` | `PayinsResource` | +| Resource class (sync) | `{Name}ResourceSync` | `PayinsResourceSync` | +| Factory function (async) | `create_{snake_name}_resource` | `create_payins_resource` | +| Factory function (sync) | `create_{snake_name}_resource_sync` | `create_payins_resource_sync` | +| Input TypedDict | `{ActionName}Input` | `CreatePayinQuoteInput` | +| Output TypedDict | `{ActionName}Response` | `CreatePayinQuoteResponse` | +| Entity TypedDict | `{EntityName}` (no suffix) | `Payout`, `Payin`, `BlockchainWallet` | +| List response | `List{Name}Response` (TypedDict with `data` + `pagination`) | `ListPayoutsResponse` | +| Literal type | `PascalCase` | `TransactionStatus`, `Network` | +| Client namespace (async) | `_{Name}Namespace` | `_PayinsNamespace` | +| Client namespace (sync) | `_{Name}NamespaceSync` | `_PayinsNamespaceSync` | + +### Code style + +- Line length: 120 (ruff config). +- Formatter: ruff format. Linter: ruff lint. Type checker: mypy (strict) + pyright. +- Ruff lint rules: `["I","B","E","F","ARG","T201","T203"]`, ignore `["B006"]`. +- All TypedDicts come from `typing_extensions.TypedDict` (NOT `typing.TypedDict`). Exception: some resource files use `typing.TypedDict` -- follow whichever the file already uses, but prefer `typing_extensions`. +- Use `Optional[X]` from `typing`, not `X | None`. +- Use `List[X]` from `typing`, not `list[X]`. +- Use `Literal[...]` from `typing_extensions` for resource-local literals, or from `typing` if already imported. +- No docstrings on resource methods (the verify_webhook_signature method is an exception). +- All methods return `BlindpayApiResponse[T]` where T is the response TypedDict. +- Double quotes for strings. +- Imports: standard library first, then third-party, then relative. Sorted by ruff isort. +- No trailing commas after the last parameter in function signatures that fit on one line. +- Factory functions are always at the bottom of the resource file. + +### Imports inside resource files + +```python +from ..._internal.api_client import InternalApiClient, InternalApiClientSync +from ...types import BlindpayApiResponse, +``` + +Resource-specific Literal types and TypedDicts are defined in the same file as the resource class, NOT in `types.py`. Only truly shared types (used across multiple resources) go in `types.py`. + +### Imports in client.py + +- Resource types are imported inside `TYPE_CHECKING` blocks only (lazy imports). +- Actual imports happen inside `@cached_property` methods using inline `from` statements. +- The `TYPE_CHECKING` import uses absolute paths: `from blindpay.resources.. import ...`. + +--- + +## 3. How to add a new resource + +### Step 1: Create the resource directory and files + +Create `src/blindpay/resources/{resource_name}/` with two files: + +**`__init__.py`**: +```python +from .{resource_name} import ( + {EntityName}, + {EntityName}Resource, + {EntityName}ResourceSync, + # ... all TypedDicts ... + create_{snake_name}_resource, + create_{snake_name}_resource_sync, +) + +__all__ = [ + "create_{snake_name}_resource", + "create_{snake_name}_resource_sync", + "{EntityName}Resource", + "{EntityName}ResourceSync", + # ... all TypedDicts ... +] +``` + +**`{resource_name}.py`** (example for an instance-scoped resource): +```python +from typing import List, Optional + +from typing_extensions import TypedDict + +from ..._internal.api_client import InternalApiClient, InternalApiClientSync +from ...types import BlindpayApiResponse + + +class {EntityName}(TypedDict): + id: str + instance_id: str + # ... fields ... + created_at: str + updated_at: str + + +class Create{EntityName}Input(TypedDict): + # ... fields ... + pass + + +class Create{EntityName}Response(TypedDict): + id: str + + +class List{EntityName}Response(TypedDict): + data: List[{EntityName}] + + +# --- Async resource --- + +class {EntityName}Resource: + def __init__(self, instance_id: str, client: InternalApiClient): + self._instance_id = instance_id + self._client = client + + async def list(self) -> BlindpayApiResponse[List{EntityName}Response]: + return await self._client.get(f"/instances/{self._instance_id}/{api_path}") + + async def get(self, {id_param}: str) -> BlindpayApiResponse[{EntityName}]: + return await self._client.get(f"/instances/{self._instance_id}/{api_path}/{{{id_param}}}") + + async def create(self, data: Create{EntityName}Input) -> BlindpayApiResponse[Create{EntityName}Response]: + return await self._client.post(f"/instances/{self._instance_id}/{api_path}", data) + + async def delete(self, {id_param}: str) -> BlindpayApiResponse[None]: + return await self._client.delete(f"/instances/{self._instance_id}/{api_path}/{{{id_param}}}") + + +# --- Sync resource --- + +class {EntityName}ResourceSync: + def __init__(self, instance_id: str, client: InternalApiClientSync): + self._instance_id = instance_id + self._client = client + + def list(self) -> BlindpayApiResponse[List{EntityName}Response]: + return self._client.get(f"/instances/{self._instance_id}/{api_path}") + + def get(self, {id_param}: str) -> BlindpayApiResponse[{EntityName}]: + return self._client.get(f"/instances/{self._instance_id}/{api_path}/{{{id_param}}}") + + def create(self, data: Create{EntityName}Input) -> BlindpayApiResponse[Create{EntityName}Response]: + return self._client.post(f"/instances/{self._instance_id}/{api_path}", data) + + def delete(self, {id_param}: str) -> BlindpayApiResponse[None]: + return self._client.delete(f"/instances/{self._instance_id}/{api_path}/{{{id_param}}}") + + +# --- Factory functions --- + +def create_{snake_name}_resource(instance_id: str, client: InternalApiClient) -> {EntityName}Resource: + return {EntityName}Resource(instance_id, client) + + +def create_{snake_name}_resource_sync(instance_id: str, client: InternalApiClientSync) -> {EntityName}ResourceSync: + return {EntityName}ResourceSync(instance_id, client) +``` + +CRITICAL: The sync class is identical to the async class except: +- Methods are NOT `async def`, they are plain `def`. +- Method bodies do NOT use `await`. +- Constructor takes `InternalApiClientSync` instead of `InternalApiClient`. + +For a resource that does NOT require `instance_id` (like `available`), omit `instance_id` from constructor and factory function, and do not prefix paths with `/instances/{self._instance_id}`. + +### Step 2: Register in `resources/__init__.py` + +Add the import and `__all__` entry: +```python +from .{resource_name} import create_{snake_name}_resource +``` + +Also add the sync factory if not already following the pattern (note: the barrel `__init__` only exports async factories currently; check existing pattern). + +### Step 3: Wire into `client.py` + +Add TYPE_CHECKING import at the top of client.py: +```python +if TYPE_CHECKING: + from blindpay.resources.{resource_name}.{resource_name} import {EntityName}Resource, {EntityName}ResourceSync +``` + +Add `@cached_property` to BOTH `BlindPay` (async) and `BlindPaySync` classes: + +**In `BlindPay`:** +```python +@cached_property +def {resource_name}(self) -> "{EntityName}Resource": + from blindpay.resources.{resource_name} import create_{snake_name}_resource + + return create_{snake_name}_resource(self._instance_id, self._api) +``` + +**In `BlindPaySync`:** +```python +@cached_property +def {resource_name}(self) -> "{EntityName}ResourceSync": + from blindpay.resources.{resource_name} import create_{snake_name}_resource_sync + + return create_{snake_name}_resource_sync(self._instance_id, self._api) +``` + +### Step 4: Add tests + +Create `tests/resources/test_{resource_name}.py`: +```python +from unittest.mock import patch + +import pytest + +from blindpay import BlindPay, BlindPaySync + + +class Test{EntityName}: + @pytest.fixture(autouse=True) + def setup(self): + self.blindpay = BlindPay(api_key="test-key", instance_id="in_000000000000") + + @pytest.mark.asyncio + async def test_list(self): + mocked_data = [{"id": "xxx_000000000000"}] + + with patch.object(self.blindpay._api, "_request") as mock_request: + mock_request.return_value = {"data": mocked_data, "error": None} + + response = await self.blindpay.{resource_name}.list() + + assert response["error"] is None + assert response["data"] == mocked_data + mock_request.assert_called_once_with("GET", "/instances/in_000000000000/{api_path}") + + +class Test{EntityName}Sync: + @pytest.fixture(autouse=True) + def setup(self): + self.blindpay = BlindPaySync(api_key="test-key", instance_id="in_000000000000") + + def test_list(self): + mocked_data = [{"id": "xxx_000000000000"}] + + with patch.object(self.blindpay._api, "_request") as mock_request: + mock_request.return_value = {"data": mocked_data, "error": None} + + response = self.blindpay.{resource_name}.list() + + assert response["error"] is None + assert response["data"] == mocked_data + mock_request.assert_called_once_with("GET", "/instances/in_000000000000/{api_path}") +``` + +Testing pattern: +- Async tests: class `Test{Name}`, use `@pytest.mark.asyncio` + `async def`, mock `self.blindpay._api._request`. +- Sync tests: class `Test{Name}Sync`, plain `def`, mock `self.blindpay._api._request`. +- Both patch `_request` on the `_api` object and assert the HTTP method + path. + +--- + +## 4. How to add a method to an existing resource + +### Step 1: Define input/output types in the resource file + +Add TypedDicts above the resource classes in the same file: +```python +class NewActionInput(TypedDict): + field_a: str + field_b: Optional[str] + + +class NewActionResponse(TypedDict): + id: str + status: str +``` + +### Step 2: Add the method to BOTH resource classes + +**Async (in `{EntityName}Resource`):** +```python +async def new_action(self, data: NewActionInput) -> BlindpayApiResponse[NewActionResponse]: + return await self._client.post(f"/instances/{self._instance_id}/{api_path}/action", data) +``` + +**Sync (in `{EntityName}ResourceSync`):** +```python +def new_action(self, data: NewActionInput) -> BlindpayApiResponse[NewActionResponse]: + return self._client.post(f"/instances/{self._instance_id}/{api_path}/action", data) +``` + +### Step 3: Export from `__init__.py` + +Add the new TypedDicts to both the import and `__all__` list in the resource's `__init__.py`. + +### Step 4: Add tests for both async and sync + +### Common method patterns + +**GET with query params:** +```python +async def list(self, params: Optional[ListInput] = None) -> BlindpayApiResponse[ListResponse]: + query_string = "" + if params: + filtered_params = {k: v for k, v in params.items() if v is not None} + if filtered_params: + query_string = f"?{urlencode(filtered_params)}" + return await self._client.get(f"/instances/{self._instance_id}/{path}{query_string}") +``` + +**POST with body:** +```python +async def create(self, data: CreateInput) -> BlindpayApiResponse[CreateResponse]: + return await self._client.post(f"/instances/{self._instance_id}/{path}", data) +``` + +**Method that extracts an ID from input to build the URL:** +```python +async def update(self, data: UpdateInput) -> BlindpayApiResponse[None]: + entity_id = data["entity_id"] + payload = {k: v for k, v in data.items() if k != "entity_id"} + return await self._client.put(f"/instances/{self._instance_id}/{path}/{entity_id}", payload) +``` + +**GET with path param:** +```python +async def get(self, entity_id: str) -> BlindpayApiResponse[Entity]: + return await self._client.get(f"/instances/{self._instance_id}/{path}/{entity_id}") +``` + +--- + +## 5. How to modify types + +### Add a field to a TypedDict + +Simply add the new field. Use `Optional[X]` if the field can be null in API responses. + +```python +class Payout(TypedDict): + # ... existing fields ... + new_field: str # required, always present + another_field: Optional[str] # nullable +``` + +### Remove a field from a TypedDict + +Delete the line. Search the codebase for any code that references the removed field (e.g., `data["removed_field"]`). + +### Rename a field + +1. Change the field name in the TypedDict. +2. Search for all references to the old name across the codebase. +3. If the API uses a different name than the Python field (e.g., `from` is reserved), use a renamed field and convert in the method body. See `quotes.py` for the `from_currency` -> `from` pattern. + +### Add a new shared Literal type + +Add to `src/blindpay/types.py`: +```python +NewType = Literal["value_a", "value_b", "value_c"] +``` + +Then export from `src/blindpay/__init__.py`: add to both the import block and the `__all__` list. + +### Add a resource-local Literal type + +Define it at the top of the resource file, after imports: +```python +LocalType = Literal["option_a", "option_b"] +``` + +--- + +## 6. How to remove a resource + +1. Delete the `src/blindpay/resources/{resource_name}/` directory. +2. Remove the import and `__all__` entry from `src/blindpay/resources/__init__.py`. +3. Remove the `TYPE_CHECKING` import from `client.py`. +4. Remove the `@cached_property` from BOTH `BlindPay` and `BlindPaySync` in `client.py`. +5. If the resource was part of a namespace, also remove the `@cached_property` from the corresponding `_*Namespace` and `_*NamespaceSync` classes. +6. Remove any re-exports from `src/blindpay/__init__.py` if applicable. +7. Delete `tests/resources/test_{resource_name}.py`. + +--- + +## 7. How to add a sub-resource (namespace pattern) + +Sub-resources are accessed like `client.payins.quotes.create(...)`. This uses a namespace class with `__getattr__` delegation. + +### Step 1: Create the sub-resource module + +Add the sub-resource file inside the parent resource directory: +`src/blindpay/resources/{parent}/{sub_resource}.py` + +Follow the same pattern as any resource file (types + async class + sync class + factory functions). + +### Step 2: Update the parent `__init__.py` + +Add imports from the new sub-resource module. + +### Step 3: Create namespace classes in `client.py` + +**Async namespace:** +```python +class _{ParentName}Namespace: + def __init__(self, instance_id: str, api_client: ApiClientImpl) -> None: + self._instance_id = instance_id + self._api = api_client + + @cached_property + def _base(self) -> "{ParentName}Resource": + from blindpay.resources.{parent}.{parent} import create_{parent}_resource + + return create_{parent}_resource(self._instance_id, self._api) + + @cached_property + def {sub_resource}(self) -> "{SubResourceName}Resource": + from blindpay.resources.{parent}.{sub_resource} import create_{sub_resource}_resource + + return create_{sub_resource}_resource(self._instance_id, self._api) + + def __getattr__(self, name: str) -> Any: + return getattr(self._base, name) +``` + +**Sync namespace:** +```python +class _{ParentName}NamespaceSync: + def __init__(self, instance_id: str, api_client: ApiClientImplSync) -> None: + self._instance_id = instance_id + self._api = api_client + + @cached_property + def _base(self) -> "{ParentName}ResourceSync": + from blindpay.resources.{parent}.{parent} import create_{parent}_resource_sync + + return create_{parent}_resource_sync(self._instance_id, self._api) + + @cached_property + def {sub_resource}(self) -> "{SubResourceName}ResourceSync": + from blindpay.resources.{parent}.{sub_resource} import create_{sub_resource}_resource_sync + + return create_{sub_resource}_resource_sync(self._instance_id, self._api) + + def __getattr__(self, name: str) -> Any: + return getattr(self._base, name) +``` + +The `__getattr__` method delegates unknown attribute access to `_base`, so `client.payins.list()` works -- it forwards to `PayinsResource.list()`. Meanwhile `client.payins.quotes.create()` hits the explicit `quotes` cached_property. + +### Step 4: Update the client class + +Change the `@cached_property` return type from the resource to the namespace: + +```python +@cached_property +def {parent}(self) -> _{ParentName}Namespace: + return _{ParentName}Namespace(self._instance_id, self._api) +``` + +Do the same in the sync client with `_{ParentName}NamespaceSync`. + +### Step 5: Add TYPE_CHECKING imports + +Add the sub-resource types to the `TYPE_CHECKING` block in `client.py`. + +### Namespace without a base resource (wallets pattern) + +The `_WalletsNamespace` has no `_base` and no `__getattr__`. It is purely a grouping: + +```python +class _WalletsNamespace: + def __init__(self, instance_id: str, api_client: ApiClientImpl) -> None: + self._instance_id = instance_id + self._api = api_client + + @cached_property + def blockchain(self) -> "BlockchainWalletsResource": + from blindpay.resources.wallets.blockchain import create_blockchain_wallets_resource + + return create_blockchain_wallets_resource(self._instance_id, self._api) + + @cached_property + def offramp(self) -> "OfframpWalletsResource": + from blindpay.resources.wallets.offramp import create_offramp_wallets_resource + + return create_offramp_wallets_resource(self._instance_id, self._api) + + @cached_property + def custodial(self) -> "CustodialWalletsResource": + from blindpay.resources.custodial_wallets.custodial_wallets import create_custodial_wallets_resource + + return create_custodial_wallets_resource(self._instance_id, self._api) +``` + +Note: `custodial` imports from `custodial_wallets` package (not from `wallets/`). Use this pattern when the parent is not itself an API resource, only a namespace. + +--- + +## 8. Testing + +### Run tests + +```bash +# All tests +pytest tests/ -v + +# Specific resource +pytest tests/resources/test_payins.py -v + +# Only async tests +pytest tests/ -v -k "not Sync" + +# Only sync tests +pytest tests/ -v -k "Sync" +``` + +### Linting and type checking + +```bash +# Format +ruff format src/ + +# Lint +ruff check src/ + +# Lint with auto-fix +ruff check src/ --fix + +# Type check +mypy src/blindpay/ +pyright src/blindpay/ +``` + +### Test file pattern + +- One test file per resource: `tests/resources/test_{resource_name}.py`. +- Two classes per file: `Test{Name}` (async) and `Test{Name}Sync` (sync). +- Each class has `@pytest.fixture(autouse=True) def setup(self)` that creates the client. +- Async class uses `BlindPay`, sync class uses `BlindPaySync`. +- Mock target: `patch.object(self.blindpay._api, "_request")`. +- Mock return: `{"data": , "error": None}` for success. +- Assert: `response["error"] is None`, `response["data"] == expected`, `mock_request.assert_called_once_with(, )`. +- For POST/PUT/PATCH methods, assert the body as well: `mock_request.assert_called_once_with("POST", "/path", {body_dict})`. + +--- + +## 9. Versioning + +### release-please with conventional commits + +The project uses [release-please](https://github.com/googleapis/release-please) for automated versioning. + +**Config:** `release-please-config.json` +- Release type: `python` +- Tags include `v` prefix (e.g., `v1.3.0`) +- Bumps version in `src/blindpay/__init__.py` (via `extra-files`) + +**Manifest:** `.release-please-manifest.json` +- Tracks the current version at the root path `.` + +**Version is stored in TWO places** (keep them in sync for local dev, release-please handles it in CI): +- `pyproject.toml` -> `[project].version` +- `src/blindpay/__init__.py` -> `__version__` +- `src/blindpay/client.py` -> `__version__` (duplicated, used in User-Agent header) + +### Commit message format + +Use [Conventional Commits](https://www.conventionalcommits.org/): + +``` +feat: add invoices resource +feat(payouts): add submit_documents method +fix(types): correct Currency literal values +chore: update dependencies +docs: update README examples +refactor(client): simplify namespace initialization +``` + +- `feat:` triggers a MINOR version bump. +- `fix:` triggers a PATCH version bump. +- `feat!:` or `BREAKING CHANGE:` in the footer triggers a MAJOR bump. + +--- + +## 10. OpenAPI to SDK mapping rules + +### Path to resource mapping + +| API path pattern | SDK access pattern | Resource location | +|---|---|---| +| `/available/*` | `client.available.method()` | `resources/available/available.py` | +| `/instances/{id}/{resource}` | `client.{resource}.method()` | `resources/{resource}/{resource}.py` | +| `/instances/{id}/{resource}/{rid}/sub` | `client.{resource}.method(data)` where data contains `{rid}` | Same resource file, ID extracted from input | +| `/instances/{id}/payin-quotes` | `client.payins.quotes.method()` | `resources/payins/quotes.py` (sub-resource) | +| `/e/{resource}/{id}` | `client.{resource}.get_track(id)` | Same resource file (external tracking endpoint) | + +### HTTP method to class method mapping + +| HTTP method | SDK method name | Notes | +|---|---|---| +| `GET /resources` | `list()` | Optional params TypedDict, builds query string | +| `GET /resources/{id}` | `get(id)` | Takes ID as string param | +| `POST /resources` | `create(data)` | Takes input TypedDict | +| `PUT /resources/{id}` | `update(data)` | ID extracted from TypedDict, excluded from payload | +| `PATCH /resources/{id}` | `update(data)` | Same as PUT | +| `DELETE /resources/{id}` | `delete(id)` | Takes ID as string param | +| `POST /resources/{id}/action` | `action_name(data)` | Descriptive method name | +| `GET /export/resources` | `export(params)` | Query string params | + +### Schema to TypedDict mapping + +| OpenAPI schema concept | Python representation | +|---|---| +| Object schema | `class MyType(TypedDict)` | +| String property | `field: str` | +| Number property | `field: float` | +| Integer property | `field: int` | +| Boolean property | `field: bool` | +| Nullable property | `field: Optional[str]` | +| String enum | `MyEnum = Literal["a", "b", "c"]` | +| Array of objects | `List[MyType]` | +| Nested object | Separate TypedDict, referenced by name | +| Optional input field | Use `total=False` on the TypedDict or `Optional[X]` per field | +| Paginated list response | TypedDict with `data: List[Entity]` + `pagination: PaginationMetadata` | +| Request body | `{Action}Input(TypedDict)` | +| Response body | `{Action}Response(TypedDict)` | + +### API path parameter handling in methods + +When a method needs to extract an ID from the input to build a URL path: +```python +async def action(self, data: ActionInput) -> BlindpayApiResponse[ActionResponse]: + entity_id = data["entity_id"] + payload = {k: v for k, v in data.items() if k != "entity_id"} + return await self._client.post(f"/instances/{self._instance_id}/path/{entity_id}/action", payload) +``` + +When multiple IDs need extraction: +```python +async def action(self, data: ActionInput) -> BlindpayApiResponse[ActionResponse]: + parent_id = data["parent_id"] + child_id = data["child_id"] + payload = {k: v for k, v in data.items() if k not in ["parent_id", "child_id"]} + return await self._client.post(f"/instances/{self._instance_id}/parents/{parent_id}/children/{child_id}", payload) +``` + +### Python reserved word handling + +If an API field name clashes with a Python keyword, rename the field and convert in the method body: +```python +# In TypedDict: use suffixed name +class MyInput(TypedDict): + from_currency: Currency # API field is "from" + +# In method: convert back +async def method(self, data: MyInput) -> BlindpayApiResponse[MyResponse]: + payload = { + "from": data["from_currency"], + "to": data["to"], + } + return await self._client.post(f"/path", payload) +``` + +--- + +## Checklist for any change + +- [ ] Types defined in the resource file (not `types.py`) unless shared across resources +- [ ] BOTH async and sync classes updated with identical logic +- [ ] Factory functions (async + sync) present at bottom of resource file +- [ ] Resource `__init__.py` re-exports all public names +- [ ] `resources/__init__.py` imports factory functions +- [ ] `client.py` has TYPE_CHECKING import + `@cached_property` in BOTH client classes +- [ ] Tests cover both async and sync variants +- [ ] `ruff format src/` and `ruff check src/` pass diff --git a/VERSIONING.md b/VERSIONING.md deleted file mode 100644 index 7ca068f..0000000 --- a/VERSIONING.md +++ /dev/null @@ -1,172 +0,0 @@ -# Package Versioning Guide - -This project uses [release-please](https://github.com/googleapis/release-please) to automate versioning, changelog generation, and publishing to PyPI. Versions follow [Semantic Versioning (semver)](https://semver.org/) and are determined automatically based on [Conventional Commits](https://www.conventionalcommits.org/). - -## How It Works - -### The Release Pipeline - -1. You merge a PR into `main` with conventional commit messages. -2. **release-please** (running in `.github/workflows/publish.yaml`) automatically opens or updates a **Release PR** titled `chore(main): release X.Y.Z`. -3. That Release PR bumps the version in all tracked files and updates `CHANGELOG.md`. -4. When the Release PR is merged, release-please creates a GitHub Release and tag, which triggers the **publish** job to build and upload the package to PyPI. - -### What release-please Updates Automatically - -When the Release PR is merged, release-please bumps the version in these files (you should **never** bump them manually): - -- `pyproject.toml` (`version = "X.Y.Z"`) -- `src/blindpay/__init__.py` (`__version__ = "X.Y.Z"`) -- `.release-please-manifest.json` (`".": "X.Y.Z"`) -- `CHANGELOG.md` (appends the new version entry) - -These are configured in `release-please-config.json` under `extra-files` and the default Python release type. - -## Conventional Commits - -The commit message format determines the version bump. **Only the PR title matters** when using squash merges (GitHub uses the PR title as the commit message). - -### Patch Release (`X.Y.Z` -> `X.Y.Z+1`) - -For bug fixes and minor corrections that don't add new functionality: - -``` -fix: correct receiver address validation -fix: handle empty response from virtual accounts endpoint -``` - -### Minor Release (`X.Y.Z` -> `X.Y+1.0`) - -For new features that are **backward-compatible**: - -``` -feat: add new tos and solana endpoints -feat: add swift code check endpoint -``` - -### Major Release (`X.Y.Z` -> `X+1.0.0`) - -For **breaking changes** — anything that could cause existing users' code to fail: - -``` -feat!: update receiver and virtual account types to match API reference -``` - -Or using a `BREAKING CHANGE` footer: - -``` -feat: update receiver and virtual account types - -BREAKING CHANGE: removed OwnerUpdate type, CreateVirtualAccountInput now requires banking_partner field -``` - -### Other Commit Types (No Release) - -These commit types do **not** trigger a version bump: - -``` -chore: update CI workflow -docs: update README -test: add unit tests for receivers -ci: fix lint workflow -refactor: reorganize internal utils -``` - -## What Counts as a Breaking Change? - -When working on this SDK, the following changes are **breaking** and require a `feat!:` prefix or `BREAKING CHANGE` footer: - -| Change | Breaking? | Why | -|--------|-----------|-----| -| Removing a previously exported type (e.g., `OwnerUpdate`) | Yes | Users importing it will get `ImportError` | -| Adding a new **required** field to an input `TypedDict` | Yes | Existing calls will fail type checking / miss the field | -| Removing a field from an input `TypedDict` | Yes | Users passing that field will get a type error | -| Changing a field's type (e.g., `str` -> `int`) | Yes | Users' existing values may become invalid | -| Changing `owners` from `List[OwnerUpdate]` to `List[Owner]` | Yes | Different type with different fields | -| Making a required field **optional** | No | Existing code still works, just less strict | -| Adding a new **optional** field to a `TypedDict` | No | Existing code ignores it | -| Adding a new exported type | No | Doesn't affect existing code | -| Adding new values to a `Literal` type | No | Existing values still valid | - -## Step-by-Step: Making a New Release - -### 1. Create Your Feature Branch - -```bash -git checkout -b eric/my-new-feature -``` - -### 2. Make Your Changes - -Edit the source code under `src/blindpay/`. - -### 3. Commit with Conventional Commit Messages - -Use the appropriate prefix based on the nature of your changes: - -```bash -# For a new non-breaking feature: -git commit -m "feat: add webhook retry configuration" - -# For a breaking change: -git commit -m "feat!: redesign receiver creation flow - -BREAKING CHANGE: CreateIndividualWithStandardKYCInput now requires account_purpose field" - -# For a bug fix: -git commit -m "fix: handle timeout in payout status polling" -``` - -### 4. Open a Pull Request - -Make sure the **PR title** follows conventional commits format, since this repo uses squash merges (the PR title becomes the final commit message on `main`). - -- Non-breaking feature: `feat: add webhook retry configuration` -- Breaking change: `feat!: redesign receiver creation flow` -- Bug fix: `fix: handle timeout in payout status polling` - -### 5. Merge to `main` - -After CI passes and the PR is approved, merge it. - -### 6. Release PR Is Created/Updated Automatically - -release-please will open (or update) a PR titled something like: - -``` -chore(main): release 1.3.0 -``` - -This PR contains: -- Version bumps in `pyproject.toml`, `src/blindpay/__init__.py`, `.release-please-manifest.json` -- Updated `CHANGELOG.md` with entries derived from commit messages - -### 7. Review and Merge the Release PR - -Once you're ready to publish the new version, merge the Release PR. This triggers: -1. A GitHub Release is created with the tag (e.g., `v1.3.0`) -2. The publish workflow builds and uploads the package to PyPI - -## Configuration Files - -| File | Purpose | -|------|---------| -| `release-please-config.json` | Configures release-please behavior (release type, changelog path, extra files to update) | -| `.release-please-manifest.json` | Tracks the current released version (updated by release-please, don't edit manually) | -| `.github/workflows/publish.yaml` | Workflow that runs release-please and publishes to PyPI | - -## Important Rules - -1. **Never manually bump versions** in `pyproject.toml`, `__init__.py`, or the manifest. release-please handles this. -2. **Always use conventional commit prefixes** in your PR title (for squash merges). -3. **Mark breaking changes explicitly** with `!` after the type or with a `BREAKING CHANGE:` footer. -4. **Don't merge the Release PR until you're ready** to publish to PyPI — merging it triggers the publish immediately. - -## Past Release History - -| Version | Type | Commit Prefix | Description | -|---------|------|---------------|-------------| -| `v0.1.0` | Initial | — | Initial release | -| `v1.0.0` | Major | `feat!:` | Stable release with breaking changes | -| `v1.1.0` | Minor | `feat:` | Added swift code check endpoint | -| `v1.2.0` | Minor | `feat:` | Added new TOS and Solana endpoints | diff --git a/pyproject.toml b/pyproject.toml index eb23a88..cdaf9e8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "blindpay" -version = "2.0.0" +version = "1.3.0" description = "Official Python SDK for the Blindpay API — Global payments infrastructure" readme = "README.md" authors = [{ name = "Blindpay", email = "alves@blindpay.com" }] diff --git a/src/blindpay/resources/customers/__init__.py b/src/blindpay/resources/customers/__init__.py deleted file mode 100644 index cdc0f6b..0000000 --- a/src/blindpay/resources/customers/__init__.py +++ /dev/null @@ -1,66 +0,0 @@ -from .customers import ( - BusinessType, - BusinessWithStandardKYB, - CreateBusinessWithStandardKYBInput, - CreateBusinessWithStandardKYBResponse, - CreateIndividualWithEnhancedKYCInput, - CreateIndividualWithEnhancedKYCResponse, - CreateIndividualWithStandardKYCInput, - CreateIndividualWithStandardKYCResponse, - EnhancedKycType, - GetCustomerLimitsResponse, - GetCustomerResponse, - IdentificationDocument, - IndividualType, - IndividualWithEnhancedKYC, - IndividualWithStandardKYC, - KycType, - KycWarning, - ListCustomersResponse, - Owner, - OwnerRole, - OwnerUpdate, - ProofOfAddressDocType, - PurposeOfTransactions, - CustomersResource, - CustomersResourceSync, - SourceOfFundsDocType, - TransactionLimit, - UpdateCustomerInput, - create_customers_resource, - create_customers_resource_sync, -) - -__all__ = [ - "create_customers_resource", - "create_customers_resource_sync", - "CustomersResource", - "CustomersResourceSync", - "IndividualWithStandardKYC", - "IndividualWithEnhancedKYC", - "BusinessWithStandardKYB", - "CreateIndividualWithStandardKYCInput", - "CreateIndividualWithEnhancedKYCInput", - "CreateBusinessWithStandardKYBInput", - "UpdateCustomerInput", - "GetCustomerLimitsResponse", - "KycType", - "BusinessType", - "IndividualType", - "EnhancedKycType", - "ProofOfAddressDocType", - "PurposeOfTransactions", - "SourceOfFundsDocType", - "IdentificationDocument", - "OwnerRole", - "KycWarning", - "TransactionLimit", - "Owner", - "CreateBusinessWithStandardKYBResponse", - "CreateIndividualWithEnhancedKYCResponse", - "CreateIndividualWithStandardKYCResponse", - "ListCustomersResponse", - "GetCustomerResponse", - "OwnerUpdate", - "UpdateCustomerInput", -] diff --git a/src/blindpay/resources/customers/customers.py b/src/blindpay/resources/customers/customers.py deleted file mode 100644 index 0a6577b..0000000 --- a/src/blindpay/resources/customers/customers.py +++ /dev/null @@ -1,516 +0,0 @@ -from typing import List, Optional, Union - -from typing_extensions import Literal, TypedDict - -from ..._internal.api_client import InternalApiClient, InternalApiClientSync -from ...types import ( - BlindpayApiResponse, - Country, -) - -IndividualType = Literal["individual"] -BusinessType = Literal["business"] -StandardKycType = Literal["standard"] -EnhancedKycType = Literal["enhanced"] -KycType = Literal["light", "standard", "enhanced"] - -ProofOfAddressDocType = Literal[ - "UTILITY_BILL", "BANK_STATEMENT", "RENTAL_AGREEMENT", "TAX_DOCUMENT", "GOVERNMENT_CORRESPONDENCE" -] - -PurposeOfTransactions = Literal[ - "business_transactions", - "charitable_donations", - "investment_purposes", - "payments_to_friends_or_family_abroad", - "personal_or_living_expenses", - "protect_wealth", - "purchase_good_and_services", - "receive_payment_for_freelancing", - "receive_salary", - "other", -] - -SourceOfFundsDocType = Literal[ - "business_income", - "gambling_proceeds", - "gifts", - "government_benefits", - "inheritance", - "investment_loans", - "pension_retirement", - "salary", - "sale_of_assets_real_estate", - "savings", - "esops", - "investment_proceeds", - "someone_else_funds", -] - -IdentificationDocument = Literal["PASSPORT", "ID_CARD", "DRIVERS"] - -OwnerRole = Literal["beneficial_controlling", "beneficial_owner", "controlling_person"] - -LimitIncreaseRequestStatus = Literal["in_review", "approved", "rejected"] - -LimitIncreaseRequestSupportingDocumentType = Literal[ - "individual_bank_statement", - "individual_tax_return", - "individual_proof_of_income", - "business_bank_statement", - "business_financial_statements", - "business_tax_return", -] - - -class KycWarning(TypedDict): - code: Optional[str] - message: Optional[str] - resolution_status: Optional[str] - warning_id: Optional[str] - - -class TransactionLimit(TypedDict): - per_transaction: float - daily: float - monthly: float - - -class Owner(TypedDict): - id: str - instance_id: str - customer_id: str - role: OwnerRole - first_name: str - last_name: str - date_of_birth: str - tax_id: str - address_line_1: str - address_line_2: Optional[str] - city: str - state_province_region: str - country: Country - postal_code: str - id_doc_country: Country - id_doc_type: IdentificationDocument - id_doc_front_file: str - id_doc_back_file: Optional[str] - proof_of_address_doc_type: ProofOfAddressDocType - proof_of_address_doc_file: str - - -class IndividualWithStandardKYC(TypedDict): - id: str - type: IndividualType - kyc_type: StandardKycType - kyc_status: str - kyc_warnings: Optional[List[KycWarning]] - email: str - tax_id: str - address_line_1: str - address_line_2: Optional[str] - city: str - state_province_region: str - country: Country - postal_code: str - ip_address: Optional[str] - image_url: Optional[str] - phone_number: str - proof_of_address_doc_type: ProofOfAddressDocType - proof_of_address_doc_file: str - first_name: str - last_name: str - date_of_birth: str - id_doc_country: Country - id_doc_type: IdentificationDocument - id_doc_front_file: str - id_doc_back_file: str - aiprise_validation_key: str - instance_id: str - tos_id: Optional[str] - created_at: str - updated_at: str - limit: TransactionLimit - - -class IndividualWithEnhancedKYC(TypedDict): - id: str - type: IndividualType - kyc_type: EnhancedKycType - kyc_status: str - kyc_warnings: Optional[List[KycWarning]] - email: str - tax_id: str - address_line_1: str - address_line_2: Optional[str] - city: str - state_province_region: str - country: Country - postal_code: str - ip_address: Optional[str] - image_url: Optional[str] - phone_number: Optional[str] - proof_of_address_doc_type: ProofOfAddressDocType - proof_of_address_doc_file: str - first_name: str - last_name: str - date_of_birth: str - id_doc_country: Country - id_doc_type: IdentificationDocument - id_doc_front_file: str - id_doc_back_file: Optional[str] - aiprise_validation_key: str - instance_id: str - source_of_funds_doc_type: str - source_of_funds_doc_file: str - individual_holding_doc_front_file: str - purpose_of_transactions: PurposeOfTransactions - purpose_of_transactions_explanation: Optional[str] - tos_id: Optional[str] - created_at: str - updated_at: str - limit: TransactionLimit - - -class BusinessWithStandardKYB(TypedDict): - id: str - type: BusinessType - kyc_type: StandardKycType - kyc_status: str - kyc_warnings: Optional[List[KycWarning]] - email: str - tax_id: str - address_line_1: str - address_line_2: Optional[str] - city: str - state_province_region: str - country: Country - postal_code: str - ip_address: Optional[str] - image_url: Optional[str] - phone_number: Optional[str] - proof_of_address_doc_type: ProofOfAddressDocType - proof_of_address_doc_file: str - legal_name: str - alternate_name: Optional[str] - formation_date: str - website: Optional[str] - owners: List[Owner] - incorporation_doc_file: str - proof_of_ownership_doc_file: str - external_id: Optional[str] - instance_id: str - tos_id: Optional[str] - aiprise_validation_key: str - created_at: str - updated_at: str - limit: TransactionLimit - - -class CreateIndividualWithStandardKYCInput(TypedDict): - external_id: Optional[str] - address_line_1: str - address_line_2: Optional[str] - city: str - country: Country - date_of_birth: str - email: str - first_name: str - phone_number: Optional[str] - id_doc_country: Country - id_doc_front_file: str - id_doc_type: IdentificationDocument - id_doc_back_file: Optional[str] - last_name: str - postal_code: str - proof_of_address_doc_file: str - proof_of_address_doc_type: ProofOfAddressDocType - state_province_region: str - tax_id: str - tos_id: str - - -class CreateIndividualWithStandardKYCResponse(TypedDict): - id: str - - -class CreateIndividualWithEnhancedKYCInput(TypedDict): - external_id: Optional[str] - address_line_1: str - address_line_2: Optional[str] - city: str - country: Country - date_of_birth: str - email: str - first_name: str - id_doc_country: Country - id_doc_front_file: str - id_doc_type: IdentificationDocument - id_doc_back_file: Optional[str] - individual_holding_doc_front_file: str - last_name: str - postal_code: str - phone_number: Optional[str] - proof_of_address_doc_file: str - proof_of_address_doc_type: ProofOfAddressDocType - purpose_of_transactions: PurposeOfTransactions - source_of_funds_doc_file: str - source_of_funds_doc_type: SourceOfFundsDocType - purpose_of_transactions_explanation: Optional[str] - state_province_region: str - tax_id: str - tos_id: str - - -class CreateIndividualWithEnhancedKYCResponse(TypedDict): - id: str - - -class CreateBusinessWithStandardKYBInput(TypedDict): - external_id: Optional[str] - address_line_1: str - address_line_2: Optional[str] - alternate_name: str - city: str - country: Country - email: str - formation_date: str - incorporation_doc_file: str - legal_name: str - owners: List[Owner] - postal_code: str - proof_of_address_doc_file: str - proof_of_address_doc_type: ProofOfAddressDocType - proof_of_ownership_doc_file: str - state_province_region: str - tax_id: str - tos_id: str - website: Optional[str] - - -class CreateBusinessWithStandardKYBResponse(TypedDict): - id: str - - -ListCustomersResponse = List[Union[IndividualWithStandardKYC, IndividualWithEnhancedKYC, BusinessWithStandardKYB]] - -GetCustomerResponse = Union[IndividualWithStandardKYC, IndividualWithEnhancedKYC, BusinessWithStandardKYB] - - -class OwnerUpdate(TypedDict): - id: str - first_name: str - last_name: str - role: OwnerRole - date_of_birth: str - tax_id: str - address_line_1: str - address_line_2: Optional[str] - city: str - state_province_region: str - country: Country - postal_code: str - id_doc_country: Country - id_doc_type: IdentificationDocument - id_doc_front_file: str - id_doc_back_file: Optional[str] - - -class UpdateCustomerInput(TypedDict): - customer_id: str - email: Optional[str] - tax_id: Optional[str] - address_line_1: Optional[str] - address_line_2: Optional[str] - city: Optional[str] - state_province_region: Optional[str] - country: Optional[Country] - postal_code: Optional[str] - ip_address: Optional[str] - image_url: Optional[str] - phone_number: Optional[str] - proof_of_address_doc_type: Optional[ProofOfAddressDocType] - proof_of_address_doc_file: Optional[str] - first_name: Optional[str] - last_name: Optional[str] - date_of_birth: Optional[str] - id_doc_country: Optional[Country] - id_doc_type: Optional[IdentificationDocument] - id_doc_front_file: Optional[str] - id_doc_back_file: Optional[str] - legal_name: Optional[str] - alternate_name: Optional[str] - formation_date: Optional[str] - website: Optional[str] - owners: Optional[List[OwnerUpdate]] - incorporation_doc_file: Optional[str] - proof_of_ownership_doc_file: Optional[str] - source_of_funds_doc_type: Optional[SourceOfFundsDocType] - source_of_funds_doc_file: Optional[str] - individual_holding_doc_front_file: Optional[str] - purpose_of_transactions: Optional[PurposeOfTransactions] - purpose_of_transactions_explanation: Optional[str] - external_id: Optional[str] - tos_id: Optional[str] - - -class PayinLimit(TypedDict): - daily: float - monthly: float - - -class PayoutLimit(TypedDict): - daily: float - monthly: float - - -class Limits(TypedDict): - payin: PayinLimit - payout: PayoutLimit - - -class GetCustomerLimitsResponse(TypedDict): - limits: Limits - - -class LimitIncreaseRequest(TypedDict): - id: str - customer_id: str - status: LimitIncreaseRequestStatus - daily: float - monthly: float - per_transaction: float - supporting_document_file: str - supporting_document_type: LimitIncreaseRequestSupportingDocumentType - created_at: str - updated_at: str - - -GetLimitIncreaseRequestsResponse = List[LimitIncreaseRequest] - - -class RequestLimitIncreaseInput(TypedDict): - customer_id: str - daily: float - monthly: float - per_transaction: float - supporting_document_file: str - supporting_document_type: LimitIncreaseRequestSupportingDocumentType - - -class RequestLimitIncreaseResponse(TypedDict): - id: str - - -class CustomersResource: - def __init__(self, instance_id: str, client: InternalApiClient): - self._instance_id = instance_id - self._client = client - - async def list(self) -> BlindpayApiResponse[ListCustomersResponse]: - return await self._client.get(f"/instances/{self._instance_id}/customers") - - async def create_individual_with_standard_kyc( - self, data: CreateIndividualWithStandardKYCInput - ) -> BlindpayApiResponse[CreateIndividualWithStandardKYCResponse]: - payload = {"kyc_type": "standard", "type": "individual", **data} - return await self._client.post(f"/instances/{self._instance_id}/customers", payload) - - async def create_individual_with_enhanced_kyc( - self, data: CreateIndividualWithEnhancedKYCInput - ) -> BlindpayApiResponse[CreateIndividualWithEnhancedKYCResponse]: - payload = {"kyc_type": "enhanced", "type": "individual", **data} - return await self._client.post(f"/instances/{self._instance_id}/customers", payload) - - async def create_business_with_standard_kyb( - self, data: CreateBusinessWithStandardKYBInput - ) -> BlindpayApiResponse[CreateBusinessWithStandardKYBResponse]: - payload = {"kyc_type": "standard", "type": "business", **data} - return await self._client.post(f"/instances/{self._instance_id}/customers", payload) - - async def get(self, customer_id: str) -> BlindpayApiResponse[GetCustomerResponse]: - return await self._client.get(f"/instances/{self._instance_id}/customers/{customer_id}") - - async def update(self, data: UpdateCustomerInput) -> BlindpayApiResponse[None]: - customer_id = data["customer_id"] - payload = {k: v for k, v in data.items() if k != "customer_id"} - return await self._client.put(f"/instances/{self._instance_id}/customers/{customer_id}", payload) - - async def delete(self, customer_id: str) -> BlindpayApiResponse[None]: - return await self._client.delete(f"/instances/{self._instance_id}/customers/{customer_id}") - - async def get_limits(self, customer_id: str) -> BlindpayApiResponse[GetCustomerLimitsResponse]: - return await self._client.get(f"/instances/{self._instance_id}/limits/customers/{customer_id}") - - async def get_limit_increase_requests( - self, customer_id: str - ) -> BlindpayApiResponse[GetLimitIncreaseRequestsResponse]: - return await self._client.get(f"/instances/{self._instance_id}/customers/{customer_id}/limit-increase") - - async def request_limit_increase( - self, data: RequestLimitIncreaseInput - ) -> BlindpayApiResponse[RequestLimitIncreaseResponse]: - customer_id = data["customer_id"] - payload = {k: v for k, v in data.items() if k != "customer_id"} - return await self._client.post( - f"/instances/{self._instance_id}/customers/{customer_id}/limit-increase", payload - ) - - -class CustomersResourceSync: - def __init__(self, instance_id: str, client: InternalApiClientSync): - self._instance_id = instance_id - self._client = client - - def list(self) -> BlindpayApiResponse[ListCustomersResponse]: - return self._client.get(f"/instances/{self._instance_id}/customers") - - def create_individual_with_standard_kyc( - self, data: CreateIndividualWithStandardKYCInput - ) -> BlindpayApiResponse[CreateIndividualWithStandardKYCResponse]: - payload = {"kyc_type": "standard", "type": "individual", **data} - return self._client.post(f"/instances/{self._instance_id}/customers", payload) - - def create_individual_with_enhanced_kyc( - self, data: CreateIndividualWithEnhancedKYCInput - ) -> BlindpayApiResponse[CreateIndividualWithEnhancedKYCResponse]: - payload = {"kyc_type": "enhanced", "type": "individual", **data} - return self._client.post(f"/instances/{self._instance_id}/customers", payload) - - def create_business_with_standard_kyb( - self, data: CreateBusinessWithStandardKYBInput - ) -> BlindpayApiResponse[CreateBusinessWithStandardKYBResponse]: - payload = {"kyc_type": "standard", "type": "business", **data} - return self._client.post(f"/instances/{self._instance_id}/customers", payload) - - def get(self, customer_id: str) -> BlindpayApiResponse[GetCustomerResponse]: - return self._client.get(f"/instances/{self._instance_id}/customers/{customer_id}") - - def update(self, data: UpdateCustomerInput) -> BlindpayApiResponse[None]: - customer_id = data["customer_id"] - payload = {k: v for k, v in data.items() if k != "customer_id"} - return self._client.put(f"/instances/{self._instance_id}/customers/{customer_id}", payload) - - def delete(self, customer_id: str) -> BlindpayApiResponse[None]: - return self._client.delete(f"/instances/{self._instance_id}/customers/{customer_id}") - - def get_limits(self, customer_id: str) -> BlindpayApiResponse[GetCustomerLimitsResponse]: - return self._client.get(f"/instances/{self._instance_id}/limits/customers/{customer_id}") - - def get_limit_increase_requests(self, customer_id: str) -> BlindpayApiResponse[GetLimitIncreaseRequestsResponse]: - return self._client.get(f"/instances/{self._instance_id}/customers/{customer_id}/limit-increase") - - def request_limit_increase( - self, data: RequestLimitIncreaseInput - ) -> BlindpayApiResponse[RequestLimitIncreaseResponse]: - customer_id = data["customer_id"] - payload = {k: v for k, v in data.items() if k != "customer_id"} - return self._client.post(f"/instances/{self._instance_id}/customers/{customer_id}/limit-increase", payload) - - -def create_customers_resource(instance_id: str, client: InternalApiClient) -> CustomersResource: - return CustomersResource(instance_id, client) - - -def create_customers_resource_sync(instance_id: str, client: InternalApiClientSync) -> CustomersResourceSync: - return CustomersResourceSync(instance_id, client)