commit 4f53889a8ec9444c81edd8060228d00003b2161d Author: zqq61 <1852150449@qq.com> Date: Sun Mar 15 23:05:08 2026 +0800 feat: Airwallex 发卡管理后台完整实现 - 后端: FastAPI + SQLAlchemy + SQLite, JWT认证, 代理支持的AirwallexClient - 前端: React 18 + Vite + Ant Design 5, 中文界面 - 功能: 卡片管理, 持卡人管理, 交易记录, API令牌, 系统设置, 审计日志 - 第三方API: X-API-Key认证, 权限控制 - Docker部署: docker-compose编排前后端 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..55e76e9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +dist/ +*.egg + +# Backend +backend/data/ +backend/.env + +# Frontend +frontend/node_modules/ +frontend/dist/ +frontend/tsconfig.node.tsbuildinfo + +# IDE +.vscode/ +.idea/ + +# OS +.DS_Store +Thumbs.db + +# Claude +.claude/ diff --git a/airwallex-sdk/.gitignore b/airwallex-sdk/.gitignore new file mode 100644 index 0000000..b33430f --- /dev/null +++ b/airwallex-sdk/.gitignore @@ -0,0 +1,167 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +.idea/ + +# VS Code +.vscode/ + +# Environment-specific files +.env +.env.local +.env.*.local + +# Test credentials +credentials.json diff --git a/airwallex-sdk/CHANGELOG.md b/airwallex-sdk/CHANGELOG.md new file mode 100644 index 0000000..1811a4b --- /dev/null +++ b/airwallex-sdk/CHANGELOG.md @@ -0,0 +1,40 @@ +# Changelog + +All notable changes to the Airwallex SDK will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.2.0] - 2025-04-14 + +### Added + +- Support for the Issuing API, including: + - Authorizations management + - Cardholders management + - Cards management + - Digital wallet tokens management + - Transaction disputes management + - Transactions management + - Issuing configuration +- Improved pagination handling with support for `has_more` responses +- Additional examples for Issuing API usage + +## [0.1.0] - 2025-04-14 + +### Added + +- Initial release of the Airwallex Python SDK +- Support for both synchronous and asynchronous API requests +- Automatic authentication and token refresh +- Comprehensive type checking with Pydantic models +- Support for the following API endpoints: + - Account management + - Balance operations + - Payment processing + - Beneficiary management + - FX operations + - Invoice management +- Pagination support for listing operations +- Error handling with detailed exception types +- Comprehensive documentation diff --git a/airwallex-sdk/README.md b/airwallex-sdk/README.md new file mode 100644 index 0000000..06b3257 --- /dev/null +++ b/airwallex-sdk/README.md @@ -0,0 +1,183 @@ +# UNOFFICIAL Airwallex Python SDK + +A simple SDK for interacting with the [Airwallex API](https://www.airwallex.com/docs/api). + +## Features + +- SOMEWHAT Comprehensive implementation of the Airwallex API +- Both synchronous and asynchronous client support +- Automatic authentication and token refresh +- Built-in rate limiting and retry logic +- Type checking with Pydantic models + +## Installation + +```bash +pip install airwallex-sdk +``` + +## Quick Start + +### Payments API + +#### Synchronous Usage + +```python +from airwallex import AirwallexClient +from airwallex.models.payment import PaymentCreateRequest, PaymentAmount + +# Initialize the client +client = AirwallexClient( + client_id="your_client_id", + api_key="your_api_key" +) + +# List accounts +accounts = client.accounts.list() +for account in accounts: + print(f"Account: {account.account_name} ({account.id})") + +# Fetch a specific account +account = client.accounts.fetch("account_id") +print(account.show()) # Print a formatted representation + +# Create a payment +payment_request = PaymentCreateRequest( + request_id="unique_request_id", + amount=PaymentAmount(value=100.00, currency="USD"), + source={ + "type": "account", + "account_id": "account_id" + }, + beneficiary={ + "type": "bank_account", + "id": "beneficiary_id" + }, + payment_method="swift" +) + +payment = client.payments.create_from_model(payment_request) +print(f"Payment created with ID: {payment.id}") + +# Use generator to iterate through all payments +for payment in client.payments(): + print(f"Payment {payment.id}: {payment.amount.value} {payment.amount.currency}") +``` + +#### Asynchronous Usage + +```python +import asyncio +from airwallex import AirwallexAsyncClient +from airwallex.models.beneficiary import BeneficiaryCreateRequest, BankDetails + +async def main(): + # Initialize the async client + client = AirwallexAsyncClient( + client_id="your_client_id", + api_key="your_api_key" + ) + + # List accounts + accounts = await client.accounts.list_async() + + # Create a beneficiary + beneficiary_request = BeneficiaryCreateRequest( + name="John Doe", + type="bank_account", + bank_details=BankDetails( + account_name="John Doe", + account_number="123456789", + swift_code="ABCDEFGH", + bank_country_code="US" + ) + ) + + beneficiary = await client.beneficiaries.create_from_model_async(beneficiary_request) + print(f"Beneficiary created with ID: {beneficiary.id}") + + # Async generator to iterate through all beneficiaries + async for ben in client.beneficiaries.paginate_async_generator(): + print(f"Beneficiary: {ben.name}") + + await client.close() + +# Run the async function +asyncio.run(main()) +``` + +### Issuing API + +#### Working with Cardholders and Cards + +```python +from airwallex import AirwallexClient +from airwallex.models.issuing_cardholder import CardholderCreateRequest, Individual, Name, Address +from airwallex.models.issuing_card import CardCreateRequest, AuthorizationControls, CardProgram + +# Initialize the client +client = AirwallexClient( + client_id="your_client_id", + api_key="your_api_key" +) + +# Create a cardholder +cardholder_request = CardholderCreateRequest( + email="john.doe@example.com", + individual=Individual( + name=Name( + first_name="John", + last_name="Doe", + title="Mr" + ), + date_of_birth="1982-11-02", + address=Address( + city="Melbourne", + country="AU", + line1="44 Example St", + postcode="3121", + state="VIC" + ), + cardholder_agreement_terms_consent_obtained="yes", + express_consent_obtained="yes" + ), + type="INDIVIDUAL" +) + +cardholder = client.issuing_cardholder.create_cardholder(cardholder_request) +print(f"Cardholder created with ID: {cardholder.cardholder_id}") + +# Create a virtual card +card_request = CardCreateRequest( + cardholder_id=cardholder.cardholder_id, + request_id="unique-request-id", + created_by="API User", + form_factor="VIRTUAL", + is_personalized=True, + authorization_controls=AuthorizationControls( + allowed_currencies=["USD", "AUD"], + allowed_transaction_count="MULTIPLE" + ), + program=CardProgram( + id="your_program_id", + name="Default Program" + ) +) + +card = client.issuing_card.create_card(card_request) +print(f"Card created with ID: {card.card_id}") + +# Get card details +card_details = client.issuing_card.get_card_details(card.card_id) +print(f"Card Number: {card_details.card_number}") +print(f"CVV: {card_details.cvv}") +print(f"Expiry: {card_details.expiry_month}/{card_details.expiry_year}") +``` + +## Documentation + +For detailed documentation, see [https://www.airwallex.com/docs/api](https://www.airwallex.com/docs/api). + +## License + +This project is licensed under the MIT License - see the LICENSE file for details. diff --git a/airwallex-sdk/airwallex/__init__.py b/airwallex-sdk/airwallex/__init__.py new file mode 100644 index 0000000..e6c7477 --- /dev/null +++ b/airwallex-sdk/airwallex/__init__.py @@ -0,0 +1,74 @@ +""" +Airwallex Python SDK. + +A fully-featured SDK for interacting with the Airwallex API. +""" +from .client import AirwallexClient, AirwallexAsyncClient +from .exceptions import ( + AirwallexAPIError, + AuthenticationError, + RateLimitError, + ResourceNotFoundError, + ValidationError, + ServerError +) + +# Import models +from .models import AirwallexModel +from .models.account import Account as AccountModel +from .models.payment import Payment as PaymentModel +from .models.beneficiary import Beneficiary as BeneficiaryModel +from .models.invoice import Invoice as InvoiceModel, InvoiceItem +from .models.financial_transaction import FinancialTransaction as FinancialTransactionModel +from .models.fx import FXConversion, FXQuote +from .models.account_detail import ( + AccountDetailModel, AccountCreateRequest, AccountUpdateRequest, + Amendment, AmendmentCreateRequest, WalletInfo, TermsAndConditionsRequest +) + +# Issuing API Models +from .models.issuing_authorization import Authorization as IssuingAuthorizationModel +from .models.issuing_cardholder import Cardholder as IssuingCardholderModel +from .models.issuing_card import Card as IssuingCardModel, CardDetails +from .models.issuing_digital_wallet_token import DigitalWalletToken as IssuingDigitalWalletTokenModel +from .models.issuing_transaction_dispute import TransactionDispute as IssuingTransactionDisputeModel +from .models.issuing_transaction import Transaction as IssuingTransactionModel +from .models.issuing_config import IssuingConfig as IssuingConfigModel + +__all__ = [ + "AirwallexClient", + "AirwallexAsyncClient", + "AirwallexAPIError", + "AuthenticationError", + "RateLimitError", + "ResourceNotFoundError", + "ValidationError", + "ServerError", + "AirwallexModel", + "AccountModel", + "PaymentModel", + "BeneficiaryModel", + "InvoiceModel", + "InvoiceItem", + "FinancialTransactionModel", + "FXConversion", + "FXQuote", + "AccountDetailModel", + "AccountCreateRequest", + "AccountUpdateRequest", + "Amendment", + "AmendmentCreateRequest", + "WalletInfo", + "TermsAndConditionsRequest", + # Issuing API + "IssuingAuthorizationModel", + "IssuingCardholderModel", + "IssuingCardModel", + "CardDetails", + "IssuingDigitalWalletTokenModel", + "IssuingTransactionDisputeModel", + "IssuingTransactionModel", + "IssuingConfigModel", +] + +__version__ = "0.2.0" \ No newline at end of file diff --git a/airwallex-sdk/airwallex/api/__init__.py b/airwallex-sdk/airwallex/api/__init__.py new file mode 100644 index 0000000..35ba1bd --- /dev/null +++ b/airwallex-sdk/airwallex/api/__init__.py @@ -0,0 +1,37 @@ +""" +API modules for the Airwallex SDK. +""" +from .base import AirwallexAPIBase +from .account import Account +from .payment import Payment +from .beneficiary import Beneficiary +from .invoice import Invoice +from .financial_transaction import FinancialTransaction +from .account_detail import AccountDetail + +# Issuing API +from .issuing_authorization import IssuingAuthorization +from .issuing_cardholder import IssuingCardholder +from .issuing_card import IssuingCard +from .issuing_digital_wallet_token import IssuingDigitalWalletToken +from .issuing_transaction_dispute import IssuingTransactionDispute +from .issuing_transaction import IssuingTransaction +from .issuing_config import IssuingConfig + +__all__ = [ + "AirwallexAPIBase", + "Account", + "Payment", + "Beneficiary", + "Invoice", + "FinancialTransaction", + "AccountDetail", + # Issuing API + "IssuingAuthorization", + "IssuingCardholder", + "IssuingCard", + "IssuingDigitalWalletToken", + "IssuingTransactionDispute", + "IssuingTransaction", + "IssuingConfig", +] \ No newline at end of file diff --git a/airwallex-sdk/airwallex/api/account.py b/airwallex-sdk/airwallex/api/account.py new file mode 100644 index 0000000..4dbeffd --- /dev/null +++ b/airwallex-sdk/airwallex/api/account.py @@ -0,0 +1,107 @@ +""" +Airwallex Account API. +""" +from typing import Dict, Any, List, Optional, Type, TypeVar, cast +from ..models.account import Account, AccountCreateRequest, AccountUpdateRequest +from .base import AirwallexAPIBase + +T = TypeVar("T", bound=Account) + + +class Account(AirwallexAPIBase[Account]): + """ + Operations for Airwallex accounts. + + Accounts represent the global accounts that can hold balances + in multiple currencies. + """ + endpoint = "accounts" + model_class = cast(Type[Account], Account) + + def fetch_balance(self, account_id: str) -> Account: + """ + Fetch the balance for a specific account. + + Args: + account_id: The ID of the account to fetch the balance for. + + Returns: + Account: Account with balance information. + """ + url = self._build_url(account_id, "balance") + if not self.client.__class__.__name__.startswith('Async'): + response = self.client._request("GET", url) + data = response.json() + account_data = {"id": account_id, "balance": data} + return self.model_class.from_api_response(account_data) + else: + raise ValueError("Use fetch_balance_async for async clients") + + async def fetch_balance_async(self, account_id: str) -> Account: + """ + Fetch the balance for a specific account asynchronously. + + Args: + account_id: The ID of the account to fetch the balance for. + + Returns: + Account: Account with balance information. + """ + url = self._build_url(account_id, "balance") + if self.client.__class__.__name__.startswith('Async'): + response = await self.client._request("GET", url) + data = response.json() + account_data = {"id": account_id, "balance": data} + return self.model_class.from_api_response(account_data) + else: + raise ValueError("Use fetch_balance for sync clients") + + def create_from_model(self, account: AccountCreateRequest) -> Account: + """ + Create a new account using a Pydantic model. + + Args: + account: AccountCreateRequest model with account creation details. + + Returns: + Account: The newly created account. + """ + return self.create(account) + + async def create_from_model_async(self, account: AccountCreateRequest) -> Account: + """ + Create a new account using a Pydantic model asynchronously. + + Args: + account: AccountCreateRequest model with account creation details. + + Returns: + Account: The newly created account. + """ + return await self.create_async(account) + + def update_from_model(self, account_id: str, account: AccountUpdateRequest) -> Account: + """ + Update an account using a Pydantic model. + + Args: + account_id: The ID of the account to update. + account: AccountUpdateRequest model with account update details. + + Returns: + Account: The updated account. + """ + return self.update(account_id, account) + + async def update_from_model_async(self, account_id: str, account: AccountUpdateRequest) -> Account: + """ + Update an account using a Pydantic model asynchronously. + + Args: + account_id: The ID of the account to update. + account: AccountUpdateRequest model with account update details. + + Returns: + Account: The updated account. + """ + return await self.update_async(account_id, account) diff --git a/airwallex-sdk/airwallex/api/account_detail.py b/airwallex-sdk/airwallex/api/account_detail.py new file mode 100644 index 0000000..4948593 --- /dev/null +++ b/airwallex-sdk/airwallex/api/account_detail.py @@ -0,0 +1,469 @@ +""" +Airwallex Account Detail API. +""" +from typing import Dict, Any, List, Optional, Type, TypeVar, Union, cast +from datetime import datetime +from ..models.account_detail import ( + AccountDetailModel, AccountCreateRequest, AccountUpdateRequest, + Amendment, AmendmentCreateRequest, WalletInfo, TermsAndConditionsRequest +) +from .base import AirwallexAPIBase + +T = TypeVar("T", bound=AccountDetailModel) + + +class AccountDetail(AirwallexAPIBase[AccountDetailModel]): + """ + Operations for Airwallex account details. + + Account details represent the complete information about an Airwallex account, + including business details, persons, and compliance information. + """ + endpoint = "accounts" + model_class = cast(Type[AccountDetailModel], AccountDetailModel) + + def get_my_account(self) -> AccountDetailModel: + """ + Retrieve account details for your own Airwallex account. + + Returns: + AccountDetailModel: Your account details. + """ + url = "/api/v1/account" + + if not self.client.__class__.__name__.startswith('Async'): + response = self.client._request("GET", url) + return self.model_class.from_api_response(response.json()) + else: + raise ValueError("Use get_my_account_async for async clients") + + async def get_my_account_async(self) -> AccountDetailModel: + """ + Retrieve account details for your own Airwallex account asynchronously. + + Returns: + AccountDetailModel: Your account details. + """ + url = "/api/v1/account" + + if self.client.__class__.__name__.startswith('Async'): + response = await self.client._request("GET", url) + return self.model_class.from_api_response(response.json()) + else: + raise ValueError("Use get_my_account for sync clients") + + def get_amendment(self, amendment_id: str) -> Amendment: + """ + Get an account amendment. + + Args: + amendment_id: The ID of the amendment to retrieve. + + Returns: + Amendment: The amendment. + """ + url = f"/api/v1/account/amendments/{amendment_id}" + + if not self.client.__class__.__name__.startswith('Async'): + response = self.client._request("GET", url) + return Amendment.from_api_response(response.json()) + else: + raise ValueError("Use get_amendment_async for async clients") + + async def get_amendment_async(self, amendment_id: str) -> Amendment: + """ + Get an account amendment asynchronously. + + Args: + amendment_id: The ID of the amendment to retrieve. + + Returns: + Amendment: The amendment. + """ + url = f"/api/v1/account/amendments/{amendment_id}" + + if self.client.__class__.__name__.startswith('Async'): + response = await self.client._request("GET", url) + return Amendment.from_api_response(response.json()) + else: + raise ValueError("Use get_amendment for sync clients") + + def create_amendment(self, amendment: AmendmentCreateRequest) -> Amendment: + """ + Create an account amendment. + + Args: + amendment: AmendmentCreateRequest model with amendment details. + + Returns: + Amendment: The created amendment. + """ + url = "/api/v1/account/amendments/create" + + if not self.client.__class__.__name__.startswith('Async'): + response = self.client._request("POST", url, json=amendment.to_api_dict()) + return Amendment.from_api_response(response.json()) + else: + raise ValueError("Use create_amendment_async for async clients") + + async def create_amendment_async(self, amendment: AmendmentCreateRequest) -> Amendment: + """ + Create an account amendment asynchronously. + + Args: + amendment: AmendmentCreateRequest model with amendment details. + + Returns: + Amendment: The created amendment. + """ + url = "/api/v1/account/amendments/create" + + if self.client.__class__.__name__.startswith('Async'): + response = await self.client._request("POST", url, json=amendment.to_api_dict()) + return Amendment.from_api_response(response.json()) + else: + raise ValueError("Use create_amendment for sync clients") + + def get_wallet_info(self) -> WalletInfo: + """ + Retrieve account wallet information. + + Returns: + WalletInfo: The wallet information. + """ + url = "/api/v1/account/wallet_info" + + if not self.client.__class__.__name__.startswith('Async'): + response = self.client._request("GET", url) + return WalletInfo.from_api_response(response.json()) + else: + raise ValueError("Use get_wallet_info_async for async clients") + + async def get_wallet_info_async(self) -> WalletInfo: + """ + Retrieve account wallet information asynchronously. + + Returns: + WalletInfo: The wallet information. + """ + url = "/api/v1/account/wallet_info" + + if self.client.__class__.__name__.startswith('Async'): + response = await self.client._request("GET", url) + return WalletInfo.from_api_response(response.json()) + else: + raise ValueError("Use get_wallet_info for sync clients") + + def create_account(self, account: AccountCreateRequest) -> AccountDetailModel: + """ + Create a new Airwallex account. + + Args: + account: AccountCreateRequest model with account creation details. + + Returns: + AccountDetailModel: The created account. + """ + url = "/api/v1/accounts/create" + + if not self.client.__class__.__name__.startswith('Async'): + response = self.client._request("POST", url, json=account.to_api_dict()) + return self.model_class.from_api_response(response.json()) + else: + raise ValueError("Use create_account_async for async clients") + + async def create_account_async(self, account: AccountCreateRequest) -> AccountDetailModel: + """ + Create a new Airwallex account asynchronously. + + Args: + account: AccountCreateRequest model with account creation details. + + Returns: + AccountDetailModel: The created account. + """ + url = "/api/v1/accounts/create" + + if self.client.__class__.__name__.startswith('Async'): + response = await self.client._request("POST", url, json=account.to_api_dict()) + return self.model_class.from_api_response(response.json()) + else: + raise ValueError("Use create_account for sync clients") + + def update_account(self, account_id: str, account: AccountUpdateRequest) -> AccountDetailModel: + """ + Update a connected account. + + Args: + account_id: The ID of the account to update. + account: AccountUpdateRequest model with account update details. + + Returns: + AccountDetailModel: The updated account. + """ + url = f"/api/v1/accounts/{account_id}/update" + + if not self.client.__class__.__name__.startswith('Async'): + response = self.client._request("POST", url, json=account.to_api_dict()) + return self.model_class.from_api_response(response.json()) + else: + raise ValueError("Use update_account_async for async clients") + + async def update_account_async(self, account_id: str, account: AccountUpdateRequest) -> AccountDetailModel: + """ + Update a connected account asynchronously. + + Args: + account_id: The ID of the account to update. + account: AccountUpdateRequest model with account update details. + + Returns: + AccountDetailModel: The updated account. + """ + url = f"/api/v1/accounts/{account_id}/update" + + if self.client.__class__.__name__.startswith('Async'): + response = await self.client._request("POST", url, json=account.to_api_dict()) + return self.model_class.from_api_response(response.json()) + else: + raise ValueError("Use update_account for sync clients") + + def submit_account(self, account_id: str) -> AccountDetailModel: + """ + Submit account for activation. + + Args: + account_id: The ID of the account to submit. + + Returns: + AccountDetailModel: The submitted account. + """ + url = f"/api/v1/accounts/{account_id}/submit" + + if not self.client.__class__.__name__.startswith('Async'): + response = self.client._request("POST", url) + return self.model_class.from_api_response(response.json()) + else: + raise ValueError("Use submit_account_async for async clients") + + async def submit_account_async(self, account_id: str) -> AccountDetailModel: + """ + Submit account for activation asynchronously. + + Args: + account_id: The ID of the account to submit. + + Returns: + AccountDetailModel: The submitted account. + """ + url = f"/api/v1/accounts/{account_id}/submit" + + if self.client.__class__.__name__.startswith('Async'): + response = await self.client._request("POST", url) + return self.model_class.from_api_response(response.json()) + else: + raise ValueError("Use submit_account for sync clients") + + def get_account(self, account_id: str) -> AccountDetailModel: + """ + Get account by ID. + + Args: + account_id: The ID of the account to retrieve. + + Returns: + AccountDetailModel: The account. + """ + url = f"/api/v1/accounts/{account_id}" + + if not self.client.__class__.__name__.startswith('Async'): + response = self.client._request("GET", url) + return self.model_class.from_api_response(response.json()) + else: + raise ValueError("Use get_account_async for async clients") + + async def get_account_async(self, account_id: str) -> AccountDetailModel: + """ + Get account by ID asynchronously. + + Args: + account_id: The ID of the account to retrieve. + + Returns: + AccountDetailModel: The account. + """ + url = f"/api/v1/accounts/{account_id}" + + if self.client.__class__.__name__.startswith('Async'): + response = await self.client._request("GET", url) + return self.model_class.from_api_response(response.json()) + else: + raise ValueError("Use get_account for sync clients") + + def list_accounts( + self, + account_status: Optional[str] = None, + email: Optional[str] = None, + from_created_at: Optional[Union[str, datetime]] = None, + identifier: Optional[str] = None, + metadata: Optional[str] = None, + page_num: Optional[int] = None, + page_size: Optional[int] = None, + to_created_at: Optional[Union[str, datetime]] = None + ) -> List[AccountDetailModel]: + """ + Get list of connected accounts with filtering options. + + Args: + account_status: Filter by account status (CREATED, SUBMITTED, ACTION_REQUIRED, ACTIVE, SUSPENDED) + email: Filter by email + from_created_at: Filter by creation date (start, inclusive) + identifier: Filter by identifier + metadata: Filter by metadata (key:value format) + page_num: Page number (0-indexed) + page_size: Number of results per page (default 100, max 500) + to_created_at: Filter by creation date (end, inclusive) + + Returns: + List[AccountDetailModel]: List of matching accounts. + """ + url = "/api/v1/accounts" + params = {} + + if account_status: + params["account_status"] = account_status + + if email: + params["email"] = email + + if from_created_at: + if isinstance(from_created_at, datetime): + from_created_at = from_created_at.isoformat() + params["from_created_at"] = from_created_at + + if identifier: + params["identifier"] = identifier + + if metadata: + params["metadata"] = metadata + + if page_num is not None: + params["page_num"] = page_num + + if page_size is not None: + params["page_size"] = page_size + + if to_created_at: + if isinstance(to_created_at, datetime): + to_created_at = to_created_at.isoformat() + params["to_created_at"] = to_created_at + + if not self.client.__class__.__name__.startswith('Async'): + response = self.client._request("GET", url, params=params) + data = response.json() + return [self.model_class.from_api_response(item) for item in data.get("items", [])] + else: + raise ValueError("Use list_accounts_async for async clients") + + async def list_accounts_async( + self, + account_status: Optional[str] = None, + email: Optional[str] = None, + from_created_at: Optional[Union[str, datetime]] = None, + identifier: Optional[str] = None, + metadata: Optional[str] = None, + page_num: Optional[int] = None, + page_size: Optional[int] = None, + to_created_at: Optional[Union[str, datetime]] = None + ) -> List[AccountDetailModel]: + """ + Get list of connected accounts with filtering options asynchronously. + + Args: + account_status: Filter by account status (CREATED, SUBMITTED, ACTION_REQUIRED, ACTIVE, SUSPENDED) + email: Filter by email + from_created_at: Filter by creation date (start, inclusive) + identifier: Filter by identifier + metadata: Filter by metadata (key:value format) + page_num: Page number (0-indexed) + page_size: Number of results per page (default 100, max 500) + to_created_at: Filter by creation date (end, inclusive) + + Returns: + List[AccountDetailModel]: List of matching accounts. + """ + url = "/api/v1/accounts" + params = {} + + if account_status: + params["account_status"] = account_status + + if email: + params["email"] = email + + if from_created_at: + if isinstance(from_created_at, datetime): + from_created_at = from_created_at.isoformat() + params["from_created_at"] = from_created_at + + if identifier: + params["identifier"] = identifier + + if metadata: + params["metadata"] = metadata + + if page_num is not None: + params["page_num"] = page_num + + if page_size is not None: + params["page_size"] = page_size + + if to_created_at: + if isinstance(to_created_at, datetime): + to_created_at = to_created_at.isoformat() + params["to_created_at"] = to_created_at + + if self.client.__class__.__name__.startswith('Async'): + response = await self.client._request("GET", url, params=params) + data = response.json() + return [self.model_class.from_api_response(item) for item in data.get("items", [])] + else: + raise ValueError("Use list_accounts for sync clients") + + def agree_to_terms(self, account_id: str, request: TermsAndConditionsRequest) -> AccountDetailModel: + """ + Agree to terms and conditions. + + Args: + account_id: The ID of the account agreeing to terms. + request: TermsAndConditionsRequest model with agreement details. + + Returns: + AccountDetailModel: The updated account. + """ + url = f"/api/v1/accounts/{account_id}/terms_and_conditions/agree" + + if not self.client.__class__.__name__.startswith('Async'): + response = self.client._request("POST", url, json=request.to_api_dict()) + return self.model_class.from_api_response(response.json()) + else: + raise ValueError("Use agree_to_terms_async for async clients") + + async def agree_to_terms_async(self, account_id: str, request: TermsAndConditionsRequest) -> AccountDetailModel: + """ + Agree to terms and conditions asynchronously. + + Args: + account_id: The ID of the account agreeing to terms. + request: TermsAndConditionsRequest model with agreement details. + + Returns: + AccountDetailModel: The updated account. + """ + url = f"/api/v1/accounts/{account_id}/terms_and_conditions/agree" + + if self.client.__class__.__name__.startswith('Async'): + response = await self.client._request("POST", url, json=request.to_api_dict()) + return self.model_class.from_api_response(response.json()) + else: + raise ValueError("Use agree_to_terms for sync clients") \ No newline at end of file diff --git a/airwallex-sdk/airwallex/api/base.py b/airwallex-sdk/airwallex/api/base.py new file mode 100644 index 0000000..12f2656 --- /dev/null +++ b/airwallex-sdk/airwallex/api/base.py @@ -0,0 +1,392 @@ +""" +Base API class for the Airwallex SDK. +""" +import asyncio +import logging +from typing import ( + Any, + Dict, + List, + Optional, + Type, + TypeVar, + Union, + Coroutine, + Generator, + AsyncGenerator, + Generic, + cast, + get_args, + get_origin +) + +from ..models.base import AirwallexModel +from ..utils import snake_to_pascal_case + +logger = logging.getLogger(__name__) + +T = TypeVar("T", bound=AirwallexModel) +ClientType = TypeVar("ClientType") + +class AirwallexAPIBase(Generic[T]): + """ + Base class for Airwallex API endpoints. + + This class provides standard CRUD methods and pagination handling + for all API endpoints. It serves as the foundation for specific + API endpoint implementations. + """ + endpoint: str = "" + model_class: Type[T] = cast(Type[T], AirwallexModel) # Will be overridden by subclasses + + def __init__( + self, + *, + client: Any, + data: Optional[Dict[str, Any]] = None, + parent: Optional["AirwallexAPIBase"] = None, + parent_path: Optional[str] = None # e.g. "/api/v1/accounts/{account_id}" + ) -> None: + self.client = client + self.data: Dict[str, Any] = data or {} + self.parent: Optional["AirwallexAPIBase"] = parent + self.parent_path: Optional[str] = parent_path + + def __getattr__(self, item: str) -> Any: + # If the attribute exists in the model's data, return it. + if item in self.data: + return self.data[item] + + # If the model has an ID, we can try to access a subresource + if not getattr(self, 'id', None): + raise AttributeError(f"No such attribute '{item}' in {self.__class__.__name__} context.") + + # Try to load an API module for this attribute. + try: + from importlib import import_module + base_package = self.client.__class__.__module__.split(".")[0] + module = import_module(f"{base_package}.api.{item.lower()}") + # We define modules in pascal case, but refer to them as attributes in snake case. + api_class = getattr(module, snake_to_pascal_case(item)) + return api_class(client=self.client, parent=self, parent_path=self._build_url(self.id)) + except (ModuleNotFoundError, AttributeError): + # Split snake case item into a path e.g. report_details -> report/details + path_item = "/".join(item.split("_")) + + # If no module exists for this attribute and model has an id, then assume the attribute + # is a valid endpoint suffix. Return a callable that makes a GET request. + def dynamic_endpoint(*args, **kwargs): + """ + :param dataframe: If True, return a DataFrame instead of a list of dictionaries. + """ + url = self._build_url(resource_id=self.id, suffix=path_item) + if not str(self.client.__class__.__name__).startswith('Async'): + response = self.client._request("GET", url, params=kwargs) + data = self._parse_response_data(response.json()) + return data + else: + async def async_endpoint(): + response = await self.client._request("GET", url, params=kwargs) + data = self._parse_response_data(response.json()) + return data + return async_endpoint() + return dynamic_endpoint + + def __repr__(self) -> str: + identifier = self.data.get("id", "unknown") + return f"<{self.__class__.__name__} id={identifier}>" + + def __call__(self, resource_id: Optional[Any] = None, **kwargs: Any) -> Union[ + T, + Generator[T, None, None], + Coroutine[Any, Any, AsyncGenerator[T, None]] + ]: + """ + If a resource_id is provided, fetch and return a single instance; + otherwise, return a generator that yields resources one by one. + + For sync clients, returns a Generator[T, None, None]. + For async clients, returns a coroutine that yields an AsyncGenerator[T, None]. + """ + if resource_id is not None: + if not str(self.client.__class__.__name__).startswith('Async'): + return self.fetch(resource_id) + else: + return self.fetch_async(resource_id) + else: + if not str(self.client.__class__.__name__).startswith('Async'): + return self.paginate_generator(**kwargs) + else: + return self.paginate_async_generator(**kwargs) + + @classmethod + def get_endpoint(cls) -> str: + """Get the API endpoint path.""" + return cls.endpoint if cls.endpoint else cls.__name__.lower() + + @staticmethod + def _parse_response_data( + response: Union[List[Any], Dict[str, Any]] + ) -> List[Dict[str, Any]]: + """Parse response data into a list of dictionaries.""" + # If response is a dictionary with an 'items' key, it's paginated + if isinstance(response, dict) and 'items' in response: + return response['items'] + # If response is a dictionary, wrap it in a list + if isinstance(response, dict): + return [response] + # If response is already a list, return it + return response + + @property + def base_path(self) -> str: + """Get the base API path for this endpoint.""" + if self.parent_path: + return f"{self.parent_path}/{self.get_endpoint()}" + return f"/api/v1/{self.get_endpoint()}" + + def _build_url(self, resource_id: Optional[Any] = None, suffix: str = "") -> str: + """Build a URL for a specific resource.""" + url = self.base_path + if resource_id is not None: + url = f"{url}/{resource_id}" + if suffix: + url = f"{url}/{suffix}" + return url + + def show(self, indent: int = 0, indent_step: int = 2) -> str: + """ + Return a nicely formatted string representation of this model and its data. + """ + pad = " " * indent + lines = [f"{pad}{self.__class__.__name__}:"] + for key, value in self.data.items(): + if isinstance(value, AirwallexAPIBase): + lines.append(f"{pad}{' ' * indent_step}{key}:") + lines.append(value.show(indent + indent_step, indent_step)) + elif isinstance(value, list): + lines.append(f"{pad}{' ' * indent_step}{key}: [") + for item in value: + if isinstance(item, AirwallexAPIBase): + lines.append(item.show(indent + indent_step, indent_step)) + else: + lines.append(f"{pad}{' ' * (indent_step * 2)}{item}") + lines.append(f"{pad}{' ' * indent_step}]") + else: + lines.append(f"{pad}{' ' * indent_step}{key}: {value}") + return "\n".join(lines) + + def to_model(self) -> T: + """Convert the raw data to a Pydantic model.""" + if not self.data: + raise ValueError("No data available to convert to a model") + return self.model_class.from_api_response(self.data) + + # Synchronous API methods + + def fetch(self, resource_id: Any) -> T: + """Fetch a single resource by ID.""" + if str(self.client.__class__.__name__).startswith('Async'): + raise ValueError("This method requires a sync client.") + url = self._build_url(resource_id) + response = self.client._request("GET", url) + data = self._parse_response_data(response.json()) + # If the returned data is a list, take the first item. + if isinstance(data, list): + data = data[0] if data else {} + return self.model_class.from_api_response(data) + + def list(self, **params: Any) -> List[T]: + """List resources with optional filtering parameters.""" + if str(self.client.__class__.__name__).startswith('Async'): + raise ValueError("This method requires a sync client.") + url = self._build_url() + response = self.client._request("GET", url, params=params) + data_list = self._parse_response_data(response.json()) + return [self.model_class.from_api_response(item) for item in data_list] + + def create(self, payload: Union[Dict[str, Any], T]) -> T: + """Create a new resource.""" + if str(self.client.__class__.__name__).startswith('Async'): + raise ValueError("This method requires a sync client.") + + payload_dict = payload + # Convert Pydantic model to dict if needed + if isinstance(payload, AirwallexModel): + payload_dict = payload.to_api_dict() + + url = self._build_url() + response = self.client._request("POST", url, json=payload_dict) + data = self._parse_response_data(response.json()) + # If the returned data is a list, take the first item. + if isinstance(data, list): + data = data[0] if data else {} + return self.model_class.from_api_response(data) + + def update(self, resource_id: Any, payload: Union[Dict[str, Any], T]) -> T: + """Update an existing resource.""" + if str(self.client.__class__.__name__).startswith('Async'): + raise ValueError("This method requires a sync client.") + + payload_dict = payload + # Convert Pydantic model to dict if needed + if isinstance(payload, AirwallexModel): + payload_dict = payload.to_api_dict() + + url = self._build_url(resource_id) + response = self.client._request("PUT", url, json=payload_dict) + data = self._parse_response_data(response.json()) + # If the returned data is a list, take the first item. + if isinstance(data, list): + data = data[0] if data else {} + return self.model_class.from_api_response(data) + + def delete(self, resource_id: Any) -> None: + """Delete a resource.""" + if str(self.client.__class__.__name__).startswith('Async'): + raise ValueError("This method requires a sync client.") + url = self._build_url(resource_id) + self.client._request("DELETE", url) + + def paginate(self, stop_page: Optional[int] = None, **params: Any) -> Generator[T, None, None]: + """ + Generate items one by one from paginated results. + + Args: + stop_page: The page number to stop at (optional). + **params: Filter parameters to pass to the API. + + Yields: + T: Each item from the paginated results. + """ + if str(self.client.__class__.__name__).startswith('Async'): + raise ValueError("This method requires a sync client.") + + page_num = params.get("page_num", 1) + page_size = params.get("page_size", 100) + + while True: + params["page_num"] = page_num + params["page_size"] = page_size + url = self._build_url() + response = self.client._request("GET", url, params=params) + data = response.json() + + items = data.get("items", []) + has_more = data.get("has_more", False) + + for item in items: + yield self.model_class.from_api_response(item) + + if not has_more or not items: + break + + page_num += 1 + + if stop_page and page_num > stop_page: + break + + + # Asynchronous API methods + + async def fetch_async(self, resource_id: Any) -> T: + """Fetch a single resource by ID asynchronously.""" + if not str(self.client.__class__.__name__).startswith('Async'): + raise ValueError("This method requires an async client.") + url = self._build_url(resource_id) + response = await self.client._request("GET", url) + data = self._parse_response_data(response.json()) + # If the returned data is a list, take the first item. + if isinstance(data, list): + data = data[0] if data else {} + return self.model_class.from_api_response(data) + + async def list_async(self, **params: Any) -> List[T]: + """List resources with optional filtering parameters asynchronously.""" + if not str(self.client.__class__.__name__).startswith('Async'): + raise ValueError("This method requires an async client.") + url = self._build_url() + response = await self.client._request("GET", url, params=params) + data_list = self._parse_response_data(response.json()) + return [self.model_class.from_api_response(item) for item in data_list] + + async def create_async(self, payload: Union[Dict[str, Any], T]) -> T: + """Create a new resource asynchronously.""" + if not str(self.client.__class__.__name__).startswith('Async'): + raise ValueError("This method requires an async client.") + + payload_dict = payload + # Convert Pydantic model to dict if needed + if isinstance(payload, AirwallexModel): + payload_dict = payload.to_api_dict() + + url = self._build_url() + response = await self.client._request("POST", url, json=payload_dict) + data = self._parse_response_data(response.json()) + # If the returned data is a list, take the first item. + if isinstance(data, list): + data = data[0] if data else {} + return self.model_class.from_api_response(data) + + async def update_async(self, resource_id: Any, payload: Union[Dict[str, Any], T]) -> T: + """Update an existing resource asynchronously.""" + if not str(self.client.__class__.__name__).startswith('Async'): + raise ValueError("This method requires an async client.") + + payload_dict = payload + # Convert Pydantic model to dict if needed + if isinstance(payload, AirwallexModel): + payload_dict = payload.to_api_dict() + + url = self._build_url(resource_id) + response = await self.client._request("PUT", url, json=payload_dict) + data = self._parse_response_data(response.json()) + # If the returned data is a list, take the first item. + if isinstance(data, list): + data = data[0] if data else {} + return self.model_class.from_api_response(data) + + async def delete_async(self, resource_id: Any) -> None: + """Delete a resource asynchronously.""" + if not str(self.client.__class__.__name__).startswith('Async'): + raise ValueError("This method requires an async client.") + url = self._build_url(resource_id) + await self.client._request("DELETE", url) + + async def paginate_async(self, stop_page: Optional[int] = None, **params: Any) -> AsyncGenerator[T, None]: + """ + Generate items one by one from paginated results, asynchronously. + + Args: + stop_page: The page number to stop at (optional). + **params: Filter parameters to pass to the API. + + Yields: + T: Each item from the paginated results. + """ + if not str(self.client.__class__.__name__).startswith('Async'): + raise ValueError("This method requires an async client.") + + page_num = params.get("page_num", 1) + page_size = params.get("page_size", 100) + + while True: + params["page_num"] = page_num + params["page_size"] = page_size + url = self._build_url() + response = await self.client._request("GET", url, params=params) + data = response.json() + + items = data.get("items", []) + has_more = data.get("has_more", False) + + for item in items: + yield self.model_class.from_api_response(item) + + if not has_more or not items: + break + + page_num += 1 + + if stop_page and page_num > stop_page: + break \ No newline at end of file diff --git a/airwallex-sdk/airwallex/api/beneficiary.py b/airwallex-sdk/airwallex/api/beneficiary.py new file mode 100644 index 0000000..f68240c --- /dev/null +++ b/airwallex-sdk/airwallex/api/beneficiary.py @@ -0,0 +1,156 @@ +""" +Airwallex Beneficiary API. +""" +from typing import Dict, Any, List, Optional, Type, TypeVar, Union, cast +from ..models.beneficiary import Beneficiary as BeneficiaryModel, BeneficiaryCreateRequest, BeneficiaryUpdateRequest +from .base import AirwallexAPIBase + +T = TypeVar("T", bound=BeneficiaryModel) + + +class Beneficiary(AirwallexAPIBase[BeneficiaryModel]): + """ + Operations for Airwallex beneficiaries. + + Beneficiaries represent recipients of payments. + """ + endpoint = "beneficiaries" + model_class = cast(Type[BeneficiaryModel], BeneficiaryModel) + + def create_from_model(self, beneficiary: BeneficiaryCreateRequest) -> BeneficiaryModel: + """ + Create a new beneficiary using a Pydantic model. + + Args: + beneficiary: BeneficiaryCreateRequest model with beneficiary creation details. + + Returns: + Beneficiary: The created beneficiary. + """ + return self.create(beneficiary) + + async def create_from_model_async(self, beneficiary: BeneficiaryCreateRequest) -> BeneficiaryModel: + """ + Create a new beneficiary using a Pydantic model asynchronously. + + Args: + beneficiary: BeneficiaryCreateRequest model with beneficiary creation details. + + Returns: + Beneficiary: The created beneficiary. + """ + return await self.create_async(beneficiary) + + def update_from_model(self, beneficiary_id: str, beneficiary: BeneficiaryUpdateRequest) -> BeneficiaryModel: + """ + Update a beneficiary using a Pydantic model. + + Args: + beneficiary_id: The ID of the beneficiary to update. + beneficiary: BeneficiaryUpdateRequest model with beneficiary update details. + + Returns: + Beneficiary: The updated beneficiary. + """ + return self.update(beneficiary_id, beneficiary) + + async def update_from_model_async(self, beneficiary_id: str, beneficiary: BeneficiaryUpdateRequest) -> BeneficiaryModel: + """ + Update a beneficiary using a Pydantic model asynchronously. + + Args: + beneficiary_id: The ID of the beneficiary to update. + beneficiary: BeneficiaryUpdateRequest model with beneficiary update details. + + Returns: + Beneficiary: The updated beneficiary. + """ + return await self.update_async(beneficiary_id, beneficiary) + + def validate(self, beneficiary: BeneficiaryCreateRequest) -> Dict[str, Any]: + """ + Validate a beneficiary without creating it. + + Args: + beneficiary: BeneficiaryCreateRequest model with beneficiary details. + + Returns: + Dict[str, Any]: Validation results. + """ + url = self._build_url(suffix="validate") + + if not self.client.__class__.__name__.startswith('Async'): + response = self.client._request("POST", url, json=beneficiary.to_api_dict()) + return response.json() + else: + raise ValueError("Use validate_async for async clients") + + async def validate_async(self, beneficiary: BeneficiaryCreateRequest) -> Dict[str, Any]: + """ + Validate a beneficiary without creating it asynchronously. + + Args: + beneficiary: BeneficiaryCreateRequest model with beneficiary details. + + Returns: + Dict[str, Any]: Validation results. + """ + url = self._build_url(suffix="validate") + + if self.client.__class__.__name__.startswith('Async'): + response = await self.client._request("POST", url, json=beneficiary.to_api_dict()) + return response.json() + else: + raise ValueError("Use validate for sync clients") + + def deactivate(self, beneficiary_id: str) -> BeneficiaryModel: + """ + Deactivate a beneficiary. + + Args: + beneficiary_id: The ID of the beneficiary to deactivate. + + Returns: + Beneficiary: The deactivated beneficiary. + """ + update_request = BeneficiaryUpdateRequest(status="disabled") + return self.update(beneficiary_id, update_request) + + async def deactivate_async(self, beneficiary_id: str) -> BeneficiaryModel: + """ + Deactivate a beneficiary asynchronously. + + Args: + beneficiary_id: The ID of the beneficiary to deactivate. + + Returns: + Beneficiary: The deactivated beneficiary. + """ + update_request = BeneficiaryUpdateRequest(status="disabled") + return await self.update_async(beneficiary_id, update_request) + + def activate(self, beneficiary_id: str) -> BeneficiaryModel: + """ + Activate a beneficiary. + + Args: + beneficiary_id: The ID of the beneficiary to activate. + + Returns: + Beneficiary: The activated beneficiary. + """ + update_request = BeneficiaryUpdateRequest(status="active") + return self.update(beneficiary_id, update_request) + + async def activate_async(self, beneficiary_id: str) -> BeneficiaryModel: + """ + Activate a beneficiary asynchronously. + + Args: + beneficiary_id: The ID of the beneficiary to activate. + + Returns: + Beneficiary: The activated beneficiary. + """ + update_request = BeneficiaryUpdateRequest(status="active") + return await self.update_async(beneficiary_id, update_request) diff --git a/airwallex-sdk/airwallex/api/financial_transaction.py b/airwallex-sdk/airwallex/api/financial_transaction.py new file mode 100644 index 0000000..cc91dff --- /dev/null +++ b/airwallex-sdk/airwallex/api/financial_transaction.py @@ -0,0 +1,123 @@ +""" +Airwallex Financial Transaction API. +""" +from typing import Dict, Any, List, Optional, Type, TypeVar, Union, cast +from datetime import datetime +from ..models.financial_transaction import FinancialTransaction +from .base import AirwallexAPIBase + +T = TypeVar("T", bound=FinancialTransaction) + + +class FinancialTransaction(AirwallexAPIBase[FinancialTransaction]): + """ + Operations for Airwallex financial transactions. + + Financial transactions represent the transactions that contributed to the Airwallex account balance. + """ + endpoint = "financial_transactions" + model_class = cast(Type[FinancialTransaction], FinancialTransaction) + + def list_with_filters( + self, + batch_id: Optional[str] = None, + currency: Optional[str] = None, + from_created_at: Optional[Union[str, datetime]] = None, + to_created_at: Optional[Union[str, datetime]] = None, + source_id: Optional[str] = None, + status: Optional[str] = None, + page_num: int = 0, + page_size: int = 100 + ) -> List[FinancialTransaction]: + """ + List financial transactions with filtering options. + + Args: + batch_id: Filter by batch ID + currency: Filter by currency (3-letter ISO-4217 code) + from_created_at: Filter by creation date (start, inclusive) + to_created_at: Filter by creation date (end, inclusive) + source_id: Filter by source ID + status: Filter by status (PENDING, SETTLED) + page_num: Page number (0-indexed) for pagination + page_size: Number of transactions per page (max 1000) + + Returns: + List[FinancialTransaction]: List of matching financial transactions + """ + params = { + "page_num": page_num, + "page_size": page_size + } + + if batch_id: + params["batch_id"] = batch_id + + if currency: + params["currency"] = currency + + if source_id: + params["source_id"] = source_id + + if status: + params["status"] = status + + if from_created_at: + params["from_created_at"] = from_created_at + + if to_created_at: + params["to_created_at"] = to_created_at + + return self.list(**params) + + async def list_with_filters_async( + self, + batch_id: Optional[str] = None, + currency: Optional[str] = None, + from_created_at: Optional[Union[str, datetime]] = None, + to_created_at: Optional[Union[str, datetime]] = None, + source_id: Optional[str] = None, + status: Optional[str] = None, + page_num: int = 0, + page_size: int = 100 + ) -> List[FinancialTransaction]: + """ + List financial transactions with filtering options asynchronously. + + Args: + batch_id: Filter by batch ID + currency: Filter by currency (3-letter ISO-4217 code) + from_created_at: Filter by creation date (start, inclusive) + to_created_at: Filter by creation date (end, inclusive) + source_id: Filter by source ID + status: Filter by status (PENDING, SETTLED) + page_num: Page number (0-indexed) for pagination + page_size: Number of transactions per page (max 1000) + + Returns: + List[FinancialTransaction]: List of matching financial transactions + """ + params = { + "page_num": page_num, + "page_size": page_size + } + + if batch_id: + params["batch_id"] = batch_id + + if currency: + params["currency"] = currency + + if source_id: + params["source_id"] = source_id + + if status: + params["status"] = status + + if from_created_at: + params["from_created_at"] = from_created_at + + if to_created_at: + params["to_created_at"] = to_created_at + + return await self.list_async(**params) \ No newline at end of file diff --git a/airwallex-sdk/airwallex/api/invoice.py b/airwallex-sdk/airwallex/api/invoice.py new file mode 100644 index 0000000..8f79563 --- /dev/null +++ b/airwallex-sdk/airwallex/api/invoice.py @@ -0,0 +1,257 @@ +""" +Airwallex Invoice API. +""" +from typing import Dict, Any, List, Optional, Type, TypeVar, Union, cast +from datetime import datetime +from ..models.invoice import Invoice, InvoiceItem, InvoicePreviewRequest, InvoicePreviewResponse +from .base import AirwallexAPIBase + +T = TypeVar("T", bound=Invoice) + + +class Invoice(AirwallexAPIBase[Invoice]): + """ + Operations for Airwallex invoices. + + Invoices record one-off sales transactions between you and your customers. + """ + endpoint = "invoices" + model_class = cast(Type[Invoice], Invoice) + + def preview(self, preview_request: InvoicePreviewRequest) -> InvoicePreviewResponse: + """ + Preview an upcoming invoice. + + This method allows you to preview the upcoming invoice of an existing subscription + or the first invoice before creating a new subscription. + + Args: + preview_request: InvoicePreviewRequest model with preview details + + Returns: + InvoicePreviewResponse: The preview of the upcoming invoice + """ + url = self._build_url(suffix="preview") + + if not self.client.__class__.__name__.startswith('Async'): + response = self.client._request("POST", url, json=preview_request.to_api_dict()) + return InvoicePreviewResponse.from_api_response(response.json()) + else: + raise ValueError("Use preview_async for async clients") + + async def preview_async(self, preview_request: InvoicePreviewRequest) -> InvoicePreviewResponse: + """ + Preview an upcoming invoice asynchronously. + + This method allows you to preview the upcoming invoice of an existing subscription + or the first invoice before creating a new subscription. + + Args: + preview_request: InvoicePreviewRequest model with preview details + + Returns: + InvoicePreviewResponse: The preview of the upcoming invoice + """ + url = self._build_url(suffix="preview") + + if self.client.__class__.__name__.startswith('Async'): + response = await self.client._request("POST", url, json=preview_request.to_api_dict()) + return InvoicePreviewResponse.from_api_response(response.json()) + else: + raise ValueError("Use preview for sync clients") + + def list_items(self, invoice_id: str, page_num: int = 0, page_size: int = 20) -> List[InvoiceItem]: + """ + List all items for a specific invoice. + + Args: + invoice_id: The ID of the invoice to fetch items for + page_num: Page number (0-indexed) for pagination + page_size: Number of items per page + + Returns: + List[InvoiceItem]: List of invoice items + """ + url = f"{self._build_url(invoice_id)}/items" + params = { + "page_num": page_num, + "page_size": page_size + } + + if not self.client.__class__.__name__.startswith('Async'): + response = self.client._request("GET", url, params=params) + data = response.json() + + if "items" in data: + return [InvoiceItem.from_api_response(item) for item in data["items"]] + return [] + else: + raise ValueError("Use list_items_async for async clients") + + async def list_items_async(self, invoice_id: str, page_num: int = 0, page_size: int = 20) -> List[InvoiceItem]: + """ + List all items for a specific invoice asynchronously. + + Args: + invoice_id: The ID of the invoice to fetch items for + page_num: Page number (0-indexed) for pagination + page_size: Number of items per page + + Returns: + List[InvoiceItem]: List of invoice items + """ + url = f"{self._build_url(invoice_id)}/items" + params = { + "page_num": page_num, + "page_size": page_size + } + + if self.client.__class__.__name__.startswith('Async'): + response = await self.client._request("GET", url, params=params) + data = response.json() + + if "items" in data: + return [InvoiceItem.from_api_response(item) for item in data["items"]] + return [] + else: + raise ValueError("Use list_items for sync clients") + + def get_item(self, invoice_id: str, item_id: str) -> InvoiceItem: + """ + Retrieve a specific invoice item. + + Args: + invoice_id: The ID of the invoice that contains the item + item_id: The ID of the invoice item to retrieve + + Returns: + InvoiceItem: The requested invoice item + """ + url = f"{self._build_url(invoice_id)}/items/{item_id}" + + if not self.client.__class__.__name__.startswith('Async'): + response = self.client._request("GET", url) + return InvoiceItem.from_api_response(response.json()) + else: + raise ValueError("Use get_item_async for async clients") + + async def get_item_async(self, invoice_id: str, item_id: str) -> InvoiceItem: + """ + Retrieve a specific invoice item asynchronously. + + Args: + invoice_id: The ID of the invoice that contains the item + item_id: The ID of the invoice item to retrieve + + Returns: + InvoiceItem: The requested invoice item + """ + url = f"{self._build_url(invoice_id)}/items/{item_id}" + + if self.client.__class__.__name__.startswith('Async'): + response = await self.client._request("GET", url) + return InvoiceItem.from_api_response(response.json()) + else: + raise ValueError("Use get_item for sync clients") + + def list_with_filters( + self, + customer_id: Optional[str] = None, + subscription_id: Optional[str] = None, + status: Optional[str] = None, + from_created_at: Optional[Union[str, datetime]] = None, + to_created_at: Optional[Union[str, datetime]] = None, + page_num: int = 0, + page_size: int = 20 + ) -> List[Invoice]: + """ + List invoices with filtering options. + + Args: + customer_id: Filter by customer ID + subscription_id: Filter by subscription ID + status: Filter by status (SENT, PAID, PAYMENT_FAILED) + from_created_at: Filter by creation date (start, inclusive) + to_created_at: Filter by creation date (end, exclusive) + page_num: Page number (0-indexed) for pagination + page_size: Number of invoices per page + + Returns: + List[Invoice]: List of matching invoices + """ + params = { + "page_num": page_num, + "page_size": page_size + } + + if customer_id: + params["customer_id"] = customer_id + + if subscription_id: + params["subscription_id"] = subscription_id + + if status: + params["status"] = status + + if from_created_at: + if isinstance(from_created_at, datetime): + from_created_at = from_created_at.isoformat() + params["from_created_at"] = from_created_at + + if to_created_at: + if isinstance(to_created_at, datetime): + to_created_at = to_created_at.isoformat() + params["to_created_at"] = to_created_at + + return self.list(**params) + + async def list_with_filters_async( + self, + customer_id: Optional[str] = None, + subscription_id: Optional[str] = None, + status: Optional[str] = None, + from_created_at: Optional[Union[str, datetime]] = None, + to_created_at: Optional[Union[str, datetime]] = None, + page_num: int = 0, + page_size: int = 20 + ) -> List[Invoice]: + """ + List invoices with filtering options asynchronously. + + Args: + customer_id: Filter by customer ID + subscription_id: Filter by subscription ID + status: Filter by status (SENT, PAID, PAYMENT_FAILED) + from_created_at: Filter by creation date (start, inclusive) + to_created_at: Filter by creation date (end, exclusive) + page_num: Page number (0-indexed) for pagination + page_size: Number of invoices per page + + Returns: + List[Invoice]: List of matching invoices + """ + params = { + "page_num": page_num, + "page_size": page_size + } + + if customer_id: + params["customer_id"] = customer_id + + if subscription_id: + params["subscription_id"] = subscription_id + + if status: + params["status"] = status + + if from_created_at: + if isinstance(from_created_at, datetime): + from_created_at = from_created_at.isoformat() + params["from_created_at"] = from_created_at + + if to_created_at: + if isinstance(to_created_at, datetime): + to_created_at = to_created_at.isoformat() + params["to_created_at"] = to_created_at + + return await self.list_async(**params) diff --git a/airwallex-sdk/airwallex/api/issuing_authorization.py b/airwallex-sdk/airwallex/api/issuing_authorization.py new file mode 100644 index 0000000..e83adcd --- /dev/null +++ b/airwallex-sdk/airwallex/api/issuing_authorization.py @@ -0,0 +1,313 @@ +""" +Airwallex Issuing Authorization API. +""" +from typing import Dict, Any, List, Optional, Type, TypeVar, Union, cast +from datetime import datetime +from ..models.issuing_authorization import Authorization, AuthorizationListResponse +from .base import AirwallexAPIBase + +T = TypeVar("T", bound=Authorization) + + +class IssuingAuthorization(AirwallexAPIBase[Authorization]): + """ + Operations for Airwallex issuing authorizations. + + Authorizations represent pre-auth and capture processed against individual cards. + """ + endpoint = "issuing/authorizations" + model_class = cast(Type[Authorization], Authorization) + + def list_with_filters( + self, + billing_currency: Optional[str] = None, + card_id: Optional[str] = None, + digital_wallet_token_id: Optional[str] = None, + from_created_at: Optional[Union[str, datetime]] = None, + lifecycle_id: Optional[str] = None, + page_num: int = 0, + page_size: int = 10, + retrieval_ref: Optional[str] = None, + status: Optional[str] = None, + to_created_at: Optional[Union[str, datetime]] = None, + ) -> List[Authorization]: + """ + List authorizations with filtering options. + + Args: + billing_currency: Currency in which transition was billed (3-letter ISO-4217 code) + card_id: Unique Identifier for card + digital_wallet_token_id: Unique Identifier for digital token + from_created_at: Start of Transaction Date in ISO8601 format (inclusive) + lifecycle_id: Unique Identifier for lifecycle + page_num: Page number, starts from 0 + page_size: Number of results per page + retrieval_ref: Retrieval reference number + status: Authorization status (CLEARED, EXPIRED, FAILED, PENDING, REVERSED) + to_created_at: End of Transaction Date in ISO8601 format (exclusive) + + Returns: + List[Authorization]: List of matching authorizations + """ + params = { + "page_num": page_num, + "page_size": page_size + } + + if billing_currency: + params["billing_currency"] = billing_currency + + if card_id: + params["card_id"] = card_id + + if digital_wallet_token_id: + params["digital_wallet_token_id"] = digital_wallet_token_id + + if from_created_at: + if isinstance(from_created_at, datetime): + from_created_at = from_created_at.isoformat() + params["from_created_at"] = from_created_at + + if lifecycle_id: + params["lifecycle_id"] = lifecycle_id + + if retrieval_ref: + params["retrieval_ref"] = retrieval_ref + + if status: + params["status"] = status + + if to_created_at: + if isinstance(to_created_at, datetime): + to_created_at = to_created_at.isoformat() + params["to_created_at"] = to_created_at + + if not self.client.__class__.__name__.startswith('Async'): + response = self.client._request("GET", self._build_url(), params=params) + data = response.json() + return [self.model_class.from_api_response(item) for item in data.get("items", [])] + else: + raise ValueError("Use list_with_filters_async for async clients") + + async def list_with_filters_async( + self, + billing_currency: Optional[str] = None, + card_id: Optional[str] = None, + digital_wallet_token_id: Optional[str] = None, + from_created_at: Optional[Union[str, datetime]] = None, + lifecycle_id: Optional[str] = None, + page_num: int = 0, + page_size: int = 10, + retrieval_ref: Optional[str] = None, + status: Optional[str] = None, + to_created_at: Optional[Union[str, datetime]] = None, + ) -> List[Authorization]: + """ + List authorizations with filtering options asynchronously. + + Args: + billing_currency: Currency in which transition was billed (3-letter ISO-4217 code) + card_id: Unique Identifier for card + digital_wallet_token_id: Unique Identifier for digital token + from_created_at: Start of Transaction Date in ISO8601 format (inclusive) + lifecycle_id: Unique Identifier for lifecycle + page_num: Page number, starts from 0 + page_size: Number of results per page + retrieval_ref: Retrieval reference number + status: Authorization status (CLEARED, EXPIRED, FAILED, PENDING, REVERSED) + to_created_at: End of Transaction Date in ISO8601 format (exclusive) + + Returns: + List[Authorization]: List of matching authorizations + """ + params = { + "page_num": page_num, + "page_size": page_size + } + + if billing_currency: + params["billing_currency"] = billing_currency + + if card_id: + params["card_id"] = card_id + + if digital_wallet_token_id: + params["digital_wallet_token_id"] = digital_wallet_token_id + + if from_created_at: + if isinstance(from_created_at, datetime): + from_created_at = from_created_at.isoformat() + params["from_created_at"] = from_created_at + + if lifecycle_id: + params["lifecycle_id"] = lifecycle_id + + if retrieval_ref: + params["retrieval_ref"] = retrieval_ref + + if status: + params["status"] = status + + if to_created_at: + if isinstance(to_created_at, datetime): + to_created_at = to_created_at.isoformat() + params["to_created_at"] = to_created_at + + if self.client.__class__.__name__.startswith('Async'): + response = await self.client._request("GET", self._build_url(), params=params) + data = response.json() + return [self.model_class.from_api_response(item) for item in data.get("items", [])] + else: + raise ValueError("Use list_with_filters for sync clients") + + def paginate(self, **params: Any) -> List[Authorization]: + """ + Fetch all pages of authorizations. + + Args: + **params: Filter parameters to pass to the API + + Returns: + List[Authorization]: All authorizations matching the filters + """ + if str(self.client.__class__.__name__).startswith('Async'): + raise ValueError("This method requires a sync client.") + + all_items: List[Dict[str, Any]] = [] + page_num = params.get("page_num", 0) + page_size = params.get("page_size", 10) + + while True: + params["page_num"] = page_num + params["page_size"] = page_size + + response = self.client._request("GET", self._build_url(), params=params) + data = response.json() + + items = data.get("items", []) + has_more = data.get("has_more", False) + + if not items: + break + + all_items.extend(items) + + if not has_more: + break + + page_num += 1 + + return [self.model_class.from_api_response(item) for item in all_items] + + async def paginate_async(self, **params: Any) -> List[Authorization]: + """ + Fetch all pages of authorizations asynchronously. + + Args: + **params: Filter parameters to pass to the API + + Returns: + List[Authorization]: All authorizations matching the filters + """ + if not self.client.__class__.__name__.startswith('Async'): + raise ValueError("This method requires an async client.") + + all_items: List[Dict[str, Any]] = [] + page_num = params.get("page_num", 0) + page_size = params.get("page_size", 10) + + while True: + params["page_num"] = page_num + params["page_size"] = page_size + + response = await self.client._request("GET", self._build_url(), params=params) + data = response.json() + + items = data.get("items", []) + has_more = data.get("has_more", False) + + if not items: + break + + all_items.extend(items) + + if not has_more: + break + + page_num += 1 + + return [self.model_class.from_api_response(item) for item in all_items] + + def paginate_generator(self, **params: Any): + """ + Generate items one by one from paginated results. + + Args: + **params: Filter parameters to pass to the API + + Yields: + Authorization: Authorization objects one by one + """ + if self.client.__class__.__name__.startswith('Async'): + raise ValueError("This method requires a sync client.") + + page_num = params.get("page_num", 0) + page_size = params.get("page_size", 10) + + while True: + params["page_num"] = page_num + params["page_size"] = page_size + + response = self.client._request("GET", self._build_url(), params=params) + data = response.json() + + items = data.get("items", []) + has_more = data.get("has_more", False) + + if not items: + break + + for item in items: + yield self.model_class.from_api_response(item) + + if not has_more: + break + + page_num += 1 + + async def paginate_async_generator(self, **params: Any): + """ + Generate items one by one from paginated results asynchronously. + + Args: + **params: Filter parameters to pass to the API + + Yields: + Authorization: Authorization objects one by one + """ + if not self.client.__class__.__name__.startswith('Async'): + raise ValueError("This method requires an async client.") + + page_num = params.get("page_num", 0) + page_size = params.get("page_size", 10) + + while True: + params["page_num"] = page_num + params["page_size"] = page_size + + response = await self.client._request("GET", self._build_url(), params=params) + data = response.json() + + items = data.get("items", []) + has_more = data.get("has_more", False) + + if not items: + break + + for item in items: + yield self.model_class.from_api_response(item) + + if not has_more: + break + + page_num += 1 diff --git a/airwallex-sdk/airwallex/api/issuing_card.py b/airwallex-sdk/airwallex/api/issuing_card.py new file mode 100644 index 0000000..bc332cb --- /dev/null +++ b/airwallex-sdk/airwallex/api/issuing_card.py @@ -0,0 +1,411 @@ +""" +Airwallex Issuing Card API. +""" +from typing import Dict, Any, List, Optional, Type, TypeVar, Union, cast +from datetime import datetime +from ..models.issuing_card import Card, CardCreateRequest, CardUpdateRequest, CardDetails, CardLimits +from .base import AirwallexAPIBase + +T = TypeVar("T", bound=Card) + + +class IssuingCard(AirwallexAPIBase[Card]): + """ + Operations for Airwallex issuing cards. + + Cards represent virtual or physical payment cards associated with cardholders. + """ + endpoint = "issuing/cards" + model_class = cast(Type[Card], Card) + + def create_card(self, card: CardCreateRequest) -> Card: + """ + Create a new card. + + Args: + card: CardCreateRequest model with card details + + Returns: + Card: The created card + """ + url = f"{self.base_path}/create" + + if not self.client.__class__.__name__.startswith('Async'): + response = self.client._request("POST", url, json=card.to_api_dict()) + return self.model_class.from_api_response(response.json()) + else: + raise ValueError("Use create_card_async for async clients") + + async def create_card_async(self, card: CardCreateRequest) -> Card: + """ + Create a new card asynchronously. + + Args: + card: CardCreateRequest model with card details + + Returns: + Card: The created card + """ + url = f"{self.base_path}/create" + + if self.client.__class__.__name__.startswith('Async'): + response = await self.client._request("POST", url, json=card.to_api_dict()) + return self.model_class.from_api_response(response.json()) + else: + raise ValueError("Use create_card for sync clients") + + def get_card_details(self, card_id: str) -> CardDetails: + """ + Get sensitive card details. + + Args: + card_id: The ID of the card + + Returns: + CardDetails: Sensitive card details + """ + url = f"{self._build_url(card_id)}/details" + + if not self.client.__class__.__name__.startswith('Async'): + response = self.client._request("GET", url) + return CardDetails.from_api_response(response.json()) + else: + raise ValueError("Use get_card_details_async for async clients") + + async def get_card_details_async(self, card_id: str) -> CardDetails: + """ + Get sensitive card details asynchronously. + + Args: + card_id: The ID of the card + + Returns: + CardDetails: Sensitive card details + """ + url = f"{self._build_url(card_id)}/details" + + if self.client.__class__.__name__.startswith('Async'): + response = await self.client._request("GET", url) + return CardDetails.from_api_response(response.json()) + else: + raise ValueError("Use get_card_details for sync clients") + + def activate_card(self, card_id: str) -> None: + """ + Activate a physical card. + + Args: + card_id: The ID of the card to activate + """ + url = f"{self._build_url(card_id)}/activate" + + if not self.client.__class__.__name__.startswith('Async'): + self.client._request("POST", url) + else: + raise ValueError("Use activate_card_async for async clients") + + async def activate_card_async(self, card_id: str) -> None: + """ + Activate a physical card asynchronously. + + Args: + card_id: The ID of the card to activate + """ + url = f"{self._build_url(card_id)}/activate" + + if self.client.__class__.__name__.startswith('Async'): + await self.client._request("POST", url) + else: + raise ValueError("Use activate_card for sync clients") + + def get_card_limits(self, card_id: str) -> CardLimits: + """ + Get card remaining limits. + + Args: + card_id: The ID of the card + + Returns: + CardLimits: Card remaining limits + """ + url = f"{self._build_url(card_id)}/limits" + + if not self.client.__class__.__name__.startswith('Async'): + response = self.client._request("GET", url) + return CardLimits.from_api_response(response.json()) + else: + raise ValueError("Use get_card_limits_async for async clients") + + async def get_card_limits_async(self, card_id: str) -> CardLimits: + """ + Get card remaining limits asynchronously. + + Args: + card_id: The ID of the card + + Returns: + CardLimits: Card remaining limits + """ + url = f"{self._build_url(card_id)}/limits" + + if self.client.__class__.__name__.startswith('Async'): + response = await self.client._request("GET", url) + return CardLimits.from_api_response(response.json()) + else: + raise ValueError("Use get_card_limits for sync clients") + + def update_card(self, card_id: str, update_data: CardUpdateRequest) -> Card: + """ + Update a card. + + Args: + card_id: The ID of the card to update + update_data: CardUpdateRequest model with update details + + Returns: + Card: The updated card + """ + url = f"{self._build_url(card_id)}/update" + + if not self.client.__class__.__name__.startswith('Async'): + response = self.client._request("POST", url, json=update_data.to_api_dict()) + return self.model_class.from_api_response(response.json()) + else: + raise ValueError("Use update_card_async for async clients") + + async def update_card_async(self, card_id: str, update_data: CardUpdateRequest) -> Card: + """ + Update a card asynchronously. + + Args: + card_id: The ID of the card to update + update_data: CardUpdateRequest model with update details + + Returns: + Card: The updated card + """ + url = f"{self._build_url(card_id)}/update" + + if self.client.__class__.__name__.startswith('Async'): + response = await self.client._request("POST", url, json=update_data.to_api_dict()) + return self.model_class.from_api_response(response.json()) + else: + raise ValueError("Use update_card for sync clients") + + def list_with_filters( + self, + card_status: Optional[str] = None, + cardholder_id: Optional[str] = None, + from_created_at: Optional[Union[str, datetime]] = None, + from_updated_at: Optional[Union[str, datetime]] = None, + nick_name: Optional[str] = None, + to_created_at: Optional[Union[str, datetime]] = None, + to_updated_at: Optional[Union[str, datetime]] = None, + page_num: int = 0, + page_size: int = 10 + ) -> List[Card]: + """ + List cards with filtering options. + + Args: + card_status: Filter by status + cardholder_id: Filter by cardholder ID + from_created_at: Filter by creation date (start, inclusive) + from_updated_at: Filter by update date (start, inclusive) + nick_name: Filter by card nickname + to_created_at: Filter by creation date (end, inclusive) + to_updated_at: Filter by update date (end, inclusive) + page_num: Page number, starts from 0 + page_size: Number of results per page + + Returns: + List[Card]: List of matching cards + """ + params = { + "page_num": page_num, + "page_size": page_size + } + + if card_status: + params["card_status"] = card_status + + if cardholder_id: + params["cardholder_id"] = cardholder_id + + if from_created_at: + if isinstance(from_created_at, datetime): + from_created_at = from_created_at.isoformat() + params["from_created_at"] = from_created_at + + if from_updated_at: + if isinstance(from_updated_at, datetime): + from_updated_at = from_updated_at.isoformat() + params["from_updated_at"] = from_updated_at + + if nick_name: + params["nick_name"] = nick_name + + if to_created_at: + if isinstance(to_created_at, datetime): + to_created_at = to_created_at.isoformat() + params["to_created_at"] = to_created_at + + if to_updated_at: + if isinstance(to_updated_at, datetime): + to_updated_at = to_updated_at.isoformat() + params["to_updated_at"] = to_updated_at + + if not self.client.__class__.__name__.startswith('Async'): + response = self.client._request("GET", self._build_url(), params=params) + data = response.json() + return [self.model_class.from_api_response(item) for item in data.get("items", [])] + else: + raise ValueError("Use list_with_filters_async for async clients") + + async def list_with_filters_async( + self, + card_status: Optional[str] = None, + cardholder_id: Optional[str] = None, + from_created_at: Optional[Union[str, datetime]] = None, + from_updated_at: Optional[Union[str, datetime]] = None, + nick_name: Optional[str] = None, + to_created_at: Optional[Union[str, datetime]] = None, + to_updated_at: Optional[Union[str, datetime]] = None, + page_num: int = 0, + page_size: int = 10 + ) -> List[Card]: + """ + List cards with filtering options asynchronously. + + Args: + card_status: Filter by status + cardholder_id: Filter by cardholder ID + from_created_at: Filter by creation date (start, inclusive) + from_updated_at: Filter by update date (start, inclusive) + nick_name: Filter by card nickname + to_created_at: Filter by creation date (end, inclusive) + to_updated_at: Filter by update date (end, inclusive) + page_num: Page number, starts from 0 + page_size: Number of results per page + + Returns: + List[Card]: List of matching cards + """ + params = { + "page_num": page_num, + "page_size": page_size + } + + if card_status: + params["card_status"] = card_status + + if cardholder_id: + params["cardholder_id"] = cardholder_id + + if from_created_at: + if isinstance(from_created_at, datetime): + from_created_at = from_created_at.isoformat() + params["from_created_at"] = from_created_at + + if from_updated_at: + if isinstance(from_updated_at, datetime): + from_updated_at = from_updated_at.isoformat() + params["from_updated_at"] = from_updated_at + + if nick_name: + params["nick_name"] = nick_name + + if to_created_at: + if isinstance(to_created_at, datetime): + to_created_at = to_created_at.isoformat() + params["to_created_at"] = to_created_at + + if to_updated_at: + if isinstance(to_updated_at, datetime): + to_updated_at = to_updated_at.isoformat() + params["to_updated_at"] = to_updated_at + + if self.client.__class__.__name__.startswith('Async'): + response = await self.client._request("GET", self._build_url(), params=params) + data = response.json() + return [self.model_class.from_api_response(item) for item in data.get("items", [])] + else: + raise ValueError("Use list_with_filters for sync clients") + + def paginate(self, **params: Any) -> List[Card]: + """ + Fetch all pages of cards. + + Args: + **params: Filter parameters to pass to the API + + Returns: + List[Card]: All cards matching the filters + """ + if str(self.client.__class__.__name__).startswith('Async'): + raise ValueError("This method requires a sync client.") + + all_items: List[Dict[str, Any]] = [] + page_num = params.get("page_num", 0) + page_size = params.get("page_size", 10) + + while True: + params["page_num"] = page_num + params["page_size"] = page_size + + response = self.client._request("GET", self._build_url(), params=params) + data = response.json() + + items = data.get("items", []) + has_more = data.get("has_more", False) + + if not items: + break + + all_items.extend(items) + + if not has_more: + break + + page_num += 1 + + return [self.model_class.from_api_response(item) for item in all_items] + + async def paginate_async(self, **params: Any) -> List[Card]: + """ + Fetch all pages of cards asynchronously. + + Args: + **params: Filter parameters to pass to the API + + Returns: + List[Card]: All cards matching the filters + """ + if not self.client.__class__.__name__.startswith('Async'): + raise ValueError("This method requires an async client.") + + all_items: List[Dict[str, Any]] = [] + page_num = params.get("page_num", 0) + page_size = params.get("page_size", 10) + + while True: + params["page_num"] = page_num + params["page_size"] = page_size + + response = await self.client._request("GET", self._build_url(), params=params) + data = response.json() + + items = data.get("items", []) + has_more = data.get("has_more", False) + + if not items: + break + + all_items.extend(items) + + if not has_more: + break + + page_num += 1 + + return [self.model_class.from_api_response(item) for item in all_items] diff --git a/airwallex-sdk/airwallex/api/issuing_cardholder.py b/airwallex-sdk/airwallex/api/issuing_cardholder.py new file mode 100644 index 0000000..1fb1fe5 --- /dev/null +++ b/airwallex-sdk/airwallex/api/issuing_cardholder.py @@ -0,0 +1,234 @@ +""" +Airwallex Issuing Cardholder API. +""" +from typing import Dict, Any, List, Optional, Type, TypeVar, Union, cast +from ..models.issuing_cardholder import Cardholder, CardholderCreateRequest, CardholderUpdateRequest +from .base import AirwallexAPIBase + +T = TypeVar("T", bound=Cardholder) + + +class IssuingCardholder(AirwallexAPIBase[Cardholder]): + """ + Operations for Airwallex issuing cardholders. + + Cardholders are authorized representatives that can be issued cards. + """ + endpoint = "issuing/cardholders" + model_class = cast(Type[Cardholder], Cardholder) + + def create_cardholder(self, cardholder: CardholderCreateRequest) -> Cardholder: + """ + Create a new cardholder. + + Args: + cardholder: CardholderCreateRequest model with cardholder details + + Returns: + Cardholder: The created cardholder + """ + url = f"{self.base_path}/create" + + if not self.client.__class__.__name__.startswith('Async'): + response = self.client._request("POST", url, json=cardholder.to_api_dict()) + return self.model_class.from_api_response(response.json()) + else: + raise ValueError("Use create_cardholder_async for async clients") + + async def create_cardholder_async(self, cardholder: CardholderCreateRequest) -> Cardholder: + """ + Create a new cardholder asynchronously. + + Args: + cardholder: CardholderCreateRequest model with cardholder details + + Returns: + Cardholder: The created cardholder + """ + url = f"{self.base_path}/create" + + if self.client.__class__.__name__.startswith('Async'): + response = await self.client._request("POST", url, json=cardholder.to_api_dict()) + return self.model_class.from_api_response(response.json()) + else: + raise ValueError("Use create_cardholder for sync clients") + + def list_with_filters( + self, + cardholder_status: Optional[str] = None, + page_num: int = 0, + page_size: int = 10 + ) -> List[Cardholder]: + """ + List cardholders with filtering options. + + Args: + cardholder_status: Filter by status (PENDING, READY, INCOMPLETE, DISABLED) + page_num: Page number, starts from 0 + page_size: Number of results per page + + Returns: + List[Cardholder]: List of matching cardholders + """ + params = { + "page_num": page_num, + "page_size": page_size + } + + if cardholder_status: + params["cardholder_status"] = cardholder_status + + if not self.client.__class__.__name__.startswith('Async'): + response = self.client._request("GET", self._build_url(), params=params) + data = response.json() + return [self.model_class.from_api_response(item) for item in data.get("items", [])] + else: + raise ValueError("Use list_with_filters_async for async clients") + + async def list_with_filters_async( + self, + cardholder_status: Optional[str] = None, + page_num: int = 0, + page_size: int = 10 + ) -> List[Cardholder]: + """ + List cardholders with filtering options asynchronously. + + Args: + cardholder_status: Filter by status (PENDING, READY, INCOMPLETE, DISABLED) + page_num: Page number, starts from 0 + page_size: Number of results per page + + Returns: + List[Cardholder]: List of matching cardholders + """ + params = { + "page_num": page_num, + "page_size": page_size + } + + if cardholder_status: + params["cardholder_status"] = cardholder_status + + if self.client.__class__.__name__.startswith('Async'): + response = await self.client._request("GET", self._build_url(), params=params) + data = response.json() + return [self.model_class.from_api_response(item) for item in data.get("items", [])] + else: + raise ValueError("Use list_with_filters for sync clients") + + def update_cardholder(self, cardholder_id: str, update_data: CardholderUpdateRequest) -> Cardholder: + """ + Update a cardholder. + + Args: + cardholder_id: The ID of the cardholder to update + update_data: CardholderUpdateRequest model with update details + + Returns: + Cardholder: The updated cardholder + """ + url = f"{self._build_url(cardholder_id)}/update" + + if not self.client.__class__.__name__.startswith('Async'): + response = self.client._request("POST", url, json=update_data.to_api_dict()) + return self.model_class.from_api_response(response.json()) + else: + raise ValueError("Use update_cardholder_async for async clients") + + async def update_cardholder_async(self, cardholder_id: str, update_data: CardholderUpdateRequest) -> Cardholder: + """ + Update a cardholder asynchronously. + + Args: + cardholder_id: The ID of the cardholder to update + update_data: CardholderUpdateRequest model with update details + + Returns: + Cardholder: The updated cardholder + """ + url = f"{self._build_url(cardholder_id)}/update" + + if self.client.__class__.__name__.startswith('Async'): + response = await self.client._request("POST", url, json=update_data.to_api_dict()) + return self.model_class.from_api_response(response.json()) + else: + raise ValueError("Use update_cardholder for sync clients") + + def paginate(self, **params: Any) -> List[Cardholder]: + """ + Fetch all pages of cardholders. + + Args: + **params: Filter parameters to pass to the API + + Returns: + List[Cardholder]: All cardholders matching the filters + """ + if str(self.client.__class__.__name__).startswith('Async'): + raise ValueError("This method requires a sync client.") + + all_items: List[Dict[str, Any]] = [] + page_num = params.get("page_num", 0) + page_size = params.get("page_size", 10) + + while True: + params["page_num"] = page_num + params["page_size"] = page_size + + response = self.client._request("GET", self._build_url(), params=params) + data = response.json() + + items = data.get("items", []) + has_more = data.get("has_more", False) + + if not items: + break + + all_items.extend(items) + + if not has_more: + break + + page_num += 1 + + return [self.model_class.from_api_response(item) for item in all_items] + + async def paginate_async(self, **params: Any) -> List[Cardholder]: + """ + Fetch all pages of cardholders asynchronously. + + Args: + **params: Filter parameters to pass to the API + + Returns: + List[Cardholder]: All cardholders matching the filters + """ + if not self.client.__class__.__name__.startswith('Async'): + raise ValueError("This method requires an async client.") + + all_items: List[Dict[str, Any]] = [] + page_num = params.get("page_num", 0) + page_size = params.get("page_size", 10) + + while True: + params["page_num"] = page_num + params["page_size"] = page_size + + response = await self.client._request("GET", self._build_url(), params=params) + data = response.json() + + items = data.get("items", []) + has_more = data.get("has_more", False) + + if not items: + break + + all_items.extend(items) + + if not has_more: + break + + page_num += 1 + + return [self.model_class.from_api_response(item) for item in all_items] diff --git a/airwallex-sdk/airwallex/api/issuing_config.py b/airwallex-sdk/airwallex/api/issuing_config.py new file mode 100644 index 0000000..52c5e9f --- /dev/null +++ b/airwallex-sdk/airwallex/api/issuing_config.py @@ -0,0 +1,80 @@ +""" +Airwallex Issuing Config API. +""" +from typing import Dict, Any, List, Optional, Type, TypeVar, Union, cast +from ..models.issuing_config import IssuingConfig, IssuingConfigUpdateRequest +from .base import AirwallexAPIBase + +T = TypeVar("T", bound=IssuingConfig) + + +class IssuingConfig(AirwallexAPIBase[IssuingConfig]): + """ + Operations for Airwallex issuing configuration. + + Configuration for issuance settings and controls. + """ + endpoint = "issuing/config" + model_class = cast(Type[IssuingConfig], IssuingConfig) + + def get_config(self) -> IssuingConfig: + """ + Get the current issuing configuration. + + Returns: + IssuingConfig: The current issuing configuration + """ + if not self.client.__class__.__name__.startswith('Async'): + response = self.client._request("GET", self._build_url()) + return self.model_class.from_api_response(response.json()) + else: + raise ValueError("Use get_config_async for async clients") + + async def get_config_async(self) -> IssuingConfig: + """ + Get the current issuing configuration asynchronously. + + Returns: + IssuingConfig: The current issuing configuration + """ + if self.client.__class__.__name__.startswith('Async'): + response = await self.client._request("GET", self._build_url()) + return self.model_class.from_api_response(response.json()) + else: + raise ValueError("Use get_config for sync clients") + + def update_config(self, update_data: IssuingConfigUpdateRequest) -> IssuingConfig: + """ + Update the issuing configuration. + + Args: + update_data: IssuingConfigUpdateRequest model with update details + + Returns: + IssuingConfig: The updated issuing configuration + """ + url = f"{self._build_url()}/update" + + if not self.client.__class__.__name__.startswith('Async'): + response = self.client._request("POST", url, json=update_data.to_api_dict()) + return self.model_class.from_api_response(response.json()) + else: + raise ValueError("Use update_config_async for async clients") + + async def update_config_async(self, update_data: IssuingConfigUpdateRequest) -> IssuingConfig: + """ + Update the issuing configuration asynchronously. + + Args: + update_data: IssuingConfigUpdateRequest model with update details + + Returns: + IssuingConfig: The updated issuing configuration + """ + url = f"{self._build_url()}/update" + + if self.client.__class__.__name__.startswith('Async'): + response = await self.client._request("POST", url, json=update_data.to_api_dict()) + return self.model_class.from_api_response(response.json()) + else: + raise ValueError("Use update_config for sync clients") diff --git a/airwallex-sdk/airwallex/api/issuing_digital_wallet_token.py b/airwallex-sdk/airwallex/api/issuing_digital_wallet_token.py new file mode 100644 index 0000000..2a59111 --- /dev/null +++ b/airwallex-sdk/airwallex/api/issuing_digital_wallet_token.py @@ -0,0 +1,249 @@ +""" +Airwallex Issuing Digital Wallet Token API. +""" +from typing import Dict, Any, List, Optional, Type, TypeVar, Union, cast +from datetime import datetime +from ..models.issuing_digital_wallet_token import DigitalWalletToken +from .base import AirwallexAPIBase + +T = TypeVar("T", bound=DigitalWalletToken) + + +class IssuingDigitalWalletToken(AirwallexAPIBase[DigitalWalletToken]): + """ + Operations for Airwallex issuing digital wallet tokens. + + Digital wallet tokens represent tokenized cards in digital wallets. + """ + endpoint = "issuing/digital_wallet_tokens" + model_class = cast(Type[DigitalWalletToken], DigitalWalletToken) + + def list_with_filters( + self, + card_id: Optional[str] = None, + cardholder_id: Optional[str] = None, + from_created_at: Optional[Union[str, datetime]] = None, + from_token_expires_on: Optional[str] = None, + to_created_at: Optional[Union[str, datetime]] = None, + to_token_expires_on: Optional[str] = None, + token_reference_id: Optional[str] = None, + token_statuses: Optional[str] = None, + token_types: Optional[str] = None, + page_num: int = 0, + page_size: int = 10 + ) -> List[DigitalWalletToken]: + """ + List digital wallet tokens with filtering options. + + Args: + card_id: Filter by card ID + cardholder_id: Filter by cardholder ID + from_created_at: Filter by creation date (start, inclusive) + from_token_expires_on: Filter by expiration date (start, inclusive, format MMyy) + to_created_at: Filter by creation date (end, inclusive) + to_token_expires_on: Filter by expiration date (end, inclusive, format MMyy) + token_reference_id: Filter by token reference ID + token_statuses: Filter by token statuses (comma-separated) + token_types: Filter by token types (comma-separated) + page_num: Page number, starts from 0 + page_size: Number of results per page + + Returns: + List[DigitalWalletToken]: List of matching digital wallet tokens + """ + params = { + "page_num": page_num, + "page_size": page_size + } + + if card_id: + params["card_id"] = card_id + + if cardholder_id: + params["cardholder_id"] = cardholder_id + + if from_created_at: + if isinstance(from_created_at, datetime): + from_created_at = from_created_at.isoformat() + params["from_created_at"] = from_created_at + + if from_token_expires_on: + params["from_token_expires_on"] = from_token_expires_on + + if to_created_at: + if isinstance(to_created_at, datetime): + to_created_at = to_created_at.isoformat() + params["to_created_at"] = to_created_at + + if to_token_expires_on: + params["to_token_expires_on"] = to_token_expires_on + + if token_reference_id: + params["token_reference_id"] = token_reference_id + + if token_statuses: + params["token_statuses"] = token_statuses + + if token_types: + params["token_types"] = token_types + + if not self.client.__class__.__name__.startswith('Async'): + response = self.client._request("GET", self._build_url(), params=params) + data = response.json() + return [self.model_class.from_api_response(item) for item in data.get("items", [])] + else: + raise ValueError("Use list_with_filters_async for async clients") + + async def list_with_filters_async( + self, + card_id: Optional[str] = None, + cardholder_id: Optional[str] = None, + from_created_at: Optional[Union[str, datetime]] = None, + from_token_expires_on: Optional[str] = None, + to_created_at: Optional[Union[str, datetime]] = None, + to_token_expires_on: Optional[str] = None, + token_reference_id: Optional[str] = None, + token_statuses: Optional[str] = None, + token_types: Optional[str] = None, + page_num: int = 0, + page_size: int = 10 + ) -> List[DigitalWalletToken]: + """ + List digital wallet tokens with filtering options asynchronously. + + Args: + card_id: Filter by card ID + cardholder_id: Filter by cardholder ID + from_created_at: Filter by creation date (start, inclusive) + from_token_expires_on: Filter by expiration date (start, inclusive, format MMyy) + to_created_at: Filter by creation date (end, inclusive) + to_token_expires_on: Filter by expiration date (end, inclusive, format MMyy) + token_reference_id: Filter by token reference ID + token_statuses: Filter by token statuses (comma-separated) + token_types: Filter by token types (comma-separated) + page_num: Page number, starts from 0 + page_size: Number of results per page + + Returns: + List[DigitalWalletToken]: List of matching digital wallet tokens + """ + params = { + "page_num": page_num, + "page_size": page_size + } + + if card_id: + params["card_id"] = card_id + + if cardholder_id: + params["cardholder_id"] = cardholder_id + + if from_created_at: + if isinstance(from_created_at, datetime): + from_created_at = from_created_at.isoformat() + params["from_created_at"] = from_created_at + + if from_token_expires_on: + params["from_token_expires_on"] = from_token_expires_on + + if to_created_at: + if isinstance(to_created_at, datetime): + to_created_at = to_created_at.isoformat() + params["to_created_at"] = to_created_at + + if to_token_expires_on: + params["to_token_expires_on"] = to_token_expires_on + + if token_reference_id: + params["token_reference_id"] = token_reference_id + + if token_statuses: + params["token_statuses"] = token_statuses + + if token_types: + params["token_types"] = token_types + + if self.client.__class__.__name__.startswith('Async'): + response = await self.client._request("GET", self._build_url(), params=params) + data = response.json() + return [self.model_class.from_api_response(item) for item in data.get("items", [])] + else: + raise ValueError("Use list_with_filters for sync clients") + + def paginate(self, **params: Any) -> List[DigitalWalletToken]: + """ + Fetch all pages of digital wallet tokens. + + Args: + **params: Filter parameters to pass to the API + + Returns: + List[DigitalWalletToken]: All digital wallet tokens matching the filters + """ + if str(self.client.__class__.__name__).startswith('Async'): + raise ValueError("This method requires a sync client.") + + all_items: List[Dict[str, Any]] = [] + page_num = params.get("page_num", 0) + page_size = params.get("page_size", 10) + + while True: + params["page_num"] = page_num + params["page_size"] = page_size + + response = self.client._request("GET", self._build_url(), params=params) + data = response.json() + + items = data.get("items", []) + has_more = data.get("has_more", False) + + if not items: + break + + all_items.extend(items) + + if not has_more: + break + + page_num += 1 + + return [self.model_class.from_api_response(item) for item in all_items] + + async def paginate_async(self, **params: Any) -> List[DigitalWalletToken]: + """ + Fetch all pages of digital wallet tokens asynchronously. + + Args: + **params: Filter parameters to pass to the API + + Returns: + List[DigitalWalletToken]: All digital wallet tokens matching the filters + """ + if not self.client.__class__.__name__.startswith('Async'): + raise ValueError("This method requires an async client.") + + all_items: List[Dict[str, Any]] = [] + page_num = params.get("page_num", 0) + page_size = params.get("page_size", 10) + + while True: + params["page_num"] = page_num + params["page_size"] = page_size + + response = await self.client._request("GET", self._build_url(), params=params) + data = response.json() + + items = data.get("items", []) + has_more = data.get("has_more", False) + + if not items: + break + + all_items.extend(items) + + if not has_more: + break + + page_num += 1 + + return [self.model_class.from_api_response(item) for item in all_items] diff --git a/airwallex-sdk/airwallex/api/issuing_transaction.py b/airwallex-sdk/airwallex/api/issuing_transaction.py new file mode 100644 index 0000000..3d4f46a --- /dev/null +++ b/airwallex-sdk/airwallex/api/issuing_transaction.py @@ -0,0 +1,192 @@ +""" +Airwallex Issuing Transaction API. +""" +from typing import Dict, Any, List, Optional, Type, TypeVar, Union, cast +from datetime import datetime +from ..models.issuing_transaction import Transaction +from .base import AirwallexAPIBase + +T = TypeVar("T", bound=Transaction) + + +class IssuingTransaction(AirwallexAPIBase[Transaction]): + """ + Operations for Airwallex issuing transactions. + + Transactions represent payments processed against cards. + """ + endpoint = "issuing/transactions" + model_class = cast(Type[Transaction], Transaction) + + def list_with_filters( + self, + billing_currency: Optional[str] = None, + card_id: Optional[str] = None, + digital_wallet_token_id: Optional[str] = None, + from_created_at: Optional[Union[str, datetime]] = None, + lifecycle_id: Optional[str] = None, + page_num: int = 0, + page_size: int = 10, + retrieval_ref: Optional[str] = None, + to_created_at: Optional[Union[str, datetime]] = None, + transaction_type: Optional[str] = None + ) -> List[Transaction]: + """ + List transactions with filtering options. + + Args: + billing_currency: Currency in which transition was billed (3-letter ISO-4217 code) + card_id: Unique Identifier for card + digital_wallet_token_id: Unique Identifier for digital token + from_created_at: Start of Transaction Date in ISO8601 format (inclusive) + lifecycle_id: Unique Identifier for lifecycle + page_num: Page number, starts from 0 + page_size: Number of results per page + retrieval_ref: Retrieval reference number + to_created_at: End of Transaction Date in ISO8601 format (inclusive) + transaction_type: Transaction type (AUTHORIZATION, CLEARING, REFUND, REVERSAL, ORIGINAL_CREDIT) + + Returns: + List[Transaction]: List of matching transactions + """ + params = { + "page_num": page_num, + "page_size": page_size + } + + if billing_currency: + params["billing_currency"] = billing_currency + + if card_id: + params["card_id"] = card_id + + if digital_wallet_token_id: + params["digital_wallet_token_id"] = digital_wallet_token_id + + if from_created_at: + params["from_created_at"] = from_created_at + + if lifecycle_id: + params["lifecycle_id"] = lifecycle_id + + if retrieval_ref: + params["retrieval_ref"] = retrieval_ref + + if to_created_at: + params["to_created_at"] = to_created_at + + if transaction_type: + params["transaction_type"] = transaction_type + + if not str(self.client.__class__.__name__).startswith('Async'): + response = self.client._request("GET", self._build_url(), params=params) + data = response.json() + return [self.model_class.from_api_response(item) for item in data.get("items", [])] + else: + raise ValueError("Use list_with_filters_async for async clients") + + async def list_with_filters_async( + self, + billing_currency: Optional[str] = None, + card_id: Optional[str] = None, + digital_wallet_token_id: Optional[str] = None, + from_created_at: Optional[Union[str, datetime]] = None, + lifecycle_id: Optional[str] = None, + page_num: int = 0, + page_size: int = 10, + retrieval_ref: Optional[str] = None, + to_created_at: Optional[Union[str, datetime]] = None, + transaction_type: Optional[str] = None + ) -> List[Transaction]: + """ + List transactions with filtering options asynchronously. + + Args: + billing_currency: Currency in which transition was billed (3-letter ISO-4217 code) + card_id: Unique Identifier for card + digital_wallet_token_id: Unique Identifier for digital token + from_created_at: Start of Transaction Date in ISO8601 format (inclusive) + lifecycle_id: Unique Identifier for lifecycle + page_num: Page number, starts from 0 + page_size: Number of results per page + retrieval_ref: Retrieval reference number + to_created_at: End of Transaction Date in ISO8601 format (inclusive) + transaction_type: Transaction type (AUTHORIZATION, CLEARING, REFUND, REVERSAL, ORIGINAL_CREDIT) + + Returns: + List[Transaction]: List of matching transactions + """ + params = { + "page_num": page_num, + "page_size": page_size + } + + if billing_currency: + params["billing_currency"] = billing_currency + + if card_id: + params["card_id"] = card_id + + if digital_wallet_token_id: + params["digital_wallet_token_id"] = digital_wallet_token_id + + if from_created_at: + params["from_created_at"] = from_created_at + + if lifecycle_id: + params["lifecycle_id"] = lifecycle_id + + if retrieval_ref: + params["retrieval_ref"] = retrieval_ref + + if to_created_at: + params["to_created_at"] = to_created_at + + if transaction_type: + params["transaction_type"] = transaction_type + + if str(self.client.__class__.__name__).startswith('Async'): + response = await self.client._request("GET", self._build_url(), params=params) + data = response.json() + return [self.model_class.from_api_response(item) for item in data.get("items", [])] + else: + raise ValueError("Use list_with_filters for sync clients") + + def paginate(self, **params: Any) -> List[Transaction]: + """ + Fetch all pages of transactions. + + Args: + **params: Filter parameters to pass to the API + + Returns: + List[Transaction]: All transactions matching the filters + """ + if str(self.client.__class__.__name__).startswith('Async'): + raise ValueError("This method requires a sync client.") + + all_items: List[Dict[str, Any]] = [] + page_num = params.get("page_num", 0) + page_size = params.get("page_size", 10) + + while True: + params["page_num"] = page_num + params["page_size"] = page_size + + response = self.client._request("GET", self._build_url(), params=params) + data = response.json() + + items = data.get("items", []) + has_more = data.get("has_more", False) + + if not items: + break + + all_items.extend(items) + + if not has_more: + break + + page_num += 1 + + return [self.model_class.from_api_response(item) for item in all_items] \ No newline at end of file diff --git a/airwallex-sdk/airwallex/api/issuing_transaction_dispute.py b/airwallex-sdk/airwallex/api/issuing_transaction_dispute.py new file mode 100644 index 0000000..39cd57c --- /dev/null +++ b/airwallex-sdk/airwallex/api/issuing_transaction_dispute.py @@ -0,0 +1,339 @@ +""" +Airwallex Issuing Transaction Dispute API. +""" +from typing import Dict, Any, List, Optional, Type, TypeVar, Union, cast +from datetime import datetime +from ..models.issuing_transaction_dispute import TransactionDispute, TransactionDisputeCreateRequest, TransactionDisputeUpdateRequest +from .base import AirwallexAPIBase + +T = TypeVar("T", bound=TransactionDispute) + + +class IssuingTransactionDispute(AirwallexAPIBase[TransactionDispute]): + """ + Operations for Airwallex issuing transaction disputes. + + Transaction disputes represent disputes against card transactions. + """ + endpoint = "issuing/transaction_disputes" + model_class = cast(Type[TransactionDispute], TransactionDispute) + + def create_dispute(self, dispute: TransactionDisputeCreateRequest) -> TransactionDispute: + """ + Create a new transaction dispute. + + Args: + dispute: TransactionDisputeCreateRequest model with dispute details + + Returns: + TransactionDispute: The created transaction dispute + """ + url = f"{self.base_path}/create" + + if not self.client.__class__.__name__.startswith('Async'): + response = self.client._request("POST", url, json=dispute.to_api_dict()) + return self.model_class.from_api_response(response.json()) + else: + raise ValueError("Use create_dispute_async for async clients") + + async def create_dispute_async(self, dispute: TransactionDisputeCreateRequest) -> TransactionDispute: + """ + Create a new transaction dispute asynchronously. + + Args: + dispute: TransactionDisputeCreateRequest model with dispute details + + Returns: + TransactionDispute: The created transaction dispute + """ + url = f"{self.base_path}/create" + + if self.client.__class__.__name__.startswith('Async'): + response = await self.client._request("POST", url, json=dispute.to_api_dict()) + return self.model_class.from_api_response(response.json()) + else: + raise ValueError("Use create_dispute for sync clients") + + def update_dispute(self, dispute_id: str, update_data: TransactionDisputeUpdateRequest) -> TransactionDispute: + """ + Update a transaction dispute. + + Args: + dispute_id: The ID of the dispute to update + update_data: TransactionDisputeUpdateRequest model with update details + + Returns: + TransactionDispute: The updated transaction dispute + """ + url = f"{self._build_url(dispute_id)}/update" + + if not self.client.__class__.__name__.startswith('Async'): + response = self.client._request("POST", url, json=update_data.to_api_dict()) + return self.model_class.from_api_response(response.json()) + else: + raise ValueError("Use update_dispute_async for async clients") + + async def update_dispute_async(self, dispute_id: str, update_data: TransactionDisputeUpdateRequest) -> TransactionDispute: + """ + Update a transaction dispute asynchronously. + + Args: + dispute_id: The ID of the dispute to update + update_data: TransactionDisputeUpdateRequest model with update details + + Returns: + TransactionDispute: The updated transaction dispute + """ + url = f"{self._build_url(dispute_id)}/update" + + if self.client.__class__.__name__.startswith('Async'): + response = await self.client._request("POST", url, json=update_data.to_api_dict()) + return self.model_class.from_api_response(response.json()) + else: + raise ValueError("Use update_dispute for sync clients") + + def submit_dispute(self, dispute_id: str) -> TransactionDispute: + """ + Submit a transaction dispute. + + Args: + dispute_id: The ID of the dispute to submit + + Returns: + TransactionDispute: The submitted transaction dispute + """ + url = f"{self._build_url(dispute_id)}/submit" + + if not self.client.__class__.__name__.startswith('Async'): + response = self.client._request("POST", url) + return self.model_class.from_api_response(response.json()) + else: + raise ValueError("Use submit_dispute_async for async clients") + + async def submit_dispute_async(self, dispute_id: str) -> TransactionDispute: + """ + Submit a transaction dispute asynchronously. + + Args: + dispute_id: The ID of the dispute to submit + + Returns: + TransactionDispute: The submitted transaction dispute + """ + url = f"{self._build_url(dispute_id)}/submit" + + if self.client.__class__.__name__.startswith('Async'): + response = await self.client._request("POST", url) + return self.model_class.from_api_response(response.json()) + else: + raise ValueError("Use submit_dispute for sync clients") + + def cancel_dispute(self, dispute_id: str) -> TransactionDispute: + """ + Cancel a transaction dispute. + + Args: + dispute_id: The ID of the dispute to cancel + + Returns: + TransactionDispute: The cancelled transaction dispute + """ + url = f"{self._build_url(dispute_id)}/cancel" + + if not self.client.__class__.__name__.startswith('Async'): + response = self.client._request("POST", url) + return self.model_class.from_api_response(response.json()) + else: + raise ValueError("Use cancel_dispute_async for async clients") + + async def cancel_dispute_async(self, dispute_id: str) -> TransactionDispute: + """ + Cancel a transaction dispute asynchronously. + + Args: + dispute_id: The ID of the dispute to cancel + + Returns: + TransactionDispute: The cancelled transaction dispute + """ + url = f"{self._build_url(dispute_id)}/cancel" + + if self.client.__class__.__name__.startswith('Async'): + response = await self.client._request("POST", url) + return self.model_class.from_api_response(response.json()) + else: + raise ValueError("Use cancel_dispute for sync clients") + + def list_with_filters( + self, + detailed_status: Optional[str] = None, + from_created_at: Optional[Union[str, datetime]] = None, + from_updated_at: Optional[Union[str, datetime]] = None, + page: Optional[str] = None, + page_size: int = 10, + reason: Optional[str] = None, + reference: Optional[str] = None, + status: Optional[str] = None, + to_created_at: Optional[Union[str, datetime]] = None, + to_updated_at: Optional[Union[str, datetime]] = None, + transaction_id: Optional[str] = None, + updated_by: Optional[str] = None + ) -> List[TransactionDispute]: + """ + List transaction disputes with filtering options. + + Args: + detailed_status: Filter by detailed status + from_created_at: Filter by creation date (start, inclusive) + from_updated_at: Filter by update date (start, inclusive) + page: Page bookmark for pagination + page_size: Number of results per page + reason: Filter by dispute reason + reference: Filter by reference + status: Filter by status + to_created_at: Filter by creation date (end, exclusive) + to_updated_at: Filter by update date (end, exclusive) + transaction_id: Filter by transaction ID + updated_by: Filter by who last updated the dispute + + Returns: + List[TransactionDispute]: List of matching transaction disputes + """ + params = { + "page_size": page_size + } + + if detailed_status: + params["detailed_status"] = detailed_status + + if from_created_at: + if isinstance(from_created_at, datetime): + from_created_at = from_created_at.isoformat() + params["from_created_at"] = from_created_at + + if from_updated_at: + if isinstance(from_updated_at, datetime): + from_updated_at = from_updated_at.isoformat() + params["from_updated_at"] = from_updated_at + + if page: + params["page"] = page + + if reason: + params["reason"] = reason + + if reference: + params["reference"] = reference + + if status: + params["status"] = status + + if to_created_at: + if isinstance(to_created_at, datetime): + to_created_at = to_created_at.isoformat() + params["to_created_at"] = to_created_at + + if to_updated_at: + if isinstance(to_updated_at, datetime): + to_updated_at = to_updated_at.isoformat() + params["to_updated_at"] = to_updated_at + + if transaction_id: + params["transaction_id"] = transaction_id + + if updated_by: + params["updated_by"] = updated_by + + if not self.client.__class__.__name__.startswith('Async'): + response = self.client._request("GET", self._build_url(), params=params) + data = response.json() + return [self.model_class.from_api_response(item) for item in data.get("items", [])] + else: + raise ValueError("Use list_with_filters_async for async clients") + + async def list_with_filters_async( + self, + detailed_status: Optional[str] = None, + from_created_at: Optional[Union[str, datetime]] = None, + from_updated_at: Optional[Union[str, datetime]] = None, + page: Optional[str] = None, + page_size: int = 10, + reason: Optional[str] = None, + reference: Optional[str] = None, + status: Optional[str] = None, + to_created_at: Optional[Union[str, datetime]] = None, + to_updated_at: Optional[Union[str, datetime]] = None, + transaction_id: Optional[str] = None, + updated_by: Optional[str] = None + ) -> List[TransactionDispute]: + """ + List transaction disputes with filtering options asynchronously. + + Args: + detailed_status: Filter by detailed status + from_created_at: Filter by creation date (start, inclusive) + from_updated_at: Filter by update date (start, inclusive) + page: Page bookmark for pagination + page_size: Number of results per page + reason: Filter by dispute reason + reference: Filter by reference + status: Filter by status + to_created_at: Filter by creation date (end, exclusive) + to_updated_at: Filter by update date (end, exclusive) + transaction_id: Filter by transaction ID + updated_by: Filter by who last updated the dispute + + Returns: + List[TransactionDispute]: List of matching transaction disputes + """ + params = { + "page_size": page_size + } + + if detailed_status: + params["detailed_status"] = detailed_status + + if from_created_at: + if isinstance(from_created_at, datetime): + from_created_at = from_created_at.isoformat() + params["from_created_at"] = from_created_at + + if from_updated_at: + if isinstance(from_updated_at, datetime): + from_updated_at = from_updated_at.isoformat() + params["from_updated_at"] = from_updated_at + + if page: + params["page"] = page + + if reason: + params["reason"] = reason + + if reference: + params["reference"] = reference + + if status: + params["status"] = status + + if to_created_at: + if isinstance(to_created_at, datetime): + to_created_at = to_created_at.isoformat() + params["to_created_at"] = to_created_at + + if to_updated_at: + if isinstance(to_updated_at, datetime): + to_updated_at = to_updated_at.isoformat() + params["to_updated_at"] = to_updated_at + + if transaction_id: + params["transaction_id"] = transaction_id + + if updated_by: + params["updated_by"] = updated_by + + if self.client.__class__.__name__.startswith('Async'): + response = await self.client._request("GET", self._build_url(), params=params) + data = response.json() + return [self.model_class.from_api_response(item) for item in data.get("items", [])] + else: + raise ValueError("Use list_with_filters for sync clients") diff --git a/airwallex-sdk/airwallex/api/payment.py b/airwallex-sdk/airwallex/api/payment.py new file mode 100644 index 0000000..3ad8259 --- /dev/null +++ b/airwallex-sdk/airwallex/api/payment.py @@ -0,0 +1,148 @@ +""" +Airwallex Payment API. +""" +from typing import Dict, Any, List, Optional, Type, TypeVar, Union, cast +from ..models.payment import Payment, PaymentCreateRequest, PaymentUpdateRequest, PaymentQuote +from .base import AirwallexAPIBase + +T = TypeVar("T", bound=Payment) + + +class Payment(AirwallexAPIBase[Payment]): + """ + Operations for Airwallex payments. + + Payments represent money transfers to beneficiaries. + """ + endpoint = "payments" + model_class = cast(Type[Payment], Payment) + + def create_from_model(self, payment: PaymentCreateRequest) -> Payment: + """ + Create a new payment using a Pydantic model. + + Args: + payment: PaymentCreateRequest model with payment creation details. + + Returns: + Payment: The created payment. + """ + return self.create(payment) + + async def create_from_model_async(self, payment: PaymentCreateRequest) -> Payment: + """ + Create a new payment using a Pydantic model asynchronously. + + Args: + payment: PaymentCreateRequest model with payment creation details. + + Returns: + Payment: The created payment. + """ + return await self.create_async(payment) + + def update_from_model(self, payment_id: str, payment: PaymentUpdateRequest) -> Payment: + """ + Update a payment using a Pydantic model. + + Args: + payment_id: The ID of the payment to update. + payment: PaymentUpdateRequest model with payment update details. + + Returns: + Payment: The updated payment. + """ + return self.update(payment_id, payment) + + async def update_from_model_async(self, payment_id: str, payment: PaymentUpdateRequest) -> Payment: + """ + Update a payment using a Pydantic model asynchronously. + + Args: + payment_id: The ID of the payment to update. + payment: PaymentUpdateRequest model with payment update details. + + Returns: + Payment: The updated payment. + """ + return await self.update_async(payment_id, payment) + + def cancel(self, payment_id: str) -> Payment: + """ + Cancel a payment. + + Args: + payment_id: The ID of the payment to cancel. + + Returns: + Payment: The cancelled payment. + """ + update_request = PaymentUpdateRequest(status="cancelled") + return self.update(payment_id, update_request) + + async def cancel_async(self, payment_id: str) -> Payment: + """ + Cancel a payment asynchronously. + + Args: + payment_id: The ID of the payment to cancel. + + Returns: + Payment: The cancelled payment. + """ + update_request = PaymentUpdateRequest(status="cancelled") + return await self.update_async(payment_id, update_request) + + def get_quote(self, source_currency: str, target_currency: str, amount: float, source_type: str = "source") -> PaymentQuote: + """ + Get a quote for a payment. + + Args: + source_currency: Source currency code (ISO 4217) + target_currency: Target currency code (ISO 4217) + amount: Amount to convert + source_type: Whether the amount is in the source or target currency ('source' or 'target') + + Returns: + PaymentQuote: The payment quote. + """ + url = self._build_url(suffix="quote") + payload = { + "source_currency": source_currency, + "target_currency": target_currency, + "amount": amount, + "source_type": source_type + } + + if not self.client.__class__.__name__.startswith('Async'): + response = self.client._request("POST", url, json=payload) + return PaymentQuote.from_api_response(response.json()) + else: + raise ValueError("Use get_quote_async for async clients") + + async def get_quote_async(self, source_currency: str, target_currency: str, amount: float, source_type: str = "source") -> PaymentQuote: + """ + Get a quote for a payment asynchronously. + + Args: + source_currency: Source currency code (ISO 4217) + target_currency: Target currency code (ISO 4217) + amount: Amount to convert + source_type: Whether the amount is in the source or target currency ('source' or 'target') + + Returns: + PaymentQuote: The payment quote. + """ + url = self._build_url(suffix="quote") + payload = { + "source_currency": source_currency, + "target_currency": target_currency, + "amount": amount, + "source_type": source_type + } + + if self.client.__class__.__name__.startswith('Async'): + response = await self.client._request("POST", url, json=payload) + return PaymentQuote.from_api_response(response.json()) + else: + raise ValueError("Use get_quote for sync clients") diff --git a/airwallex-sdk/airwallex/client.py b/airwallex-sdk/airwallex/client.py new file mode 100644 index 0000000..34b904a --- /dev/null +++ b/airwallex-sdk/airwallex/client.py @@ -0,0 +1,396 @@ +""" +Client for interacting with the Airwallex API. +""" +import asyncio +import logging +import time +import httpx +import json +from datetime import datetime, timedelta, timezone, date +from typing import Any, Dict, List, Optional, Union, Type, TypeVar, cast +from importlib import import_module + +from .utils import snake_to_pascal_case +from .exceptions import create_exception_from_response, AuthenticationError + +logger = logging.getLogger(__name__) + +DEFAULT_BASE_URL = 'https://api.airwallex.com/' +DEFAULT_AUTH_URL = 'https://api.airwallex.com/api/v1/authentication/login' + +T = TypeVar("T") + + +class AirwallexClient: + """ + Client for interacting with the Airwallex API. + + This client handles authentication, rate limiting, and provides + access to all API endpoints through dynamic attribute access. + """ + def __init__( + self, + *, + client_id: str, + api_key: str, + base_url: str = DEFAULT_BASE_URL, + auth_url: str = DEFAULT_AUTH_URL, + request_timeout: int = 60, + on_behalf_of: Optional[str] = None + ): + if not client_id or not api_key: + raise ValueError("Client ID and API key are required") + + self.client_id = client_id + self.api_key = api_key + self.base_url = base_url + self.auth_url = auth_url + self.request_timeout = request_timeout + self.on_behalf_of = on_behalf_of + + # Authentication state + self._token: Optional[str] = None + self._token_expiry: Optional[datetime] = None + + # Create persistent httpx client + self._client = httpx.Client( + base_url=self.base_url, + timeout=self.request_timeout, + ) + + # Cache for API instances + self._api_instances: Dict[str, Any] = {} + + @property + def headers(self) -> Dict[str, str]: + """Default headers to use for all requests.""" + headers = { + "Content-Type": "application/json", + "Accept": "application/json", + } + + # Add authentication token if available + if self._token: + headers["Authorization"] = f"Bearer {self._token}" + + # Add on-behalf-of header if specified + if self.on_behalf_of: + headers["x-on-behalf-of"] = self.on_behalf_of + + return headers + + @staticmethod + def _prepare_params(params: Dict[str, Any]) -> Dict[str, str]: + """Convert parameters to string format for URL encoding. + + datetime objects are formatted as ISO8601 strings. + Lists are joined by commas. + All other types are converted to strings. + + Args: + params (Dict[str, Any]): _description_ + + Returns: + Dict[str, str]: _description_ + """ + prepared_params = {} + for key, value in params.items(): + if isinstance(value, (date, datetime)): + prepared_params[key] = value.isoformat() + elif isinstance(value, list): + prepared_params[key] = ",".join(map(str, value)) + else: + prepared_params[key] = str(value) + return prepared_params + + def _prepare_request(self, **kwargs) -> Dict[str, Any]: + """Merge default headers and allow caller overrides.""" + headers = kwargs.pop('headers', {}) + params = kwargs.pop('params', {}) + kwargs['headers'] = {**self.headers, **headers} + kwargs['params'] = self._prepare_params(params) + return kwargs + + def authenticate(self) -> None: + """ + Authenticate with the Airwallex API and get an access token. + + Airwallex auth requires sending the API key and client ID in headers + and returns a token valid for 30 minutes. + """ + # Return early if we already have a valid token + if self._token and self._token_expiry and datetime.now(timezone.utc) < self._token_expiry: + return + + # Use a separate client for authentication to avoid base_url issues + auth_client = httpx.Client(timeout=self.request_timeout) + try: + # Airwallex requires x-client-id and x-api-key in the headers, not in the body + response = auth_client.post( + self.auth_url, + headers={ + "Content-Type": "application/json", + "x-client-id": self.client_id, + "x-api-key": self.api_key + } + ) + + if response.status_code != 201: # Airwallex returns 201 for successful auth + raise AuthenticationError( + status_code=response.status_code, + response=response, + method="POST", + url=self.auth_url, + kwargs={"headers": {"x-client-id": self.client_id, "x-api-key": "**redacted**"}}, + message="Authentication failed" + ) + + auth_data = response.json() + self._token = auth_data.get("token") + + # Set token expiry based on expires_at if provided, or default to 30 minutes + if "expires_at" in auth_data: + # Parse ISO8601 format date + self._token_expiry = datetime.fromisoformat(auth_data["expires_at"].replace("Z", "+00:00")) + else: + # Default to 30 minutes if no expires_at provided + self._token_expiry = datetime.now(timezone.utc) + timedelta(minutes=30) + + logger.debug("Successfully authenticated with Airwallex API") + + finally: + auth_client.close() + + def _request(self, method: str, url: str, **kwargs) -> Optional[httpx.Response]: + """ + Make a synchronous HTTP request with automatic authentication. + + Args: + method: HTTP method (GET, POST, PUT, DELETE, etc.) + url: API endpoint URL (relative to base_url) + **kwargs: Additional arguments to pass to httpx.request() + + Returns: + httpx.Response: The HTTP response + + Raises: + AirwallexAPIError: For API errors + """ + # Ensure we're authenticated before making a request + self.authenticate() + + retries = 5 + kwargs = self._prepare_request(**kwargs) + + while retries > 0: + response = self._client.request(method, url, **kwargs) + + # Handle successful responses + if 200 <= response.status_code < 300: + return response + + # Handle authentication errors + if response.status_code == 401: + # Token might be expired, force refresh and retry + self._token = None + self._token_expiry = None + self.authenticate() + kwargs['headers'].update({"Authorization": f"Bearer {self._token}"}) + retries -= 1 + continue + + # Handle rate limiting + if response.status_code == 429: + retry_after = response.headers.get('Retry-After') + if retry_after and retry_after.isdigit(): + wait_time = int(retry_after) + logger.info(f"Rate limited, sleeping for {wait_time} seconds") + time.sleep(wait_time) + continue + else: + # Default backoff: 1 second + time.sleep(1) + continue + + # Retry on server errors (HTTP 5xx) + if response.status_code >= 500 and retries > 0: + retries -= 1 + logger.warning(f"Server error ({response.status_code}), retrying {retries} more time(s)...") + time.sleep(1) + continue + + # Create and raise the appropriate exception based on the response + raise create_exception_from_response( + response=response, + method=method, + url=url, + kwargs=kwargs + ) + + def __getattr__(self, item: str) -> Any: + """ + Dynamically load an API wrapper from the `api` subpackage. + For example, accessing `client.account` will load the Account API wrapper. + """ + # Check cache first + if item in self._api_instances: + return self._api_instances[item] + + try: + base_package = self.__class__.__module__.split(".")[0] + module = import_module(f"{base_package}.api.{item.lower()}") + # Expect the API class to have the same name but capitalized. + api_class = getattr(module, snake_to_pascal_case(item)) + api_instance = api_class(client=self) + + # Cache the instance + self._api_instances[item] = api_instance + return api_instance + except (ModuleNotFoundError, AttributeError) as e: + raise AttributeError(f"'{self.__class__.__name__}' object has no attribute '{item}'") from e + + def close(self) -> None: + """Close the HTTP client.""" + self._client.close() + + def __enter__(self) -> "AirwallexClient": + return self + + def __exit__(self, exc_type, exc_val, exc_tb) -> None: + self.close() + + +class AirwallexAsyncClient(AirwallexClient): + """ + Asynchronous client for interacting with the Airwallex API. + """ + def __init__(self, **kwargs): + super().__init__(**kwargs) + + # Replace the HTTP client with an async one + self._client = httpx.AsyncClient( + base_url=self.base_url, + timeout=self.request_timeout, + ) + + async def authenticate(self) -> None: + """ + Authenticate with the Airwallex API and get an access token. + + Airwallex auth requires sending the API key and client ID in headers + and returns a token valid for 30 minutes. + """ + # Return early if we already have a valid token + if self._token and self._token_expiry and datetime.now() < self._token_expiry: + return + + # Use a separate client for authentication to avoid base_url issues + async with httpx.AsyncClient(timeout=self.request_timeout) as auth_client: + # Airwallex requires x-client-id and x-api-key in the headers, not in the body + response = await auth_client.post( + self.auth_url, + headers={ + "Content-Type": "application/json", + "x-client-id": self.client_id, + "x-api-key": self.api_key + } + ) + + if response.status_code != 201: # Airwallex returns 201 for successful auth + raise AuthenticationError( + status_code=response.status_code, + response=response, + method="POST", + url=self.auth_url, + kwargs={"headers": {"x-client-id": self.client_id, "x-api-key": "**redacted**"}}, + message="Authentication failed" + ) + + auth_data = response.json() + self._token = auth_data.get("token") + + # Set token expiry based on expires_at if provided, or default to 30 minutes + if "expires_at" in auth_data: + # Parse ISO8601 format date + self._token_expiry = datetime.fromisoformat(auth_data["expires_at"].replace("Z", "+00:00")) + else: + # Default to 30 minutes if no expires_at provided + self._token_expiry = datetime.now() + timedelta(minutes=30) + + logger.debug("Successfully authenticated with Airwallex API") + + async def _request(self, method: str, url: str, **kwargs) -> httpx.Response: + """ + Make an asynchronous HTTP request with automatic authentication. + + Args: + method: HTTP method (GET, POST, PUT, DELETE, etc.) + url: API endpoint URL (relative to base_url) + **kwargs: Additional arguments to pass to httpx.request() + + Returns: + httpx.Response: The HTTP response + + Raises: + AirwallexAPIError: For API errors + """ + # Ensure we're authenticated before making a request + await self.authenticate() + + retries = 5 + kwargs = self._prepare_request(**kwargs) + + while retries > 0: + response = await self._client.request(method, url, **kwargs) + + # Handle successful responses + if 200 <= response.status_code < 300: + return response + + # Handle authentication errors + if response.status_code == 401: + # Token might be expired, force refresh and retry + self._token = None + self._token_expiry = None + await self.authenticate() + kwargs['headers'].update({"Authorization": f"Bearer {self._token}"}) + retries -= 1 + continue + + # Handle rate limiting + if response.status_code == 429: + retry_after = response.headers.get('Retry-After') + if retry_after and retry_after.isdigit(): + wait_time = int(retry_after) + logger.info(f"Rate limited, sleeping for {wait_time} seconds") + await asyncio.sleep(wait_time) + continue + else: + # Default backoff: 1 second + await asyncio.sleep(1) + continue + + # Retry on server errors (HTTP 5xx) + if response.status_code >= 500 and retries > 0: + retries -= 1 + logger.warning(f"Server error ({response.status_code}), retrying {retries} more time(s)...") + await asyncio.sleep(1) + continue + + # Create and raise the appropriate exception based on the response + raise create_exception_from_response( + response=response, + method=method, + url=url, + kwargs=kwargs + ) + + async def close(self) -> None: + """Close the async HTTP client.""" + await self._client.aclose() + + async def __aenter__(self) -> "AirwallexAsyncClient": + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb) -> None: + await self.close() diff --git a/airwallex-sdk/airwallex/exceptions.py b/airwallex-sdk/airwallex/exceptions.py new file mode 100644 index 0000000..17331bc --- /dev/null +++ b/airwallex-sdk/airwallex/exceptions.py @@ -0,0 +1,222 @@ +""" +Exceptions for the Airwallex API client. +""" +from typing import Any, Dict, Optional, Type, ClassVar, Mapping +import httpx + + +class AirwallexAPIError(Exception): + """Base exception for Airwallex API errors.""" + + def __init__( + self, + *, + status_code: int, + response: httpx.Response, + method: str, + url: str, + kwargs: Dict[str, Any], + message: Optional[str] = None + ): + self.status_code = status_code + self.response = response + self.method = method + self.url = url + self.kwargs = kwargs + + # Try to parse error details from the response + try: + error_data = response.json() + self.error_code = error_data.get("code", "unknown") + self.error_message = error_data.get("message", "Unknown error") + self.error_source = error_data.get("source", None) + self.request_id = error_data.get("request_id", None) + except Exception: + self.error_code = "unknown" + self.error_message = message or f"HTTP {status_code} error" + self.error_source = None + self.request_id = None + + super().__init__(self.__str__()) + + def __str__(self) -> str: + source_info = f" (source: {self.error_source})" if self.error_source else "" + return ( + f"Airwallex API Error (HTTP {self.status_code}): [{self.error_code}] {self.error_message}{source_info} " + f"for {self.method} {self.url}" + ) + + +class AuthenticationError(AirwallexAPIError): + """Raised when there's an authentication issue.""" + pass + + +class RateLimitError(AirwallexAPIError): + """Raised when the API rate limit has been exceeded.""" + pass + + +class ResourceNotFoundError(AirwallexAPIError): + """Raised when a requested resource is not found.""" + pass + + +class ValidationError(AirwallexAPIError): + """Raised when the request data is invalid.""" + pass + + +class ServerError(AirwallexAPIError): + """Raised when the server returns a 5xx error.""" + pass + + +class ResourceExistsError(ValidationError): + """Raised when trying to create a resource that already exists.""" + pass + + +class AmountLimitError(ValidationError): + """Raised when a transaction amount exceeds or falls below the allowed limits.""" + pass + + +class EditForbiddenError(ValidationError): + """Raised when trying to edit a resource that can't be modified.""" + pass + + +class CurrencyError(ValidationError): + """Raised for currency-related errors like invalid pairs or unsupported currencies.""" + pass + + +class DateError(ValidationError): + """Raised for date-related validation errors.""" + pass + + +class TransferMethodError(ValidationError): + """Raised when the transfer method is not supported.""" + pass + + +class ConversionError(AirwallexAPIError): + """Raised for conversion-related errors.""" + pass + + +class ServiceUnavailableError(ServerError): + """Raised when a service is temporarily unavailable.""" + pass + + +# Mapping of error codes to exception classes +ERROR_CODE_MAP: Dict[str, Type[AirwallexAPIError]] = { + # Authentication errors + "credentials_expired": AuthenticationError, + "credentials_invalid": AuthenticationError, + + # Rate limiting + "too_many_requests": RateLimitError, + + # Resource exists + "already_exists": ResourceExistsError, + + # Amount limit errors + "amount_above_limit": AmountLimitError, + "amount_below_limit": AmountLimitError, + "amount_above_transfer_method_limit": AmountLimitError, + + # Edit forbidden + "can_not_be_edited": EditForbiddenError, + + # Conversion errors + "conversion_create_failed": ConversionError, + + # Validation errors + "field_required": ValidationError, + "invalid_argument": ValidationError, + "term_agreement_is_required": ValidationError, + + # Currency errors + "invalid_currency_pair": CurrencyError, + "unsupported_currency": CurrencyError, + + # Date errors + "invalid_transfer_date": DateError, + "invalid_conversion_date": DateError, + + # Transfer method errors + "unsupported_country_code": TransferMethodError, + "unsupported_transfer_method": TransferMethodError, + + # Service unavailable + "service_unavailable": ServiceUnavailableError, +} + + +def create_exception_from_response( + *, + response: httpx.Response, + method: str, + url: str, + kwargs: Dict[str, Any], + message: Optional[str] = None +) -> AirwallexAPIError: + """ + Create the appropriate exception based on the API response. + + This function first checks for specific error codes in the response body. + If no specific error code is found or it's not recognized, it falls back + to using the HTTP status code to determine the exception type. + + Args: + response: The HTTP response + method: HTTP method used for the request + url: URL of the request + kwargs: Additional keyword arguments passed to the request + message: Optional custom error message + + Returns: + An instance of the appropriate AirwallexAPIError subclass + """ + status_code = response.status_code + + try: + error_data = response.json() + error_code = error_data.get("code") + + if error_code and error_code in ERROR_CODE_MAP: + exception_class = ERROR_CODE_MAP[error_code] + else: + # Fall back to status code-based exception + exception_class = exception_for_status(status_code) + except Exception: + # If we can't parse the response JSON, fall back to status code + exception_class = exception_for_status(status_code) + + return exception_class( + status_code=status_code, + response=response, + method=method, + url=url, + kwargs=kwargs, + message=message + ) + + +def exception_for_status(status_code: int) -> Type[AirwallexAPIError]: + """Return the appropriate exception class for a given HTTP status code.""" + if status_code == 401: + return AuthenticationError + elif status_code == 429: + return RateLimitError + elif status_code == 404: + return ResourceNotFoundError + elif 400 <= status_code < 500: + return ValidationError + elif 500 <= status_code < 600: + return ServerError + return AirwallexAPIError # Default to the base exception for other status codes diff --git a/airwallex-sdk/airwallex/models/__init__.py b/airwallex-sdk/airwallex/models/__init__.py new file mode 100644 index 0000000..409d629 --- /dev/null +++ b/airwallex-sdk/airwallex/models/__init__.py @@ -0,0 +1,69 @@ +""" +Pydantic models for the Airwallex API. +""" +from .base import AirwallexModel +from .account import Account as AccountModel +from .payment import Payment as PaymentModel +from .beneficiary import Beneficiary as BeneficiaryModel +from .invoice import Invoice as InvoiceModel, InvoiceItem +from .financial_transaction import FinancialTransaction as FinancialTransactionModel +from .fx import FXConversion, FXQuote +from .account_detail import ( + AccountDetailModel, AccountCreateRequest, AccountUpdateRequest, + Amendment, AmendmentCreateRequest, WalletInfo, TermsAndConditionsRequest +) + +# Issuing API Models +from .issuing_common import ( + Address, + Name, + Merchant, + RiskDetails, + DeviceInformation, + TransactionUsage, + DeliveryDetails, + HasMoreResponse +) +from .issuing_authorization import Authorization as IssuingAuthorizationModel +from .issuing_cardholder import Cardholder as IssuingCardholderModel +from .issuing_card import Card as IssuingCardModel, CardDetails +from .issuing_digital_wallet_token import DigitalWalletToken as IssuingDigitalWalletTokenModel +from .issuing_transaction_dispute import TransactionDispute as IssuingTransactionDisputeModel +from .issuing_transaction import Transaction as IssuingTransactionModel +from .issuing_config import IssuingConfig as IssuingConfigModel + +__all__ = [ + "AirwallexModel", + "AccountModel", + "PaymentModel", + "BeneficiaryModel", + "InvoiceModel", + "InvoiceItem", + "FinancialTransactionModel", + "FXConversion", + "FXQuote", + "AccountDetailModel", + "AccountCreateRequest", + "AccountUpdateRequest", + "Amendment", + "AmendmentCreateRequest", + "WalletInfo", + "TermsAndConditionsRequest", + # Issuing API + "Address", + "Name", + "Merchant", + "RiskDetails", + "DeviceInformation", + "TransactionUsage", + "DeliveryDetails", + "HasMoreResponse", + "IssuingAuthorizationModel", + "IssuingCardholderModel", + "IssuingCardModel", + "CardDetails", + "IssuingDigitalWalletTokenModel", + "IssuingTransactionDisputeModel", + "IssuingTransactionModel", + "IssuingConfigModel", +] \ No newline at end of file diff --git a/airwallex-sdk/airwallex/models/account.py b/airwallex-sdk/airwallex/models/account.py new file mode 100644 index 0000000..22facc1 --- /dev/null +++ b/airwallex-sdk/airwallex/models/account.py @@ -0,0 +1,51 @@ +""" +Models for the Airwallex account API. +""" +from typing import Optional, List, Dict, Any +from datetime import datetime +from pydantic import Field +from .base import AirwallexModel + + +class AccountBalanceAmount(AirwallexModel): + """Model for account balance amount.""" + currency: str = Field(..., description="Currency code (ISO 4217)") + value: float = Field(..., description="Balance amount value") + + +class AccountBalance(AirwallexModel): + """Model for account balance.""" + available_amount: AccountBalanceAmount = Field(..., description="Available account balance") + pending_amount: Optional[AccountBalanceAmount] = Field(None, description="Pending account balance") + + +class Account(AirwallexModel): + """Model for an Airwallex account.""" + resource_name: str = "accounts" + + id: str = Field(..., description="Unique account ID") + account_name: str = Field(..., description="Account name") + account_type: str = Field(..., description="Account type") + account_currency: str = Field(..., description="Account currency (ISO 4217)") + status: str = Field(..., description="Account status") + swift_code: Optional[str] = Field(None, description="SWIFT/BIC code") + iban: Optional[str] = Field(None, description="IBAN (International Bank Account Number)") + routing_number: Optional[str] = Field(None, description="Routing number") + account_number: Optional[str] = Field(None, description="Account number") + bsb: Optional[str] = Field(None, description="BSB (Bank State Branch code) for AU accounts") + sort_code: Optional[str] = Field(None, description="Sort code for UK accounts") + created_at: datetime = Field(..., description="Account creation timestamp") + updated_at: Optional[datetime] = Field(None, description="Account last update timestamp") + balance: Optional[AccountBalance] = Field(None, description="Account balance") + + +class AccountCreateRequest(AirwallexModel): + """Model for account creation request.""" + account_name: str = Field(..., description="Name for the account") + account_currency: str = Field(..., description="Currency code (ISO 4217)") + + +class AccountUpdateRequest(AirwallexModel): + """Model for account update request.""" + account_name: Optional[str] = Field(None, description="New name for the account") + status: Optional[str] = Field(None, description="New status for the account") diff --git a/airwallex-sdk/airwallex/models/account_detail.py b/airwallex-sdk/airwallex/models/account_detail.py new file mode 100644 index 0000000..45d3f8d --- /dev/null +++ b/airwallex-sdk/airwallex/models/account_detail.py @@ -0,0 +1,259 @@ +""" +Models for the Airwallex Account Detail API. +""" +from typing import Optional, List, Dict, Any, Union +from datetime import datetime +from pydantic import Field, EmailStr +from .base import AirwallexModel + +class Address(AirwallexModel): + """Model for an address.""" + address_line1: str = Field(..., description="Address line 1") + address_line2: Optional[str] = Field(None, description="Address line 2") + country_code: str = Field(..., description="Country code (ISO 3166-1 alpha-2)") + postcode: str = Field(..., description="Postal code") + state: Optional[str] = Field(None, description="State or province") + suburb: Optional[str] = Field(None, description="Suburb or city") + +class BusinessIdentifier(AirwallexModel): + """Model for a business identifier.""" + country_code: str = Field(..., description="Country code (ISO 3166-1 alpha-2)") + number: str = Field(..., description="Identifier number") + type: str = Field(..., description="Identifier type (e.g., 'BRN')") + +class EstimatedMonthlyRevenue(AirwallexModel): + """Model for estimated monthly revenue.""" + amount: str = Field(..., description="Amount as string") + currency: str = Field(..., description="Currency code (ISO 4217)") + +class AccountUsage(AirwallexModel): + """Model for account usage information.""" + estimated_monthly_revenue: EstimatedMonthlyRevenue = Field(..., description="Estimated monthly revenue") + product_reference: List[str] = Field(..., description="List of product references") + +class BusinessDocument(AirwallexModel): + """Model for a business document.""" + description: Optional[str] = Field(None, description="Document description") + file_id: str = Field(..., description="File ID") + tag: str = Field(..., description="Document tag (e.g., 'BUSINESS_LICENSE')") + +class BusinessDocuments(AirwallexModel): + """Model for business documents.""" + business_documents: List[BusinessDocument] = Field(..., description="List of business documents") + +class BusinessAttachments(AirwallexModel): + """Model for business attachments.""" + business_documents: Optional[List[BusinessDocument]] = Field(None, description="List of business documents") + +class BusinessDetails(AirwallexModel): + """Model for business details.""" + account_usage: AccountUsage = Field(..., description="Account usage information") + as_trustee: Optional[bool] = Field(None, description="Whether the business acts as a trustee") + attachments: Optional[BusinessAttachments] = Field(None, description="Business attachments") + business_address: Address = Field(..., description="Business address") + business_identifiers: List[BusinessIdentifier] = Field(..., description="Business identifiers") + business_name: str = Field(..., description="Business name") + business_name_english: Optional[str] = Field(None, description="Business name in English") + business_name_trading: Optional[str] = Field(None, description="Trading name of the business") + business_start_date: Optional[str] = Field(None, description="Business start date (YYYY-MM-DD)") + business_structure: str = Field(..., description="Business structure (e.g., 'COMPANY')") + contact_number: Optional[str] = Field(None, description="Contact phone number") + description_of_goods_or_services: Optional[str] = Field(None, description="Description of goods or services") + explanation_for_high_risk_countries_exposure: Optional[str] = Field(None, description="Explanation for high risk countries exposure") + has_nominee_shareholders: Optional[bool] = Field(None, description="Whether the business has nominee shareholders") + industry_category_code: str = Field(..., description="Industry category code") + no_shareholders_with_over_25percent: Optional[bool] = Field(None, description="Whether there are no shareholders with over 25% ownership") + operating_country: List[str] = Field(..., description="Operating countries (ISO 3166-1 alpha-2 codes)") + registration_address: Address = Field(..., description="Registration address") + registration_address_english: Optional[Address] = Field(None, description="Registration address in English") + state_of_incorporation: Optional[str] = Field(None, description="State of incorporation") + url: Optional[str] = Field(None, description="Business website URL") + +class DriversLicense(AirwallexModel): + """Model for driver's license identification.""" + back_file_id: Optional[str] = Field(None, description="File ID for back of license") + effective_at: str = Field(..., description="Effective date (YYYY-MM-DD)") + expire_at: str = Field(..., description="Expiry date (YYYY-MM-DD)") + front_file_id: str = Field(..., description="File ID for front of license") + gender: Optional[str] = Field(None, description="Gender (e.g., 'F' for female)") + issuing_state: Optional[str] = Field(None, description="Issuing state") + number: str = Field(..., description="License number") + version: Optional[str] = Field(None, description="License version") + +class Passport(AirwallexModel): + """Model for passport identification.""" + effective_at: str = Field(..., description="Effective date (YYYY-MM-DD)") + expire_at: str = Field(..., description="Expiry date (YYYY-MM-DD)") + front_file_id: str = Field(..., description="File ID for passport") + mrz_line1: Optional[str] = Field(None, description="MRZ line 1") + mrz_line2: Optional[str] = Field(None, description="MRZ line 2") + number: str = Field(..., description="Passport number") + +class PersonalId(AirwallexModel): + """Model for personal ID identification.""" + back_file_id: Optional[str] = Field(None, description="File ID for back of ID") + effective_at: str = Field(..., description="Effective date (YYYY-MM-DD)") + expire_at: str = Field(..., description="Expiry date (YYYY-MM-DD)") + front_file_id: str = Field(..., description="File ID for front of ID") + number: str = Field(..., description="ID number") + +class PrimaryIdentification(AirwallexModel): + """Model for primary identification.""" + drivers_license: Optional[DriversLicense] = Field(None, description="Driver's license details") + identification_type: str = Field(..., description="Identification type (e.g., 'PASSPORT')") + issuing_country_code: str = Field(..., description="Issuing country code (ISO 3166-1 alpha-2)") + passport: Optional[Passport] = Field(None, description="Passport details") + personal_id: Optional[PersonalId] = Field(None, description="Personal ID details") + +class Identifications(AirwallexModel): + """Model for identifications.""" + primary: PrimaryIdentification = Field(..., description="Primary identification") + +class BusinessPersonDocument(AirwallexModel): + """Model for a business person document.""" + description: Optional[str] = Field(None, description="Document description") + file_id: str = Field(..., description="File ID") + tag: str = Field(..., description="Document tag") + +class BusinessPersonAttachments(AirwallexModel): + """Model for business person attachments.""" + business_person_documents: List[BusinessPersonDocument] = Field(..., description="List of business person documents") + +class BusinessPersonDetails(AirwallexModel): + """Model for business person details.""" + attachments: Optional[BusinessPersonAttachments] = Field(None, description="Business person attachments") + date_of_birth: str = Field(..., description="Date of birth (YYYY-MM-DD)") + email: EmailStr = Field(..., description="Email address") + first_name: str = Field(..., description="First name") + first_name_english: Optional[str] = Field(None, description="First name in English") + identifications: Identifications = Field(..., description="Identifications") + last_name: str = Field(..., description="Last name") + last_name_english: Optional[str] = Field(None, description="Last name in English") + middle_name: Optional[str] = Field(None, description="Middle name") + middle_name_english: Optional[str] = Field(None, description="Middle name in English") + mobile: Optional[str] = Field(None, description="Mobile phone number") + residential_address: Optional[Address] = Field(None, description="Residential address") + residential_address_english: Optional[Address] = Field(None, description="Residential address in English") + role: str = Field(..., description="Role (e.g., 'DIRECTOR')") + title: Optional[str] = Field(None, description="Title (e.g., 'Mr')") + +class AdditionalFile(AirwallexModel): + """Model for an additional file.""" + description: Optional[str] = Field(None, description="File description") + file_id: str = Field(..., description="File ID") + tag: str = Field(..., description="File tag") + +class AccountAttachments(AirwallexModel): + """Model for account attachments.""" + additional_files: Optional[List[AdditionalFile]] = Field(None, description="List of additional files") + +class AccountDetails(AirwallexModel): + """Model for account details.""" + attachments: Optional[AccountAttachments] = Field(None, description="Account attachments") + business_details: BusinessDetails = Field(..., description="Business details") + business_person_details: List[BusinessPersonDetails] = Field(..., description="Business person details") + +class CustomerAgreements(AirwallexModel): + """Model for customer agreements.""" + tnc_accepted: bool = Field(..., description="Whether terms and conditions are accepted") + marketing_emails_opt_in: Optional[bool] = Field(None, description="Whether marketing emails are opted in") + +class PrimaryContact(AirwallexModel): + """Model for primary contact information.""" + email: EmailStr = Field(..., description="Email address") + mobile: Optional[str] = Field(None, description="Mobile phone number") + +class NextAction(AirwallexModel): + """Model for next action information.""" + type: str = Field(..., description="Action type") + message: Optional[str] = Field(None, description="Action message") + +class Requirements(AirwallexModel): + """Model for requirements information.""" + current_deadline: Optional[str] = Field(None, description="Current deadline (ISO 8601 format)") + currently_due: List[str] = Field(..., description="List of currently due requirements") + eventually_due: List[str] = Field(..., description="List of eventually due requirements") + past_due: List[str] = Field(..., description="List of past due requirements") + +class AccountDetailModel(AirwallexModel): + """Model for an Airwallex account detail.""" + resource_name: str = "accounts" + + account_details: Optional[AccountDetails] = Field(None, description="Account details") + created_at: str = Field(..., description="Account creation timestamp (ISO 8601 format)") + customer_agreements: Optional[CustomerAgreements] = Field(None, description="Customer agreements") + id: str = Field(..., description="Airwallex account ID") + identifier: Optional[str] = Field(None, description="Platform identifier for the merchant") + metadata: Optional[Dict[str, str]] = Field(None, description="Additional metadata") + next_action: Optional[NextAction] = Field(None, description="Next action information") + nickname: Optional[str] = Field(None, description="Human-friendly account name") + primary_contact: Optional[PrimaryContact] = Field(None, description="Primary contact information") + requirements: Optional[Requirements] = Field(None, description="Requirements information") + status: str = Field(..., description="Account status (CREATED, SUBMITTED, ACTION_REQUIRED, ACTIVE, SUSPENDED)") + view_type: Optional[str] = Field(None, description="View type information") + +# Request Models +class AccountCreateRequest(AirwallexModel): + """Model for account creation request.""" + account_details: Optional[AccountDetails] = Field(None, description="Account details") + customer_agreements: Optional[CustomerAgreements] = Field(None, description="Customer agreements") + identifier: Optional[str] = Field(None, description="Platform identifier for the merchant") + metadata: Optional[Dict[str, str]] = Field(None, description="Additional metadata") + nickname: Optional[str] = Field(None, description="Human-friendly account name") + primary_contact: Optional[PrimaryContact] = Field(None, description="Primary contact information") + +class AccountUpdateRequest(AirwallexModel): + """Model for account update request.""" + account_details: Optional[AccountDetails] = Field(None, description="Account details") + metadata: Optional[Dict[str, str]] = Field(None, description="Additional metadata") + nickname: Optional[str] = Field(None, description="Human-friendly account name") + +# Amendments +class StoreDetails(AirwallexModel): + """Model for store details.""" + cross_border_transaction_percent: Optional[str] = Field(None, description="Cross-border transaction percentage") + dispute_percent: Optional[str] = Field(None, description="Dispute percentage") + employee_size: Optional[int] = Field(None, description="Employee size") + estimated_transaction_volume: Optional[Dict[str, Any]] = Field(None, description="Estimated transaction volume") + financial_statements: Optional[List[Dict[str, str]]] = Field(None, description="Financial statements") + fulfillment_days: Optional[int] = Field(None, description="Fulfillment days") + industry_code: Optional[str] = Field(None, description="Industry code") + mcc: Optional[str] = Field(None, description="Merchant Category Code") + operating_models: Optional[List[str]] = Field(None, description="Operating models") + payment_distribution: Optional[List[Dict[str, Any]]] = Field(None, description="Payment distribution") + +class Amendment(AirwallexModel): + """Model for an account amendment.""" + resource_name: str = "account/amendments" + + id: str = Field(..., description="Amendment ID") + primary_contact: Optional[PrimaryContact] = Field(None, description="Primary contact information") + status: str = Field(..., description="Amendment status (PENDING, APPROVED, REJECTED)") + store_details: Optional[StoreDetails] = Field(None, description="Store details") + target: str = Field(..., description="Amendment target") + +class AmendmentCreateRequest(AirwallexModel): + """Model for amendment creation request.""" + primary_contact: Optional[PrimaryContact] = Field(None, description="Primary contact information") + store_details: Optional[StoreDetails] = Field(None, description="Store details") + target: str = Field(..., description="Amendment target") + +# Wallet Info +class WalletInfo(AirwallexModel): + """Model for wallet information.""" + resource_name: str = "account/wallet_info" + + account_name: str = Field(..., description="Account name") + account_number: str = Field(..., description="Account number") + +# Terms and Conditions Agreement +class DeviceData(AirwallexModel): + """Model for device data.""" + ip_address: Optional[str] = Field(None, description="IP address") + user_agent: Optional[str] = Field(None, description="User agent") + +class TermsAndConditionsRequest(AirwallexModel): + """Model for terms and conditions agreement request.""" + agreed_at: str = Field(..., description="Agreement timestamp (ISO 8601 format)") + device_data: Optional[DeviceData] = Field(None, description="Device data") + service_agreement_type: Optional[str] = Field("FULL", description="Service agreement type (FULL or RECIPIENT)") \ No newline at end of file diff --git a/airwallex-sdk/airwallex/models/base.py b/airwallex-sdk/airwallex/models/base.py new file mode 100644 index 0000000..698d124 --- /dev/null +++ b/airwallex-sdk/airwallex/models/base.py @@ -0,0 +1,121 @@ +""" +Base Pydantic models for the Airwallex API. +""" +from typing import Any, Dict, List, Optional, ClassVar, Type, TypeVar, Generic, get_origin, get_args +from datetime import datetime +import re +from pydantic import BaseModel, Field, ConfigDict, model_validator +from ..utils import snake_to_camel_case, camel_to_snake_case + +T = TypeVar('T', bound='AirwallexModel') + + +class AirwallexModel(BaseModel): + """Base model for all Airwallex API models with camelCase conversion.""" + + model_config = ConfigDict( + populate_by_name=True, + extra='ignore', + arbitrary_types_allowed=True + ) + + # Class variable to store the API resource name + resource_name: ClassVar[str] = "" + + @model_validator(mode='before') + @classmethod + def _convert_keys_to_snake_case(cls, data: Any) -> Any: + """Convert camelCase keys to snake_case.""" + if not isinstance(data, dict): + return data + + result = {} + for key, value in data.items(): + # Convert camelCase keys to snake_case + snake_key = camel_to_snake_case(key) + + # Handle nested dictionaries and lists + if isinstance(value, dict): + result[snake_key] = cls._convert_keys_to_snake_case(value) + elif isinstance(value, list) and all(isinstance(item, dict) for item in value): + result[snake_key] = [cls._convert_keys_to_snake_case(item) for item in value] + else: + result[snake_key] = value + + return result + + def to_api_dict(self) -> Dict[str, Any]: + """Convert the model to a dictionary with camelCase keys for API requests.""" + data = self.model_dump(exclude_unset=True) + result: Dict[str, Any] = {} + + for key, value in data.items(): + # Convert snake_case keys to camelCase + camel_key = snake_to_camel_case(key) + + # Handle nested models, dictionaries, and lists + if isinstance(value, AirwallexModel): + result[camel_key] = value.to_api_dict() + elif isinstance(value, dict): + # Convert dict keys to camelCase + nested_dict = {} + for k, v in value.items(): + if isinstance(v, AirwallexModel): + nested_dict[snake_to_camel_case(k)] = v.to_api_dict() + elif isinstance(v, list) and all(isinstance(item, AirwallexModel) for item in v): + nested_dict[snake_to_camel_case(k)] = [item.to_api_dict() for item in v] + else: + nested_dict[snake_to_camel_case(k)] = v + result[camel_key] = nested_dict + elif isinstance(value, list): + # Handle lists of models + if all(isinstance(item, AirwallexModel) for item in value): + result[camel_key] = [item.to_api_dict() for item in value] + else: + result[camel_key] = value + elif isinstance(value, datetime): + # Convert datetime to ISO format + result[camel_key] = value.isoformat() + else: + result[camel_key] = value + + return result + + @classmethod + def from_api_response(cls: Type[T], data: Dict[str, Any]) -> T: + """Create a model instance from API response data.""" + return cls.model_validate(cls._convert_keys_to_snake_case(data)) + + +# Common types used across the SDK +class PaginationParams(AirwallexModel): + """Common pagination parameters.""" + page: Optional[int] = Field(None, description="Page number (1-indexed)") + page_size: Optional[int] = Field(None, description="Number of items per page") + + +class PaginatedResponse(AirwallexModel, Generic[T]): + """Base model for paginated responses.""" + items: List[T] = Field(..., description="List of items") + page: int = Field(..., description="Current page number") + page_size: int = Field(..., description="Number of items per page") + total_count: int = Field(..., description="Total number of items") + total_pages: int = Field(..., description="Total number of pages") + + @classmethod + def from_api_response(cls, data: Dict[str, Any], item_class: Type[T]) -> 'PaginatedResponse[T]': + """Create a paginated response with the correct item type.""" + # Extract the items and convert them to the specified model + items_data = data.get("items", []) + items = [item_class.from_api_response(item) for item in items_data] + + # Create the paginated response with the converted items + paginated_data = { + "items": items, + "page": data.get("page", 1), + "page_size": data.get("pageSize", len(items)), + "total_count": data.get("totalCount", len(items)), + "total_pages": data.get("totalPages", 1) + } + + return cls.model_validate(paginated_data) diff --git a/airwallex-sdk/airwallex/models/beneficiary.py b/airwallex-sdk/airwallex/models/beneficiary.py new file mode 100644 index 0000000..4037918 --- /dev/null +++ b/airwallex-sdk/airwallex/models/beneficiary.py @@ -0,0 +1,70 @@ +""" +Models for the Airwallex beneficiary API. +""" +from typing import Optional, List, Dict, Any, Union +from datetime import datetime +from pydantic import Field, EmailStr +from .base import AirwallexModel + + +class BankDetails(AirwallexModel): + """Model for bank account details.""" + account_name: str = Field(..., description="Account holder name") + account_number: Optional[str] = Field(None, description="Account number") + swift_code: Optional[str] = Field(None, description="SWIFT/BIC code") + iban: Optional[str] = Field(None, description="IBAN (International Bank Account Number)") + bsb: Optional[str] = Field(None, description="BSB (Bank State Branch) for AU accounts") + sort_code: Optional[str] = Field(None, description="Sort code for UK accounts") + routing_number: Optional[str] = Field(None, description="ACH routing number for US accounts") + bank_name: Optional[str] = Field(None, description="Bank name") + bank_country_code: str = Field(..., description="Bank country code (ISO 3166-1 alpha-2)") + bank_address: Optional[Dict[str, str]] = Field(None, description="Bank address details") + + +class Address(AirwallexModel): + """Model for address details.""" + country_code: str = Field(..., description="Country code (ISO 3166-1 alpha-2)") + state: Optional[str] = Field(None, description="State or province") + city: str = Field(..., description="City") + postcode: Optional[str] = Field(None, description="Postal or ZIP code") + street_address: str = Field(..., description="Street address") + street_address_2: Optional[str] = Field(None, description="Additional street address details") + + +class Beneficiary(AirwallexModel): + """Model for an Airwallex beneficiary.""" + resource_name: str = "beneficiaries" + + id: str = Field(..., description="Unique beneficiary ID") + name: str = Field(..., description="Beneficiary name") + type: str = Field(..., description="Beneficiary type (e.g., 'bank_account', 'email')") + email: Optional[EmailStr] = Field(None, description="Beneficiary email address") + bank_details: Optional[BankDetails] = Field(None, description="Bank account details for bank_account type") + address: Optional[Address] = Field(None, description="Beneficiary address") + company_name: Optional[str] = Field(None, description="Beneficiary company name") + entity_type: Optional[str] = Field(None, description="Beneficiary entity type (individual/company)") + payment_methods: List[str] = Field(default_factory=list, description="Supported payment methods") + status: str = Field(..., description="Beneficiary status") + created_at: datetime = Field(..., description="Beneficiary creation timestamp") + updated_at: Optional[datetime] = Field(None, description="Beneficiary last update timestamp") + + +class BeneficiaryCreateRequest(AirwallexModel): + """Model for beneficiary creation request.""" + name: str = Field(..., description="Beneficiary name") + type: str = Field(..., description="Beneficiary type (e.g., 'bank_account', 'email')") + email: Optional[EmailStr] = Field(None, description="Beneficiary email address") + bank_details: Optional[BankDetails] = Field(None, description="Bank account details for bank_account type") + address: Optional[Address] = Field(None, description="Beneficiary address") + company_name: Optional[str] = Field(None, description="Beneficiary company name") + entity_type: Optional[str] = Field(None, description="Beneficiary entity type (individual/company)") + + +class BeneficiaryUpdateRequest(AirwallexModel): + """Model for beneficiary update request.""" + name: Optional[str] = Field(None, description="Updated beneficiary name") + email: Optional[EmailStr] = Field(None, description="Updated beneficiary email address") + bank_details: Optional[BankDetails] = Field(None, description="Updated bank account details") + address: Optional[Address] = Field(None, description="Updated beneficiary address") + company_name: Optional[str] = Field(None, description="Updated beneficiary company name") + status: Optional[str] = Field(None, description="Updated beneficiary status") diff --git a/airwallex-sdk/airwallex/models/financial_transaction.py b/airwallex-sdk/airwallex/models/financial_transaction.py new file mode 100644 index 0000000..6838113 --- /dev/null +++ b/airwallex-sdk/airwallex/models/financial_transaction.py @@ -0,0 +1,30 @@ +""" +Models for the Airwallex financial transaction API. +""" +from typing import Optional +from datetime import datetime +from pydantic import Field +from .base import AirwallexModel + + +class FinancialTransaction(AirwallexModel): + """Model for an Airwallex financial transaction.""" + resource_name: str = "financial_transactions" + + id: str = Field(..., description="ID of the transaction") + amount: float = Field(..., description="Gross amount of the transaction") + net: float = Field(..., description="Net amount of the transaction") + fee: float = Field(..., description="Fee paid for the transaction") + currency: str = Field(..., description="Currency of the transaction (3-letter ISO-4217 code)") + status: str = Field(..., description="Status of the transaction (PENDING, SETTLED, CANCELLED)") + description: Optional[str] = Field(None, description="Description of the transaction") + batch_id: Optional[str] = Field(None, description="Batch ID of the settlement the transaction belongs to") + client_rate: Optional[float] = Field(None, description="Client rate for the transaction") + currency_pair: Optional[str] = Field(None, description="Currency pair that the client_rate is quoted in") + source_id: Optional[str] = Field(None, description="Source ID of the transaction") + source_type: Optional[str] = Field(None, description="Type of the source transaction") + transaction_type: Optional[str] = Field(None, description="Type of the transaction") + funding_source_id: Optional[str] = Field(None, description="ID of the funding source") + created_at: datetime = Field(..., description="Transaction creation timestamp") + estimated_settled_at: Optional[datetime] = Field(None, description="Estimated settlement timestamp") + settled_at: Optional[datetime] = Field(None, description="Actual settlement timestamp") \ No newline at end of file diff --git a/airwallex-sdk/airwallex/models/fx.py b/airwallex-sdk/airwallex/models/fx.py new file mode 100644 index 0000000..522ee59 --- /dev/null +++ b/airwallex-sdk/airwallex/models/fx.py @@ -0,0 +1,58 @@ +""" +Models for the Airwallex FX API. +""" +from typing import Optional, List, Dict, Any +from datetime import datetime +from pydantic import Field +from .base import AirwallexModel + + +class ExchangeRate(AirwallexModel): + """Model for exchange rate information.""" + source_currency: str = Field(..., description="Source currency code (ISO 4217)") + target_currency: str = Field(..., description="Target currency code (ISO 4217)") + rate: float = Field(..., description="Exchange rate") + timestamp: datetime = Field(..., description="Timestamp when the rate was fetched") + + +class FXQuote(AirwallexModel): + """Model for FX quote.""" + id: str = Field(..., description="Quote ID") + source_currency: str = Field(..., description="Source currency code (ISO 4217)") + target_currency: str = Field(..., description="Target currency code (ISO 4217)") + source_amount: Optional[float] = Field(None, description="Source amount") + target_amount: Optional[float] = Field(None, description="Target amount") + rate: float = Field(..., description="Exchange rate") + fee: Optional[Dict[str, Any]] = Field(None, description="Fee details") + expires_at: datetime = Field(..., description="Quote expiration timestamp") + created_at: datetime = Field(..., description="Quote creation timestamp") + + +class FXConversion(AirwallexModel): + """Model for an FX conversion.""" + resource_name: str = "fx/conversions" + + id: str = Field(..., description="Conversion ID") + request_id: str = Field(..., description="Client-generated request ID") + source_currency: str = Field(..., description="Source currency code (ISO 4217)") + target_currency: str = Field(..., description="Target currency code (ISO 4217)") + source_amount: float = Field(..., description="Source amount") + target_amount: float = Field(..., description="Target amount") + rate: float = Field(..., description="Exchange rate") + status: str = Field(..., description="Conversion status") + created_at: datetime = Field(..., description="Conversion creation timestamp") + updated_at: Optional[datetime] = Field(None, description="Conversion last update timestamp") + account_id: str = Field(..., description="Source account ID") + settlement_date: Optional[datetime] = Field(None, description="Settlement date") + quote_id: Optional[str] = Field(None, description="Quote ID used for this conversion") + + +class FXConversionCreateRequest(AirwallexModel): + """Model for FX conversion creation request.""" + request_id: str = Field(..., description="Client-generated unique ID for the request") + source_currency: str = Field(..., description="Source currency code (ISO 4217)") + target_currency: str = Field(..., description="Target currency code (ISO 4217)") + source_amount: Optional[float] = Field(None, description="Source amount (required if target_amount is not provided)") + target_amount: Optional[float] = Field(None, description="Target amount (required if source_amount is not provided)") + account_id: str = Field(..., description="Source account ID") + quote_id: Optional[str] = Field(None, description="Quote ID to use for this conversion") diff --git a/airwallex-sdk/airwallex/models/invoice.py b/airwallex-sdk/airwallex/models/invoice.py new file mode 100644 index 0000000..21ed599 --- /dev/null +++ b/airwallex-sdk/airwallex/models/invoice.py @@ -0,0 +1,102 @@ +""" +Models for the Airwallex Invoice API. +""" +from typing import Optional, List, Dict, Any, Union +from datetime import datetime +from pydantic import Field, validator +from .base import AirwallexModel + + +class RecurringBilling(AirwallexModel): + """Model for recurring billing information.""" + period: int = Field(..., description="The length of the billing cycle") + period_unit: str = Field(..., description="The unit of the billing cycle, e.g., 'MONTH', 'YEAR'") + + +class PriceTier(AirwallexModel): + """Model for price tier information.""" + amount: float = Field(..., description="The price for this tier") + upper_bound: Optional[float] = Field(None, description="The upper bound of this tier") + + +class Price(AirwallexModel): + """Model for price information.""" + id: str = Field(..., description="Unique price ID") + name: str = Field(..., description="Price name") + description: Optional[str] = Field(None, description="Price description") + active: bool = Field(..., description="Whether this price is active") + currency: str = Field(..., description="Currency code (ISO 4217)") + product_id: str = Field(..., description="ID of the associated product") + pricing_model: str = Field(..., description="Pricing model type (e.g., 'tiered')") + recurring: Optional[RecurringBilling] = Field(None, description="Recurring billing details") + tiers: Optional[List[PriceTier]] = Field(None, description="Pricing tiers for tiered pricing") + metadata: Optional[Dict[str, str]] = Field(None, description="Additional metadata") + request_id: Optional[str] = Field(None, description="Request ID when creating this price") + + +class InvoiceItem(AirwallexModel): + """Model for an Airwallex invoice item.""" + id: str = Field(..., description="Unique invoice item ID") + invoice_id: str = Field(..., description="ID of the invoice this item belongs to") + amount: float = Field(..., description="Amount for this invoice item") + currency: str = Field(..., description="Currency code (ISO 4217)") + period_start_at: datetime = Field(..., description="Billing period start (inclusive)") + period_end_at: datetime = Field(..., description="Billing period end (exclusive)") + price: Price = Field(..., description="Price details") + quantity: Optional[float] = Field(None, description="Product quantity") + + +class Invoice(AirwallexModel): + """Model for an Airwallex invoice.""" + resource_name: str = "invoices" + + id: str = Field(..., description="Unique invoice ID") + customer_id: str = Field(..., description="ID of the customer who will be charged") + subscription_id: Optional[str] = Field(None, description="ID of the subscription which generated this invoice") + currency: str = Field(..., description="Currency code (ISO 4217)") + total_amount: float = Field(..., description="Total amount of the invoice") + status: str = Field(..., description="Invoice status (SENT, PAID, PAYMENT_FAILED)") + payment_intent_id: Optional[str] = Field(None, description="ID of the associated payment intent") + period_start_at: datetime = Field(..., description="Billing period start (inclusive)") + period_end_at: datetime = Field(..., description="Billing period end (exclusive)") + created_at: datetime = Field(..., description="Invoice creation timestamp") + updated_at: Optional[datetime] = Field(None, description="Invoice last update timestamp") + paid_at: Optional[datetime] = Field(None, description="Timestamp when invoice was paid") + last_payment_attempt_at: Optional[datetime] = Field(None, description="Timestamp of last payment attempt") + next_payment_attempt_at: Optional[datetime] = Field(None, description="Timestamp of next scheduled payment attempt") + past_payment_attempt_count: Optional[int] = Field(None, description="Number of payment attempts made so far") + remaining_payment_attempt_count: Optional[int] = Field(None, description="Number of remaining payment attempts") + items: Optional[List[InvoiceItem]] = Field(None, description="Invoice items") + + @validator('status') + def validate_status(cls, v): + """Validate invoice status.""" + valid_statuses = ['SENT', 'PAID', 'PAYMENT_FAILED'] + if v not in valid_statuses: + raise ValueError(f"Status must be one of {valid_statuses}") + return v + + +class InvoicePreviewRequest(AirwallexModel): + """Model for invoice preview request.""" + customer_id: str = Field(..., description="ID of the customer for this invoice") + subscription_id: Optional[str] = Field(None, description="ID of the subscription to preview the invoice for") + trial_end_at: Optional[datetime] = Field(None, description="End of the trial period if applicable") + recurring: Optional[RecurringBilling] = Field(None, description="Recurring billing details") + + class SubscriptionItem(AirwallexModel): + """Model for subscription item in invoice preview.""" + price_id: str = Field(..., description="ID of the price") + quantity: float = Field(1, description="Quantity of the product") + + items: Optional[List[SubscriptionItem]] = Field(None, description="List of subscription items") + + +class InvoicePreviewResponse(AirwallexModel): + """Model for invoice preview response.""" + customer_id: str = Field(..., description="ID of the customer for this invoice") + subscription_id: Optional[str] = Field(None, description="ID of the subscription for this invoice") + currency: str = Field(..., description="Currency code (ISO 4217)") + total_amount: float = Field(..., description="Total amount of the invoice") + created_at: datetime = Field(..., description="Expected invoice creation timestamp") + items: List[InvoiceItem] = Field(..., description="Invoice items in the preview") diff --git a/airwallex-sdk/airwallex/models/issuing_authorization.py b/airwallex-sdk/airwallex/models/issuing_authorization.py new file mode 100644 index 0000000..079f5f2 --- /dev/null +++ b/airwallex-sdk/airwallex/models/issuing_authorization.py @@ -0,0 +1,41 @@ +""" +Models for the Airwallex Issuing Authorization API. +""" +from typing import Optional, List, Dict, Any +from datetime import datetime +from pydantic import Field +from .base import AirwallexModel +from .issuing_common import Merchant, RiskDetails, HasMoreResponse + + +class Authorization(AirwallexModel): + """Model for an Airwallex issuing authorization.""" + resource_name: str = "issuing/authorizations" + + acquiring_institution_identifier: Optional[str] = Field(None, description="Unique Identifier for acquiring institution") + auth_code: Optional[str] = Field(None, description="Authorization Code") + billing_amount: float = Field(..., description="Billing Amount") + billing_currency: str = Field(..., description="Billing Currency") + card_id: str = Field(..., description="Unique Identifier for card") + card_nickname: Optional[str] = Field(None, description="The nickname of the card used") + client_data: Optional[str] = Field(None, description="Client data stored against the card record") + create_time: datetime = Field(..., description="The time this outstanding authorization was created") + digital_wallet_token_id: Optional[str] = Field(None, description="Unique Identifier for digital token") + expiry_date: Optional[datetime] = Field(None, description="The authorization will expire after this date if not posted") + failure_reason: Optional[str] = Field(None, description="The reason why this authorization failed (if status is FAILED)") + lifecycle_id: Optional[str] = Field(None, description="A identifier that links multiple related transactions") + masked_card_number: Optional[str] = Field(None, description="Masked card number") + merchant: Optional[Merchant] = Field(None, description="Merchant details") + network_transaction_id: Optional[str] = Field(None, description="The transaction ID from network") + retrieval_ref: Optional[str] = Field(None, description="Transaction retrieval reference number") + risk_details: Optional[RiskDetails] = Field(None, description="Risk details") + status: str = Field(..., description="The status of this authorization") + transaction_amount: float = Field(..., description="Transaction amount") + transaction_currency: str = Field(..., description="Transaction currency") + transaction_id: str = Field(..., description="Unique id for transaction") + updated_by_transaction: Optional[str] = Field(None, description="Id of the transaction which updated status of this transaction") + + +class AuthorizationListResponse(HasMoreResponse): + """Model for authorization list response.""" + items: List[Authorization] = Field(..., description="List of authorizations") diff --git a/airwallex-sdk/airwallex/models/issuing_card.py b/airwallex-sdk/airwallex/models/issuing_card.py new file mode 100644 index 0000000..6aa89f7 --- /dev/null +++ b/airwallex-sdk/airwallex/models/issuing_card.py @@ -0,0 +1,135 @@ +""" +Models for the Airwallex Issuing Card API. +""" +from typing import Optional, List, Dict, Any +from datetime import datetime +from pydantic import Field +from .base import AirwallexModel +from .issuing_common import Address, TransactionUsage, DeliveryDetails, HasMoreResponse + + +class CardVersionInfo(AirwallexModel): + """Model for card version information.""" + card_number: str = Field(..., description="Masked card number") + card_status: str = Field(..., description="Current card status") + card_version: int = Field(..., description="Card version") + created_at: datetime = Field(..., description="Creation time of the card version") + + +class SpendLimit(AirwallexModel): + """Model for spend limit.""" + amount: float = Field(..., description="Limit amount") + interval: str = Field(..., description="Limit interval (e.g., 'PER_TRANSACTION')") + remaining: Optional[float] = Field(None, description="Remaining limit amount") + + +class CardLimits(AirwallexModel): + """Model for card limits.""" + cash_withdrawal_limits: List[SpendLimit] = Field(..., description="Cash withdrawal limits") + currency: str = Field(..., description="Currency of limits") + limits: List[SpendLimit] = Field(..., description="Spending limits") + + +class PrimaryContactDetails(AirwallexModel): + """Model for primary contact details.""" + email: Optional[str] = Field(None, description="Email address") + name: Optional[str] = Field(None, description="Name") + phone_number: Optional[str] = Field(None, description="Phone number") + + +class CardProgram(AirwallexModel): + """Model for card program.""" + id: str = Field(..., description="Program ID") + name: str = Field(..., description="Program name") + + +class AuthorizationControls(AirwallexModel): + """Model for card authorization controls.""" + active_from: Optional[datetime] = Field(None, description="Start time for card validity") + active_to: Optional[datetime] = Field(None, description="End time for card validity") + allowed_currencies: Optional[List[str]] = Field(None, description="Allowed currencies") + allowed_merchant_categories: Optional[List[str]] = Field(None, description="Allowed merchant category codes") + allowed_transaction_count: Optional[str] = Field(None, description="Allowed transaction count (SINGLE or MULTIPLE)") + blocked_transaction_usages: Optional[List[TransactionUsage]] = Field(None, description="Blocked transaction usages") + country_limitations: Optional[List[str]] = Field(None, description="Country limitations") + spend_limits: Optional[List[SpendLimit]] = Field(None, description="Spend limits") + + +class CardCreateRequest(AirwallexModel): + """Model for card creation request.""" + activate_on_issue: Optional[bool] = Field(None, description="Activate on issue") + additional_cardholder_ids: Optional[List[str]] = Field(None, description="Additional cardholder IDs") + authorization_controls: AuthorizationControls = Field(..., description="Authorization controls") + brand: Optional[str] = Field(None, description="Card brand") + cardholder_id: str = Field(..., description="Cardholder ID") + client_data: Optional[str] = Field(None, description="Client data") + created_by: str = Field(..., description="Full legal name of user requesting new card") + form_factor: str = Field(..., description="Form factor (PHYSICAL or VIRTUAL)") + funding_source_id: Optional[str] = Field(None, description="Funding source ID") + is_personalized: bool = Field(..., description="Whether the card is personalized") + metadata: Optional[Dict[str, str]] = Field(None, description="Metadata") + nick_name: Optional[str] = Field(None, description="Card nickname") + note: Optional[str] = Field(None, description="Note") + postal_address: Optional[Address] = Field(None, description="Postal address") + program: CardProgram = Field(..., description="Card program") + purpose: Optional[str] = Field(None, description="Card purpose") + request_id: str = Field(..., description="Request ID") + + +class CardUpdateRequest(AirwallexModel): + """Model for card update request.""" + additional_cardholder_ids: Optional[List[str]] = Field(None, description="Additional cardholder IDs") + authorization_controls: Optional[AuthorizationControls] = Field(None, description="Authorization controls") + card_status: Optional[str] = Field(None, description="Card status") + cardholder_id: Optional[str] = Field(None, description="Cardholder ID") + metadata: Optional[Dict[str, str]] = Field(None, description="Metadata") + nick_name: Optional[str] = Field(None, description="Card nickname") + purpose: Optional[str] = Field(None, description="Card purpose") + + +class Card(AirwallexModel): + """Model for an Airwallex issuing card.""" + resource_name: str = "issuing/cards" + + activate_on_issue: Optional[bool] = Field(None, description="Activate on issue") + additional_cardholder_ids: Optional[List[str]] = Field(None, description="Additional cardholder IDs") + all_card_versions: Optional[List[CardVersionInfo]] = Field(None, description="All card versions") + authorization_controls: AuthorizationControls = Field(..., description="Authorization controls") + brand: str = Field(..., description="Card brand") + card_id: str = Field(..., description="Card ID") + card_number: str = Field(..., description="Masked card number") + card_status: str = Field(..., description="Card status") + card_version: int = Field(..., description="Card version") + cardholder_id: str = Field(..., description="Cardholder ID") + client_data: Optional[str] = Field(None, description="Client data") + created_at: datetime = Field(..., description="Creation time") + created_by: str = Field(..., description="Created by") + delivery_details: Optional[DeliveryDetails] = Field(None, description="Delivery details") + form_factor: str = Field(..., description="Form factor") + funding_source_id: Optional[str] = Field(None, description="Funding source ID") + is_personalized: bool = Field(..., description="Whether the card is personalized") + issue_to: str = Field(..., description="Who the card is issued to") + metadata: Optional[Dict[str, str]] = Field(None, description="Metadata") + name_on_card: Optional[str] = Field(None, description="Name on card") + nick_name: Optional[str] = Field(None, description="Nickname") + note: Optional[str] = Field(None, description="Note") + postal_address: Optional[Address] = Field(None, description="Postal address") + primary_contact_details: Optional[PrimaryContactDetails] = Field(None, description="Primary contact details") + program: CardProgram = Field(..., description="Card program") + purpose: Optional[str] = Field(None, description="Purpose") + request_id: str = Field(..., description="Request ID") + updated_at: datetime = Field(..., description="Last update time") + + +class CardDetails(AirwallexModel): + """Model for sensitive card details.""" + card_number: str = Field(..., description="Full card number") + cvv: str = Field(..., description="Card verification value") + expiry_month: int = Field(..., description="Expiry month") + expiry_year: int = Field(..., description="Expiry year") + name_on_card: str = Field(..., description="Name on card") + + +class CardListResponse(HasMoreResponse): + """Model for card list response.""" + items: List[Card] = Field(..., description="List of cards") diff --git a/airwallex-sdk/airwallex/models/issuing_cardholder.py b/airwallex-sdk/airwallex/models/issuing_cardholder.py new file mode 100644 index 0000000..595eae9 --- /dev/null +++ b/airwallex-sdk/airwallex/models/issuing_cardholder.py @@ -0,0 +1,52 @@ +""" +Models for the Airwallex Issuing Cardholder API. +""" +from typing import Optional, List, Dict, Any, Literal +from pydantic import Field, EmailStr +from .base import AirwallexModel +from .issuing_common import Address, Name, Employer, HasMoreResponse + + +class Individual(AirwallexModel): + """Model for individual cardholder details.""" + address: Address = Field(..., description="Address of the cardholder") + cardholder_agreement_terms_consent_obtained: Optional[str] = Field(None, description="Whether consent was obtained") + date_of_birth: str = Field(..., description="Date of birth in YYYY-MM-DD format") + employers: Optional[List[Employer]] = Field(None, description="Employers") + express_consent_obtained: Optional[str] = Field(None, description="Whether express consent was obtained") + name: Name = Field(..., description="Name of the cardholder") + + +class CardholderCreateRequest(AirwallexModel): + """Model for cardholder creation request.""" + email: EmailStr = Field(..., description="Email address of the cardholder") + individual: Individual = Field(..., description="Details about the cardholder") + mobile_number: Optional[str] = Field(None, description="Mobile number of the cardholder") + postal_address: Optional[Address] = Field(None, description="Postal address of the cardholder") + type: str = Field(..., description="The type of cardholder (INDIVIDUAL or DELEGATE)") + + +class CardholderUpdateRequest(AirwallexModel): + """Model for cardholder update request.""" + individual: Optional[Individual] = Field(None, description="Details about the cardholder") + mobile_number: Optional[str] = Field(None, description="Mobile number of the cardholder") + postal_address: Optional[Address] = Field(None, description="Postal address of the cardholder") + type: Optional[str] = Field(None, description="The type of cardholder (INDIVIDUAL or DELEGATE)") + + +class Cardholder(AirwallexModel): + """Model for an Airwallex cardholder.""" + resource_name: str = "issuing/cardholders" + + cardholder_id: str = Field(..., description="Unique Identifier for cardholder") + email: EmailStr = Field(..., description="Email address of the cardholder") + individual: Optional[Individual] = Field(None, description="Details about the cardholder") + mobile_number: Optional[str] = Field(None, description="The mobile number of the cardholder") + postal_address: Optional[Address] = Field(None, description="Postal address for the cardholder") + status: str = Field(..., description="The status of the cardholder (PENDING, READY, DISABLED, INCOMPLETE)") + type: str = Field(..., description="The type of cardholder (INDIVIDUAL or DELEGATE)") + + +class CardholderListResponse(HasMoreResponse): + """Model for cardholder list response.""" + items: List[Cardholder] = Field(..., description="List of cardholders") diff --git a/airwallex-sdk/airwallex/models/issuing_common.py b/airwallex-sdk/airwallex/models/issuing_common.py new file mode 100644 index 0000000..dd8a5ac --- /dev/null +++ b/airwallex-sdk/airwallex/models/issuing_common.py @@ -0,0 +1,83 @@ +""" +Common models for the Airwallex Issuing API. +""" +from typing import Optional, List, Dict, Any, Union +from datetime import datetime +from pydantic import Field, EmailStr +from .base import AirwallexModel + + +class Address(AirwallexModel): + """Model for address information.""" + city: str = Field(..., description="City") + country: str = Field(..., description="Country code (ISO 3166-1 alpha-2)") + line1: str = Field(..., description="Street address line 1") + line2: Optional[str] = Field(None, description="Street address line 2") + postcode: Optional[str] = Field(None, description="Postal or ZIP code") + state: Optional[str] = Field(None, description="State or province") + + +class Name(AirwallexModel): + """Model for person name.""" + first_name: str = Field(..., description="First name") + last_name: str = Field(..., description="Last name") + middle_name: Optional[str] = Field(None, description="Middle name") + title: Optional[str] = Field(None, description="Title (Mr, Mrs, etc.)") + + +class BusinessIdentifier(AirwallexModel): + """Model for business identifier.""" + country_code: str = Field(..., description="Country code (ISO 3166-1 alpha-2)") + number: str = Field(..., description="Identifier number") + type: str = Field(..., description="Identifier type (e.g., 'BRN')") + + +class Employer(AirwallexModel): + """Model for employer information.""" + business_name: str = Field(..., description="Business name") + business_identifiers: Optional[List[BusinessIdentifier]] = Field(None, description="Business identifiers") + + +class Merchant(AirwallexModel): + """Model for merchant information.""" + category_code: Optional[str] = Field(None, description="Merchant category code") + city: Optional[str] = Field(None, description="Merchant city") + country: Optional[str] = Field(None, description="Merchant country") + identifier: Optional[str] = Field(None, description="Merchant identifier") + name: Optional[str] = Field(None, description="Merchant name") + postcode: Optional[str] = Field(None, description="Merchant postal code") + state: Optional[str] = Field(None, description="Merchant state") + + +class RiskDetails(AirwallexModel): + """Model for risk details.""" + risk_actions_performed: Optional[List[str]] = Field(None, description="Risk actions performed") + risk_factors: Optional[List[str]] = Field(None, description="Risk factors identified") + three_dsecure_outcome: Optional[str] = Field(None, description="3D Secure outcome") + + +class DeviceInformation(AirwallexModel): + """Model for device information.""" + device_id: Optional[str] = Field(None, description="Device identifier") + device_type: Optional[str] = Field(None, description="Device type") + + +class TransactionUsage(AirwallexModel): + """Model for transaction usage.""" + transaction_scope: str = Field(..., description="Transaction scope (e.g., 'MAGSTRIPE')") + usage_scope: str = Field(..., description="Usage scope (e.g., 'INTERNATIONAL')") + + +class DeliveryDetails(AirwallexModel): + """Model for delivery details.""" + delivery_method: Optional[str] = Field(None, description="Delivery method") + tracking_number: Optional[str] = Field(None, description="Tracking number") + courier: Optional[str] = Field(None, description="Courier") + status: Optional[str] = Field(None, description="Delivery status") + estimated_delivery_date: Optional[datetime] = Field(None, description="Estimated delivery date") + + +class HasMoreResponse(AirwallexModel): + """Base model for paginated responses with has_more field.""" + has_more: bool = Field(..., description="Whether there are more items available") + items: List[Any] = Field(..., description="List of items") diff --git a/airwallex-sdk/airwallex/models/issuing_config.py b/airwallex-sdk/airwallex/models/issuing_config.py new file mode 100644 index 0000000..564b7c0 --- /dev/null +++ b/airwallex-sdk/airwallex/models/issuing_config.py @@ -0,0 +1,62 @@ +""" +Models for the Airwallex Issuing Config API. +""" +from typing import Optional, List, Dict, Any +from datetime import datetime +from pydantic import Field +from .base import AirwallexModel +from .issuing_common import TransactionUsage + + +class RemoteAuthSettings(AirwallexModel): + """Model for remote auth settings.""" + created_at: Optional[datetime] = Field(None, description="Creation timestamp") + default_action: Optional[str] = Field(None, description="Default action when remote auth fails") + enabled: Optional[bool] = Field(None, description="Whether remote auth is enabled") + shared_secret: Optional[str] = Field(None, description="Shared secret key") + updated_at: Optional[datetime] = Field(None, description="Last update timestamp") + url: Optional[str] = Field(None, description="Remote auth endpoint URL") + + +class RemoteCallMethod(AirwallexModel): + """Model for remote call method.""" + name: str = Field(..., description="Method name") + path: str = Field(..., description="Method path") + + +class RemoteCallConfig(AirwallexModel): + """Model for remote call configuration.""" + methods: List[RemoteCallMethod] = Field(..., description="Available methods") + shared_secret: Optional[str] = Field(None, description="Shared secret key") + url: str = Field(..., description="Base URL") + + +class RemoteProvisioningConfig(AirwallexModel): + """Model for remote provisioning configuration.""" + activated: Optional[bool] = Field(None, description="Whether remote provisioning is activated") + shared_secret: Optional[str] = Field(None, description="Shared secret key") + url: Optional[str] = Field(None, description="Remote provisioning endpoint URL") + + +class SpendingLimitSettings(AirwallexModel): + """Model for spending limit settings.""" + default_limits: Optional[Dict[str, Dict[str, float]]] = Field(None, description="Default limits") + maximum_limits: Optional[Dict[str, Dict[str, float]]] = Field(None, description="Maximum limits") + + +class IssuingConfig(AirwallexModel): + """Model for issuing configuration.""" + resource_name: str = "issuing/config" + + blocked_transaction_usages: Optional[List[TransactionUsage]] = Field(None, description="Blocked transaction usages") + remote_auth_settings: Optional[RemoteAuthSettings] = Field(None, description="Remote authorization settings") + remote_call_config: Optional[RemoteCallConfig] = Field(None, description="Remote call configuration") + remote_provisioning_config: Optional[RemoteProvisioningConfig] = Field(None, description="Remote provisioning configuration") + spending_limit_settings: Optional[SpendingLimitSettings] = Field(None, description="Spending limit settings") + + +class IssuingConfigUpdateRequest(AirwallexModel): + """Model for issuing config update request.""" + remote_auth: Optional[RemoteAuthSettings] = Field(None, description="Remote authorization configuration") + remote_call_config: Optional[RemoteCallConfig] = Field(None, description="Remote call configuration") + remote_provisioning_config: Optional[RemoteProvisioningConfig] = Field(None, description="Remote provisioning configuration") diff --git a/airwallex-sdk/airwallex/models/issuing_digital_wallet_token.py b/airwallex-sdk/airwallex/models/issuing_digital_wallet_token.py new file mode 100644 index 0000000..d4423d4 --- /dev/null +++ b/airwallex-sdk/airwallex/models/issuing_digital_wallet_token.py @@ -0,0 +1,38 @@ +""" +Models for the Airwallex Issuing Digital Wallet Token API. +""" +from typing import Optional, List, Dict, Any +from datetime import datetime +from pydantic import Field +from .base import AirwallexModel +from .issuing_common import DeviceInformation, HasMoreResponse + + +class RiskInformation(AirwallexModel): + """Model for token risk information.""" + wallet_provider_account_score: Optional[str] = Field(None, description="Wallet provider account score") + wallet_provider_device_score: Optional[str] = Field(None, description="Wallet provider device score") + + +class DigitalWalletToken(AirwallexModel): + """Model for an Airwallex digital wallet token.""" + resource_name: str = "issuing/digital_wallet_tokens" + + card_id: str = Field(..., description="Unique identifier for card associated with the token") + cardholder_id: str = Field(..., description="Unique identifier for cardholder associated with the token") + create_time: datetime = Field(..., description="The time this token was created") + device_information: Optional[DeviceInformation] = Field(None, description="Device information") + expiry_month: int = Field(..., description="Token expiry month") + expiry_year: int = Field(..., description="Token expiry year") + masked_card_number: str = Field(..., description="Masked card number") + pan_reference_id: str = Field(..., description="Unique identifier for the tokenization of this card") + risk_information: Optional[RiskInformation] = Field(None, description="Risk information") + token_id: str = Field(..., description="Unique Identifier for token") + token_reference_id: str = Field(..., description="Unique identifier of the digital wallet token within the card network") + token_status: str = Field(..., description="Status of the token") + token_type: str = Field(..., description="The type of this token") + + +class DigitalWalletTokenListResponse(HasMoreResponse): + """Model for digital wallet token list response.""" + items: List[DigitalWalletToken] = Field(..., description="List of digital wallet tokens") diff --git a/airwallex-sdk/airwallex/models/issuing_transaction.py b/airwallex-sdk/airwallex/models/issuing_transaction.py new file mode 100644 index 0000000..cf686ea --- /dev/null +++ b/airwallex-sdk/airwallex/models/issuing_transaction.py @@ -0,0 +1,42 @@ +""" +Models for the Airwallex Issuing Transaction API. +""" +from typing import Optional, List, Dict, Any +from datetime import datetime +from pydantic import Field +from .base import AirwallexModel +from .issuing_common import Merchant, RiskDetails, HasMoreResponse + + +class Transaction(AirwallexModel): + """Model for an Airwallex issuing transaction.""" + resource_name: str = "issuing/transactions" + + acquiring_institution_identifier: Optional[str] = Field(None, description="Unique Identifier for acquiring institution") + auth_code: Optional[str] = Field(None, description="Authorization Code") + billing_amount: float = Field(..., description="Billing amount") + billing_currency: str = Field(..., description="Billing Currency") + card_id: str = Field(..., description="Unique Identifier for card") + card_nickname: Optional[str] = Field(None, description="The nickname of the card used") + client_data: Optional[str] = Field(None, description="Client data stored against the card record") + digital_wallet_token_id: Optional[str] = Field(None, description="Unique Identifier for digital token") + failure_reason: Optional[str] = Field(None, description="The reason why this transaction failed") + lifecycle_id: Optional[str] = Field(None, description="Lifecycle ID") + masked_card_number: str = Field(..., description="Masked card number") + matched_authorizations: Optional[List[str]] = Field(None, description="Matched authorization IDs") + merchant: Optional[Merchant] = Field(None, description="Merchant details") + network_transaction_id: Optional[str] = Field(None, description="Network transaction ID") + posted_date: Optional[datetime] = Field(None, description="Posted date") + retrieval_ref: Optional[str] = Field(None, description="Retrieval reference number") + risk_details: Optional[RiskDetails] = Field(None, description="Risk details") + status: str = Field(..., description="Transaction status") + transaction_amount: float = Field(..., description="Transaction amount") + transaction_currency: str = Field(..., description="Transaction currency") + transaction_date: datetime = Field(..., description="Transaction date") + transaction_id: str = Field(..., description="Transaction ID") + transaction_type: str = Field(..., description="Transaction type") + + +class TransactionListResponse(HasMoreResponse): + """Model for transaction list response.""" + items: List[Transaction] = Field(..., description="List of transactions") diff --git a/airwallex-sdk/airwallex/models/issuing_transaction_dispute.py b/airwallex-sdk/airwallex/models/issuing_transaction_dispute.py new file mode 100644 index 0000000..716790b --- /dev/null +++ b/airwallex-sdk/airwallex/models/issuing_transaction_dispute.py @@ -0,0 +1,59 @@ +""" +Models for the Airwallex Issuing Transaction Dispute API. +""" +from typing import Optional, List, Dict, Any +from datetime import datetime +from pydantic import Field +from .base import AirwallexModel + + +class DisputeUpdateHistory(AirwallexModel): + """Model for dispute update history.""" + evidence_files: Optional[List[str]] = Field(None, description="Evidence files") + note: Optional[str] = Field(None, description="Note") + updated_at: datetime = Field(..., description="Update timestamp") + updated_by: str = Field(..., description="Entity that performed the update") + + +class TransactionDisputeCreateRequest(AirwallexModel): + """Model for transaction dispute creation request.""" + amount: Optional[float] = Field(None, description="The amount to be disputed") + evidence_files: Optional[List[str]] = Field(None, description="Evidence file IDs") + notes: Optional[str] = Field(None, description="Explanation for the dispute") + reason: str = Field(..., description="The reason for raising the dispute") + reference: Optional[str] = Field(None, description="Internal reference") + transaction_id: str = Field(..., description="The transaction ID to dispute") + + +class TransactionDisputeUpdateRequest(AirwallexModel): + """Model for transaction dispute update request.""" + amount: Optional[float] = Field(None, description="The disputed amount") + evidence_files: Optional[List[str]] = Field(None, description="Evidence file IDs") + notes: Optional[str] = Field(None, description="Explanation for the dispute") + reason: Optional[str] = Field(None, description="The reason for raising the dispute") + request_id: str = Field(..., description="A unique request ID") + + +class TransactionDispute(AirwallexModel): + """Model for an Airwallex transaction dispute.""" + resource_name: str = "issuing/transaction_disputes" + + amount: float = Field(..., description="Dispute amount") + created_at: datetime = Field(..., description="Creation timestamp") + detailed_status: Optional[str] = Field(None, description="Detailed status") + id: str = Field(..., description="Unique identifier") + notes: Optional[str] = Field(None, description="Notes") + reason: str = Field(..., description="Dispute reason") + reference: Optional[str] = Field(None, description="Internal reference") + status: str = Field(..., description="Status") + transaction_id: str = Field(..., description="Transaction ID") + update_history: List[DisputeUpdateHistory] = Field(..., description="Update history") + updated_at: datetime = Field(..., description="Last update timestamp") + updated_by: str = Field(..., description="Last updated by") + + +class TransactionDisputeListResponse(AirwallexModel): + """Model for transaction dispute list response.""" + items: List[TransactionDispute] = Field(..., description="List of transaction disputes") + page_after: Optional[str] = Field(None, description="Page bookmark for next page") + page_before: Optional[str] = Field(None, description="Page bookmark for previous page") diff --git a/airwallex-sdk/airwallex/models/payment.py b/airwallex-sdk/airwallex/models/payment.py new file mode 100644 index 0000000..ad6392c --- /dev/null +++ b/airwallex-sdk/airwallex/models/payment.py @@ -0,0 +1,81 @@ +""" +Models for the Airwallex payment API. +""" +from typing import Optional, List, Dict, Any, Union +from datetime import datetime +from pydantic import Field, EmailStr +from .base import AirwallexModel + + +class PaymentAmount(AirwallexModel): + """Model for payment amount.""" + value: float = Field(..., description="Payment amount value") + currency: str = Field(..., description="Currency code (ISO 4217)") + + +class PaymentSourceDetails(AirwallexModel): + """Model for payment source details.""" + type: str = Field(..., description="Source type (e.g., 'account')") + account_id: Optional[str] = Field(None, description="Account ID for account sources") + card_id: Optional[str] = Field(None, description="Card ID for card sources") + + +class PaymentBeneficiary(AirwallexModel): + """Model for payment beneficiary.""" + type: str = Field(..., description="Beneficiary type (e.g., 'bank_account', 'email')") + id: Optional[str] = Field(None, description="Beneficiary ID for saved beneficiaries") + name: Optional[str] = Field(None, description="Beneficiary name") + email: Optional[EmailStr] = Field(None, description="Beneficiary email") + country_code: Optional[str] = Field(None, description="Beneficiary country code (ISO 3166-1 alpha-2)") + bank_details: Optional[Dict[str, Any]] = Field(None, description="Bank details for bank transfers") + + +class Payment(AirwallexModel): + """Model for an Airwallex payment.""" + resource_name: str = "payments" + + id: str = Field(..., description="Unique payment ID") + request_id: Optional[str] = Field(None, description="Client-generated request ID") + amount: PaymentAmount = Field(..., description="Payment amount") + source: PaymentSourceDetails = Field(..., description="Payment source details") + beneficiary: PaymentBeneficiary = Field(..., description="Payment beneficiary details") + payment_method: str = Field(..., description="Payment method type") + status: str = Field(..., description="Payment status") + payment_date: Optional[datetime] = Field(None, description="Payment date") + reference: Optional[str] = Field(None, description="Payment reference") + description: Optional[str] = Field(None, description="Payment description") + metadata: Optional[Dict[str, str]] = Field(None, description="Additional metadata") + created_at: datetime = Field(..., description="Payment creation timestamp") + updated_at: Optional[datetime] = Field(None, description="Payment last update timestamp") + + +class PaymentCreateRequest(AirwallexModel): + """Model for payment creation request.""" + request_id: str = Field(..., description="Client-generated unique ID for the request") + amount: PaymentAmount = Field(..., description="Payment amount") + source: PaymentSourceDetails = Field(..., description="Payment source details") + beneficiary: PaymentBeneficiary = Field(..., description="Payment beneficiary details") + payment_method: str = Field(..., description="Payment method type") + payment_date: Optional[datetime] = Field(None, description="Requested payment date") + reference: Optional[str] = Field(None, description="Payment reference visible to the beneficiary") + description: Optional[str] = Field(None, description="Internal payment description") + metadata: Optional[Dict[str, str]] = Field(None, description="Additional metadata") + + +class PaymentUpdateRequest(AirwallexModel): + """Model for payment update request.""" + status: Optional[str] = Field(None, description="New payment status (for cancellation)") + payment_date: Optional[datetime] = Field(None, description="Updated payment date") + reference: Optional[str] = Field(None, description="Updated payment reference") + description: Optional[str] = Field(None, description="Updated payment description") + metadata: Optional[Dict[str, str]] = Field(None, description="Updated metadata") + + +class PaymentQuote(AirwallexModel): + """Model for payment quote details.""" + id: str = Field(..., description="Quote ID") + source_amount: PaymentAmount = Field(..., description="Source amount") + target_amount: PaymentAmount = Field(..., description="Target amount") + fx_rate: float = Field(..., description="FX rate applied") + fee: Optional[PaymentAmount] = Field(None, description="Fee amount") + expires_at: datetime = Field(..., description="Quote expiration timestamp") diff --git a/airwallex-sdk/airwallex/utils.py b/airwallex-sdk/airwallex/utils.py new file mode 100644 index 0000000..bdfaf37 --- /dev/null +++ b/airwallex-sdk/airwallex/utils.py @@ -0,0 +1,107 @@ +""" +Utility functions for the Airwallex SDK. +""" +import re +from datetime import datetime +from typing import Any, Dict, List, Union, TypeVar + +T = TypeVar('T') + + +def snake_to_pascal_case(snake_str: str) -> str: + """Convert snake_case to PascalCase.""" + return ''.join(word.title() for word in snake_str.split('_')) + + +def pascal_to_snake_case(pascal_str: str) -> str: + """Convert PascalCase to snake_case.""" + return re.sub(r'(? str: + """Convert camelCase to snake_case.""" + pattern = re.compile(r'(? str: + """Convert snake_case to camelCase.""" + components = snake_str.split('_') + return components[0] + ''.join(x.title() for x in components[1:]) + + +def serialize(data: Union[Dict[str, Any], List[Dict[str, Any]]]) -> Union[Dict[str, Any], List[Dict[str, Any]]]: + """ + Serialize data for the Airwallex API. + Converts keys from snake_case to camelCase as required by the API. + Handles datetime objects, converting them to ISO format strings. + """ + if isinstance(data, list): + return [serialize(item) for item in data] + + if not isinstance(data, dict): + if isinstance(data, datetime): + return data.isoformat() + return data + + result: Dict[str, Any] = {} + for key, value in data.items(): + # Convert snake_case keys to camelCase + camel_key = snake_to_camel_case(key) + + # Handle nested dictionaries and lists + if isinstance(value, dict): + result[camel_key] = serialize(value) + elif isinstance(value, list): + result[camel_key] = [serialize(item) for item in value] + elif isinstance(value, datetime): + # Convert datetime objects to ISO format strings + result[camel_key] = value.isoformat() + else: + result[camel_key] = value + + return result + + +def deserialize(data: Union[Dict[str, Any], List[Dict[str, Any]]]) -> Union[Dict[str, Any], List[Dict[str, Any]]]: + """ + Deserialize data from the Airwallex API. + Converts keys from camelCase to snake_case for Python convention. + Attempts to parse ISO format date strings to datetime objects. + """ + if isinstance(data, list): + return [deserialize(item) for item in data] + + if not isinstance(data, dict): + # Try to parse ISO date strings + if isinstance(data, str): + try: + if 'T' in data and ('+' in data or 'Z' in data): + return datetime.fromisoformat(data.replace('Z', '+00:00')) + except ValueError: + pass + return data + + result: Dict[str, Any] = {} + for key, value in data.items(): + # Convert camelCase keys to snake_case + snake_key = camel_to_snake_case(key) + + # Handle nested dictionaries and lists + if isinstance(value, dict): + result[snake_key] = deserialize(value) + elif isinstance(value, list): + result[snake_key] = [deserialize(item) for item in value] + elif isinstance(value, str): + # Try to parse ISO date strings + try: + if 'T' in value and ('+' in value or 'Z' in value): + result[snake_key] = datetime.fromisoformat(value.replace('Z', '+00:00')) + else: + result[snake_key] = value + except ValueError: + result[snake_key] = value + else: + result[snake_key] = value + + return result diff --git a/airwallex-sdk/examples/example_financial_transactions.py b/airwallex-sdk/examples/example_financial_transactions.py new file mode 100644 index 0000000..2589117 --- /dev/null +++ b/airwallex-sdk/examples/example_financial_transactions.py @@ -0,0 +1,27 @@ +from airwallex import AirwallexClient, AirwallexAsyncClient + +# Synchronous example +client = AirwallexClient(client_id="your_client_id", api_key="your_api_key") + +# Fetch a specific financial transaction +transaction = client.financial_transaction.fetch("transaction_id") +print(f"Transaction: {transaction.id} - {transaction.amount} {transaction.currency}") + +# List financial transactions with filters +from datetime import datetime, timedelta +thirty_days_ago = datetime.now() - timedelta(days=30) + +transactions = client.financial_transaction.list_with_filters( + from_created_at=thirty_days_ago, + status="SETTLED" +) + +for tx in transactions: + print(f"Transaction: {tx.id} - {tx.amount} {tx.currency}") + +async def main_async(): +# Asynchronous example + async with AirwallexAsyncClient(client_id="your_client_id", api_key="your_api_key") as client: + # List transactions using async pagination + async for tx in client.financial_transaction.paginate_async_generator(): + print(f"Transaction: {tx.id} - {tx.amount} {tx.currency}") \ No newline at end of file diff --git a/airwallex-sdk/examples/issuing_examples.py b/airwallex-sdk/examples/issuing_examples.py new file mode 100644 index 0000000..4919de4 --- /dev/null +++ b/airwallex-sdk/examples/issuing_examples.py @@ -0,0 +1,219 @@ +""" +Airwallex SDK Issuing API Usage Examples +""" +import asyncio +import os +from datetime import datetime, timedelta + +from airwallex import ( + AirwallexClient, + AirwallexAsyncClient, + IssuingCardholderModel, + IssuingCardModel +) +from airwallex.models.issuing_cardholder import CardholderCreateRequest, Individual, Name, Address +from airwallex.models.issuing_card import CardCreateRequest, AuthorizationControls, CardProgram +from airwallex.models.issuing_transaction_dispute import TransactionDisputeCreateRequest + + +def get_client_credentials(): + """Get client credentials from environment variables.""" + client_id = os.environ.get("AIRWALLEX_CLIENT_ID") + api_key = os.environ.get("AIRWALLEX_API_KEY") + + if not client_id or not api_key: + raise ValueError( + "Please set AIRWALLEX_CLIENT_ID and AIRWALLEX_API_KEY environment variables" + ) + + return client_id, api_key + + +def sync_examples(): + """Examples using the synchronous client.""" + client_id, api_key = get_client_credentials() + + # Initialize the client + client = AirwallexClient( + client_id=client_id, + api_key=api_key + ) + + try: + # Get issuing configuration + print("==== Getting Issuing Configuration ====") + config = client.issuing_config.get_config() + print(f"Remote auth enabled: {config.remote_auth_settings.enabled if config.remote_auth_settings else 'Not configured'}") + + # Create a cardholder + print("\n==== Creating a Cardholder ====") + cardholder_request = CardholderCreateRequest( + email="john.doe@example.com", + individual=Individual( + name=Name( + first_name="John", + last_name="Doe", + title="Mr" + ), + date_of_birth="1982-11-02", + address=Address( + city="Melbourne", + country="AU", + line1="44 Example St", + postcode="3121", + state="VIC" + ), + cardholder_agreement_terms_consent_obtained="yes", + express_consent_obtained="yes" + ), + type="INDIVIDUAL" + ) + + try: + cardholder = client.issuing_cardholder.create_cardholder(cardholder_request) + print(f"Cardholder created with ID: {cardholder.cardholder_id}") + + # List cardholders + print("\n==== Listing Cardholders ====") + cardholders = client.issuing_cardholder.list_with_filters(page_size=5) + for ch in cardholders: + print(f"Cardholder: {ch.cardholder_id} - {ch.email} - Status: {ch.status}") + + # Create a card for the cardholder + print("\n==== Creating a Card ====") + card_request = CardCreateRequest( + cardholder_id=cardholder.cardholder_id, + request_id="test-request-" + datetime.now().strftime("%Y%m%d%H%M%S"), + created_by="API Test User", + form_factor="VIRTUAL", + is_personalized=True, + authorization_controls=AuthorizationControls( + allowed_currencies=["USD", "AUD"], + allowed_transaction_count="MULTIPLE" + ), + program=CardProgram( + id="default_program_id", # This would need to be replaced with a real program ID + name="Default Program" + ) + ) + + try: + card = client.issuing_card.create_card(card_request) + print(f"Card created with ID: {card.card_id}") + + # Get card details + print("\n==== Getting Card Details ====") + try: + card_details = client.issuing_card.get_card_details(card.card_id) + print(f"Card Number: {card_details.card_number}") + print(f"CVV: {card_details.cvv}") + print(f"Expiry: {card_details.expiry_month}/{card_details.expiry_year}") + except Exception as e: + print(f"Could not retrieve card details: {str(e)}") + + # List cards + print("\n==== Listing Cards ====") + cards = client.issuing_card.list_with_filters(page_size=5) + for c in cards: + print(f"Card: {c.card_id} - Status: {c.card_status}") + + # List transactions + print("\n==== Listing Transactions ====") + transactions = client.issuing_transaction.list_with_filters( + card_id=card.card_id, + page_size=5 + ) + if transactions: + for tx in transactions: + print(f"Transaction: {tx.transaction_id} - {tx.transaction_amount} {tx.transaction_currency}") + else: + print("No transactions found for this card.") + + # Create a transaction dispute (example) + print("\n==== Creating a Transaction Dispute (Example) ====") + if transactions: + dispute_request = TransactionDisputeCreateRequest( + transaction_id=transactions[0].transaction_id, + reason="SUSPECTED_FRAUD", + notes="This is a test dispute" + ) + + try: + dispute = client.issuing_transaction_dispute.create_dispute(dispute_request) + print(f"Dispute created with ID: {dispute.id}") + except Exception as e: + print(f"Could not create dispute: {str(e)}") + else: + print("No transactions available to dispute.") + + except Exception as e: + print(f"Card creation failed: {str(e)}") + + except Exception as e: + print(f"Cardholder creation failed: {str(e)}") + + finally: + # Close the client + client.close() + + +async def async_examples(): + """Examples using the asynchronous client.""" + client_id, api_key = get_client_credentials() + + # Initialize the async client + client = AirwallexAsyncClient( + client_id=client_id, + api_key=api_key + ) + + try: + # Get issuing configuration + print("==== Async: Getting Issuing Configuration ====") + config = await client.issuing_config.get_config_async() + print(f"Remote auth enabled: {config.remote_auth_settings.enabled if config.remote_auth_settings else 'Not configured'}") + + # List cardholders + print("\n==== Async: Listing Cardholders ====") + cardholders = await client.issuing_cardholder.list_with_filters_async(page_size=5) + for ch in cardholders: + print(f"Cardholder: {ch.cardholder_id} - {ch.email} - Status: {ch.status}") + + # List cards + print("\n==== Async: Listing Cards ====") + cards = await client.issuing_card.list_with_filters_async(page_size=5) + for c in cards: + print(f"Card: {c.card_id} - Status: {c.card_status}") + + # List transactions + print("\n==== Async: Listing Transactions ====") + transactions = await client.issuing_transaction.list_with_filters_async(page_size=5) + for tx in transactions: + print(f"Transaction: {tx.transaction_id} - {tx.transaction_amount} {tx.transaction_currency}") + + # Use pagination generator + print("\n==== Async: Using Pagination Generator for Cards ====") + count = 0 + async for card in client.issuing_card.paginate_async_generator(page_size=2): + print(f"Card {count}: {card.card_id}") + count += 1 + if count >= 5: + break + + finally: + # Close the client + await client.close() + + +if __name__ == "__main__": + print("Running synchronous examples...") + try: + sync_examples() + except Exception as e: + print(f"Synchronous examples failed: {str(e)}") + + print("\n\nRunning asynchronous examples...") + try: + asyncio.run(async_examples()) + except Exception as e: + print(f"Asynchronous examples failed: {str(e)}") diff --git a/airwallex-sdk/examples/usage_examples.py b/airwallex-sdk/examples/usage_examples.py new file mode 100644 index 0000000..b99d10a --- /dev/null +++ b/airwallex-sdk/examples/usage_examples.py @@ -0,0 +1,146 @@ +""" +Airwallex SDK Usage Examples +""" +import asyncio +import os +from datetime import datetime, timedelta + +from airwallex import ( + AirwallexClient, + AirwallexAsyncClient, + InvoiceModel +) +from airwallex.models.invoice import InvoicePreviewRequest + + +def get_client_credentials(): + """Get client credentials from environment variables.""" + client_id = os.environ.get("AIRWALLEX_CLIENT_ID") + api_key = os.environ.get("AIRWALLEX_API_KEY") + + if not client_id or not api_key: + raise ValueError( + "Please set AIRWALLEX_CLIENT_ID and AIRWALLEX_API_KEY environment variables" + ) + + return client_id, api_key + + +def sync_examples(): + """Examples using the synchronous client.""" + client_id, api_key = get_client_credentials() + + # Initialize the client + client = AirwallexClient( + client_id=client_id, + api_key=api_key + ) + + try: + # List accounts + print("==== Listing Accounts ====") + accounts = client.account.list() + for account in accounts: + print(f"Account: {account.id} - {account.account_currency}") + + # Get account details + if accounts: + print(f"\n==== Account Details for {accounts[0].id} ====") + account = client.account.fetch(accounts[0].id) + print(account.show()) + + # List invoices + print("\n==== Listing Invoices ====") + invoices = client.invoice.list() + for invoice in invoices: + print(f"Invoice: {invoice.id} - {invoice.total_amount} {invoice.currency}") + + # List invoice with filtering + print("\n==== Filtered Invoices (last 30 days) ====") + thirty_days_ago = datetime.now() - timedelta(days=30) + filtered_invoices = client.invoice.list_with_filters( + from_created_at=thirty_days_ago, + status="PAID" + ) + for invoice in filtered_invoices: + print(f"Invoice: {invoice.id} - Status: {invoice.status}") + + # If we have an invoice, get its items + if invoices: + invoice_id = invoices[0].id + print(f"\n==== Items for Invoice {invoice_id} ====") + items = client.invoice.list_items(invoice_id) + for item in items: + print(f"Item: {item.id} - {item.amount} {item.currency}") + + # Preview an invoice (example) + print("\n==== Invoice Preview Example ====") + preview_request = InvoicePreviewRequest( + customer_id="cus_example123", + items=[ + InvoicePreviewRequest.SubscriptionItem( + price_id="pri_example456", + quantity=2 + ) + ], + recurring={ + "period": 1, + "period_unit": "MONTH" + } + ) + + try: + # Note: this will likely fail without valid IDs + preview = client.invoice.preview(preview_request) + print(f"Preview: {preview.total_amount} {preview.currency}") + except Exception as e: + print(f"Preview failed (expected without valid IDs): {str(e)}") + + finally: + # Close the client + client.close() + + +async def async_examples(): + """Examples using the asynchronous client.""" + client_id, api_key = get_client_credentials() + + # Initialize the async client + client = AirwallexAsyncClient( + client_id=client_id, + api_key=api_key + ) + + try: + # List accounts + print("==== Async: Listing Accounts ====") + accounts = await client.account.list_async() + for account in accounts: + print(f"Account: {account.id} - {account.account_currency}") + + # List invoices + print("\n==== Async: Listing Invoices ====") + invoices = await client.invoice.list_async() + for invoice in invoices: + print(f"Invoice: {invoice.id} - {invoice.total_amount} {invoice.currency}") + + # Use pagination generator + print("\n==== Async: Iterating Through Invoices ====") + count = 0 + async for invoice in client.invoice.paginate_async_generator(page_size=5): + print(f"Invoice {count}: {invoice.id}") + count += 1 + if count >= 10: + break + + finally: + # Close the client + await client.close() + + +if __name__ == "__main__": + print("Running synchronous examples...") + sync_examples() + + print("\n\nRunning asynchronous examples...") + asyncio.run(async_examples()) diff --git a/airwallex-sdk/pyproject.toml b/airwallex-sdk/pyproject.toml new file mode 100644 index 0000000..5b0bd1f --- /dev/null +++ b/airwallex-sdk/pyproject.toml @@ -0,0 +1,26 @@ +[tool.poetry] +name = "airwallex-sdk" +version = "0.1.1" +description = "Unofficial Airwallex SDK for Python" +authors = ["duneraccoon "] +license = "MIT" +readme = "README.md" +packages = [{ include = "airwallex" }] + + +[tool.poetry.dependencies] +python = "^3.10" +httpx = "^0.28.1" +pydantic = "^2.11.3" + +[tool.black] +line-length = 100 +target-version = ["py38", "py39", "py310", "py311"] + + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" + +[tool.poetry.urls] +Source = "https://github.com/DuneRaccoon/airwallex-sdk" \ No newline at end of file diff --git a/airwallex-sdk/tests/test_client.py b/airwallex-sdk/tests/test_client.py new file mode 100644 index 0000000..92df711 --- /dev/null +++ b/airwallex-sdk/tests/test_client.py @@ -0,0 +1,107 @@ +""" +Tests for the Airwallex SDK client. +""" +import unittest +from unittest.mock import patch, MagicMock +from datetime import datetime, timedelta +import json + +from airwallex import AirwallexClient, AirwallexAsyncClient +from airwallex.exceptions import AuthenticationError, create_exception_from_response + + +class TestAirwallexClient(unittest.TestCase): + """Tests for the AirwallexClient class.""" + + def setUp(self): + """Set up test fixtures.""" + self.client_id = "test_client_id" + self.api_key = "test_api_key" + + @patch('httpx.Client.post') + def test_authentication(self, mock_post): + """Test authentication flow.""" + # Mock response for authentication + mock_response = MagicMock() + mock_response.status_code = 201 + mock_response.json.return_value = { + "token": "test_token", + "expires_at": (datetime.now() + timedelta(minutes=30)).isoformat() + } + mock_post.return_value = mock_response + + # Initialize client and authenticate + client = AirwallexClient(client_id=self.client_id, api_key=self.api_key) + client.authenticate() + + # Check authentication request + mock_post.assert_called_once() + args, kwargs = mock_post.call_args + self.assertEqual(kwargs['headers']['x-client-id'], self.client_id) + self.assertEqual(kwargs['headers']['x-api-key'], self.api_key) + + # Check token is stored + self.assertEqual(client._token, "test_token") + self.assertIsNotNone(client._token_expiry) + + @patch('httpx.Client.post') + def test_authentication_error(self, mock_post): + """Test authentication error handling.""" + # Mock authentication error + mock_response = MagicMock() + mock_response.status_code = 401 + mock_response.json.return_value = { + "code": "credentials_invalid", + "message": "Invalid credentials" + } + mock_post.return_value = mock_response + + # Initialize client + client = AirwallexClient(client_id=self.client_id, api_key=self.api_key) + + # Authentication should raise error + with self.assertRaises(AuthenticationError): + client.authenticate() + + @patch('httpx.Client.request') + @patch('httpx.Client.post') + def test_request_with_authentication(self, mock_post, mock_request): + """Test request flow with authentication.""" + # Mock authentication response + auth_response = MagicMock() + auth_response.status_code = 201 + auth_response.json.return_value = { + "token": "test_token", + "expires_at": (datetime.now() + timedelta(minutes=30)).isoformat() + } + mock_post.return_value = auth_response + + # Mock API request response + request_response = MagicMock() + request_response.status_code = 200 + request_response.json.return_value = {"id": "test_id", "name": "Test Account"} + mock_request.return_value = request_response + + # Initialize client + client = AirwallexClient(client_id=self.client_id, api_key=self.api_key) + + # Make request + response = client._request("GET", "/api/v1/test") + + # Check authentication was called + mock_post.assert_called_once() + + # Check request was made with authentication token + mock_request.assert_called_once() + args, kwargs = mock_request.call_args + self.assertEqual(args[0], "GET") + self.assertEqual(args[1], "/api/v1/test") + self.assertEqual(kwargs['headers']['Authorization'], "Bearer test_token") + + # Check response + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), {"id": "test_id", "name": "Test Account"}) + + +if __name__ == '__main__': + unittest.main() diff --git a/airwallex-sdk/tests/test_exceptions.py b/airwallex-sdk/tests/test_exceptions.py new file mode 100644 index 0000000..6ca6ce2 --- /dev/null +++ b/airwallex-sdk/tests/test_exceptions.py @@ -0,0 +1,249 @@ +""" +Tests for the Airwallex SDK exception handling. +""" +import unittest +from unittest.mock import MagicMock +import json + +from airwallex.exceptions import ( + AirwallexAPIError, + AuthenticationError, + RateLimitError, + ResourceNotFoundError, + ValidationError, + ServerError, + ResourceExistsError, + AmountLimitError, + EditForbiddenError, + CurrencyError, + DateError, + TransferMethodError, + ConversionError, + ServiceUnavailableError, + create_exception_from_response +) + + +class TestExceptions(unittest.TestCase): + """Tests for the exception handling system.""" + + def setUp(self): + """Set up test fixtures.""" + self.mock_response = MagicMock() + self.method = "GET" + self.url = "/api/v1/test" + self.kwargs = {"headers": {"Authorization": "Bearer test_token"}} + + def test_exception_from_status_code(self): + """Test creating exceptions based only on status code.""" + # 401 should create AuthenticationError + self.mock_response.status_code = 401 + self.mock_response.json.side_effect = ValueError("Invalid JSON") + + exception = create_exception_from_response( + response=self.mock_response, + method=self.method, + url=self.url, + kwargs=self.kwargs + ) + + self.assertIsInstance(exception, AuthenticationError) + self.assertEqual(exception.status_code, 401) + + # 404 should create ResourceNotFoundError + self.mock_response.status_code = 404 + + exception = create_exception_from_response( + response=self.mock_response, + method=self.method, + url=self.url, + kwargs=self.kwargs + ) + + self.assertIsInstance(exception, ResourceNotFoundError) + self.assertEqual(exception.status_code, 404) + + # 429 should create RateLimitError + self.mock_response.status_code = 429 + + exception = create_exception_from_response( + response=self.mock_response, + method=self.method, + url=self.url, + kwargs=self.kwargs + ) + + self.assertIsInstance(exception, RateLimitError) + self.assertEqual(exception.status_code, 429) + + # 400 should create ValidationError + self.mock_response.status_code = 400 + + exception = create_exception_from_response( + response=self.mock_response, + method=self.method, + url=self.url, + kwargs=self.kwargs + ) + + self.assertIsInstance(exception, ValidationError) + self.assertEqual(exception.status_code, 400) + + # 500 should create ServerError + self.mock_response.status_code = 500 + + exception = create_exception_from_response( + response=self.mock_response, + method=self.method, + url=self.url, + kwargs=self.kwargs + ) + + self.assertIsInstance(exception, ServerError) + self.assertEqual(exception.status_code, 500) + + def test_exception_from_error_code(self): + """Test creating exceptions based on error code.""" + self.mock_response.status_code = 400 + + # Test credentials_invalid (should be AuthenticationError) + self.mock_response.json.return_value = { + "code": "credentials_invalid", + "message": "Invalid credentials", + "source": "api_key" + } + + exception = create_exception_from_response( + response=self.mock_response, + method=self.method, + url=self.url, + kwargs=self.kwargs + ) + + self.assertIsInstance(exception, AuthenticationError) + self.assertEqual(exception.error_code, "credentials_invalid") + self.assertEqual(exception.error_source, "api_key") + + # Test already_exists (should be ResourceExistsError) + self.mock_response.json.return_value = { + "code": "already_exists", + "message": "Resource already exists", + "source": "id" + } + + exception = create_exception_from_response( + response=self.mock_response, + method=self.method, + url=self.url, + kwargs=self.kwargs + ) + + self.assertIsInstance(exception, ResourceExistsError) + self.assertEqual(exception.error_code, "already_exists") + + # Test amount_above_limit (should be AmountLimitError) + self.mock_response.json.return_value = { + "code": "amount_above_limit", + "message": "Amount exceeds the maximum limit", + "source": "amount" + } + + exception = create_exception_from_response( + response=self.mock_response, + method=self.method, + url=self.url, + kwargs=self.kwargs + ) + + self.assertIsInstance(exception, AmountLimitError) + self.assertEqual(exception.error_code, "amount_above_limit") + + # Test invalid_currency_pair (should be CurrencyError) + self.mock_response.json.return_value = { + "code": "invalid_currency_pair", + "message": "The currency pair is invalid", + "source": "currency_pair" + } + + exception = create_exception_from_response( + response=self.mock_response, + method=self.method, + url=self.url, + kwargs=self.kwargs + ) + + self.assertIsInstance(exception, CurrencyError) + self.assertEqual(exception.error_code, "invalid_currency_pair") + + # Test invalid_transfer_date (should be DateError) + self.mock_response.json.return_value = { + "code": "invalid_transfer_date", + "message": "The transfer date is invalid", + "source": "transfer_date" + } + + exception = create_exception_from_response( + response=self.mock_response, + method=self.method, + url=self.url, + kwargs=self.kwargs + ) + + self.assertIsInstance(exception, DateError) + self.assertEqual(exception.error_code, "invalid_transfer_date") + + # Test service_unavailable (should be ServiceUnavailableError) + self.mock_response.status_code = 503 + self.mock_response.json.return_value = { + "code": "service_unavailable", + "message": "The service is currently unavailable", + } + + exception = create_exception_from_response( + response=self.mock_response, + method=self.method, + url=self.url, + kwargs=self.kwargs + ) + + self.assertIsInstance(exception, ServiceUnavailableError) + self.assertEqual(exception.error_code, "service_unavailable") + + def test_exception_string_representation(self): + """Test the string representation of exceptions.""" + self.mock_response.status_code = 400 + self.mock_response.json.return_value = { + "code": "invalid_argument", + "message": "The argument is invalid", + "source": "amount" + } + + exception = create_exception_from_response( + response=self.mock_response, + method=self.method, + url=self.url, + kwargs=self.kwargs + ) + + expected_str = "Airwallex API Error (HTTP 400): [invalid_argument] The argument is invalid (source: amount) for GET /api/v1/test" + self.assertEqual(str(exception), expected_str) + + # Test without source + self.mock_response.json.return_value = { + "code": "invalid_argument", + "message": "The argument is invalid" + } + + exception = create_exception_from_response( + response=self.mock_response, + method=self.method, + url=self.url, + kwargs=self.kwargs + ) + + expected_str = "Airwallex API Error (HTTP 400): [invalid_argument] The argument is invalid for GET /api/v1/test" + self.assertEqual(str(exception), expected_str) + + +if __name__ == '__main__': + unittest.main() diff --git a/airwallex-sdk/tests/test_invoice.py b/airwallex-sdk/tests/test_invoice.py new file mode 100644 index 0000000..da6f579 --- /dev/null +++ b/airwallex-sdk/tests/test_invoice.py @@ -0,0 +1,168 @@ +""" +Tests for the Airwallex SDK invoice API. +""" +import unittest +from unittest.mock import patch, MagicMock +from datetime import datetime, timedelta + +from airwallex import AirwallexClient +from airwallex.models.invoice import ( + Invoice, + InvoiceItem, + InvoicePreviewRequest, + InvoicePreviewResponse +) + + +class TestInvoiceAPI(unittest.TestCase): + """Tests for the Invoice API.""" + + def setUp(self): + """Set up test fixtures.""" + self.client_id = "test_client_id" + self.api_key = "test_api_key" + + # Create a client with authentication mocked + patcher = patch.object(AirwallexClient, 'authenticate') + self.mock_auth = patcher.start() + self.addCleanup(patcher.stop) + + self.client = AirwallexClient(client_id=self.client_id, api_key=self.api_key) + self.client._token = "test_token" + self.client._token_expiry = datetime.now() + timedelta(minutes=30) + + @patch('httpx.Client.request') + def test_list_invoices(self, mock_request): + """Test listing invoices.""" + # Mock response + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "items": [ + { + "id": "inv_test123", + "customer_id": "cus_test456", + "currency": "USD", + "total_amount": 100.00, + "status": "PAID", + "period_start_at": "2023-01-01T00:00:00Z", + "period_end_at": "2023-02-01T00:00:00Z", + "created_at": "2023-01-01T00:00:00Z" + } + ], + "has_more": False + } + mock_request.return_value = mock_response + + # Call list method + invoices = self.client.invoice.list() + + # Check request was made correctly + mock_request.assert_called_once() + args, kwargs = mock_request.call_args + self.assertEqual(args[0], "GET") + self.assertTrue(args[1].endswith("/invoices")) + + # Check response parsing + self.assertEqual(len(invoices), 1) + self.assertIsInstance(invoices[0], Invoice) + self.assertEqual(invoices[0].id, "inv_test123") + self.assertEqual(invoices[0].currency, "USD") + self.assertEqual(invoices[0].total_amount, 100.00) + + @patch('httpx.Client.request') + def test_get_invoice(self, mock_request): + """Test fetching a single invoice.""" + # Mock response + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "id": "inv_test123", + "customer_id": "cus_test456", + "currency": "USD", + "total_amount": 100.00, + "status": "PAID", + "period_start_at": "2023-01-01T00:00:00Z", + "period_end_at": "2023-02-01T00:00:00Z", + "created_at": "2023-01-01T00:00:00Z" + } + mock_request.return_value = mock_response + + # Call fetch method + invoice = self.client.invoice.fetch("inv_test123") + + # Check request was made correctly + mock_request.assert_called_once() + args, kwargs = mock_request.call_args + self.assertEqual(args[0], "GET") + self.assertTrue(args[1].endswith("/invoices/inv_test123")) + + # Check response parsing + self.assertIsInstance(invoice, Invoice) + self.assertEqual(invoice.id, "inv_test123") + self.assertEqual(invoice.currency, "USD") + self.assertEqual(invoice.total_amount, 100.00) + + @patch('httpx.Client.request') + def test_preview_invoice(self, mock_request): + """Test previewing an invoice.""" + # Mock response + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "customer_id": "cus_test456", + "currency": "USD", + "total_amount": 100.00, + "created_at": "2023-01-01T00:00:00Z", + "items": [ + { + "id": "item_test123", + "invoice_id": "inv_test123", + "amount": 100.00, + "currency": "USD", + "period_start_at": "2023-01-01T00:00:00Z", + "period_end_at": "2023-02-01T00:00:00Z", + "price": { + "id": "pri_test123", + "name": "Test Price", + "active": True, + "currency": "USD", + "product_id": "prod_test123", + "pricing_model": "flat" + } + } + ] + } + mock_request.return_value = mock_response + + # Create preview request + preview_request = InvoicePreviewRequest( + customer_id="cus_test456", + items=[ + InvoicePreviewRequest.SubscriptionItem( + price_id="pri_test123", + quantity=1 + ) + ] + ) + + # Call preview method + preview = self.client.invoice.preview(preview_request) + + # Check request was made correctly + mock_request.assert_called_once() + args, kwargs = mock_request.call_args + self.assertEqual(args[0], "POST") + self.assertTrue(args[1].endswith("/invoices/preview")) + + # Check response parsing + self.assertIsInstance(preview, InvoicePreviewResponse) + self.assertEqual(preview.customer_id, "cus_test456") + self.assertEqual(preview.currency, "USD") + self.assertEqual(preview.total_amount, 100.00) + self.assertEqual(len(preview.items), 1) + self.assertEqual(preview.items[0].amount, 100.00) + + +if __name__ == '__main__': + unittest.main() diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..4b3d03a --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,19 @@ +# Admin credentials +ADMIN_USERNAME=admin +ADMIN_PASSWORD=admin123 + +# JWT settings +SECRET_KEY=your-secret-key-change-in-production +JWT_ALGORITHM=HS256 +JWT_EXPIRE_MINUTES=480 + +# Database +DATABASE_URL=sqlite:///./data/airwallex.db + +# Airwallex API credentials +AIRWALLEX_CLIENT_ID=your-client-id +AIRWALLEX_API_KEY=your-api-key +AIRWALLEX_BASE_URL=https://api.airwallex.com/ + +# HTTP proxy (optional) +# PROXY_URL=http://127.0.0.1:7890 diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..5509413 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,27 @@ +FROM python:3.11-slim + +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc \ + && rm -rf /var/lib/apt/lists/* + +# Copy and install the airwallex SDK first (changes less often) +COPY airwallex-sdk /opt/airwallex-sdk +RUN pip install --no-cache-dir /opt/airwallex-sdk + +# Copy and install Python dependencies +COPY backend/requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application code +COPY backend/app ./app +COPY backend/.env* ./ + +# Create data directory for SQLite +RUN mkdir -p /app/data + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/auth.py b/backend/app/auth.py new file mode 100644 index 0000000..f40d0d5 --- /dev/null +++ b/backend/app/auth.py @@ -0,0 +1,74 @@ +"""Authentication utilities: JWT tokens, password hashing, and dependencies.""" +from datetime import datetime, timedelta, timezone + +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from jose import JWTError, jwt +from passlib.context import CryptContext +from pydantic import BaseModel + +from .config import settings + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") +security = HTTPBearer() + + +class AdminUser(BaseModel): + """Represents the authenticated admin user.""" + username: str + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + """Verify a plain password against its hash.""" + return pwd_context.verify(plain_password, hashed_password) + + +def get_password_hash(password: str) -> str: + """Hash a password using bcrypt.""" + return pwd_context.hash(password) + + +def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str: + """Create a JWT access token. + + Args: + data: Payload data to encode in the token. + expires_delta: Optional custom expiration time. + + Returns: + Encoded JWT string. + """ + to_encode = data.copy() + expire = datetime.now(timezone.utc) + ( + expires_delta or timedelta(minutes=settings.JWT_EXPIRE_MINUTES) + ) + to_encode.update({"exp": expire}) + return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.JWT_ALGORITHM) + + +async def get_current_user( + credentials: HTTPAuthorizationCredentials = Depends(security), +) -> AdminUser: + """FastAPI dependency that extracts and validates the current user from a JWT token. + + Raises: + HTTPException: If the token is invalid or missing required claims. + """ + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or expired token", + headers={"WWW-Authenticate": "Bearer"}, + ) + try: + payload = jwt.decode( + credentials.credentials, + settings.SECRET_KEY, + algorithms=[settings.JWT_ALGORITHM], + ) + username: str | None = payload.get("sub") + if username is None: + raise credentials_exception + except JWTError: + raise credentials_exception + + return AdminUser(username=username) diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..1e06d61 --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,38 @@ +"""Application configuration using pydantic-settings.""" +import secrets +from typing import Optional + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + """Application settings loaded from environment variables and .env file.""" + + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + case_sensitive=False, + ) + + # Admin credentials + ADMIN_USERNAME: str = "admin" + ADMIN_PASSWORD: str = "admin123" + + # JWT settings + SECRET_KEY: str = secrets.token_urlsafe(32) + JWT_ALGORITHM: str = "HS256" + JWT_EXPIRE_MINUTES: int = 480 + + # Database + DATABASE_URL: str = "sqlite:///./data/airwallex.db" + + # Airwallex API + AIRWALLEX_CLIENT_ID: str = "" + AIRWALLEX_API_KEY: str = "" + AIRWALLEX_BASE_URL: str = "https://api.airwallex.com/" + + # Proxy (optional) + PROXY_URL: Optional[str] = None + + +settings = Settings() diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..e59cc47 --- /dev/null +++ b/backend/app/database.py @@ -0,0 +1,35 @@ +"""Database setup and session management.""" +from typing import Generator + +from sqlalchemy import create_engine +from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker + +from .config import settings + + +engine = create_engine( + settings.DATABASE_URL, + connect_args={"check_same_thread": False}, # SQLite specific + echo=False, +) + +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + +class Base(DeclarativeBase): + """Declarative base for all ORM models.""" + pass + + +def get_db() -> Generator[Session, None, None]: + """FastAPI dependency that provides a database session.""" + db = SessionLocal() + try: + yield db + finally: + db.close() + + +def create_tables() -> None: + """Create all database tables.""" + Base.metadata.create_all(bind=engine) diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..9bc3255 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,105 @@ +"""FastAPI application entry point.""" +import logging +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from .auth import get_password_hash +from .config import settings +from .database import SessionLocal, create_tables +from .models.db_models import SystemSetting + +logger = logging.getLogger(__name__) + +logging.basicConfig(level=logging.INFO) + + +def _init_admin_and_defaults() -> None: + """Initialize admin password hash and default settings.""" + db = SessionLocal() + try: + # Store admin password hash + existing = ( + db.query(SystemSetting) + .filter(SystemSetting.key == "admin_password_hash") + .first() + ) + if not existing: + hashed = get_password_hash(settings.ADMIN_PASSWORD) + db.add(SystemSetting(key="admin_password_hash", value=hashed, encrypted=True)) + logger.info("Admin user initialized.") + + # Store Airwallex credentials from env if provided + for key, env_val in [ + ("airwallex_client_id", settings.AIRWALLEX_CLIENT_ID), + ("airwallex_api_key", settings.AIRWALLEX_API_KEY), + ("airwallex_base_url", settings.AIRWALLEX_BASE_URL), + ]: + if env_val: + s = db.query(SystemSetting).filter(SystemSetting.key == key).first() + if not s: + db.add(SystemSetting(key=key, value=env_val, encrypted=(key == "airwallex_api_key"))) + + # Default daily card limit + if not db.query(SystemSetting).filter(SystemSetting.key == "daily_card_limit").first(): + db.add(SystemSetting(key="daily_card_limit", value="100")) + + db.commit() + finally: + db.close() + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Startup and shutdown logic.""" + create_tables() + _init_admin_and_defaults() + logger.info("Application started.") + yield + logger.info("Application shutting down.") + + +app = FastAPI( + title="Airwallex Card Management", + version="1.0.0", + lifespan=lifespan, +) + +# CORS - allow all origins for development +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +# --- Mount routers (each router defines its own prefix) --- +from .routers import auth as auth_router +from .routers import cards as cards_router +from .routers import cardholders as cardholders_router +from .routers import dashboard as dashboard_router +from .routers import settings as settings_router +from .routers import logs as logs_router +from .routers import tokens as tokens_router +from .routers import transactions as transactions_router +from .routers import external_api as external_api_router + +app.include_router(auth_router.router) +app.include_router(dashboard_router.router) +app.include_router(cards_router.router) +app.include_router(cardholders_router.router) +app.include_router(transactions_router.router) +app.include_router(settings_router.router) +app.include_router(tokens_router.router) +app.include_router(logs_router.router) +app.include_router(external_api_router.router) + + +# --- Health check --- +@app.get("/api/health") +async def health_check(): + """Simple health check endpoint.""" + return {"status": "ok"} diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/models/db_models.py b/backend/app/models/db_models.py new file mode 100644 index 0000000..1f13856 --- /dev/null +++ b/backend/app/models/db_models.py @@ -0,0 +1,67 @@ +"""SQLAlchemy ORM models for the application database.""" +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, Integer, String, Text, func +from sqlalchemy.orm import Mapped, mapped_column + +from ..database import Base + + +class SystemSetting(Base): + """Stores application-level configuration key-value pairs.""" + + __tablename__ = "system_settings" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + key: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True) + value: Mapped[str] = mapped_column(Text, nullable=False) + encrypted: Mapped[bool] = mapped_column(Boolean, default=False) + updated_at: Mapped[datetime] = mapped_column( + DateTime, default=func.now(), onupdate=func.now() + ) + + +class ApiToken(Base): + """API tokens for programmatic access.""" + + __tablename__ = "api_tokens" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column(String(255), nullable=False) + token: Mapped[str] = mapped_column(String(512), unique=True, nullable=False, index=True) + permissions: Mapped[str] = mapped_column(Text, default="[]") # JSON string + is_active: Mapped[bool] = mapped_column(Boolean, default=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=func.now()) + expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + last_used_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + + +class CardLog(Base): + """Logs for card-related operations.""" + + __tablename__ = "card_logs" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + card_id: Mapped[str | None] = mapped_column(String(255), nullable=True) + cardholder_id: Mapped[str | None] = mapped_column(String(255), nullable=True) + action: Mapped[str] = mapped_column(String(100), nullable=False) + status: Mapped[str] = mapped_column(String(50), nullable=False) + operator: Mapped[str] = mapped_column(String(255), nullable=False) + request_data: Mapped[str | None] = mapped_column(Text, nullable=True) + response_data: Mapped[str | None] = mapped_column(Text, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=func.now()) + + +class AuditLog(Base): + """General audit trail for all administrative actions.""" + + __tablename__ = "audit_logs" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + action: Mapped[str] = mapped_column(String(100), nullable=False) + resource_type: Mapped[str] = mapped_column(String(100), nullable=False) + resource_id: Mapped[str | None] = mapped_column(String(255), nullable=True) + operator: Mapped[str] = mapped_column(String(255), nullable=False) + ip_address: Mapped[str | None] = mapped_column(String(45), nullable=True) + details: Mapped[str | None] = mapped_column(Text, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=func.now()) diff --git a/backend/app/models/schemas.py b/backend/app/models/schemas.py new file mode 100644 index 0000000..1e5330f --- /dev/null +++ b/backend/app/models/schemas.py @@ -0,0 +1,148 @@ +"""Pydantic schemas for request/response validation.""" +from datetime import datetime +from typing import Any, Optional + +from pydantic import BaseModel, Field + + +# --- Auth --- + +class LoginRequest(BaseModel): + username: str + password: str + + +class TokenResponse(BaseModel): + access_token: str + token_type: str = "bearer" + + +# --- System Settings --- + +class SystemSettingUpdate(BaseModel): + key: str + value: str + + +class SystemSettingResponse(BaseModel): + key: str + value: str + updated_at: datetime | None = None + + model_config = {"from_attributes": True} + + +# --- API Tokens --- + +class ApiTokenCreate(BaseModel): + name: str + permissions: list[str] = Field(default_factory=list) + expires_in_days: int | None = None + + +class ApiTokenResponse(BaseModel): + id: int + name: str + token: str | None = None # Only shown on create + permissions: list[str] = Field(default_factory=list) + is_active: bool + created_at: datetime | None = None + expires_at: datetime | None = None + + model_config = {"from_attributes": True} + + +# --- Logs --- + +class CardLogResponse(BaseModel): + id: int + card_id: str | None = None + cardholder_id: str | None = None + action: str + status: str + operator: str + request_data: str | None = None + response_data: str | None = None + created_at: datetime | None = None + + model_config = {"from_attributes": True} + + +class AuditLogResponse(BaseModel): + id: int + action: str + resource_type: str + resource_id: str | None = None + operator: str + ip_address: str | None = None + details: str | None = None + created_at: datetime | None = None + + model_config = {"from_attributes": True} + + +# --- Dashboard --- + +class DashboardResponse(BaseModel): + total_cards: int = 0 + active_cards: int = 0 + today_card_count: int = 0 + daily_card_limit: int = 0 + account_balance: dict[str, Any] | None = None + + +# --- Cards --- + +class CardCreateRequest(BaseModel): + cardholder_id: str + card_nickname: str | None = None + authorization_controls: dict[str, Any] | None = None + form_factor: str = "VIRTUAL" + purpose: str | None = None + + +class CardUpdateRequest(BaseModel): + card_nickname: str | None = None + authorization_controls: dict[str, Any] | None = None + status: str | None = None + + +# --- Cardholders --- + +class CardholderCreateRequest(BaseModel): + email: str + type: str = "INDIVIDUAL" + individual: dict[str, Any] = Field( + ..., + description="Individual details: first_name, last_name, date_of_birth, etc.", + ) + address: dict[str, Any] = Field( + ..., + description="Address: street_address, city, state, postcode, country_code.", + ) + + +# --- Pagination --- + +class PaginatedResponse(BaseModel): + items: list[Any] + page_num: int = 0 + page_size: int = 20 + total: int | None = None + has_more: bool = False + + +# --- External / Third-party --- + +class ExternalCardCreateRequest(BaseModel): + cardholder_id: str + card_nickname: str | None = None + authorization_controls: dict[str, Any] | None = None + form_factor: str = "VIRTUAL" + purpose: str | None = None + + +# --- Balance --- + +class BalanceResponse(BaseModel): + available: list[dict[str, Any]] = Field(default_factory=list) diff --git a/backend/app/proxy_client.py b/backend/app/proxy_client.py new file mode 100644 index 0000000..531b25a --- /dev/null +++ b/backend/app/proxy_client.py @@ -0,0 +1,51 @@ +"""Airwallex client with proxy support.""" +import httpx +from airwallex.client import AirwallexClient + + +class ProxiedAirwallexClient(AirwallexClient): + """AirwallexClient that routes requests through an HTTP proxy.""" + + def __init__(self, proxy_url: str | None = None, **kwargs): + self._proxy_url = proxy_url + super().__init__(**kwargs) + # Replace the default httpx client with a proxied one + if proxy_url: + self._client.close() + self._client = httpx.Client( + base_url=self.base_url, + timeout=self.request_timeout, + proxy=proxy_url, + ) + + def authenticate(self) -> None: + """Override authenticate to use proxy for auth requests too.""" + from datetime import datetime, timezone + + if self._token and self._token_expiry and datetime.now(timezone.utc) < self._token_expiry: + return + + # Use a proxied client for authentication + auth_kwargs = {"timeout": self.request_timeout} + if self._proxy_url: + auth_kwargs["proxy"] = self._proxy_url + + auth_client = httpx.Client(**auth_kwargs) + try: + response = auth_client.post( + self.auth_url, + headers={ + "Content-Type": "application/json", + "x-client-id": self.client_id, + "x-api-key": self.api_key, + }, + content="{}", + ) + response.raise_for_status() + data = response.json() + self._token = data.get("token") + # Token valid for 30 minutes, refresh a bit early + from datetime import timedelta + self._token_expiry = datetime.now(timezone.utc) + timedelta(minutes=28) + finally: + auth_client.close() diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py new file mode 100644 index 0000000..24f0bb7 --- /dev/null +++ b/backend/app/routers/auth.py @@ -0,0 +1,37 @@ +"""Authentication router.""" +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from app.auth import verify_password, create_access_token, get_current_user, AdminUser +from app.config import settings +from app.database import get_db +from app.models.db_models import SystemSetting +from app.models.schemas import LoginRequest, TokenResponse +from app.services.audit_log import create_audit_log + +router = APIRouter(prefix="/api/auth", tags=["auth"]) + + +@router.post("/login", response_model=TokenResponse) +def login(req: LoginRequest, db: Session = Depends(get_db)): + """Admin login endpoint.""" + if req.username != settings.ADMIN_USERNAME: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials") + + # Get password hash from database + setting = db.query(SystemSetting).filter(SystemSetting.key == "admin_password_hash").first() + if not setting: + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Admin not initialized") + + if not verify_password(req.password, setting.value): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials") + + token = create_access_token(data={"sub": req.username}) + create_audit_log(db, action="login", resource_type="auth", operator=req.username) + return TokenResponse(access_token=token, token_type="bearer") + + +@router.get("/me") +def get_me(user: AdminUser = Depends(get_current_user)): + """Get current authenticated user.""" + return {"username": user.username} diff --git a/backend/app/routers/cardholders.py b/backend/app/routers/cardholders.py new file mode 100644 index 0000000..c18f297 --- /dev/null +++ b/backend/app/routers/cardholders.py @@ -0,0 +1,47 @@ +"""Cardholders management router.""" +import json + +from fastapi import APIRouter, Depends, HTTPException, Query, Request +from sqlalchemy.orm import Session + +from app.auth import get_current_user, AdminUser +from app.database import get_db +from app.services import airwallex_service +from app.services.audit_log import create_audit_log + +router = APIRouter(prefix="/api/cardholders", tags=["cardholders"]) + + +@router.get("") +def list_cardholders( + page_num: int = Query(0, ge=0), + page_size: int = Query(20, ge=1, le=100), + db: Session = Depends(get_db), + user: AdminUser = Depends(get_current_user), +): + """List cardholders.""" + return airwallex_service.list_cardholders(db, page_num, page_size) + + +@router.post("") +def create_cardholder( + cardholder_data: dict, + request: Request, + db: Session = Depends(get_db), + user: AdminUser = Depends(get_current_user), +): + """Create a new cardholder.""" + try: + result = airwallex_service.create_cardholder(db, cardholder_data) + create_audit_log( + db, + action="create_cardholder", + resource_type="cardholder", + resource_id=result.get("cardholder_id", result.get("id", "")), + operator=user.username, + ip_address=request.client.host if request.client else "", + details=f"Created cardholder {cardholder_data.get('email', '')}", + ) + return result + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) diff --git a/backend/app/routers/cards.py b/backend/app/routers/cards.py new file mode 100644 index 0000000..9e51060 --- /dev/null +++ b/backend/app/routers/cards.py @@ -0,0 +1,146 @@ +"""Cards management router.""" +import json +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Query, Request +from sqlalchemy.orm import Session +from sqlalchemy import func +from datetime import datetime, timezone + +from app.auth import get_current_user, AdminUser +from app.database import get_db +from app.models.db_models import CardLog, SystemSetting +from app.services import airwallex_service +from app.services.audit_log import create_card_log, create_audit_log + +router = APIRouter(prefix="/api/cards", tags=["cards"]) + + +@router.get("") +def list_cards( + page_num: int = Query(0, ge=0), + page_size: int = Query(20, ge=1, le=100), + cardholder_id: Optional[str] = None, + status: Optional[str] = None, + db: Session = Depends(get_db), + user: AdminUser = Depends(get_current_user), +): + """List cards with optional filters.""" + return airwallex_service.list_cards(db, page_num, page_size, cardholder_id, status) + + +@router.post("") +def create_card( + request: Request, + card_data: dict, + db: Session = Depends(get_db), + user: AdminUser = Depends(get_current_user), +): + """Create a new card.""" + # Check daily limit + today_start = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0) + today_count = ( + db.query(func.count(CardLog.id)) + .filter(CardLog.action == "create_card", CardLog.status == "success", CardLog.created_at >= today_start) + .scalar() + ) or 0 + + limit_setting = db.query(SystemSetting).filter(SystemSetting.key == "daily_card_limit").first() + daily_limit = int(limit_setting.value) if limit_setting else 100 + + if today_count >= daily_limit: + raise HTTPException(status_code=429, detail=f"Daily card creation limit ({daily_limit}) reached") + + try: + result = airwallex_service.create_card(db, card_data) + create_card_log( + db, + action="create_card", + status="success", + operator=user.username, + card_id=result.get("card_id", result.get("id", "")), + cardholder_id=card_data.get("cardholder_id", ""), + request_data=json.dumps(card_data), + response_data=json.dumps(result, default=str), + ) + create_audit_log( + db, + action="create_card", + resource_type="card", + resource_id=result.get("card_id", result.get("id", "")), + operator=user.username, + ip_address=request.client.host if request.client else "", + details=f"Created card for cardholder {card_data.get('cardholder_id', '')}", + ) + return result + except Exception as e: + create_card_log( + db, + action="create_card", + status="failed", + operator=user.username, + cardholder_id=card_data.get("cardholder_id", ""), + request_data=json.dumps(card_data), + response_data=str(e), + ) + raise HTTPException(status_code=400, detail=str(e)) + + +@router.get("/{card_id}") +def get_card( + card_id: str, + db: Session = Depends(get_db), + user: AdminUser = Depends(get_current_user), +): + """Get card by ID.""" + try: + return airwallex_service.get_card(db, card_id) + except Exception as e: + raise HTTPException(status_code=404, detail=str(e)) + + +@router.get("/{card_id}/details") +def get_card_details( + card_id: str, + request: Request, + db: Session = Depends(get_db), + user: AdminUser = Depends(get_current_user), +): + """Get sensitive card details (card number, CVV, expiry).""" + create_audit_log( + db, + action="view_card_details", + resource_type="card", + resource_id=card_id, + operator=user.username, + ip_address=request.client.host if request.client else "", + ) + try: + return airwallex_service.get_card_details(db, card_id) + except Exception as e: + raise HTTPException(status_code=404, detail=str(e)) + + +@router.put("/{card_id}") +def update_card( + card_id: str, + update_data: dict, + request: Request, + db: Session = Depends(get_db), + user: AdminUser = Depends(get_current_user), +): + """Update card attributes.""" + try: + result = airwallex_service.update_card(db, card_id, update_data) + create_audit_log( + db, + action="update_card", + resource_type="card", + resource_id=card_id, + operator=user.username, + ip_address=request.client.host if request.client else "", + details=json.dumps(update_data), + ) + return result + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) diff --git a/backend/app/routers/dashboard.py b/backend/app/routers/dashboard.py new file mode 100644 index 0000000..744a29f --- /dev/null +++ b/backend/app/routers/dashboard.py @@ -0,0 +1,57 @@ +"""Dashboard router.""" +from datetime import datetime, timezone, timedelta + +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session +from sqlalchemy import func + +from app.auth import get_current_user, AdminUser +from app.database import get_db +from app.models.db_models import CardLog, SystemSetting +from app.services import airwallex_service + +router = APIRouter(prefix="/api/dashboard", tags=["dashboard"]) + + +@router.get("") +def get_dashboard( + db: Session = Depends(get_db), + user: AdminUser = Depends(get_current_user), +): + """Get dashboard summary data.""" + # Today's card count from local logs + today_start = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0) + today_card_count = ( + db.query(func.count(CardLog.id)) + .filter(CardLog.action == "create_card", CardLog.status == "success", CardLog.created_at >= today_start) + .scalar() + ) or 0 + + # Daily limit from settings + limit_setting = db.query(SystemSetting).filter(SystemSetting.key == "daily_card_limit").first() + daily_card_limit = int(limit_setting.value) if limit_setting else 100 + + # Try to get live data from Airwallex + total_cards = 0 + active_cards = 0 + account_balance = None + + try: + cards_data = airwallex_service.list_cards(db, page_num=0, page_size=1) + # We can't get total from a page_size=1 call easily, so we show what we have + total_cards = cards_data.get("total", 0) + except Exception: + pass + + try: + account_balance = airwallex_service.get_balance(db) + except Exception: + pass + + return { + "total_cards": total_cards, + "active_cards": active_cards, + "today_card_count": today_card_count, + "daily_card_limit": daily_card_limit, + "account_balance": account_balance, + } diff --git a/backend/app/routers/external_api.py b/backend/app/routers/external_api.py new file mode 100644 index 0000000..317a702 --- /dev/null +++ b/backend/app/routers/external_api.py @@ -0,0 +1,152 @@ +"""External API endpoints for third-party access via X-API-Key.""" +import json +from datetime import datetime, timezone +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Header, Query, Request +from sqlalchemy.orm import Session + +from app.database import get_db +from app.models.db_models import ApiToken +from app.services import airwallex_service +from app.services.audit_log import create_card_log, create_audit_log + +router = APIRouter(prefix="/api/v1", tags=["external"]) + + +def verify_api_key( + x_api_key: str = Header(..., alias="X-API-Key"), + db: Session = Depends(get_db), +) -> ApiToken: + """Verify API key and return token record.""" + token = db.query(ApiToken).filter(ApiToken.token == x_api_key, ApiToken.is_active == True).first() + if not token: + raise HTTPException(status_code=401, detail="Invalid or inactive API key") + + # Check expiry + if token.expires_at and token.expires_at < datetime.now(timezone.utc): + raise HTTPException(status_code=401, detail="API key has expired") + + # Update last used + token.last_used_at = datetime.now(timezone.utc) + db.commit() + return token + + +def check_permission(token: ApiToken, required: str): + """Check if token has a required permission.""" + permissions = json.loads(token.permissions) if token.permissions else [] + if required not in permissions and "*" not in permissions: + raise HTTPException(status_code=403, detail=f"Token lacks permission: {required}") + + +@router.post("/cards/create") +def external_create_card( + card_data: dict, + request: Request, + db: Session = Depends(get_db), + token: ApiToken = Depends(verify_api_key), +): + """Create a new card (external API).""" + check_permission(token, "create_cards") + try: + result = airwallex_service.create_card(db, card_data) + create_card_log( + db, + action="create_card", + status="success", + operator=f"api:{token.name}", + card_id=result.get("card_id", result.get("id", "")), + cardholder_id=card_data.get("cardholder_id", ""), + request_data=json.dumps(card_data), + response_data=json.dumps(result, default=str), + ) + return result + except Exception as e: + create_card_log( + db, + action="create_card", + status="failed", + operator=f"api:{token.name}", + cardholder_id=card_data.get("cardholder_id", ""), + request_data=json.dumps(card_data), + response_data=str(e), + ) + raise HTTPException(status_code=400, detail=str(e)) + + +@router.get("/cards") +def external_list_cards( + page_num: int = Query(0, ge=0), + page_size: int = Query(20, ge=1, le=100), + status: Optional[str] = None, + db: Session = Depends(get_db), + token: ApiToken = Depends(verify_api_key), +): + """List cards (external API).""" + check_permission(token, "read_cards") + return airwallex_service.list_cards(db, page_num, page_size, status=status) + + +@router.get("/cards/{card_id}") +def external_get_card( + card_id: str, + db: Session = Depends(get_db), + token: ApiToken = Depends(verify_api_key), +): + """Get card details (external API).""" + check_permission(token, "read_cards") + try: + return airwallex_service.get_card(db, card_id) + except Exception as e: + raise HTTPException(status_code=404, detail=str(e)) + + +@router.post("/cards/{card_id}/freeze") +def external_freeze_card( + card_id: str, + request: Request, + db: Session = Depends(get_db), + token: ApiToken = Depends(verify_api_key), +): + """Freeze a card (external API).""" + check_permission(token, "create_cards") + try: + result = airwallex_service.update_card(db, card_id, {"status": "SUSPENDED"}) + create_audit_log( + db, + action="freeze_card", + resource_type="card", + resource_id=card_id, + operator=f"api:{token.name}", + ip_address=request.client.host if request.client else "", + ) + return result + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.get("/transactions") +def external_list_transactions( + page_num: int = Query(0, ge=0), + page_size: int = Query(20, ge=1, le=100), + card_id: Optional[str] = None, + db: Session = Depends(get_db), + token: ApiToken = Depends(verify_api_key), +): + """List transactions (external API).""" + check_permission(token, "read_transactions") + return airwallex_service.list_transactions(db, page_num, page_size, card_id) + + +@router.get("/balance") +def external_get_balance( + db: Session = Depends(get_db), + token: ApiToken = Depends(verify_api_key), +): + """Get account balance (external API).""" + check_permission(token, "read_balance") + balance = airwallex_service.get_balance(db) + if balance is None: + raise HTTPException(status_code=503, detail="Unable to fetch balance") + return balance diff --git a/backend/app/routers/logs.py b/backend/app/routers/logs.py new file mode 100644 index 0000000..09641c9 --- /dev/null +++ b/backend/app/routers/logs.py @@ -0,0 +1,98 @@ +"""Card logs and audit logs router.""" +from typing import Optional + +from fastapi import APIRouter, Depends, Query +from sqlalchemy.orm import Session + +from app.auth import get_current_user, AdminUser +from app.database import get_db +from app.models.db_models import CardLog, AuditLog + +router = APIRouter(prefix="/api", tags=["logs"]) + + +@router.get("/card-logs") +def list_card_logs( + page_num: int = Query(0, ge=0), + page_size: int = Query(20, ge=1, le=100), + card_id: Optional[str] = None, + action: Optional[str] = None, + db: Session = Depends(get_db), + user: AdminUser = Depends(get_current_user), +): + """List card operation logs.""" + query = db.query(CardLog).order_by(CardLog.created_at.desc()) + if card_id: + query = query.filter(CardLog.card_id == card_id) + if action: + query = query.filter(CardLog.action == action) + + total = query.count() + logs = query.offset(page_num * page_size).limit(page_size).all() + + return { + "items": [ + { + "id": log.id, + "card_id": log.card_id, + "cardholder_id": log.cardholder_id, + "action": log.action, + "status": log.status, + "operator": log.operator, + "request_data": log.request_data, + "response_data": log.response_data, + "created_at": log.created_at.isoformat() if log.created_at else None, + } + for log in logs + ], + "page_num": page_num, + "page_size": page_size, + "total": total, + "has_more": (page_num + 1) * page_size < total, + } + + +@router.get("/audit-logs") +def list_audit_logs( + page_num: int = Query(0, ge=0), + page_size: int = Query(20, ge=1, le=100), + action: Optional[str] = None, + resource_type: Optional[str] = None, + from_date: Optional[str] = None, + to_date: Optional[str] = None, + db: Session = Depends(get_db), + user: AdminUser = Depends(get_current_user), +): + """List audit logs.""" + query = db.query(AuditLog).order_by(AuditLog.created_at.desc()) + if action: + query = query.filter(AuditLog.action == action) + if resource_type: + query = query.filter(AuditLog.resource_type == resource_type) + if from_date: + query = query.filter(AuditLog.created_at >= from_date) + if to_date: + query = query.filter(AuditLog.created_at <= to_date) + + total = query.count() + logs = query.offset(page_num * page_size).limit(page_size).all() + + return { + "items": [ + { + "id": log.id, + "action": log.action, + "resource_type": log.resource_type, + "resource_id": log.resource_id, + "operator": log.operator, + "ip_address": log.ip_address, + "details": log.details, + "created_at": log.created_at.isoformat() if log.created_at else None, + } + for log in logs + ], + "page_num": page_num, + "page_size": page_size, + "total": total, + "has_more": (page_num + 1) * page_size < total, + } diff --git a/backend/app/routers/settings.py b/backend/app/routers/settings.py new file mode 100644 index 0000000..0c49f6c --- /dev/null +++ b/backend/app/routers/settings.py @@ -0,0 +1,85 @@ +"""System settings router.""" +from typing import List + +from fastapi import APIRouter, Depends, Request +from sqlalchemy.orm import Session + +from app.auth import get_current_user, AdminUser +from app.database import get_db +from app.models.db_models import SystemSetting +from app.services import airwallex_service +from app.services.audit_log import create_audit_log + +router = APIRouter(prefix="/api/settings", tags=["settings"]) + +# Keys that should have their values masked in responses +SENSITIVE_KEYS = {"airwallex_api_key", "proxy_password"} + + +@router.get("") +def get_settings( + db: Session = Depends(get_db), + user: AdminUser = Depends(get_current_user), +): + """Get all system settings.""" + settings = db.query(SystemSetting).all() + result = [] + for s in settings: + value = "********" if s.key in SENSITIVE_KEYS and s.value else s.value + result.append({ + "key": s.key, + "value": value, + "encrypted": s.encrypted, + "updated_at": s.updated_at.isoformat() if s.updated_at else None, + }) + return result + + +@router.put("") +def update_settings( + updates: List[dict], + request: Request, + db: Session = Depends(get_db), + user: AdminUser = Depends(get_current_user), +): + """Update system settings. Accepts a list of {key, value} objects.""" + for item in updates: + key = item.get("key") + value = item.get("value") + if not key: + continue + # Skip if masked value sent back unchanged + if value == "********": + continue + + existing = db.query(SystemSetting).filter(SystemSetting.key == key).first() + if existing: + existing.value = str(value) + existing.encrypted = key in SENSITIVE_KEYS + else: + db.add(SystemSetting( + key=key, + value=str(value), + encrypted=key in SENSITIVE_KEYS, + )) + + db.commit() + + create_audit_log( + db, + action="update_settings", + resource_type="settings", + operator=user.username, + ip_address=request.client.host if request.client else "", + details=f"Updated keys: {[item.get('key') for item in updates]}", + ) + return {"message": "Settings updated"} + + +@router.post("/test-connection") +def test_connection( + db: Session = Depends(get_db), + user: AdminUser = Depends(get_current_user), +): + """Test Airwallex API connection with current settings.""" + return airwallex_service.test_connection(db) diff --git a/backend/app/routers/tokens.py b/backend/app/routers/tokens.py new file mode 100644 index 0000000..7cf183a --- /dev/null +++ b/backend/app/routers/tokens.py @@ -0,0 +1,114 @@ +"""API token management router.""" +import secrets +import json +from datetime import datetime, timezone, timedelta +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Request +from sqlalchemy.orm import Session + +from app.auth import get_current_user, AdminUser +from app.database import get_db +from app.models.db_models import ApiToken +from app.services.audit_log import create_audit_log + +router = APIRouter(prefix="/api/tokens", tags=["tokens"]) + + +@router.get("") +def list_tokens( + db: Session = Depends(get_db), + user: AdminUser = Depends(get_current_user), +): + """List all API tokens.""" + tokens = db.query(ApiToken).order_by(ApiToken.created_at.desc()).all() + return [ + { + "id": t.id, + "name": t.name, + "token": t.token[:8] + "..." + t.token[-4:] if t.token else "", + "permissions": json.loads(t.permissions) if t.permissions else [], + "is_active": t.is_active, + "created_at": t.created_at.isoformat() if t.created_at else None, + "expires_at": t.expires_at.isoformat() if t.expires_at else None, + "last_used_at": t.last_used_at.isoformat() if t.last_used_at else None, + } + for t in tokens + ] + + +@router.post("") +def create_token( + data: dict, + request: Request, + db: Session = Depends(get_db), + user: AdminUser = Depends(get_current_user), +): + """Create a new API token.""" + name = data.get("name", "Unnamed Token") + permissions = data.get("permissions", []) + expires_in_days = data.get("expires_in_days") + + raw_token = secrets.token_urlsafe(32) + expires_at = None + if expires_in_days: + expires_at = datetime.now(timezone.utc) + timedelta(days=int(expires_in_days)) + + token = ApiToken( + name=name, + token=raw_token, + permissions=json.dumps(permissions), + is_active=True, + created_at=datetime.now(timezone.utc), + expires_at=expires_at, + ) + db.add(token) + db.commit() + db.refresh(token) + + create_audit_log( + db, + action="create_token", + resource_type="api_token", + resource_id=str(token.id), + operator=user.username, + ip_address=request.client.host if request.client else "", + details=f"Created token '{name}' with permissions {permissions}", + ) + + return { + "id": token.id, + "name": token.name, + "token": raw_token, # Only shown once on creation + "permissions": permissions, + "is_active": True, + "created_at": token.created_at.isoformat(), + "expires_at": token.expires_at.isoformat() if token.expires_at else None, + } + + +@router.delete("/{token_id}") +def delete_token( + token_id: int, + request: Request, + db: Session = Depends(get_db), + user: AdminUser = Depends(get_current_user), +): + """Revoke an API token.""" + token = db.query(ApiToken).filter(ApiToken.id == token_id).first() + if not token: + raise HTTPException(status_code=404, detail="Token not found") + + token.is_active = False + db.commit() + + create_audit_log( + db, + action="revoke_token", + resource_type="api_token", + resource_id=str(token_id), + operator=user.username, + ip_address=request.client.host if request.client else "", + ) + + return {"message": "Token revoked"} diff --git a/backend/app/routers/transactions.py b/backend/app/routers/transactions.py new file mode 100644 index 0000000..ffbde9d --- /dev/null +++ b/backend/app/routers/transactions.py @@ -0,0 +1,44 @@ +"""Transactions router.""" +from typing import Optional + +from fastapi import APIRouter, Depends, Query +from sqlalchemy.orm import Session + +from app.auth import get_current_user, AdminUser +from app.database import get_db +from app.services import airwallex_service + +router = APIRouter(prefix="/api", tags=["transactions"]) + + +@router.get("/transactions") +def list_transactions( + page_num: int = Query(0, ge=0), + page_size: int = Query(20, ge=1, le=100), + card_id: Optional[str] = None, + from_created_at: Optional[str] = None, + to_created_at: Optional[str] = None, + db: Session = Depends(get_db), + user: AdminUser = Depends(get_current_user), +): + """List transactions.""" + return airwallex_service.list_transactions( + db, page_num, page_size, card_id, from_created_at, to_created_at + ) + + +@router.get("/authorizations") +def list_authorizations( + page_num: int = Query(0, ge=0), + page_size: int = Query(20, ge=1, le=100), + card_id: Optional[str] = None, + status: Optional[str] = None, + from_created_at: Optional[str] = None, + to_created_at: Optional[str] = None, + db: Session = Depends(get_db), + user: AdminUser = Depends(get_current_user), +): + """List authorizations.""" + return airwallex_service.list_authorizations( + db, page_num, page_size, card_id, status, from_created_at, to_created_at + ) diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/airwallex_service.py b/backend/app/services/airwallex_service.py new file mode 100644 index 0000000..19eb227 --- /dev/null +++ b/backend/app/services/airwallex_service.py @@ -0,0 +1,258 @@ +"""Airwallex API service layer.""" +import json +import logging +from typing import Any, Dict, List, Optional + +from sqlalchemy.orm import Session + +from app.models.db_models import SystemSetting +from app.proxy_client import ProxiedAirwallexClient + +logger = logging.getLogger(__name__) + +# Cached client instance +_client_instance: Optional[ProxiedAirwallexClient] = None +_client_config_hash: Optional[str] = None + + +def _get_setting(db: Session, key: str, default: str = "") -> str: + """Get a system setting value.""" + setting = db.query(SystemSetting).filter(SystemSetting.key == key).first() + return setting.value if setting else default + + +def _build_proxy_url(db: Session) -> Optional[str]: + """Build proxy URL from settings.""" + proxy_ip = _get_setting(db, "proxy_ip") + proxy_port = _get_setting(db, "proxy_port") + if not proxy_ip or not proxy_port: + return None + proxy_user = _get_setting(db, "proxy_username") + proxy_pass = _get_setting(db, "proxy_password") + if proxy_user and proxy_pass: + return f"http://{proxy_user}:{proxy_pass}@{proxy_ip}:{proxy_port}" + return f"http://{proxy_ip}:{proxy_port}" + + +def get_client(db: Session) -> ProxiedAirwallexClient: + """Get or create an Airwallex client with current settings.""" + global _client_instance, _client_config_hash + + client_id = _get_setting(db, "airwallex_client_id") + api_key = _get_setting(db, "airwallex_api_key") + base_url = _get_setting(db, "airwallex_base_url", "https://api.airwallex.com/") + proxy_url = _build_proxy_url(db) + + if not client_id or not api_key: + raise ValueError("Airwallex credentials not configured. Please set client_id and api_key in Settings.") + + config_hash = f"{client_id}:{api_key}:{base_url}:{proxy_url}" + if _client_instance and _client_config_hash == config_hash: + return _client_instance + + # Close old client + if _client_instance: + try: + _client_instance.close() + except Exception: + pass + + _client_instance = ProxiedAirwallexClient( + proxy_url=proxy_url, + client_id=client_id, + api_key=api_key, + base_url=base_url, + ) + _client_config_hash = config_hash + return _client_instance + + +def ensure_authenticated(db: Session) -> ProxiedAirwallexClient: + """Get client and ensure it's authenticated.""" + client = get_client(db) + client.authenticate() + return client + + +# ─── Card operations ──────────────────────────────────────────────── + +def list_cards( + db: Session, + page_num: int = 0, + page_size: int = 20, + cardholder_id: Optional[str] = None, + status: Optional[str] = None, +) -> Dict[str, Any]: + """List cards with optional filters.""" + client = ensure_authenticated(db) + params: Dict[str, Any] = {"page_num": page_num, "page_size": page_size} + if cardholder_id: + params["cardholder_id"] = cardholder_id + if status: + params["status"] = status + + cards = client.issuing_card.list_with_filters(**params) + return { + "items": [c.to_dict() if hasattr(c, "to_dict") else c.__dict__ for c in cards], + "page_num": page_num, + "page_size": page_size, + "has_more": len(cards) == page_size, + } + + +def create_card(db: Session, card_data: Dict[str, Any]) -> Dict[str, Any]: + """Create a new card.""" + from airwallex.models.issuing_card import CardCreateRequest + + client = ensure_authenticated(db) + request = CardCreateRequest(**card_data) + card = client.issuing_card.create_card(request) + return card.to_dict() if hasattr(card, "to_dict") else card.__dict__ + + +def get_card(db: Session, card_id: str) -> Dict[str, Any]: + """Get card by ID.""" + client = ensure_authenticated(db) + card = client.issuing_card.get(card_id) + return card.to_dict() if hasattr(card, "to_dict") else card.__dict__ + + +def get_card_details(db: Session, card_id: str) -> Dict[str, Any]: + """Get sensitive card details (card number, CVV, expiry).""" + client = ensure_authenticated(db) + details = client.issuing_card.get_card_details(card_id) + return details.to_dict() if hasattr(details, "to_dict") else details.__dict__ + + +def update_card(db: Session, card_id: str, update_data: Dict[str, Any]) -> Dict[str, Any]: + """Update card attributes.""" + from airwallex.models.issuing_card import CardUpdateRequest + + client = ensure_authenticated(db) + request = CardUpdateRequest(**update_data) + card = client.issuing_card.update_card(card_id, request) + return card.to_dict() if hasattr(card, "to_dict") else card.__dict__ + + +# ─── Cardholder operations ────────────────────────────────────────── + +def list_cardholders( + db: Session, + page_num: int = 0, + page_size: int = 20, +) -> Dict[str, Any]: + """List cardholders.""" + client = ensure_authenticated(db) + cardholders = client.issuing_cardholder.list(page_num=page_num, page_size=page_size) + return { + "items": [c.to_dict() if hasattr(c, "to_dict") else c.__dict__ for c in cardholders], + "page_num": page_num, + "page_size": page_size, + "has_more": len(cardholders) == page_size, + } + + +def create_cardholder(db: Session, cardholder_data: Dict[str, Any]) -> Dict[str, Any]: + """Create a new cardholder.""" + from airwallex.models.issuing_cardholder import CardholderCreateRequest + + client = ensure_authenticated(db) + request = CardholderCreateRequest(**cardholder_data) + cardholder = client.issuing_cardholder.create_cardholder(request) + return cardholder.to_dict() if hasattr(cardholder, "to_dict") else cardholder.__dict__ + + +# ─── Transaction operations ───────────────────────────────────────── + +def list_transactions( + db: Session, + page_num: int = 0, + page_size: int = 20, + card_id: Optional[str] = None, + from_created_at: Optional[str] = None, + to_created_at: Optional[str] = None, +) -> Dict[str, Any]: + """List transactions.""" + client = ensure_authenticated(db) + params: Dict[str, Any] = {"page_num": page_num, "page_size": page_size} + if card_id: + params["card_id"] = card_id + if from_created_at: + params["from_created_at"] = from_created_at + if to_created_at: + params["to_created_at"] = to_created_at + + txns = client.issuing_transaction.list_with_filters(**params) + return { + "items": [t.to_dict() if hasattr(t, "to_dict") else t.__dict__ for t in txns], + "page_num": page_num, + "page_size": page_size, + "has_more": len(txns) == page_size, + } + + +def list_authorizations( + db: Session, + page_num: int = 0, + page_size: int = 20, + card_id: Optional[str] = None, + status: Optional[str] = None, + from_created_at: Optional[str] = None, + to_created_at: Optional[str] = None, +) -> Dict[str, Any]: + """List authorizations.""" + client = ensure_authenticated(db) + params: Dict[str, Any] = {"page_num": page_num, "page_size": page_size} + if card_id: + params["card_id"] = card_id + if status: + params["status"] = status + if from_created_at: + params["from_created_at"] = from_created_at + if to_created_at: + params["to_created_at"] = to_created_at + + auths = client.issuing_authorization.list_with_filters(**params) + return { + "items": [a.to_dict() if hasattr(a, "to_dict") else a.__dict__ for a in auths], + "page_num": page_num, + "page_size": page_size, + "has_more": len(auths) == page_size, + } + + +# ─── Account / Balance ────────────────────────────────────────────── + +def get_balance(db: Session) -> Optional[Dict[str, Any]]: + """Get account balance. Returns None if not available.""" + try: + client = ensure_authenticated(db) + # Try to get global account balance via the balances endpoint + response = client._request("GET", "api/v1/balances/current") + return response.json() + except Exception as e: + logger.warning("Failed to fetch balance: %s", e) + return None + + +# ─── Config ────────────────────────────────────────────────────────── + +def get_issuing_config(db: Session) -> Optional[Dict[str, Any]]: + """Get issuing configuration.""" + try: + client = ensure_authenticated(db) + config = client.issuing_config.get_config() + return config.to_dict() if hasattr(config, "to_dict") else config.__dict__ + except Exception as e: + logger.warning("Failed to fetch issuing config: %s", e) + return None + + +def test_connection(db: Session) -> Dict[str, Any]: + """Test Airwallex API connection.""" + try: + client = get_client(db) + client.authenticate() + return {"success": True, "message": "Connection successful"} + except Exception as e: + return {"success": False, "message": str(e)} diff --git a/backend/app/services/audit_log.py b/backend/app/services/audit_log.py new file mode 100644 index 0000000..a4c5f5d --- /dev/null +++ b/backend/app/services/audit_log.py @@ -0,0 +1,56 @@ +"""Audit logging service.""" +from datetime import datetime, timezone +from sqlalchemy.orm import Session +from app.models.db_models import AuditLog, CardLog + + +def create_audit_log( + db: Session, + action: str, + resource_type: str, + operator: str, + resource_id: str = "", + ip_address: str = "", + details: str = "", +) -> AuditLog: + """Create an audit log entry.""" + log = AuditLog( + action=action, + resource_type=resource_type, + resource_id=resource_id, + operator=operator, + ip_address=ip_address, + details=details, + created_at=datetime.now(timezone.utc), + ) + db.add(log) + db.commit() + db.refresh(log) + return log + + +def create_card_log( + db: Session, + action: str, + status: str, + operator: str, + card_id: str = "", + cardholder_id: str = "", + request_data: str = "", + response_data: str = "", +) -> CardLog: + """Create a card operation log entry.""" + log = CardLog( + card_id=card_id, + cardholder_id=cardholder_id, + action=action, + status=status, + operator=operator, + request_data=request_data, + response_data=response_data, + created_at=datetime.now(timezone.utc), + ) + db.add(log) + db.commit() + db.refresh(log) + return log diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..34d201f --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,11 @@ +fastapi==0.115.0 +uvicorn[standard]==0.30.0 +sqlalchemy==2.0.35 +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +bcrypt==4.1.3 +python-dotenv==1.0.1 +pydantic==2.11.3 +pydantic-settings==2.6.0 +httpx==0.28.1 +cryptography==43.0.0 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c87ad6c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,31 @@ +version: "3.8" + +services: + backend: + build: + context: . + dockerfile: backend/Dockerfile + container_name: airwallex-backend + ports: + - "8000:8000" + volumes: + - db_data:/app/data + env_file: + - backend/.env + environment: + - DATABASE_URL=sqlite:///./data/airwallex.db + restart: unless-stopped + + frontend: + build: + context: frontend + dockerfile: Dockerfile + container_name: airwallex-frontend + ports: + - "3000:3000" + depends_on: + - backend + restart: unless-stopped + +volumes: + db_data: diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..0ed6736 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,23 @@ +# Build stage +FROM node:20-alpine AS builder + +WORKDIR /app + +COPY package.json ./ +RUN npm install + +COPY . . +RUN npm run build + +# Production stage +FROM nginx:alpine + +# Copy built assets +COPY --from=builder /app/dist /usr/share/nginx/html + +# Nginx config for SPA routing + API proxy +COPY nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 3000 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..6af4da3 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Airwallex 发卡管理 + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..6d02a2d --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,26 @@ +server { + listen 3000; + server_name localhost; + root /usr/share/nginx/html; + index index.html; + + # API proxy to backend + location /api/ { + proxy_pass http://backend:8000/api/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # SPA fallback + location / { + try_files $uri $uri/ /index.html; + } + + # Cache static assets + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..8d311bb --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,28 @@ +{ + "name": "airwallex-admin", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.26.0", + "antd": "^5.20.0", + "@ant-design/icons": "^5.4.0", + "axios": "^1.7.4", + "dayjs": "^1.11.12", + "zustand": "^4.5.4" + }, + "devDependencies": { + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.1", + "typescript": "^5.5.4", + "vite": "^5.4.0" + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..bb6a13b --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,49 @@ +import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' +import { useAuthStore } from '@/stores/auth' +import Layout from '@/components/Layout' +import Login from '@/pages/Login' +import Dashboard from '@/pages/Dashboard' +import Cards from '@/pages/Cards' +import CardDetail from '@/pages/CardDetail' +import Cardholders from '@/pages/Cardholders' +import Transactions from '@/pages/Transactions' +import ApiTokens from '@/pages/ApiTokens' +import Settings from '@/pages/Settings' +import AuditLog from '@/pages/AuditLog' + +function ProtectedRoute({ children }: { children: React.ReactNode }) { + const isAuthenticated = useAuthStore((s) => s.isAuthenticated) + if (!isAuthenticated) { + return + } + return <>{children} +} + +function App() { + return ( + + + } /> + + + + } + > + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + ) +} + +export default App diff --git a/frontend/src/components/CreateCardModal.tsx b/frontend/src/components/CreateCardModal.tsx new file mode 100644 index 0000000..7d58cfd --- /dev/null +++ b/frontend/src/components/CreateCardModal.tsx @@ -0,0 +1,118 @@ +import { useState, useEffect } from 'react' +import { Modal, Form, Input, Select, InputNumber, Divider, message } from 'antd' +import { cardsApi, cardholdersApi } from '@/services/api' + +interface Props { + open: boolean + onClose: () => void + onSuccess: () => void +} + +interface CardholderOption { + cardholder_id: string + first_name: string + last_name: string + email: string +} + +export default function CreateCardModal({ open, onClose, onSuccess }: Props) { + const [form] = Form.useForm() + const [loading, setLoading] = useState(false) + const [cardholders, setCardholders] = useState([]) + const [chLoading, setChLoading] = useState(false) + + useEffect(() => { + if (open) { + setChLoading(true) + cardholdersApi + .getCardholders({ page_size: 100 }) + .then((res) => { + setCardholders(res.data.items || res.data || []) + }) + .catch(() => { + message.error('获取持卡人列表失败') + }) + .finally(() => setChLoading(false)) + } + }, [open]) + + const handleSubmit = async (values: Record) => { + setLoading(true) + try { + await cardsApi.createCard(values) + message.success('卡片创建成功') + form.resetFields() + onSuccess() + } catch (err: unknown) { + const error = err as { response?: { data?: { detail?: string } } } + message.error(error.response?.data?.detail || '创建卡片失败') + } finally { + setLoading(false) + } + } + + return ( + form.submit()} + confirmLoading={loading} + okText="创建" + cancelText="取消" + width={520} + > +
+ + + + + + + + + +
+
+ ) +} diff --git a/frontend/src/components/CreateCardholderModal.tsx b/frontend/src/components/CreateCardholderModal.tsx new file mode 100644 index 0000000..da3c81e --- /dev/null +++ b/frontend/src/components/CreateCardholderModal.tsx @@ -0,0 +1,142 @@ +import { useState } from 'react' +import { Modal, Form, Input, DatePicker, Select, Row, Col, message } from 'antd' +import { cardholdersApi } from '@/services/api' + +interface Props { + open: boolean + onClose: () => void + onSuccess: () => void +} + +const countries = [ + { label: '中国', value: 'CN' }, + { label: '美国', value: 'US' }, + { label: '英国', value: 'GB' }, + { label: '香港', value: 'HK' }, + { label: '新加坡', value: 'SG' }, + { label: '澳大利亚', value: 'AU' }, + { label: '加拿大', value: 'CA' }, + { label: '日本', value: 'JP' }, + { label: '韩国', value: 'KR' }, + { label: '德国', value: 'DE' }, +] + +export default function CreateCardholderModal({ open, onClose, onSuccess }: Props) { + const [form] = Form.useForm() + const [loading, setLoading] = useState(false) + + const handleSubmit = async (values: Record) => { + setLoading(true) + try { + const data = { + ...values, + date_of_birth: (values.date_of_birth as { format: (f: string) => string })?.format('YYYY-MM-DD'), + } + await cardholdersApi.createCardholder(data) + message.success('持卡人创建成功') + form.resetFields() + onSuccess() + } catch (err: unknown) { + const error = err as { response?: { data?: { detail?: string } } } + message.error(error.response?.data?.detail || '创建持卡人失败') + } finally { + setLoading(false) + } + } + + return ( + form.submit()} + confirmLoading={loading} + okText="创建" + cancelText="取消" + width={600} + > +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + setNewToken(null)} + onCancel={() => setNewToken(null)} + footer={[ + , + ]} + > +

+ 请立即复制并保存此令牌,关闭后将无法再次查看。 +

+ + , + tooltips: ['复制', '已复制'], + }} + style={{ + fontFamily: 'monospace', + background: '#f5f5f5', + padding: '12px 16px', + borderRadius: 6, + wordBreak: 'break-all', + }} + > + {newToken} + + +
+ + ) +} diff --git a/frontend/src/pages/AuditLog.tsx b/frontend/src/pages/AuditLog.tsx new file mode 100644 index 0000000..f8e1e65 --- /dev/null +++ b/frontend/src/pages/AuditLog.tsx @@ -0,0 +1,109 @@ +import { useState, useEffect, useCallback } from 'react' +import { Table, DatePicker, message } from 'antd' +import type { ColumnsType } from 'antd/es/table' +import dayjs from 'dayjs' +import type { Dayjs } from 'dayjs' +import { auditLogsApi } from '@/services/api' + +const { RangePicker } = DatePicker + +interface AuditLogRecord { + id: string + action: string + resource_type: string + resource_id: string + operator: string + ip_address: string + details: string + created_at: string +} + +export default function AuditLog() { + const [data, setData] = useState([]) + const [loading, setLoading] = useState(true) + const [total, setTotal] = useState(0) + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(20) + const [dateRange, setDateRange] = useState<[Dayjs, Dayjs] | null>(null) + + const fetchData = useCallback(async () => { + setLoading(true) + const params: Record = { page, page_size: pageSize } + if (dateRange) { + params.start_date = dateRange[0].format('YYYY-MM-DD') + params.end_date = dateRange[1].format('YYYY-MM-DD') + } + try { + const res = await auditLogsApi.getAuditLogs(params) + setData(res.data.items || res.data || []) + setTotal(res.data.total || 0) + } catch { + message.error('获取操作日志失败') + } finally { + setLoading(false) + } + }, [page, pageSize, dateRange]) + + useEffect(() => { + fetchData() + }, [fetchData]) + + const columns: ColumnsType = [ + { + title: '时间', + dataIndex: 'created_at', + key: 'created_at', + width: 180, + render: (t: string) => dayjs(t).format('YYYY-MM-DD HH:mm:ss'), + }, + { title: '操作', dataIndex: 'action', key: 'action', width: 120 }, + { title: '资源类型', dataIndex: 'resource_type', key: 'resource_type', width: 120 }, + { + title: '资源 ID', + dataIndex: 'resource_id', + key: 'resource_id', + width: 160, + render: (v: string) => v ? {v.slice(0, 12)}... : '-', + }, + { title: '操作人', dataIndex: 'operator', key: 'operator', width: 120 }, + { title: 'IP 地址', dataIndex: 'ip_address', key: 'ip_address', width: 140 }, + { + title: '详情', + dataIndex: 'details', + key: 'details', + ellipsis: true, + }, + ] + + return ( +
+
+

操作日志

+
+
+ { + setDateRange(dates as [Dayjs, Dayjs] | null) + setPage(1) + }} + placeholder={['开始日期', '结束日期']} + /> +
+ `共 ${t} 条`, + onChange: (p, ps) => { setPage(p); setPageSize(ps) }, + }} + /> + + ) +} diff --git a/frontend/src/pages/CardDetail.tsx b/frontend/src/pages/CardDetail.tsx new file mode 100644 index 0000000..9faea1a --- /dev/null +++ b/frontend/src/pages/CardDetail.tsx @@ -0,0 +1,269 @@ +import { useState, useEffect, useCallback } from 'react' +import { useParams, useNavigate } from 'react-router-dom' +import { + Card, + Descriptions, + Button, + Tag, + Table, + Space, + message, + Spin, + Modal, + InputNumber, + Form, +} from 'antd' +import { + ArrowLeftOutlined, + EyeOutlined, + EyeInvisibleOutlined, + LockOutlined, + UnlockOutlined, +} from '@ant-design/icons' +import type { ColumnsType } from 'antd/es/table' +import dayjs from 'dayjs' +import { cardsApi, transactionsApi } from '@/services/api' + +interface CardInfo { + card_id: string + nickname: string + cardholder_id: string + cardholder_name: string + status: string + form_factor: string + brand: string + currency: string + created_at: string + is_personalized: boolean + card_number?: string + cvv?: string + expiry_month?: string + expiry_year?: string +} + +interface Transaction { + transaction_id: string + amount: number + currency: string + status: string + merchant: string + created_at: string +} + +const statusColors: Record = { + ACTIVE: 'green', + INACTIVE: 'default', + FROZEN: 'blue', + CLOSED: 'red', +} + +export default function CardDetail() { + const { id } = useParams<{ id: string }>() + const navigate = useNavigate() + const [card, setCard] = useState(null) + const [loading, setLoading] = useState(true) + const [showSensitive, setShowSensitive] = useState(false) + const [sensitiveLoading, setSensitiveLoading] = useState(false) + const [transactions, setTransactions] = useState([]) + const [txLoading, setTxLoading] = useState(false) + const [limitModalOpen, setLimitModalOpen] = useState(false) + const [limitForm] = Form.useForm() + + const fetchCard = useCallback(async () => { + if (!id) return + setLoading(true) + try { + const res = await cardsApi.getCard(id) + setCard(res.data) + } catch { + message.error('获取卡片信息失败') + } finally { + setLoading(false) + } + }, [id]) + + const fetchTransactions = useCallback(async () => { + if (!id) return + setTxLoading(true) + try { + const res = await transactionsApi.getTransactions({ card_id: id }) + setTransactions(res.data.items || res.data || []) + } catch { + // ignore + } finally { + setTxLoading(false) + } + }, [id]) + + useEffect(() => { + fetchCard() + fetchTransactions() + }, [fetchCard, fetchTransactions]) + + const handleShowSensitive = async () => { + if (showSensitive) { + setShowSensitive(false) + return + } + if (!id) return + setSensitiveLoading(true) + try { + const res = await cardsApi.getCardDetails(id) + setCard((prev) => (prev ? { ...prev, ...res.data } : prev)) + setShowSensitive(true) + } catch { + message.error('获取敏感信息失败') + } finally { + setSensitiveLoading(false) + } + } + + const handleStatusChange = async (action: 'activate' | 'freeze') => { + if (!id) return + try { + await cardsApi.updateCard(id, { + status: action === 'activate' ? 'ACTIVE' : 'FROZEN', + }) + message.success(action === 'activate' ? '卡片已激活' : '卡片已冻结') + fetchCard() + } catch { + message.error('操作失败') + } + } + + const handleUpdateLimits = async (values: { daily_limit: number; monthly_limit: number }) => { + if (!id) return + try { + await cardsApi.updateCard(id, { authorization_controls: values }) + message.success('限额更新成功') + setLimitModalOpen(false) + fetchCard() + } catch { + message.error('更新限额失败') + } + } + + const txColumns: ColumnsType = [ + { title: '交易 ID', dataIndex: 'transaction_id', key: 'transaction_id', width: 160, render: (v: string) => v?.slice(0, 12) + '...' }, + { title: '金额', dataIndex: 'amount', key: 'amount', render: (v: number, r: Transaction) => `${r.currency} ${v?.toFixed(2)}` }, + { title: '商户', dataIndex: 'merchant', key: 'merchant' }, + { title: '状态', dataIndex: 'status', key: 'status', render: (s: string) => {s} }, + { title: '时间', dataIndex: 'created_at', key: 'created_at', render: (t: string) => dayjs(t).format('YYYY-MM-DD HH:mm') }, + ] + + if (loading) return + + if (!card) return
卡片不存在
+ + return ( +
+
+ + +

卡片详情

+
+ + {card.status === 'INACTIVE' && ( + + )} + {card.status === 'ACTIVE' && ( + + )} + {card.status === 'FROZEN' && ( + + )} + + +
+ + + + {card.card_id} + {card.nickname || '-'} + {card.cardholder_id} + {card.cardholder_name || '-'} + + {card.status} + + {card.form_factor} + {card.brand || '-'} + {card.currency} + + {dayjs(card.created_at).format('YYYY-MM-DD HH:mm:ss')} + + + + + : } + loading={sensitiveLoading} + onClick={handleShowSensitive} + > + {showSensitive ? '隐藏' : '查看敏感信息'} + + } + > + {showSensitive ? ( + + + {card.card_number || '-'} + + + {card.cvv || '-'} + + + + {card.expiry_month && card.expiry_year + ? `${card.expiry_month}/${card.expiry_year}` + : '-'} + + + + ) : ( +
+ 点击上方按钮查看敏感信息 +
+ )} +
+ + +
`共 ${t} 条` }} + /> + + + setLimitModalOpen(false)} + onOk={() => limitForm.submit()} + > +
+ + + + + + + +
+ + ) +} diff --git a/frontend/src/pages/Cardholders.tsx b/frontend/src/pages/Cardholders.tsx new file mode 100644 index 0000000..177b6fb --- /dev/null +++ b/frontend/src/pages/Cardholders.tsx @@ -0,0 +1,102 @@ +import { useState, useEffect, useCallback } from 'react' +import { Table, Button, Tag, message } from 'antd' +import { PlusOutlined } from '@ant-design/icons' +import type { ColumnsType } from 'antd/es/table' +import dayjs from 'dayjs' +import { cardholdersApi } from '@/services/api' +import CreateCardholderModal from '@/components/CreateCardholderModal' + +interface Cardholder { + cardholder_id: string + first_name: string + last_name: string + email: string + status: string + created_at: string +} + +export default function Cardholders() { + const [data, setData] = useState([]) + const [loading, setLoading] = useState(true) + const [total, setTotal] = useState(0) + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(20) + const [modalOpen, setModalOpen] = useState(false) + + const fetchData = useCallback(async () => { + setLoading(true) + try { + const res = await cardholdersApi.getCardholders({ page, page_size: pageSize }) + setData(res.data.items || res.data) + setTotal(res.data.total || 0) + } catch { + message.error('获取持卡人列表失败') + } finally { + setLoading(false) + } + }, [page, pageSize]) + + useEffect(() => { + fetchData() + }, [fetchData]) + + const columns: ColumnsType = [ + { + title: 'ID', + dataIndex: 'cardholder_id', + key: 'cardholder_id', + width: 160, + render: (id: string) => {id?.slice(0, 12)}..., + }, + { + title: '姓名', + key: 'name', + render: (_: unknown, r: Cardholder) => `${r.first_name} ${r.last_name}`, + }, + { title: '邮箱', dataIndex: 'email', key: 'email' }, + { + title: '状态', + dataIndex: 'status', + key: 'status', + render: (s: string) => ( + {s} + ), + }, + { + title: '创建时间', + dataIndex: 'created_at', + key: 'created_at', + render: (t: string) => dayjs(t).format('YYYY-MM-DD HH:mm'), + }, + ] + + return ( +
+
+

持卡人管理

+ +
+
`共 ${t} 条`, + onChange: (p, ps) => { setPage(p); setPageSize(ps) }, + }} + /> + setModalOpen(false)} + onSuccess={() => { setModalOpen(false); fetchData() }} + /> + + ) +} diff --git a/frontend/src/pages/Cards.tsx b/frontend/src/pages/Cards.tsx new file mode 100644 index 0000000..0729629 --- /dev/null +++ b/frontend/src/pages/Cards.tsx @@ -0,0 +1,142 @@ +import { useState, useEffect, useCallback } from 'react' +import { useNavigate } from 'react-router-dom' +import { Table, Button, Select, Input, Tag, Space, message } from 'antd' +import { PlusOutlined, SearchOutlined } from '@ant-design/icons' +import type { ColumnsType } from 'antd/es/table' +import dayjs from 'dayjs' +import { cardsApi } from '@/services/api' +import CreateCardModal from '@/components/CreateCardModal' + +interface CardRecord { + card_id: string + nickname: string + cardholder_name: string + status: string + form_factor: string + created_at: string +} + +const statusColors: Record = { + ACTIVE: 'green', + INACTIVE: 'default', + FROZEN: 'blue', + CLOSED: 'red', +} + +export default function Cards() { + const [data, setData] = useState([]) + const [loading, setLoading] = useState(true) + const [total, setTotal] = useState(0) + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(20) + const [status, setStatus] = useState() + const [search, setSearch] = useState('') + const [modalOpen, setModalOpen] = useState(false) + const navigate = useNavigate() + + const fetchData = useCallback(async () => { + setLoading(true) + try { + const params: Record = { page, page_size: pageSize } + if (status) params.status = status + if (search) params.search = search + const res = await cardsApi.getCards(params) + setData(res.data.items || res.data) + setTotal(res.data.total || 0) + } catch { + message.error('获取卡片列表失败') + } finally { + setLoading(false) + } + }, [page, pageSize, status, search]) + + useEffect(() => { + fetchData() + }, [fetchData]) + + const columns: ColumnsType = [ + { + title: '卡片 ID', + dataIndex: 'card_id', + key: 'card_id', + width: 160, + render: (id: string) => ( + {id?.slice(0, 12)}... + ), + }, + { title: '昵称', dataIndex: 'nickname', key: 'nickname' }, + { title: '持卡人', dataIndex: 'cardholder_name', key: 'cardholder_name' }, + { + title: '状态', + dataIndex: 'status', + key: 'status', + render: (s: string) => {s}, + }, + { title: '类型', dataIndex: 'form_factor', key: 'form_factor' }, + { + title: '创建时间', + dataIndex: 'created_at', + key: 'created_at', + render: (t: string) => dayjs(t).format('YYYY-MM-DD HH:mm'), + }, + ] + + return ( +
+
+

卡片管理

+ +
+
+ setSearch(e.target.value)} + onPressEnter={() => { setPage(1); fetchData() }} + style={{ width: 250 }} + /> +
+
`共 ${t} 条`, + onChange: (p, ps) => { setPage(p); setPageSize(ps) }, + }} + onRow={(record) => ({ + onClick: () => navigate(`/cards/${record.card_id}`), + style: { cursor: 'pointer' }, + })} + /> + setModalOpen(false)} + onSuccess={() => { setModalOpen(false); fetchData() }} + /> + + ) +} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx new file mode 100644 index 0000000..97ba09d --- /dev/null +++ b/frontend/src/pages/Dashboard.tsx @@ -0,0 +1,103 @@ +import { useState, useEffect } from 'react' +import { Row, Col, Card, Statistic, Button, Skeleton, message } from 'antd' +import { + DollarOutlined, + CreditCardOutlined, + CheckCircleOutlined, + PlusCircleOutlined, + ReloadOutlined, +} from '@ant-design/icons' +import { dashboardApi } from '@/services/api' + +interface DashboardData { + account_balance: number + balance_currency: string + total_cards: number + active_cards: number + today_new_cards: number +} + +export default function Dashboard() { + const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) + + const fetchData = async () => { + setLoading(true) + try { + const res = await dashboardApi.getDashboard() + setData(res.data) + } catch { + message.error('获取仪表板数据失败') + } finally { + setLoading(false) + } + } + + useEffect(() => { + fetchData() + }, []) + + const cards = [ + { + title: '账户余额', + value: data?.account_balance ?? 0, + prefix: data?.balance_currency === 'USD' ? '$' : data?.balance_currency, + precision: 2, + icon: , + color: '#f6ffed', + }, + { + title: '总卡片数', + value: data?.total_cards ?? 0, + icon: , + color: '#e6f7ff', + }, + { + title: '活跃卡片', + value: data?.active_cards ?? 0, + icon: , + color: '#f6ffed', + }, + { + title: '今日新增', + value: data?.today_new_cards ?? 0, + icon: , + color: '#f9f0ff', + }, + ] + + return ( +
+
+

仪表板

+ +
+ + {cards.map((card) => ( +
+ + {loading ? ( + + ) : ( + `${card.prefix || ''} ${val}` + : undefined + } + /> + )} + + + ))} + + + ) +} diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx new file mode 100644 index 0000000..444a7e1 --- /dev/null +++ b/frontend/src/pages/Login.tsx @@ -0,0 +1,51 @@ +import { useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { Card, Form, Input, Button, message } from 'antd' +import { UserOutlined, LockOutlined, CreditCardOutlined } from '@ant-design/icons' +import { useAuthStore } from '@/stores/auth' + +export default function Login() { + const [loading, setLoading] = useState(false) + const login = useAuthStore((s) => s.login) + const navigate = useNavigate() + + const onFinish = async (values: { username: string; password: string }) => { + setLoading(true) + try { + await login(values.username, values.password) + message.success('登录成功') + navigate('/') + } catch (err: unknown) { + const error = err as { response?: { data?: { detail?: string } } } + message.error(error.response?.data?.detail || '登录失败,请检查用户名和密码') + } finally { + setLoading(false) + } + } + + return ( +
+ +
+ +

+ Airwallex 发卡管理系统 +

+
+
+ + } placeholder="用户名" /> + + + } placeholder="密码" /> + + + + + +
+
+ ) +} diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx new file mode 100644 index 0000000..78a5bc4 --- /dev/null +++ b/frontend/src/pages/Settings.tsx @@ -0,0 +1,143 @@ +import { useState, useEffect } from 'react' +import { Card, Form, Input, InputNumber, Button, Divider, message, Space } from 'antd' +import { SaveOutlined, ApiOutlined } from '@ant-design/icons' +import { settingsApi } from '@/services/api' +import api from '@/services/api' + +interface SettingsData { + airwallex_client_id: string + airwallex_api_key: string + airwallex_base_url: string + proxy_host: string + proxy_port: number | null + proxy_username: string + proxy_password: string + daily_card_limit: number +} + +export default function Settings() { + const [form] = Form.useForm() + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [testing, setTesting] = useState(false) + + const fetchSettings = async () => { + setLoading(true) + try { + const res = await settingsApi.getSettings() + // Backend returns a list of {key, value} — convert to object for form + const data: Record = {} + if (Array.isArray(res.data)) { + for (const item of res.data) { + data[item.key] = item.value + } + } + form.setFieldsValue(data) + } catch { + message.error('获取设置失败') + } finally { + setLoading(false) + } + } + + useEffect(() => { + fetchSettings() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + const handleSave = async (values: SettingsData) => { + setSaving(true) + try { + // Convert form object to list of {key, value} for backend + const updates = Object.entries(values) + .filter(([, v]) => v !== undefined && v !== null) + .map(([key, value]) => ({ key, value: String(value) })) + await settingsApi.updateSettings(updates) + message.success('设置已保存') + } catch { + message.error('保存设置失败') + } finally { + setSaving(false) + } + } + + const handleTestConnection = async () => { + setTesting(true) + try { + const res = await api.post('/settings/test-connection') + if (res.data.success) { + message.success('连接测试成功') + } else { + message.error(res.data.message || '连接测试失败') + } + } catch { + message.error('连接测试失败') + } finally { + setTesting(false) + } + } + + return ( +
+
+

系统设置

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ ) +} diff --git a/frontend/src/pages/Transactions.tsx b/frontend/src/pages/Transactions.tsx new file mode 100644 index 0000000..39690dd --- /dev/null +++ b/frontend/src/pages/Transactions.tsx @@ -0,0 +1,184 @@ +import { useState, useEffect, useCallback } from 'react' +import { Table, Tag, DatePicker, Input, Select, Tabs, message } from 'antd' +import type { ColumnsType } from 'antd/es/table' +import dayjs from 'dayjs' +import type { Dayjs } from 'dayjs' +import { transactionsApi, authorizationsApi } from '@/services/api' + +const { RangePicker } = DatePicker + +interface Transaction { + transaction_id: string + card_id: string + amount: number + currency: string + status: string + transaction_type: string + merchant: string + created_at: string +} + +interface Authorization { + authorization_id: string + card_id: string + amount: number + currency: string + status: string + merchant: string + created_at: string +} + +const statusColors: Record = { + COMPLETED: 'green', + PENDING: 'orange', + FAILED: 'red', + REVERSED: 'purple', + APPROVED: 'green', + DECLINED: 'red', +} + +export default function Transactions() { + const [activeTab, setActiveTab] = useState('transactions') + const [txData, setTxData] = useState([]) + const [authData, setAuthData] = useState([]) + const [loading, setLoading] = useState(true) + const [total, setTotal] = useState(0) + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(20) + const [dateRange, setDateRange] = useState<[Dayjs, Dayjs] | null>(null) + const [cardId, setCardId] = useState('') + const [status, setStatus] = useState() + + const fetchData = useCallback(async () => { + setLoading(true) + const params: Record = { page, page_size: pageSize } + if (dateRange) { + params.start_date = dateRange[0].format('YYYY-MM-DD') + params.end_date = dateRange[1].format('YYYY-MM-DD') + } + if (cardId) params.card_id = cardId + if (status) params.status = status + try { + if (activeTab === 'transactions') { + const res = await transactionsApi.getTransactions(params) + setTxData(res.data.items || res.data) + setTotal(res.data.total || 0) + } else { + const res = await authorizationsApi.getAuthorizations(params) + setAuthData(res.data.items || res.data) + setTotal(res.data.total || 0) + } + } catch { + message.error('获取数据失败') + } finally { + setLoading(false) + } + }, [activeTab, page, pageSize, dateRange, cardId, status]) + + useEffect(() => { + fetchData() + }, [fetchData]) + + const txColumns: ColumnsType = [ + { title: '交易 ID', dataIndex: 'transaction_id', key: 'transaction_id', width: 160, render: (v: string) => {v?.slice(0, 12)}... }, + { title: '卡片 ID', dataIndex: 'card_id', key: 'card_id', width: 140, render: (v: string) => v?.slice(0, 10) + '...' }, + { title: '金额', dataIndex: 'amount', key: 'amount', render: (v: number, r: Transaction) => `${r.currency} ${v?.toFixed(2)}` }, + { title: '状态', dataIndex: 'status', key: 'status', render: (s: string) => {s} }, + { title: '类型', dataIndex: 'transaction_type', key: 'transaction_type' }, + { title: '商户', dataIndex: 'merchant', key: 'merchant' }, + { title: '时间', dataIndex: 'created_at', key: 'created_at', render: (t: string) => dayjs(t).format('YYYY-MM-DD HH:mm') }, + ] + + const authColumns: ColumnsType = [ + { title: '授权 ID', dataIndex: 'authorization_id', key: 'authorization_id', width: 160, render: (v: string) => {v?.slice(0, 12)}... }, + { title: '卡片 ID', dataIndex: 'card_id', key: 'card_id', width: 140, render: (v: string) => v?.slice(0, 10) + '...' }, + { title: '金额', dataIndex: 'amount', key: 'amount', render: (v: number, r: Authorization) => `${r.currency} ${v?.toFixed(2)}` }, + { title: '状态', dataIndex: 'status', key: 'status', render: (s: string) => {s} }, + { title: '商户', dataIndex: 'merchant', key: 'merchant' }, + { title: '时间', dataIndex: 'created_at', key: 'created_at', render: (t: string) => dayjs(t).format('YYYY-MM-DD HH:mm') }, + ] + + return ( +
+
+

交易记录

+
+
+ { + setDateRange(dates as [Dayjs, Dayjs] | null) + setPage(1) + }} + placeholder={['开始日期', '结束日期']} + /> + { setCardId(e.target.value); setPage(1) }} + style={{ width: 200 }} + allowClear + /> +
`共 ${t} 条`, + onChange: (p, ps) => { setPage(p); setPageSize(ps) }, + }} + /> + ), + }, + { + key: 'authorizations', + label: '授权', + children: ( +
`共 ${t} 条`, + onChange: (p, ps) => { setPage(p); setPageSize(ps) }, + }} + /> + ), + }, + ]} + /> + + ) +} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts new file mode 100644 index 0000000..ab1f5b1 --- /dev/null +++ b/frontend/src/services/api.ts @@ -0,0 +1,84 @@ +import axios from 'axios' + +const api = axios.create({ + baseURL: '/api', + timeout: 30000, +}) + +api.interceptors.request.use((config) => { + const token = localStorage.getItem('token') + if (token) { + config.headers.Authorization = `Bearer ${token}` + } + return config +}) + +api.interceptors.response.use( + (response) => response, + (error) => { + if (error.response?.status === 401) { + localStorage.removeItem('token') + localStorage.removeItem('username') + window.location.href = '/login' + } + return Promise.reject(error) + } +) + +export const authApi = { + login: (username: string, password: string) => + api.post('/auth/login', { username, password }), +} + +export const dashboardApi = { + getDashboard: () => api.get('/dashboard'), +} + +export const cardsApi = { + getCards: (params?: Record) => api.get('/cards', { params }), + createCard: (data: Record) => api.post('/cards', data), + getCard: (id: string) => api.get(`/cards/${id}`), + getCardDetails: (id: string) => api.get(`/cards/${id}/details`), + updateCard: (id: string, data: Record) => api.put(`/cards/${id}`, data), +} + +export const cardholdersApi = { + getCardholders: (params?: Record) => api.get('/cardholders', { params }), + createCardholder: (data: Record) => api.post('/cardholders', data), +} + +export const transactionsApi = { + getTransactions: (params?: Record) => api.get('/transactions', { params }), +} + +export const authorizationsApi = { + getAuthorizations: (params?: Record) => api.get('/authorizations', { params }), +} + +export const tokensApi = { + getTokens: () => api.get('/tokens'), + createToken: (data: Record) => api.post('/tokens', data), + deleteToken: (id: string) => api.delete(`/tokens/${id}`), +} + +// Alias for components that import cardholderApi (singular) +export const cardholderApi = { + list: (params?: Record) => api.get('/cardholders', { params }), + create: (data: Record) => api.post('/cardholders', data), +} + +export const settingsApi = { + getSettings: () => api.get('/settings'), + updateSettings: (data: { key: string; value: string }[]) => api.put('/settings', data), + testConnection: () => api.post('/settings/test-connection'), +} + +export const cardLogsApi = { + getCardLogs: (params?: Record) => api.get('/card-logs', { params }), +} + +export const auditLogsApi = { + getAuditLogs: (params?: Record) => api.get('/audit-logs', { params }), +} + +export default api diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts new file mode 100644 index 0000000..01b0d60 --- /dev/null +++ b/frontend/src/stores/auth.ts @@ -0,0 +1,28 @@ +import { create } from 'zustand' +import { authApi } from '@/services/api' + +interface AuthState { + token: string | null + username: string | null + isAuthenticated: boolean + login: (username: string, password: string) => Promise + logout: () => void +} + +export const useAuthStore = create((set) => ({ + token: localStorage.getItem('token'), + username: localStorage.getItem('username'), + isAuthenticated: !!localStorage.getItem('token'), + login: async (username: string, password: string) => { + const res = await authApi.login(username, password) + const token = res.data.access_token + localStorage.setItem('token', token) + localStorage.setItem('username', username) + set({ token, username, isAuthenticated: true }) + }, + logout: () => { + localStorage.removeItem('token') + localStorage.removeItem('username') + set({ token: null, username: null, isAuthenticated: false }) + }, +})) diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..4cfb6de --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..899a78e --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "composite": true, + "emitDeclarationOnly": true, + "declaration": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..6ac0c64 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import path from 'path' + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, + server: { + proxy: { + '/api': { + target: 'http://localhost:8000', + changeOrigin: true, + }, + }, + }, +})