├── .bandit ├── .cursor └── rules │ ├── 3ds_secure_flow.mdc │ ├── architecture_overview.mdc │ ├── coding_standards_and_linting.mdc │ ├── configuration_management.mdc │ ├── error_handling_conventions.mdc │ ├── linting_and_formatting_standards.mdc │ ├── models_and_data_handling.mdc │ ├── python_best_practices.mdc │ ├── schemas_validation_logic.mdc │ └── test_structure_guidelines.mdc ├── .env.copy ├── .flake8 ├── .github ├── FUNDING.yml └── workflows │ ├── cd.yaml │ ├── linting.yaml │ ├── tests-e2e.yaml │ └── tests-unit.yaml ├── .gitignore ├── .isort.cfg ├── .pre-commit-config.yaml ├── .trunk ├── .gitignore ├── configs │ ├── .markdownlint.yaml │ ├── .yamllint.yaml │ ├── ruff.toml │ └── svgo.config.mjs └── trunk.yaml ├── LICENSE ├── MANIFEST.in ├── README.md ├── docs ├── decisions │ ├── 0001-pluggable-3ds-session-management.md │ ├── 0002-optional-synchronous-api-wrapper.md │ ├── 0003-advanced-http-client-customization.md │ ├── 0004-consolidate-settings-logic-in-azulapi.md │ ├── 0005-standardized-test-structure.md │ ├── 0006-reorganize-models-by-business-domain.md │ ├── README.md │ ├── example.md │ └── template.md ├── development │ └── azul_documentation.md └── interrogate_badge.svg ├── examples ├── datavault_flow_example.py ├── hold_example.py ├── payment_example.py ├── payment_page_server.py ├── post_example.py ├── refund_example.py ├── secure_3ds_example.py ├── templates │ ├── error.html │ ├── iframe_3ds.html │ ├── index.html │ ├── processing.html │ ├── result.html │ └── token_index.html ├── test_certificates.py ├── token_secure_webapp.py └── verify_example.py ├── pyazul ├── __init__.py ├── api │ ├── __init__.py │ ├── client.py │ └── constants.py ├── core │ ├── __init__.py │ ├── base.py │ ├── config.py │ └── exceptions.py ├── index.py ├── models │ ├── __init__.py │ ├── datavault │ │ ├── __init__.py │ │ └── models.py │ ├── payment │ │ ├── __init__.py │ │ └── models.py │ ├── payment_page │ │ ├── __init__.py │ │ └── models.py │ ├── schemas.py │ ├── three_ds │ │ ├── __init__.py │ │ └── models.py │ └── verification │ │ ├── __init__.py │ │ └── models.py └── services │ ├── __init__.py │ ├── datavault.py │ ├── payment_page.py │ ├── secure.py │ └── transaction.py ├── pyproject.toml ├── requirements-dev.txt ├── requirements.txt └── tests ├── conftest.py ├── e2e ├── conftest.py └── services │ ├── test_datavault_integration.py │ ├── test_hold_integration.py │ ├── test_payment_integration.py │ ├── test_payment_page_integration.py │ ├── test_post_integration.py │ ├── test_secure_integration.py │ ├── test_verify_integration.py │ └── test_void_integration.py ├── fixtures ├── cards.py └── order.py └── unit ├── conftest.py ├── core ├── test_config_unit.py └── test_exceptions_unit.py ├── models ├── test_schemas_unit.py └── test_three_ds.py └── services ├── test_secure_service_unit.py ├── test_token_secure_service_unit.py └── test_transaction_service_unit.py /.bandit: -------------------------------------------------------------------------------- 1 | [bandit] 2 | exclude = tests,.venv,.mypy_cache,.ruff_cache,.pytest_cache 3 | tests = B201,B301 4 | skips = B101,B601 5 | -------------------------------------------------------------------------------- /.cursor/rules/3ds_secure_flow.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: 3 | globs: 4 | alwaysApply: true 5 | --- 6 | \# PyAzul 3D Secure (3DS) Implementation Guide 7 | 8 | This guide details the 3D Secure flow implementation within the `pyazul` library. 9 | 10 | ## Core Components for 3DS 11 | 12 | 1. **`SecureService` ([pyazul/pyazul/services/secure.py](mdc:pyazul/pyazul/pyazul/pyazul/services/secure.py))**: 13 | * Handles all logic specific to 3DS transactions, including: 14 | * Initiating 3DS sales, token sales, and holds (`process_sale`, `process_token_sale`, `process_hold`). 15 | * Processing 3DS method notifications (`process_3ds_method`). 16 | * Processing 3DS challenge responses (`process_challenge`). 17 | * Generating HTML forms for ACS redirection (`_create_challenge_form`). 18 | * Uses the shared `AzulAPI` client (passed during its initialization), ensuring `is_secure=True` is used for 3DS-specific authentication via `AzulAPI`. 19 | * Manages 3DS session state internally using dictionaries keyed by `secure_id` (for initial session data like `term_url` and `azul_order_id`) and `AzulOrderId` (for transaction processing states). 20 | 21 | 2. **`PyAzul` Facade ([pyazul/pyazul/index.py](mdc:pyazul/pyazul/pyazul/pyazul/index.py))**: 22 | * Exposes user-friendly methods for 3DS operations: 23 | * `secure_sale`, `secure_token_sale`, `secure_hold` 24 | * `process_3ds_method` (Note: the facade method is `process_3ds_method`, not `secure_3ds_method` as previously might have been implied by some docs/tests) 25 | * `process_challenge` 26 | * `create_challenge_form` (convenience wrapper around `SecureService._create_challenge_form`). 27 | * `get_session_info(secure_id)`: retrieves session data stored by `SecureService`. 28 | * These methods delegate to the `SecureService` instance, which is initialized by `PyAzul`. 29 | 30 | ## Flow Overview 31 | 32 | 1. **Initiation**: 33 | * User calls `azul.secure_sale()` (or `secure_token_sale`, `secure_hold`) with payment details, `cardHolderInfo`, and `threeDSAuth` (which includes `TermUrl` and `MethodNotificationUrl`). 34 | * `SecureService` generates a unique `secure_id` (UUID). 35 | * `TermUrl` and `MethodNotificationUrl` provided by the user are internally appended with `?secure_id=` by `SecureService` before being sent to Azul. 36 | * `SecureService` makes an initial request to Azul. It then stores initial session data (including `azul_order_id` from the Azul response and the modified `term_url`) internally, associated with the generated `secure_id`. 37 | * The response from `azul.secure_sale()` may include HTML for immediate redirection (to the ACS for a challenge or to the 3DS Method URL). This response will also contain the `id` (which is the `secure_id`). 38 | 39 | 2. **Method Notification Callback (Your `MethodNotificationUrl`)**: 40 | * Your application endpoint (the `MethodNotificationUrl` you provided) is called by the ACS/PSP, with `secure_id` available as a query parameter. 41 | * Your application should first use `secure_id` to retrieve the stored session data: `session_data = await azul.get_session_info(secure_id)`. 42 | * From `session_data`, retrieve the `azul_order_id`: `azul_order_id = session_data.get("azul_order_id")`. 43 | * Then, call `await azul.process_3ds_method(azul_order_id=azul_order_id, method_notification_status="RECEIVED")`. 44 | * `SecureService` uses its internal state to check if this method was already processed (to prevent duplicates) and updates the transaction state. 45 | * The response from `azul.process_3ds_method` might trigger a challenge, requiring redirection. In this case, use `azul.create_challenge_form(...)` with data from the response and the `term_url` (retrieved from `session_data.get("term_url")`) to generate the necessary HTML form. 46 | 47 | 3. **Challenge Callback (Your `TermUrl`)**: 48 | * Your application endpoint (the `TermUrl` you provided) is called by the ACS, typically via POST, after the cardholder completes (or skips) the challenge. 49 | * The `secure_id` (from the `TermUrl` query parameters) and `CRes` (Challenge Response, from the POST body) are received. 50 | * Your application calls `await azul.process_challenge(session_id=secure_id, challenge_response=CRes)`. 51 | * `SecureService` uses `secure_id` to retrieve session data (like `azul_order_id`) from its internal store and makes the final API call to process the challenge result. 52 | * The final transaction status (Approved/Declined) is returned. 53 | 54 | Refer to [pyazul/pyazul/README.md](mdc:pyazul/pyazul/pyazul/pyazul/README.md) for detailed FastAPI examples of this flow. 55 | -------------------------------------------------------------------------------- /.cursor/rules/architecture_overview.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: 3 | globs: 4 | alwaysApply: true 5 | --- 6 | \ 7 | # PyAzul Architecture Overview 8 | 9 | This document outlines the high-level architecture of the `pyazul` library. 10 | 11 | ## Core Components 12 | 13 | 1. **`PyAzul` Facade ([pyazul/index.py](mdc:pyazul/index.py))**: 14 | * This is the main entry point for users of the library. 15 | * It initializes and provides access to all underlying services. 16 | * It manages a shared `AzulAPI` client instance and configuration (`AzulSettings`). 17 | 18 | 2. **`AzulAPI` Client ([pyazul/api/client.py](mdc:pyazul/api/client.py))**: 19 | * A single instance of `AzulAPI` is created by `PyAzul` and passed to services that require API communication. 20 | * It's responsible for all HTTP requests to the Azul gateway. 21 | * Handles SSL context with certificates, request signing (including `Auth1`/`Auth2` headers), and distinguishes between standard and 3DS (`is_secure=True`) authentication headers using credentials from `AzulSettings`. 22 | * Implements retry logic for production environments. 23 | 24 | 3. **Service Layer ([pyazul/services/](mdc:pyazul/services))**: 25 | * Functionality is modularized into services: 26 | * `TransactionService` ([pyazul/services/transaction.py](mdc:pyazul/services/transaction.py)): Handles standard payment operations (sale, hold, refund, etc.). 27 | * `DataVaultService` ([pyazul/services/datavault.py](mdc:pyazul/services/datavault.py)): Manages card tokenization. 28 | * `PaymentPageService` ([pyazul/services/payment_page.py](mdc:pyazul/services/payment_page.py)): Generates HTML for Azul's hosted payment page. 29 | * `SecureService` ([pyazul/services/secure.py](mdc:pyazul/services/secure.py)): Manages the 3D Secure authentication flow. 30 | * Services receive the shared `AzulAPI` client and `AzulSettings` (or just `AzulAPI` if settings are only needed by the client itself, as `AzulAPI` now takes `settings` in its constructor). 31 | 32 | 4. **Configuration ([pyazul/core/config.py](mdc:pyazul/core/config.py))**: 33 | * Managed by the `AzulSettings` Pydantic model. 34 | * Settings are loaded from a `.env` file and environment variables. 35 | * `PyAzul` can be initialized with a custom `AzulSettings` instance. 36 | 37 | 5. **Pydantic Models ([pyazul/models/](mdc:pyazul/models))**: 38 | * Used for request and response data validation and serialization. 39 | * Centralized and re-exported via `[pyazul/models/__init__.py](mdc:pyazul/models/__init__.py)`. 40 | 41 | ## Key Principles 42 | * **Facade Pattern**: `PyAzul` simplifies interaction with the various services. 43 | * **Dependency Injection**: `AzulAPI` client and `AzulSettings` are managed by `PyAzul` and provided to services. The 3DS session store is injected into `PyAzul` and then into `SecureService`. 44 | * **Asynchronous**: Core operations are `async/await` based. 45 | 46 | Refer to [README.md](mdc:README.md) for usage examples. 47 | -------------------------------------------------------------------------------- /.cursor/rules/coding_standards_and_linting.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: 3 | globs: 4 | alwaysApply: false 5 | --- 6 | # PyAzul Coding Standards and Linting 7 | 8 | This document outlines key coding standards, linting practices, and items to add to the `pyazul` library to ensure code quality, consistency, and maintainability. 9 | 10 | ## Exception Handling 11 | * **Chaining (`raise ... from ...`)**: 12 | * As detailed in `[error_handling_conventions.mdc](mdc:pyazul/.cursor/rules/error_handling_conventions.mdc)`, always use `raise NewException(...) from original_exception` when re-raising exceptions. 13 | * **Linter Issue**: The linter (Trunk) correctly flags violations of this. Ensure all `raise` statements within `except` blocks adhere to this. 14 | * **Files to Check/Verify**: 15 | * `[pyazul/api/client.py](mdc:pyazul/pyazul/api/client.py)`: (e.g., in `_load_certificates`, `_async_request`). Ensure all are covered. 16 | 17 | ## Linter Compliance & Specific Issues 18 | * **Trunk Linters**: The project uses Trunk for linting. All linter warnings and errors should be addressed promptly. 19 | * **Unused Variables**: 20 | * Pay attention to warnings about unused variables. If a variable is truly not needed, remove it. If it's part of a tuple unpacking and intentionally unused, prefix its name with an underscore (e.g., `_`). 21 | * **Specific Linter Issue**: In `[pyazul/api/client.py](mdc:pyazul/pyazul/api/client.py)`, method `_check_for_errors`, the loop `for field, value in error_indicators:` does not use `field`. 22 | * **Action**: Change to `for _, value in error_indicators:`. 23 | 24 | ## Type Hinting 25 | * **Comprehensive Typing**: All functions and methods must have comprehensive type hints for parameters and return values. This improves readability and allows for static analysis. 26 | * **`typing.NoReturn`**: For functions that are guaranteed to always raise an exception and never return normally (e.g., they end in `raise`), use `typing.NoReturn` as the return type. This was applied to `_log_and_raise_api_error` in `[pyazul/api/client.py](mdc:pyazul/pyazul/api/client.py)`. 27 | * **Pydantic Models**: Utilize Pydantic models for data structures passed between components. Service method signatures should generally expect these models, even if the `PyAzul` facade offers dictionary-based input for user convenience. 28 | 29 | ## Logging 30 | * Employ the standard `logging` module for clear, contextual logs. 31 | * **`AzulAPI` ([pyazul/api/client.py](mdc:pyazul/pyazul/api/client.py))**: 32 | * Log request/response data at `DEBUG` level for troubleshooting. 33 | * Log significant errors at `ERROR` level. 34 | * **Services (e.g., `[pyazul/services/secure.py](mdc:pyazul/pyazul/services/secure.py)`)**: 35 | * Log key events in flows at `INFO` or `DEBUG` level. 36 | * Log warnings for unexpected but recoverable situations with `WARNING`. 37 | * Log errors that lead to exceptions with `ERROR`. 38 | 39 | ## Documentation 40 | * **Docstrings**: All public classes, methods, and functions require clear docstrings. Describe purpose, arguments (`Args:`), return values (`Returns:`), and any exceptions raised (`Raises:`). 41 | * **[README.md](mdc:pyazul/README.md)**: Must be kept current, reflecting the library's public API and common usage patterns, especially for complex flows like 3DS. 42 | * **Code Comments**: Use to explain non-obvious logic. Avoid redundant comments that reiterate what the code clearly states. 43 | 44 | ## Modularity and Cohesion 45 | * Maintain the established separation of concerns: `PyAzul` (facade), Services (business logic), `AzulAPI` (HTTP client), `AzulSettings` (config), Models (data contracts). 46 | -------------------------------------------------------------------------------- /.cursor/rules/configuration_management.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: Details how configuration is managed in PyAzul, focusing on AzulSettings and .env files. 3 | globs: 4 | alwaysApply: true 5 | --- 6 | # PyAzul Configuration Management 7 | 8 | Configuration for the `pyazul` library is managed through the `AzulSettings` Pydantic model, defined in `[pyazul/core/config.py](mdc:pyazul/pyazul/core/config.py)`. 9 | 10 | ## Loading Configuration 11 | * By default, `PyAzul()` (see `[pyazul/index.py](mdc:pyazul/pyazul/index.py)`) initializes `AzulSettings` by loading values from a `.env` file in the project root and then from environment variables (environment variables override `.env` values). 12 | * A custom, pre-configured `AzulSettings` instance can also be passed directly to the `PyAzul` constructor: `PyAzul(settings=my_custom_settings)`. 13 | 14 | ## Key Configuration Variables (in `.env` or as environment variables) 15 | 16 | * **API Credentials**: 17 | * `AUTH1`: Your primary Auth1 key. Used for all API requests. 18 | * `AUTH2`: Your primary Auth2 key. Used for all API requests. 19 | * `MERCHANT_ID`: Your merchant identifier. Used for all API calls and for the Payment Page. 20 | * **Certificates**: 21 | * `AZUL_CERT`: Path to your SSL certificate file OR the full PEM content as a string. 22 | * `AZUL_KEY`: Path to your SSL private key file OR the full PEM content as a string OR Base64 encoded PEM content. 23 | * The library handles writing PEM content to temporary files if direct content is provided (see `_load_certificates` in `[pyazul/core/config.py](mdc:pyazul/pyazul/core/config.py)`). The `AzulAPI` client in `[pyazul/api/client.py](mdc:pyazul/pyazul/api/client.py)` uses these settings to establish its SSL context. 24 | * **Environment**: 25 | * `ENVIRONMENT`: Set to `dev` for development/testing or `prod` for production. Controls API endpoints. 26 | * **Payment Page Settings (Optional, if using Payment Page)**: 27 | * `AZUL_AUTH_KEY`: Authentication key for generating the payment page hash. 28 | * `MERCHANT_NAME`: Your business name displayed on the page. 29 | * `MERCHANT_TYPE`: Your business type. 30 | * **Other**: 31 | * `CHANNEL`: Default payment channel (e.g., "EC" for E-Commerce). 32 | * `CUSTOM_URL`: Optionally override base API URLs. 33 | 34 | 35 | Consult `[pyazul/core/config.py](mdc:pyazul/pyazul/core/config.py)` for all available settings fields. 36 | The [README.md](mdc:pyazul/README.md) provides a `.env` example and further details. 37 | -------------------------------------------------------------------------------- /.cursor/rules/error_handling_conventions.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: 3 | globs: 4 | alwaysApply: true 5 | --- 6 | # PyAzul Error Handling Conventions 7 | 8 | `pyazul` uses a hierarchy of custom exceptions to report errors, all defined in `[pyazul/core/exceptions.py](mdc:pyazul/core/exceptions.py)`. 9 | 10 | ## Custom Exception Hierarchy 11 | All custom exceptions inherit from `pyazul.core.exceptions.AzulError`. 12 | 13 | * **`AzulError`**: Base class for all library-specific errors. 14 | * **`SSLError`**: Raised for issues related to SSL certificate loading or configuration (e.g., file not found, invalid format). This is primarily raised from `_load_certificates` in `[pyazul/api/client.py](mdc:pyazul/api/client.py)`. 15 | * **`APIError`**: 16 | * Raised for general issues during HTTP communication with the Azul API. 17 | * Examples: Network errors, unexpected HTTP status codes not covered by `AzulResponseError`, issues decoding JSON responses. 18 | * **`AzulResponseError`**: 19 | * Raised when the Azul API explicitly returns an error in its response payload (e.g., transaction declined, invalid field value). 20 | * Contains a `response_data` attribute holding the raw dictionary response from Azul for inspection. 21 | * The error message typically includes `ErrorMessage` or `ErrorDescription` from the Azul response. 22 | * This is checked and raised in the `_check_for_errors` method of `[pyazul/api/client.py](mdc:pyazul/api/client.py)`. 23 | 24 | ## Best Practices for Using and Raising Exceptions 25 | * **Catch Specific Exceptions**: When handling errors from `pyazul`, catch more specific exceptions first, then broader ones. 26 | ```python 27 | from pyazul import AzulError, AzulResponseError, SSLError 28 | # from pyazul.core.exceptions import APIError (if needing to catch it separately) 29 | 30 | try: 31 | response = await azul.sale(...) 32 | except AzulResponseError as e: 33 | print(f"Azul API Error: {e.message}") 34 | print(f"Response Data: {e.response_data}") 35 | except SSLError as e: 36 | print(f"SSL Configuration Error: {e}") 37 | except APIError as e: # Catches other API communication issues 38 | print(f"API Communication Error: {e}") 39 | except AzulError as e: # Catch-all for other pyazul errors 40 | print(f"PyAzul Library Error: {e}") 41 | except Exception as e: 42 | print(f"An unexpected error occurred: {e}") 43 | ``` 44 | * **Chaining Exceptions (`raise ... from ...`)**: 45 | * **Critically Important**: When catching an exception and raising a new custom `pyazul` exception (or any exception), use the `raise NewException(...) from original_exception` syntax. This preserves the context and stack trace of the original error, which is invaluable for debugging. 46 | * This practice is implemented in `[pyazul/services/secure.py](mdc:pyazul/services/secure.py)` and `[pyazul/api/client.py](mdc:pyazul/api/client.py)`. 47 | * Failure to do this can hide the root cause of problems. 48 | * Example: `raise AzulError("Something went wrong") from caught_exception` 49 | 50 | See the [README.md](mdc:README.md) for a basic error handling example. 51 | The `[coding_standards_and_linting.mdc](mdc:coding_standards_and_linting.mdc)` rule also emphasizes this. 52 | -------------------------------------------------------------------------------- /.cursor/rules/linting_and_formatting_standards.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: 3 | globs: 4 | alwaysApply: false 5 | --- 6 | # PyAzul Linting and Formatting Standards 7 | 8 | This document outlines the linting and formatting standards that must be followed in the `pyazul` project. All code contributions must pass these checks before being merged. 9 | 10 | ## Configuration Files 11 | 12 | The project uses the following configuration files for linting and formatting: 13 | 14 | - **[.bandit](mdc:.bandit)**: Security linting configuration 15 | - **[.flake8](mdc:.flake8)**: Style checking configuration 16 | - **[.isort.cfg](mdc:.isort.cfg)**: Import sorting configuration 17 | - **[.pre-commit-config.yaml](mdc:.pre-commit-config.yaml)**: Git pre-commit hooks 18 | - **[pyproject.toml](mdc:pyproject.toml)**: Central Python project configuration 19 | - **[.trunk/trunk.yaml](mdc:.trunk/trunk.yaml)**: Trunk.io linting orchestration 20 | 21 | ## Code Style Standards 22 | 23 | ### Line Length 24 | - Maximum line length: **88 characters** (Black default) 25 | - This applies to all Python code, including docstrings 26 | 27 | ### Import Ordering 28 | - Use **isort** with Black profile 29 | - Import groups should be ordered as: 30 | 1. Standard library imports 31 | 2. Third-party imports 32 | 3. Local application imports 33 | - Each group should be separated by a blank line 34 | 35 | ### Code Formatting 36 | - Use **Black** formatter with default settings 37 | - Target Python version: **3.12** 38 | - All code must be formatted with Black before committing 39 | 40 | ## Documentation Standards 41 | 42 | ### Module Docstrings 43 | - **REQUIRED** for all Python modules (files) 44 | - Must be placed at the very beginning of the file 45 | - Format: Google style 46 | - Example: 47 | ```python 48 | """Module for handling Azul API client operations. 49 | 50 | This module provides the core HTTP client for communicating with Azul's API. 51 | """ 52 | ``` 53 | 54 | ### Class Docstrings 55 | - **REQUIRED** for all public classes 56 | - Use Google style format 57 | - Must include a brief description of the class purpose 58 | - Example: 59 | ```python 60 | class AzulAPI: 61 | """HTTP client for Azul API communication. 62 | 63 | Handles authentication, SSL context, and request/response processing. 64 | """ 65 | ``` 66 | 67 | ### Method/Function Docstrings 68 | - **REQUIRED** for all public methods and functions 69 | - Use Google style format 70 | - First line must be in **imperative mood** (e.g., "Parse", "Create", "Handle") 71 | - Must end with a period 72 | - Include Args, Returns, and Raises sections when applicable 73 | - Example: 74 | ```python 75 | def parse_response(self, data: dict) -> dict: 76 | """Parse the API response data. 77 | 78 | Args: 79 | data: Raw response dictionary from the API. 80 | 81 | Returns: 82 | Processed response data. 83 | 84 | Raises: 85 | AzulResponseError: If the response contains an error. 86 | """ 87 | ``` 88 | 89 | ### Docstring Exceptions 90 | The following are explicitly ignored by our configuration: 91 | - D100: Missing docstring in public module (we override this - modules DO need docstrings) 92 | - D104: Missing docstring in public package 93 | - D107: Missing docstring in `__init__` method 94 | 95 | ## Security Standards 96 | 97 | ### Bandit Configuration 98 | - Security scanning is enabled for all code 99 | - Test files are excluded from security scanning 100 | - Specific checks disabled: 101 | - B101: Use of assert (allowed in test code) 102 | - B601: Shell injection (if needed, must be explicitly justified) 103 | 104 | ## Pre-commit Hooks 105 | 106 | All developers must install pre-commit hooks: 107 | ```bash 108 | pre-commit install 109 | ``` 110 | 111 | The following checks run automatically on commit: 112 | 1. **trailing-whitespace**: Remove trailing whitespace 113 | 2. **end-of-file-fixer**: Ensure files end with a newline 114 | 3. **check-yaml**: Validate YAML syntax 115 | 4. **check-toml**: Validate TOML syntax 116 | 5. **check-merge-conflict**: Prevent committing merge conflicts 117 | 6. **check-added-large-files**: Prevent large file commits 118 | 7. **isort**: Sort imports 119 | 8. **black**: Format code 120 | 9. **flake8**: Check style violations 121 | 10. **interrogate**: Check docstring coverage (minimum 70%) 122 | 123 | ## Running Linters Manually 124 | 125 | ### Individual Tools 126 | ```bash 127 | # Format code with Black 128 | black pyazul tests 129 | 130 | # Sort imports with isort 131 | isort pyazul tests 132 | 133 | # Check style with flake8 134 | flake8 pyazul tests 135 | 136 | # Check security with bandit 137 | bandit -r pyazul 138 | 139 | # Check docstring coverage 140 | interrogate -vv pyazul 141 | ``` 142 | 143 | ### Using Trunk 144 | ```bash 145 | # Run all configured linters 146 | trunk check 147 | 148 | # Auto-fix issues where possible 149 | trunk fmt 150 | ``` 151 | 152 | ## IDE Integration 153 | 154 | ### VSCode 155 | - Install Python extension 156 | - Configure to use Black as formatter 157 | - Enable format on save 158 | - Configure isort for import sorting 159 | 160 | ### PyCharm 161 | - Configure Black as external tool 162 | - Set up file watchers for automatic formatting 163 | - Configure isort for import optimization 164 | 165 | ## Continuous Integration 166 | 167 | All pull requests must pass: 168 | 1. All pre-commit hooks 169 | 2. Full test suite with coverage 170 | 3. All linting checks 171 | 4. Security scanning 172 | 173 | ## Common Issues and Solutions 174 | 175 | ### Long Lines 176 | - Break long strings using parentheses for implicit concatenation 177 | - Use intermediate variables for complex expressions 178 | - For URLs or long strings that cannot be broken, use `# noqa: E501` sparingly 179 | 180 | ### Import Order 181 | - Let isort handle import ordering automatically 182 | - If manual intervention needed, follow the Black-compatible profile 183 | 184 | ### Docstring Formatting 185 | - Ensure blank line between summary and description 186 | - End all docstrings with a period 187 | - Use imperative mood for function/method docstrings 188 | 189 | ## Enforcement 190 | 191 | - Pre-commit hooks prevent commits with violations 192 | - CI/CD pipeline blocks PRs with linting failures 193 | - Regular codebase audits ensure compliance 194 | - New code must maintain or improve docstring coverage percentage 195 | -------------------------------------------------------------------------------- /.cursor/rules/models_and_data_handling.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: 3 | globs: 4 | alwaysApply: false 5 | --- 6 | # PyAzul Pydantic Models and API Data Handling 7 | 8 | This document describes how data is modeled and handled in `pyazul`, aligning with `[docs/development/azul_documentation.md](mdc:docs/development/azul_documentation.md)`. 9 | 10 | ## Pydantic Models 11 | * **Core Principle**: `pyazul` uses Pydantic models for defining the structure of request data, ensuring validation and type safety. The `[docs/development/azul_documentation.md](mdc:docs/development/azul_documentation.md)` is the **source of truth** for field names, data types (e.g., N(12), X(3)), optionality ('Si'/'No' for Obligatorio), and specific literal values (e.g., for `TrxType`). 12 | * **Location**: 13 | * Standard transaction models (e.g., `SaleTransactionModel`, `HoldTransactionModel`) are in `[pyazul/models/schemas.py](mdc:pyazul/models/schemas.py)`. 14 | * 3D Secure specific models (e.g., `SecureSaleRequest`, `CardHolderInfo`) are in `[pyazul/models/secure.py](mdc:pyazul/models/secure.py)`. 15 | * **Centralized Export**: User-facing Pydantic models are re-exported from `[pyazul/models/__init__.py](mdc:pyazul/models/__init__.py)`. 16 | * **Inheritance of `AzulBaseModel`**: 17 | * `AzulBaseModel` in `[pyazul/models/schemas.py](mdc:pyazul/models/schemas.py)` defines common fields like `Channel`, `Store`, `PosInputMode`, `Amount`, `Itbis` based on the most common transaction types (like Sale/Hold). These `Amount` and `Itbis` fields are defined as `int` (cents) in the model. 18 | * Models that strictly adhere to this common structure (e.g., `SaleTransactionModel`, `HoldTransactionModel`) inherit from `AzulBaseModel`. 19 | * However, several models like `DataVaultCreateModel`, `TokenSaleModel`, `PostSaleTransactionModel`, `VerifyTransactionModel`, and `VoidTransactionModel` do **not** inherit from `AzulBaseModel`. This is because their API contracts, as defined in `[docs/development/azul_documentation.md](mdc:docs/development/azul_documentation.md)`, differ significantly. They define their fields explicitly, including `Amount` and `Itbis` as `int` (cents) where applicable. 20 | * **`TrxType` Field**: 21 | * Many transaction models include a `TrxType: Literal[...]` field which is a key discriminator for the Azul API (e.g., "Sale", "Hold", "Refund", "CREATE", "DELETE"). 22 | * However, some operations like `ProcessPost` (`PostSaleTransactionModel`), `VerifyPayment` (`VerifyTransactionModel`), and `ProcessVoid` (`VoidTransactionModel`) do **not** use a `TrxType` field in their API request payloads. Their corresponding models reflect this. 23 | * **Usage in `PyAzul` Facade & Service Layer**: 24 | * `PyAzul` methods generally accept dictionaries, which are parsed into Pydantic models internally. 25 | * Service layer methods (in `[pyazul/services/transaction.py](mdc:pyazul/services/transaction.py)` and `[pyazul/services/secure.py](mdc:pyazul/services/secure.py)`) are typed to accept these Pydantic models. 26 | * **Serialization for API**: 27 | * When preparing data for an API call, service methods first dump the Pydantic model to a dictionary, typically using `model.model_dump(exclude_none=True)`. This prevents sending `null` or `None` values for fields that were not explicitly set. 28 | * The data (now a dictionary, with amounts as integers) is then passed towards `AzulAPI._async_request`. 29 | 30 | ## Specific Field Formatting for API 31 | * **Amounts and ITBIS**: 32 | * Pydantic models in `[pyazul/models/schemas.py](mdc:pyazul/models/schemas.py)` and `[pyazul/models/secure.py](mdc:pyazul/models/secure.py)` define `Amount` and `Itbis` fields (where applicable) as **integers**, representing the value in the smallest currency unit (e.g., cents). This is for a better developer experience when using the library. 33 | * The **library is responsible** for converting these integer values from the models into the specific **string format** required by the Azul API *before* the final JSON payload is constructed and sent in the HTTP request. This conversion includes: 34 | * Converting the integer cent value for `Amount` to its string representation (e.g., `1000` becomes `"1000"`). 35 | * Converting the integer cent value for `Itbis` to its string representation, and specifically mapping an integer `0` to the string `"000"` (e.g., `180` becomes `"180"`, `0` becomes `"000"`). 36 | * The Azul API expects amounts without commas or decimal points, where the last two digits represent decimals (e.g., API string `"1000"` for $10.00). 37 | * This final string conversion step should ideally occur centrally before JSON serialization, for example, within or just before `AzulAPI._async_request`. 38 | 39 | Refer to `[docs/development/azul_documentation.md](mdc:docs/development/azul_documentation.md)` for precise field specifications for each API operation. 40 | See [README.md](mdc:README.md) for examples of request data structures as dictionaries. 41 | -------------------------------------------------------------------------------- /.cursor/rules/schemas_validation_logic.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: 3 | globs: 4 | alwaysApply: false 5 | --- 6 | # PyAzul `PaymentSchema` Validation Logic 7 | 8 | This document outlines the logic used within `PaymentSchema.validate_payment` in `[pyazul/models/schemas.py](mdc:pyazul/models/schemas.py)` to discriminate and parse input data into the correct Pydantic transaction model. This is based on the decisions in `[docs/decisions/005-refactor-paymentschema-validation.md](mdc:docs/decisions/005-refactor-paymentschema-validation.md)`. 9 | 10 | ## Context 11 | 12 | Due to varying API request structures for different Azul Webservices operations (some use `TrxType`, others are identified by unique field combinations), a `RootModel` (`PaymentSchema`) is used with a custom `validate_payment` classmethod to dynamically instantiate the correct Pydantic model. 13 | 14 | The primary source of truth for model fields and API requirements is `[docs/development/azul_documentation.md](mdc:docs/development/azul_documentation.md)`. 15 | 16 | ## Discrimination Strategy 17 | 18 | A sequential, heuristic-based approach is used. The order of checks is critical: 19 | 20 | 1. **Identify by Unique Fields (Non-`TrxType` Models First)**: 21 | * **`VerifyTransactionModel`**: Primarily identified by the presence of `CustomOrderId` AND the absence of other key fields that might cause ambiguity (like `AzulOrderId` or `Amount` without `TrxType`). The specific check is `"CustomOrderId" in value and "AzulOrderId" not in value and "Amount" not in value and "TrxType" not in value`. 22 | * **Models with `AzulOrderId` (and no `TrxType`)**: 23 | * **`PostSaleTransactionModel`**: Identified by `AzulOrderId`, `Amount`, `Itbis`, `Channel`, `Store`. A length check (`len(value) <= 5`) is used as a heuristic, assuming these are the *only* fields for a direct `ProcessPost` call. 24 | * **`VoidTransactionModel`**: Identified by `AzulOrderId`, `Channel`, `Store`. A length check (`len(value) <= 3`) is used as a heuristic for this minimal field set. 25 | 26 | 2. **Identify by `TrxType` (Fallback)**: 27 | * If the model isn't identified by the unique checks above, `trx_type = value.get("TrxType")` is used. 28 | * `TrxType == "CREATE"`: Returns `DataVaultCreateModel`. 29 | * `TrxType == "DELETE"`: Returns `DataVaultDeleteModel`. 30 | * `TrxType == "Hold"`: Returns `HoldTransactionModel`. 31 | * `TrxType == "Refund"`: Returns `RefundTransactionModel`. (Additional check for `AzulOrderId` and `Amount` for robustness). 32 | * `TrxType == "Sale"`: 33 | * If `"DataVaultToken" in value`: Returns `TokenSaleModel`. 34 | * Else if `"CardNumber" in value`: Returns `SaleTransactionModel`. 35 | 36 | 3. **Error on No Match**: 37 | * If no model can be confidently identified based on the above logic, a `ValueError` is raised. This is preferable to incorrectly defaulting to a model. 38 | 39 | ## Included Models in `PaymentSchema.root` Union 40 | 41 | The `PaymentSchema.root` field (a `Union`) must include all transaction models that `validate_payment` can produce: 42 | - `SaleTransactionModel` 43 | - `HoldTransactionModel` 44 | - `RefundTransactionModel` 45 | - `DataVaultCreateModel` 46 | - `DataVaultDeleteModel` 47 | - `TokenSaleModel` 48 | - `PostSaleTransactionModel` 49 | - `VerifyTransactionModel` 50 | - `VoidTransactionModel` 51 | 52 | ## Important Considerations 53 | 54 | * The heuristic checks (like `len(value)`) for `PostSaleTransactionModel` and `VoidTransactionModel` assume that the input `value` dictionary passed to `validate_payment` accurately represents only the fields intended for that specific operation. If `value` might contain extra, unrelated keys, these length checks could be fragile. 55 | * This logic is tightly coupled with the field definitions in each model and the Azul API specifications. Changes to API requirements or model structures will necessitate updates to `validate_payment` and potentially this rule. 56 | -------------------------------------------------------------------------------- /.cursor/rules/test_structure_guidelines.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: 3 | globs: 4 | alwaysApply: false 5 | --- 6 | 7 | 8 | ## PyAzul Test Structure & Best Practices 9 | 10 | **Purpose**: To establish consistent, maintainable, and effective testing practices for the `pyazul` library, clearly distinguishing between unit and integration tests. 11 | 12 | **Scope**: These guidelines apply to all new and refactored tests within the `pyazul` project. 13 | 14 | 15 | 16 | ### 1. Test Directory Structure 17 | 18 | Tests must be organized into two main categories within the `tests/` directory: 19 | 20 | - **`tests/unit/`**: Houses all unit tests. These tests focus on individual components in isolation and must mock external dependencies (especially network calls). 21 | - Subdirectories should mirror the `pyazul` source structure, e.g.: 22 | - `tests/unit/api/` 23 | - `tests/unit/core/` 24 | - `tests/unit/models/` 25 | - `tests/unit/services/` 26 | - **`tests/integration/`**: Houses all integration tests. These tests verify the interaction between `pyazul` and the live Azul API (test environment) and will involve actual network calls. 27 | - Subdirectories can be used to organize tests, e.g., `tests/integration/services/`. 28 | - **`[tests/conftest.py](mdc:tests/conftest.py)`**: Contains global test configurations and fixtures, most notably the primary `settings` fixture. 29 | - Component-specific `conftest.py` files can be used within `tests/unit/` or `tests/integration/` subdirectories for fixtures relevant only to that scope. 30 | 31 | ### 2. Configuration and Settings (`settings` Fixture) 32 | 33 | - A single, **session-scoped `settings` fixture** must be defined in `[tests/conftest.py](mdc:tests/conftest.py)`. This is the authoritative source for `AzulSettings`. 34 | ```python 35 | # Example from tests/conftest.py 36 | import pytest 37 | from pyazul.core.config import AzulSettings, get_azul_settings 38 | 39 | @pytest.fixture(scope="session") 40 | def settings() -> AzulSettings: 41 | return get_azul_settings() 42 | ``` 43 | - All other fixtures and tests requiring `AzulSettings` must depend on this global `settings` fixture. 44 | - Unit tests for `AzulSettings` itself (e.g., in `tests/unit/core/test_config_unit.py`) should mock environment variables or file access rather than relying on actual `.env` files during the test run. 45 | 46 | ### 3. Service Fixtures 47 | 48 | - **Integration Test Service Fixtures** (e.g., for `TransactionService`): 49 | - Must depend on the global `settings` fixture. 50 | - Instantiate `[AzulAPI](mdc:pyazul/api/client.py)` with these `settings`. 51 | - Instantiate the specific service class with the `AzulAPI` instance. 52 | - Typically defined in the respective integration test file or an integration-specific conftest. 53 | ```python 54 | # Example for an integration test 55 | @pytest.fixture 56 | def transaction_service_integration(settings: AzulSettings): 57 | api_client = AzulAPI(settings) 58 | return TransactionService(api_client) 59 | ``` 60 | - **Unit Test Service Fixtures/Setup**: 61 | - When unit testing a service, the service should be instantiated with a mocked `AzulAPI` client. 62 | ```python 63 | # Example in a unit test for a service 64 | from unittest.mock import MagicMock 65 | # ... 66 | mock_api_client = MagicMock(spec=AzulAPI) 67 | # Configure mock_api_client.settings if necessary for the service 68 | mock_api_client.settings = settings # or a MagicMock(spec=AzulSettings) 69 | service_under_test = MyService(mock_api_client) 70 | ``` 71 | 72 | ### 4. Test Data Fixtures 73 | 74 | - Fixtures providing data for Pydantic models (e.g., `card_payment_data`) should also depend on the global `settings` fixture if they need to source values from it (like `Store` or `Channel`). 75 | - **Data Sourcing Priority for Fixtures**: 76 | 1. **Test-Specific Values**: Explicit values critical for the scenario (e.g., `Amount`, `CardNumber`, specific `OrderNumber`). 77 | 2. **Settings Values**: For common configurable parameters like `Store`, always use `settings.MERCHANT_ID`. For `Channel`, use `settings.CHANNEL`. (Note: `[pyazul/api/client.py](mdc:pyazul/api/client.py):_prepare_request` ensures `settings.CHANNEL` and `settings.MERCHANT_ID` are used for the final API call, overriding model values if necessary. Test data should reflect what would realistically be set or defaulted at model instantiation). 78 | 3. **Pydantic Model Defaults**: Rely on Pydantic model defaults (e.g., `Channel` in `AzulBaseModel`, `PosInputMode` in `BaseTransactionAttributes`) unless the test specifically verifies behavior with a non-default value or if consistency with `settings` is preferred at the fixture level. 79 | 4. **Avoid Hardcoding**: Minimize hardcoding values that are available via `settings` or have sensible model defaults, unless explicitly testing an override. 80 | - Test data fixtures should return a dictionary ready for unpacking into a Pydantic model. 81 | 82 | ### 5. Model Instantiation in Tests 83 | 84 | - Clearly instantiate Pydantic models at the beginning of the test (or setup fixture) using data from dedicated test data fixtures. 85 | ```python 86 | # Example 87 | async def test_some_feature(my_service, specific_data_fixture): 88 | model_instance = SpecificModel(**specific_data_fixture) 89 | response = await my_service.process(model_instance) 90 | # ... assertions ... 91 | ``` 92 | 93 | ### 6. Mocking Strategy (Unit Tests) 94 | 95 | - Use `pytest-mock` (the `mocker` fixture) for all mocking in unit tests. 96 | - Mock external dependencies at appropriate boundaries. For service unit tests, this typically means mocking methods on the `AzulAPI` instance (e.g., `mocker.patch.object(mock_api_client, '_async_request', return_value=mock_response)`). 97 | - For unit tests of `AzulAPI` itself, mock `httpx.AsyncClient.post` or lower-level network components. 98 | - Ensure mocks return realistic data structures (e.g., dictionaries matching expected API responses). 99 | - Verify mock call arguments (`assert_called_once_with`, etc.) when the interaction contract is important. 100 | 101 | ### 7. Imports 102 | 103 | - Follow standard Python import ordering (standard library, third-party, application-specific). 104 | - Use specific imports: `from pyazul.models.schemas import SaleTransactionModel` is preferred over `import pyazul.models.schemas`. 105 | 106 | ### 8. Test Naming and Structure 107 | 108 | - Use descriptive names for test functions (e.g., `test_feature_when_condition_then_behavior()`). 109 | - Employ the Arrange-Act-Assert pattern. 110 | - Keep tests focused on a single scenario or piece of functionality. 111 | 112 | ### 9. General Principles 113 | 114 | - **Integration Tests**: Verify the end-to-end flow and correct interaction with the actual Azul API (test environment). They use real settings and make network calls. 115 | - **Unit Tests**: Verify the internal logic of individual components in isolation. They must be fast and not rely on external services or network. 116 | - Do not commit sensitive information (like production credentials) directly into test data or fixtures. Rely on `.env` files for integration test configurations, which should be gitignored. 117 | -------------------------------------------------------------------------------- /.env.copy: -------------------------------------------------------------------------------- 1 | # PyAzul Environment Configuration 2 | # Ensure this file is copied to .env and populated with your actual credentials. 3 | 4 | # --------------------------- 5 | # Core Authentication & Merchant Details 6 | # --------------------------- 7 | # These are generally always required. 8 | AUTH1= 9 | AUTH2= 10 | MERCHANT_ID= # Your primary merchant identifier for API calls 11 | 12 | # --------------------------- 13 | # Payment Page Settings 14 | # --------------------------- 15 | # Required only if using Azul's Hosted Payment Page. 16 | AZUL_AUTH_KEY= # Authentication key for Payment Page hash generation 17 | MERCHANT_NAME="Your Merchant Name" # Your business name displayed on the payment page 18 | MERCHANT_TYPE="Your Merchant Type" # Your business type for the payment page 19 | 20 | # --------------------------- 21 | # API & Transaction Defaults 22 | # --------------------------- 23 | CHANNEL="EC" # Default payment channel (e.g., "EC" for E-Commerce) 24 | 25 | # --------------------------- 26 | # Environment & API URLs 27 | # --------------------------- 28 | # ENVIRONMENT: Set to "dev" for development/testing or "prod" for production. 29 | # This controls which API endpoints are used by default. 30 | ENVIRONMENT="dev" 31 | 32 | # CUSTOM_URL can optionally override all default base API URLs if provided. 33 | # If not set, the SDK will use default URLs from constants.py based on ENVIRONMENT. 34 | CUSTOM_URL= 35 | 36 | # --------------------------- 37 | # SSL Certificate Configuration 38 | # --------------------------- 39 | # Provide EITHER file paths OR direct PEM content for AZUL_CERT and AZUL_KEY. 40 | # The library will attempt to load these and write them to temporary secure files if needed. 41 | 42 | # Option 1: File paths 43 | # AZUL_CERT=/path/to/your/azul_certificate.pem 44 | # AZUL_KEY=/path/to/your/azul_private_key.key 45 | AZUL_CERT= 46 | AZUL_KEY= 47 | 48 | # Option 2: Direct PEM content (ensure correct formatting with \\n for newlines) 49 | # Example: 50 | # AZUL_CERT="-----BEGIN CERTIFICATE-----\\nMIIE...\\n...-----END CERTIFICATE-----" 51 | # AZUL_KEY="-----BEGIN PRIVATE KEY-----\\nMIIE...\\n...-----END PRIVATE KEY-----" 52 | 53 | # Note: If providing Base64 encoded PEM content, it should be decodable to 54 | # the standard PEM format (including -----BEGIN...----- and -----END...----- markers). 55 | # The library will attempt to decode it if it doesn't look like a file path or direct PEM. 56 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 88 3 | 4 | # pydocstyle configurations through flake8-docstrings 5 | # For pydocstyle convention and ignore, these are typically passed as CLI or in its own config. 6 | # However, flake8-docstrings might pick up some, or you might need a separate pydocstyle config. 7 | # For now, we'll set what's directly supported by flake8 or common plugins. 8 | 9 | # The `match` and `add_ignore` for pydocstyle are specific to pydocstyle's own config or CLI. 10 | # If flake8-docstrings doesn't translate these directly, a separate pydocstyle config might be needed 11 | # or you'd rely on pydocstyle's defaults/direct CLI invocation if used outside flake8. 12 | # We will keep the pydocstyle section in pyproject.toml for now and revisit if needed. 13 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: indexa-git 4 | -------------------------------------------------------------------------------- /.github/workflows/cd.yaml: -------------------------------------------------------------------------------- 1 | name: Build & publish 2 | 3 | concurrency: 4 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 5 | cancel-in-progress: true 6 | 7 | permissions: read-all 8 | 9 | on: 10 | workflow_dispatch: 11 | push: 12 | branches: [main] 13 | paths: 14 | - '**/*.py' 15 | - 'requirements.txt' 16 | - 'pyproject.toml' 17 | - .github/workflows/cd.yml 18 | release: 19 | types: 20 | - published 21 | 22 | jobs: 23 | build-package: 24 | name: Build & verify package 25 | runs-on: ubuntu-latest 26 | permissions: 27 | attestations: write 28 | id-token: write 29 | steps: 30 | - uses: actions/checkout@v4.2.2 31 | with: 32 | fetch-depth: 0 33 | persist-credentials: false 34 | 35 | - uses: hynek/build-and-inspect-python-package@v2.12.0 36 | id: build-package 37 | with: 38 | attest-build-provenance-github: "true" 39 | outputs: 40 | package-version: ${{ steps.build-package.outputs.package_version }} 41 | 42 | # Upload to Test PyPI on every commit on main. 43 | release-test-pypi: 44 | name: Publish in-dev package to test.pypi.org 45 | needs: build-package 46 | runs-on: ubuntu-latest 47 | environment: test-pypi 48 | permissions: 49 | id-token: write 50 | attestations: write 51 | contents: read 52 | if: github.repository_owner == 'indexa-git' && github.event_name == 'push' && github.ref == 'refs/heads/main' 53 | 54 | steps: 55 | - name: Download packages built by build-and-inspect-python-package 56 | uses: actions/download-artifact@v4.2.1 57 | with: 58 | name: Packages 59 | path: dist 60 | 61 | - name: Upload package to Test PyPI 62 | uses: pypa/gh-action-pypi-publish@release/v1 63 | with: 64 | repository-url: https://test.pypi.org/legacy/ 65 | 66 | # Upload to real PyPI on GitHub Releases. 67 | release-pypi: 68 | name: Publish released package to pypi.org 69 | needs: build-package 70 | runs-on: ubuntu-latest 71 | environment: 72 | name: pypi 73 | url: https://pypi.org/project/pyazul/${{ needs.build-package.outputs.package-version }} 74 | 75 | permissions: 76 | id-token: write 77 | attestations: write 78 | contents: read 79 | if: github.repository_owner == 'indexa-git' && github.event_name == 'release' && github.event.action == 'published' 80 | 81 | steps: 82 | - name: Download packages built by build-and-inspect-python-package 83 | uses: actions/download-artifact@v4.2.1 84 | with: 85 | name: Packages 86 | path: dist 87 | 88 | - name: Generate artifact attestations 89 | uses: actions/attest-build-provenance@v2.2.3 90 | with: 91 | subject-path: dist/* 92 | 93 | - name: Upload package to PyPI 94 | uses: pypa/gh-action-pypi-publish@release/v1 95 | with: 96 | attestations: true 97 | -------------------------------------------------------------------------------- /.github/workflows/linting.yaml: -------------------------------------------------------------------------------- 1 | name: Lint Code Base 2 | 3 | concurrency: 4 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 5 | cancel-in-progress: true 6 | 7 | permissions: read-all 8 | 9 | on: 10 | pull_request: 11 | branches: [main] 12 | paths: 13 | - '**/*.py' 14 | - 'requirements.txt' 15 | - 'pyproject.toml' 16 | - .flake8 17 | - .isort.cfg 18 | - .github/workflows/linting.yml 19 | 20 | push: 21 | branches: [main] 22 | paths: 23 | - '**/*.py' 24 | - 'requirements.txt' 25 | - 'pyproject.toml' 26 | - .flake8 27 | - .isort.cfg 28 | - .github/workflows/linting.yml 29 | 30 | jobs: 31 | linter: 32 | runs-on: ubuntu-latest 33 | steps: 34 | - name: Checkout Code Repository 35 | uses: actions/checkout@v4.2.2 36 | with: 37 | # Full git history is needed to get a proper 38 | # list of changed files within `super-linter` 39 | fetch-depth: 0 40 | 41 | - name: Lint Code Base 42 | uses: super-linter/super-linter/slim@v7.4.0 43 | env: 44 | LOG_LEVEL: ERROR 45 | VALIDATE_ALL_CODEBASE: false 46 | VALIDATE_SHELL_SHFMT: false 47 | VALIDATE_JSCPD: false 48 | VALIDATE_CSS: false 49 | VALIDATE_EDITORCONFIG: false 50 | VALIDATE_MARKDOWN: false 51 | VALIDATE_JAVASCRIPT_ES: false 52 | VALIDATE_JAVASCRIPT_STANDARD: false 53 | LINTER_RULES_PATH: / 54 | DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} 55 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 56 | -------------------------------------------------------------------------------- /.github/workflows/tests-e2e.yaml: -------------------------------------------------------------------------------- 1 | name: End-to-End Tests 2 | 3 | concurrency: 4 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 5 | cancel-in-progress: true 6 | 7 | permissions: 8 | contents: read 9 | 10 | on: 11 | pull_request: 12 | branches: [main] 13 | paths: 14 | - '**/*.py' 15 | - 'requirements.txt' 16 | - 'pyproject.toml' 17 | - '.github/workflows/tests-e2e.yaml' 18 | push: 19 | branches: [main] 20 | paths: 21 | - '**/*.py' 22 | - 'requirements.txt' 23 | - 'pyproject.toml' 24 | - '.github/workflows/tests-e2e.yaml' 25 | 26 | env: 27 | PYTHONUNBUFFERED: 1 28 | AUTH1: ${{ secrets.AUTH1 }} 29 | AUTH2: ${{ secrets.AUTH2 }} 30 | MERCHANT_ID: ${{ secrets.MERCHANT_ID }} 31 | AZUL_CERT: ${{ secrets.AZUL_CERT }} 32 | AZUL_KEY: ${{ secrets.AZUL_KEY }} 33 | MERCHANT_NAME: indexa 34 | MERCHANT_TYPE: 1 35 | AZUL_AUTH_KEY: ${{ secrets.AUTH1 }} 36 | 37 | jobs: 38 | e2e-tests: 39 | runs-on: ubuntu-latest 40 | steps: 41 | - name: Checkout Code Repository 42 | uses: actions/checkout@v4.2.2 43 | 44 | - name: Set up Python 3.12 45 | uses: actions/setup-python@v5 46 | with: 47 | python-version: '3.12' 48 | 49 | - name: Install dependencies 50 | run: | 51 | python -m pip install --upgrade pip 52 | pip install -r requirements.txt 53 | pip install .[dev] 54 | 55 | - name: Run e2e tests 56 | run: python -m pytest tests/e2e 57 | -------------------------------------------------------------------------------- /.github/workflows/tests-unit.yaml: -------------------------------------------------------------------------------- 1 | name: Unit Tests 2 | 3 | concurrency: 4 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 5 | cancel-in-progress: true 6 | 7 | permissions: 8 | contents: read 9 | 10 | on: 11 | pull_request: 12 | branches: [main] 13 | paths: 14 | - '**/*.py' 15 | - 'requirements.txt' 16 | - 'pyproject.toml' 17 | - '.github/workflows/tests-unit.yaml' 18 | push: 19 | branches: [main] 20 | paths: 21 | - '**/*.py' 22 | - 'requirements.txt' 23 | - 'pyproject.toml' 24 | - '.github/workflows/tests-unit.yaml' 25 | 26 | jobs: 27 | unit-tests: 28 | runs-on: ubuntu-latest 29 | steps: 30 | - name: Checkout Code Repository 31 | uses: actions/checkout@v4.2.2 32 | 33 | - name: Set up Python 3.12 34 | uses: actions/setup-python@v5.6.0 35 | with: 36 | python-version: '3.12' 37 | 38 | - name: Install dependencies 39 | run: | 40 | python -m pip install --upgrade pip 41 | pip install -r requirements.txt 42 | pip install .[dev] 43 | 44 | - name: Run unit tests 45 | run: python -m pytest tests/unit 46 | -------------------------------------------------------------------------------- /.isort.cfg: -------------------------------------------------------------------------------- 1 | [settings] 2 | profile = black 3 | line_length = 88 4 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | default_language_version: 2 | python: python3.12 3 | 4 | repos: 5 | - repo: https://github.com/pre-commit/pre-commit-hooks 6 | rev: v4.6.0 7 | hooks: 8 | - id: trailing-whitespace 9 | - id: end-of-file-fixer 10 | - id: check-yaml 11 | - id: check-toml 12 | - id: check-merge-conflict 13 | - id: check-added-large-files 14 | 15 | - repo: https://github.com/pycqa/isort 16 | rev: 5.13.2 17 | hooks: 18 | - id: isort 19 | args: [--profile, black] 20 | stages: [commit] 21 | 22 | - repo: https://github.com/psf/black 23 | rev: 25.1.0 24 | hooks: 25 | - id: black 26 | 27 | - repo: https://github.com/PyCQA/flake8 28 | rev: 7.2.0 29 | hooks: 30 | - id: flake8 31 | args: [--max-line-length=88] 32 | additional_dependencies: [flake8-docstrings>=1.7.0] 33 | 34 | - repo: https://github.com/econchick/interrogate 35 | rev: 1.7.0 36 | hooks: 37 | - id: interrogate 38 | args: 39 | [ 40 | -vv, 41 | --config=pyproject.toml, 42 | -I, 43 | tests/, 44 | -I, 45 | --ignore-init-module, 46 | --ignore-init-method, 47 | --ignore-magic, 48 | --ignore-semiprivate, 49 | --ignore-private, 50 | --ignore-property-decorators, 51 | --ignore-setters, 52 | --ignore-regex, 53 | ^test_.*, 54 | ] 55 | -------------------------------------------------------------------------------- /.trunk/.gitignore: -------------------------------------------------------------------------------- 1 | *out 2 | *logs 3 | *actions 4 | *notifications 5 | *tools 6 | plugins 7 | user_trunk.yaml 8 | user.yaml 9 | tmp 10 | -------------------------------------------------------------------------------- /.trunk/configs/.markdownlint.yaml: -------------------------------------------------------------------------------- 1 | # Prettier friendly markdownlint config (all formatting rules disabled) 2 | extends: markdownlint/style/prettier 3 | -------------------------------------------------------------------------------- /.trunk/configs/.yamllint.yaml: -------------------------------------------------------------------------------- 1 | rules: 2 | quoted-strings: 3 | required: only-when-needed 4 | extra-allowed: ["{|}"] 5 | key-duplicates: {} 6 | octal-values: 7 | forbid-implicit-octal: true 8 | -------------------------------------------------------------------------------- /.trunk/configs/ruff.toml: -------------------------------------------------------------------------------- 1 | # Generic, formatter-friendly config. 2 | select = ["B", "D3", "E", "F"] 3 | 4 | # Never enforce `E501` (line length violations). This should be handled by formatters. 5 | ignore = ["E501"] 6 | -------------------------------------------------------------------------------- /.trunk/configs/svgo.config.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: [ 3 | { 4 | name: "preset-default", 5 | params: { 6 | overrides: { 7 | removeViewBox: false, // https://github.com/svg/svgo/issues/1128 8 | sortAttrs: true, 9 | removeOffCanvasPaths: true, 10 | }, 11 | }, 12 | }, 13 | ], 14 | }; 15 | -------------------------------------------------------------------------------- /.trunk/trunk.yaml: -------------------------------------------------------------------------------- 1 | # This file controls the behavior of Trunk: https://docs.trunk.io/cli 2 | # To learn more about the format of this file, see https://docs.trunk.io/reference/trunk-yaml 3 | version: 0.1 4 | cli: 5 | version: 1.22.15 6 | # Trunk provides extensibility via plugins. (https://docs.trunk.io/plugins) 7 | plugins: 8 | sources: 9 | - id: trunk 10 | ref: v1.6.8 11 | uri: https://github.com/trunk-io/plugins 12 | # Many linters and tools depend on runtimes - configure them here. (https://docs.trunk.io/runtimes) 13 | runtimes: 14 | enabled: 15 | - node@18.20.5 16 | - python@3.10.8 17 | # This is the section where you manage your linters. (https://docs.trunk.io/check/configuration) 18 | lint: 19 | enabled: 20 | - actionlint@1.7.7 21 | - bandit@1.8.3 22 | - black@25.1.0 23 | - checkov@3.2.432 24 | - flake8@7.2.0 25 | - git-diff-check 26 | - isort@6.0.1 27 | - markdownlint@0.45.0 28 | - osv-scanner@2.0.2 29 | - prettier@3.5.3 30 | - ruff@0.11.11 31 | - svgo@3.3.2 32 | - taplo@0.9.3 33 | - trufflehog@3.88.33 34 | - yamllint@1.37.1 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 INDEXA, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.md 3 | include pyproject.toml 4 | include setup.py 5 | recursive-include pyazul *.py 6 | recursive-include tests *.py 7 | recursive-include templates *.html 8 | recursive-include certs * 9 | -------------------------------------------------------------------------------- /docs/decisions/0001-pluggable-3ds-session-management.md: -------------------------------------------------------------------------------- 1 | --- 2 | status: proposed 3 | date: 2025-05-25 4 | builds-on: {} 5 | story: Improve 3DS session management for production readiness and flexibility, as identified in library evaluation. 6 | --- 7 | 8 | # ADR 0001: Pluggable 3D Secure Session Management 9 | 10 | ## Context & Problem Statement 11 | 12 | The current 3D Secure (3DS) implementation in `pyazul` (specifically within `[pyazul/services/secure.py](mdc:pyazul/services/secure.py)`) manages 3DS session state (including `secure_id` to `azul_order_id` mapping, processed method flags, and transaction states) using internal Python dictionaries. While functional for single-instance applications or testing, this approach lacks persistence and scalability required for robust production environments, especially those involving multiple server instances or serverless functions. 13 | 14 | A more flexible solution is needed to allow users to integrate their own session storage mechanisms (e.g., Redis, databases) without modifying the core library logic. 15 | 16 | ## Priorities & Constraints 17 | 18 | - **Flexibility**: Users should be able to provide their own session storage backend. 19 | - **Production Readiness**: The solution should support persistent and shared session storage suitable for distributed systems. 20 | - **Ease of Use**: The default behavior should remain simple (e.g., in-memory store for basic use/testing), with clear instructions for custom implementations. 21 | - **Maintainability**: The library's internal logic for 3DS flow should not be overly complicated by the session management abstraction. 22 | - **Minimal Breaking Changes (if any)**: Ideally, this can be added extensibly. 23 | 24 | ## Considered Options 25 | 26 | 1. **Current In-Memory Dictionary**: Keep the existing internal dictionary-based session management within `SecureService`. (This is the state after reverting the prior implementation attempt). 27 | 28 | - Pros: Simple, no immediate changes needed. 29 | - Cons: Not suitable for production, no persistence, doesn't scale across multiple instances. 30 | 31 | 2. **Pluggable Session Store via Abstract Base Class (ABC)**: 32 | 33 | - Define an ABC (`ThreeDSSessionStore`) in `pyazul.core.sessions` outlining methods for session operations (e.g., `save_session`, `get_session`, `delete_session`, `mark_method_processed`, `is_method_processed`, `get_transaction_state`, `set_transaction_state`). 34 | - Provide a default `InMemorySessionStore` implementation of this ABC, using dictionaries (similar to the current internal logic but encapsulated). 35 | - Modify `SecureService` to accept an instance of `ThreeDSSessionStore` in its constructor and use it for all session-related operations. 36 | - Modify `PyAzul` to accept an optional `session_store` argument in its constructor. If none is provided, it defaults to instantiating `InMemorySessionStore`. This store instance is then passed to `SecureService`. 37 | - Users can implement the `ThreeDSSessionStore` ABC with their preferred backend (Redis, database, etc.) and pass their custom store instance when initializing `PyAzul`. 38 | 39 | 3. **Callback-Based Session Management**: Require the user to implement specific callback functions for saving/loading session data, which `SecureService` would call. 40 | - Pros: Shifts responsibility entirely to the user. 41 | - Cons: Can be more cumbersome for the user to implement multiple callbacks, potentially tighter coupling with library internals if callbacks are too granular. 42 | 43 | ## Decision Outcome 44 | 45 | Chosen option: **[Option 2: Pluggable Session Store via Abstract Base Class (ABC)]** 46 | 47 | This approach was chosen because: 48 | 49 | - **Balances Flexibility and Ease of Use**: It provides a clear contract (the ABC) for custom implementations while offering a sensible default (`InMemorySessionStore`) for simple use cases and backward compatibility with current behavior if no custom store is provided. 50 | - **Production Viability**: Directly addresses the need for persistent and potentially distributed session storage by allowing users to integrate robust backends. 51 | - **Decoupling**: Decouples the 3DS session storage logic from the core `SecureService` flow logic, making both more maintainable. 52 | - **Clear Interface**: The ABC methods clearly define the required session operations. 53 | 54 | ### Expected Consequences 55 | 56 | - **Positive**: 57 | - Library becomes more suitable for production use in diverse environments. 58 | - Users gain control over 3DS session persistence and scalability. 59 | - Clear separation of concerns regarding session storage. 60 | - **Neutral/Slightly Positive**: 61 | - Users wanting custom storage will need to implement the `ThreeDSSessionStore` interface. 62 | - The `PyAzul` constructor will have an additional optional parameter (`session_store`). 63 | - **Potentially Negative (if not managed well)**: 64 | - If the ABC interface is not well-designed, it might be restrictive or difficult to implement for some backends (mitigated by making methods async and using simple data types). 65 | 66 | ## More Information 67 | 68 | - The initial implementation of this was started and then reverted to document this ADR first. 69 | - Relevant files for implementation would be: 70 | - `pyazul/core/sessions.py` (new file for ABC and InMemorySessionStore) 71 | - `pyazul/services/secure.py` (to use the injected store) 72 | - `pyazul/index.py` (to accept and pass the store instance) 73 | - `pyazul/__init__.py` (to export session store classes) 74 | - The [README.md](mdc:README.md) would need updates to document this feature. 75 | -------------------------------------------------------------------------------- /docs/decisions/0002-optional-synchronous-api-wrapper.md: -------------------------------------------------------------------------------- 1 | --- 2 | status: proposed 3 | date: 2025-05-25 4 | builds-on: {} 5 | story: Enhance usability for developers not using asyncio by providing a synchronous API. 6 | --- 7 | 8 | # ADR 0002: Optional Synchronous API Wrapper 9 | 10 | ## Context & Problem Statement 11 | 12 | The `pyazul` library is currently exclusively asynchronous (`async/await`), leveraging `httpx.AsyncClient` for API calls. While this is efficient for I/O-bound operations and integrates well with modern async frameworks (like FastAPI, as shown in README examples), it presents a barrier to entry or inconvenience for developers working in synchronous codebases or writing simple scripts where an async event loop is not readily available or desired. 13 | 14 | Should `pyazul` offer an optional synchronous API wrapper to improve its accessibility? 15 | 16 | ## Priorities & Constraints 17 | 18 | - **Ease of Use**: Developers in synchronous environments should be able to use the library without managing an asyncio event loop explicitly for `pyazul` calls. 19 | - **Maintainability**: The synchronous wrapper should not significantly complicate the core async library or introduce a large maintenance burden. 20 | - **API Consistency**: The synchronous API should mirror the asynchronous API as closely as possible in terms of methods and parameters. 21 | - **Performance**: While convenience is key, the synchronous wrapper should be implemented efficiently (e.g., by properly managing the event loop for its calls). 22 | 23 | ## Considered Options 24 | 25 | 1. **No Synchronous Wrapper (Current State)**: Continue with an async-only API. 26 | 27 | - Pros: Simplest to maintain the core library; no additional code. 28 | - Cons: Limits usability for synchronous use cases; users would have to manage `asyncio.run()` or similar themselves. 29 | 30 | 2. **Provide a Separate Synchronous Client (`PyAzulSync`)**: 31 | 32 | - Create a parallel set of classes (e.g., `PyAzulSync`, `TransactionServiceSync`) that internally use `httpx.Client` (the synchronous version) and synchronous method definitions. 33 | - Pros: Clear separation between async and sync versions; potentially more performant for purely synchronous calls as it avoids `asyncio.run()` overhead per call. 34 | - Cons: Significant code duplication (methods, Pydantic model handling, etc.), leading to a much larger maintenance burden. Keeping features and fixes in sync across two client implementations would be challenging. 35 | 36 | 3. **Provide a Thin Synchronous Wrapper Around the Async API**: 37 | 38 | - Create a synchronous facade class (e.g., `PyAzulSync` or add sync methods to `PyAzul` distinguished by name or a mode). 39 | - Synchronous methods in this facade would internally call the corresponding `async` methods of the core library using `asyncio.run()` or a similar mechanism to execute them in a temporary event loop. 40 | - Example: `PyAzulSync.sale(...)` would call `asyncio.run(core_pyazul_instance.sale(...))`. 41 | - Pros: Minimal code duplication as it reuses the entire core async logic; easier to maintain feature parity. 42 | - Cons: Each synchronous call incurs the overhead of starting/stopping an event loop via `asyncio.run()`, which might be less performant for rapid, numerous calls compared to a native sync client. However, for typical API call frequencies, this might be acceptable. 43 | 44 | 4. **Auto-Generating Sync Wrapper (e.g., using tools like `unasync`)**: 45 | - Develop the library as async-first. 46 | - Use a tool (like `unasync`) to automatically generate a synchronous version of the API by transforming the async code (e.g., replacing `async def` with `def`, `await` with direct calls, `AsyncClient` with `Client`). 47 | - Pros: Single source of truth (the async code); sync version is generated, reducing manual duplication and maintenance. 48 | - Cons: Introduces a build-time dependency and process; generated code might sometimes need manual adjustments or careful structuring of the async code to translate well. 49 | 50 | ## Decision Outcome 51 | 52 | Chosen option: **[Option 3: Provide a Thin Synchronous Wrapper Around the Async API]** (with consideration for Option 4 if Option 3 proves too cumbersome or unperformant). 53 | 54 | Option 3 is preferred initially because: 55 | 56 | - **Leverages Existing Code**: It makes maximal reuse of the already well-structured and tested asynchronous core, minimizing new code that needs to be written and maintained specifically for the sync wrapper. 57 | - **Lower Maintenance Overhead (than Option 2)**: Far less duplication than maintaining two separate client implementations. 58 | - **Good User Experience for Simple Cases**: For users needing occasional synchronous calls, the `asyncio.run()` overhead is likely acceptable and the convenience high. 59 | 60 | If performance issues arise with Option 3 or the pattern becomes repetitive and error-prone, **Option 4 (Auto-Generating Sync Wrapper)** should be seriously investigated as a more robust long-term solution for maintaining both sync and async interfaces from a single async codebase. 61 | 62 | ### Expected Consequences 63 | 64 | - **Positive**: 65 | - Increased adoption of the library by users in synchronous environments. 66 | - Improved ease of use for simple scripting tasks. 67 | - **Neutral/Slightly Positive**: 68 | - A new synchronous facade class (e.g., `PyAzulSync`) or additional methods would be added to the public API. 69 | - **Potentially Negative**: 70 | - Performance overhead for each synchronous call due to `asyncio.run()`. This needs to be acceptable for typical use patterns. 71 | - Care must be taken if the wrapper needs to manage any state across calls, though `PyAzul` is largely stateless per method call for API interactions. 72 | 73 | ## More Information 74 | 75 | - This decision primarily impacts the main `PyAzul` facade ([pyazul/index.py](mdc:pyazul/index.py)) and how it would offer synchronous methods. 76 | - The underlying services and `AzulAPI` client would remain async. 77 | - Python's `asyncio.run()` is the standard way to run an async function from sync code. 78 | -------------------------------------------------------------------------------- /docs/decisions/0003-advanced-http-client-customization.md: -------------------------------------------------------------------------------- 1 | --- 2 | status: proposed 3 | date: 2025-05-25 4 | builds-on: {} 5 | story: Allow users more control over the HTTP client for advanced scenarios like custom proxies, timeouts, or retry strategies. 6 | --- 7 | 8 | # ADR 0003: Advanced HTTP Client Customization 9 | 10 | ## Context & Problem Statement 11 | 12 | The `pyazul` library internally creates and manages an `httpx.AsyncClient` instance within `AzulAPI` ([pyazul/api/client.py](mdc:pyazul/api/client.py)). While `AzulAPI` handles common configurations like SSL context (from `AzulSettings`) and default timeouts, users with advanced networking requirements (e.g., specific proxy configurations, custom retry strategies beyond the built-in alternate URL retry, fine-grained timeout controls, custom transport layers, or event hooks) currently have no way to customize or provide their own `httpx.AsyncClient` instance. 13 | 14 | Should `pyazul` allow users to provide a pre-configured `httpx.AsyncClient` instance? 15 | 16 | ## Priorities & Constraints 17 | 18 | - **Flexibility**: Users with advanced needs should be able to customize HTTP client behavior. 19 | - **Maintain Internal Defaults**: The library should still work out-of-the-box with sensible default client behavior if no custom client is provided. 20 | - **Encapsulation**: Avoid exposing too many low-level HTTP client details directly on the `PyAzul` or `AzulSettings` API if a simpler mechanism suffices. 21 | - **Security**: Ensure that if a user provides a custom client, critical security configurations managed by `pyazul` (like SSL context for Azul certs, specific Auth headers) are still correctly applied or that the user is made aware of their responsibility. 22 | 23 | ## Considered Options 24 | 25 | 1. **No Custom Client (Current State)**: `AzulAPI` always creates its own `httpx.AsyncClient` with fixed internal configurations (plus what `AzulSettings` provides for certs/timeouts). 26 | 27 | - Pros: Simple for the library to manage; consistent client behavior. 28 | - Cons: No flexibility for users with advanced proxy, retry, or transport needs. 29 | 30 | 2. **Expose More `AzulSettings` for HTTP Client Params**: Add more fields to `AzulSettings` for common `httpx.AsyncClient` parameters (e.g., proxy URLs, more timeout options, retry counts). 31 | 32 | - Pros: Keeps configuration within the existing `AzulSettings` pattern. 33 | - Cons: Can lead to a bloated `AzulSettings` if many `httpx` options are exposed; might still not cover all advanced use cases (like custom transports or event hooks). 34 | 35 | 3. **Allow Passing a Pre-configured `httpx.AsyncClient` to `AzulAPI` (and thus to `PyAzul`)**: 36 | - Modify `AzulAPI.__init__` to optionally accept an `httpx.AsyncClient` instance. 37 | - If provided, `AzulAPI` uses this instance. If not, it creates its own as it does currently. 38 | - `PyAzul.__init__` would also be modified to optionally accept this client and pass it to `AzulAPI`. 39 | - **Crucial Consideration**: If a user provides their own client, `AzulAPI` must still ensure its own SSL context (from `AzulSettings` for Azul certs) is used, or clearly document that the user-provided client must be pre-configured with the appropriate `verify` settings. Azul-specific headers (`Auth1`, `Auth2`) are added per-request by `AzulAPI` so these would still apply. 40 | - Pros: Maximum flexibility for users – they can configure the client exactly as needed. 41 | - Cons: Users need to understand `httpx.AsyncClient` configuration. Potential for misconfiguration if the interaction regarding SSL/verify context isn't handled or documented clearly. 42 | 43 | ## Decision Outcome 44 | 45 | Chosen option: **[Option 3: Allow Passing a Pre-configured `httpx.AsyncClient` to `AzulAPI`]** 46 | 47 | This option offers the highest degree of flexibility for users with advanced networking requirements, which is often necessary when dealing with enterprise environments, specific proxy setups, or complex network policies. 48 | 49 | To mitigate potential issues: 50 | 51 | - **Documentation**: Clearly document how to pass a custom client and the responsibilities of the user regarding its configuration, especially SSL/TLS verification if they intend to override `pyazul`'s default certificate handling. 52 | - **Sensible Merging/Defaults**: When a custom client is passed to `AzulAPI`, `AzulAPI` should ideally still attempt to apply its specific SSL context derived from `AzulSettings` _unless_ the user-provided client already has a custom SSL context explicitly set. `AzulAPI` will continue to manage Azul-specific authentication headers (`Auth1`/`Auth2`) on each request. 53 | - The default behavior (library creates and manages its own client) must remain for ease of use in common scenarios. 54 | 55 | ### Expected Consequences 56 | 57 | - **Positive**: 58 | - Library becomes usable in a wider range of network environments. 59 | - Users gain fine-grained control over HTTP behavior for optimization or specific requirements. 60 | - **Neutral/Slightly Positive**: 61 | - `PyAzul` and `AzulAPI` constructors will have an additional optional parameter (`http_client: Optional[httpx.AsyncClient] = None`). 62 | - **Potentially Negative**: 63 | - Increased complexity for users who choose to provide their own client if they are not familiar with `httpx` configuration details. 64 | - Risk of user misconfiguration (e.g., disabling SSL verification inappropriately) if documentation and warnings are not clear. 65 | 66 | ## More Information 67 | 68 | - This change primarily affects `[pyazul/api/client.py](mdc:pyazul/api/client.py)` (`AzulAPI.__init__`) and `[pyazul/index.py](mdc:pyazul/index.py)` (`PyAzul.__init__`). 69 | - The `httpx` documentation on advanced client configuration, transports, and SSL would be relevant for users choosing this option. 70 | - The library should continue to set its `base_headers` and per-request `Auth1`/`Auth2` headers even on a user-provided client. 71 | -------------------------------------------------------------------------------- /docs/decisions/0004-consolidate-settings-logic-in-azulapi.md: -------------------------------------------------------------------------------- 1 | --- 2 | status: implemented 3 | date: 2025-05-26 4 | builds-on: 5 | story: Improve separation of concerns and simplify service dependencies by centralizing API endpoint and authentication parameter selection within the AzulAPI client. 6 | --- 7 | 8 | # Consolidate API Settings Logic within AzulAPI 9 | 10 | ## Context & Problem Statement 11 | 12 | Currently, while `AzulAPI` handles some settings-based selections (like choosing between standard and 3DS authentication keys based on an `is_secure` flag), there's a potential for services to also access the `AzulSettings` object to determine API call parameters like base URLs or other specific settings. This can lead to a diluted responsibility for `AzulAPI` and require services to carry `AzulSettings` as a dependency even if their primary need is just to make API calls via `AzulAPI`. The goal is to make `AzulAPI` the sole authority on how to communicate with the Azul gateway once it's configured. 13 | 14 | ## Priorities & Constraints 15 | 16 | - **Clear Separation of Concerns**: `AzulAPI` should be the single point of truth for how to construct and send requests to the Azul API, including selecting appropriate endpoints and authentication credentials. 17 | - **Simplified Service Dependencies**: Services should ideally only depend on `AzulAPI` for API interactions, reducing the need to also depend on `AzulSettings` if `AzulAPI` can derive all necessary call parameters. 18 | - **Maintainability**: Centralizing this logic should make the codebase easier to understand and maintain, as changes to endpoint selection or auth logic would be localized to `AzulAPI`. 19 | - **Ease of Use**: While the `PyAzul` facade simplifies things for the end-user, internal consistency and clarity benefit development and testing. 20 | 21 | ## Considered Options 22 | 23 | - **Option 1: Services Retain Access to `AzulSettings` for API Parameters**: Services continue to potentially use `AzulSettings` to determine specific API call details (e.g., base URLs for different environments or transaction types). This is the implicit status quo if not actively changed. 24 | - **Option 2: `AzulAPI` Fully Manages API Call Parameters**: `AzulAPI`, once initialized with `AzulSettings`, becomes solely responsible for selecting the correct base URLs, authentication keys, and other necessary parameters for an API call, primarily based on its internal settings and explicit flags like `is_secure` passed during request method calls. Services would not need to consult `AzulSettings` for these details. 25 | 26 | ## Decision Outcome 27 | 28 | Chosen option: **Option 2: `AzulAPI` Fully Manages API Call Parameters** 29 | 30 | `AzulAPI` will be enhanced to fully encapsulate the logic for selecting appropriate API base URLs (e.g., dev vs. prod, standard vs. secure/3DS) and any other settings-derived parameters needed for making API calls. This complements its existing responsibility for choosing authentication keys. 31 | 32 | Services will primarily rely on the `is_secure` flag (and potentially other flags if new distinct API endpoints/behaviors are introduced) when calling methods on `AzulAPI`. `AzulAPI` will then use these flags in conjunction with its `self.settings` to make all necessary choices. 33 | 34 | This means that most services, after this change, might no longer require direct injection of `AzulSettings` if their only use of it was to determine API call parameters. They would only need the `api_client` instance. `SecureService` is a known exception due to its specific needs for URL manipulation (`TermUrl`, `MethodNotificationUrl`) and session management, which might still benefit from direct `settings` access. 35 | 36 | ### Expected Consequences 37 | 38 | - **Reduced Coupling**: Services (excluding potentially `SecureService`) will be less coupled to the `AzulSettings` structure for API call mechanics. 39 | - **Improved Clarity**: The responsibility for choosing API endpoints and auth details will be clearly located within `AzulAPI`. 40 | - **Simplified Service Signatures**: Constructors for many services might be simplified to only require `api_client`. 41 | - **Refactoring Effort**: `AzulAPI` will need to be refactored to include this expanded logic. Services might need minor adjustments if they were previously deriving API call parameters from `settings`. 42 | - **Testing**: Unit testing for `AzulAPI` will become even more critical as it handles more complex decision-making. Service testing might become simpler as they delegate more to `AzulAPI`. 43 | 44 | ## More Information 45 | 46 | - This decision builds upon the existing architecture where `AzulAPI` already selects authentication keys (standard vs. 3DS) based on `is_secure` and `AzulSettings`. 47 | - See `configuration_management.mdc` and `architecture_overview.mdc` for context on the current roles of `AzulAPI` and `AzulSettings`. 48 | -------------------------------------------------------------------------------- /docs/decisions/0006-reorganize-models-by-business-domain.md: -------------------------------------------------------------------------------- 1 | --- 2 | status: proposed 3 | date: 2024-12-19 4 | story: Reorganize PyAzul models structure to align with Azul's business domains for better developer experience and maintainability 5 | --- 6 | 7 | # Reorganize Models by Business Domain 8 | 9 | ## Context & Problem Statement 10 | 11 | The current PyAzul models are organized by technical concerns (`schemas.py` for general models, `secure.py` for 3DS models), which creates confusion and doesn't align with how Azul organizes their services. Developers working with PyAzul need to understand both the business domain they're working with and hunt across different files to find related models. 12 | 13 | The current structure mixes different business domains in the same files: 14 | 15 | - `schemas.py` contains transaction models, DataVault models, and Payment Page models 16 | - `secure.py` contains only 3D Secure models 17 | - Import patterns show inconsistency, with some code importing from top-level `pyazul.models` and others from specific modules 18 | 19 | ## Priorities & Constraints 20 | 21 | - **Developer Experience**: Models should be discoverable based on business intent 22 | - **Backwards Compatibility**: Existing imports should continue to work during transition 23 | - **Alignment with Azul Documentation**: Structure should match how Azul organizes their services 24 | - **Maintainability**: Related models should be co-located for easier maintenance 25 | - **Clear Separation of Concerns**: Each domain should have clear boundaries 26 | 27 | ## Considered Options 28 | 29 | ### Option 1: Keep Current Technical Structure 30 | 31 | - Continue with `schemas.py` and `secure.py` organization 32 | - Pros: No breaking changes, minimal effort 33 | - Cons: Doesn't scale well, confusing for developers, misaligned with business domains 34 | 35 | ### Option 2: Business Domain Organization 36 | 37 | Organize models by Azul's business domains: 38 | 39 | - `payment/` - Core payment processing (Sale, Hold, Refund, Void, Post) 40 | - `datavault/` - Card tokenization and token management 41 | - `three_ds/` - 3D Secure authentication and challenge flows 42 | - `payment_page/` - Hosted payment page integration 43 | - `verification/` - Transaction status and verification 44 | - `recurring/` - Subscription and recurring payment management 45 | - `installments/` - Payment plans and installment models 46 | - `dcc/` - Dynamic Currency Conversion 47 | 48 | ### Option 3: Hybrid Approach 49 | 50 | - Keep current structure but add domain-specific modules 51 | - Pros: Gradual migration possible 52 | - Cons: Creates duplication and confusion during transition 53 | 54 | ### Option 4: Functional Organization 55 | 56 | - Group by operation type (requests, responses, errors) 57 | - Pros: Clear technical boundaries 58 | - Cons: Doesn't match business understanding 59 | 60 | ## Decision Outcome 61 | 62 | Chosen option: **Option 2: Business Domain Organization** 63 | 64 | This approach aligns with how Azul documents and organizes their services, making it intuitive for developers to find relevant models. Each domain will have its own directory with focused responsibilities. 65 | 66 | ### Implementation Plan 67 | 68 | 1. **Phase 1**: Create new domain-based structure while maintaining backwards compatibility 69 | - Create domain directories under `pyazul/models/` 70 | - Move existing models to appropriate domains 71 | - Update `__init__.py` to re-export from new locations 72 | 73 | 2. **Phase 2**: Update internal imports and services 74 | - Update service classes to import from new locations 75 | - Update tests to use new structure 76 | - Add deprecation warnings for old import paths 77 | 78 | 3. **Phase 3**: Documentation and examples 79 | - Update documentation to reflect new structure 80 | - Update examples to demonstrate domain-based usage 81 | - Create migration guide for existing users 82 | 83 | ### Expected Consequences 84 | 85 | **Positive:** 86 | 87 | - Improved developer experience through intuitive model discovery 88 | - Better alignment with Azul's business organization 89 | - Easier maintenance with co-located related models 90 | - Clearer separation of concerns 91 | - More scalable structure for future Azul service additions 92 | 93 | **Negative:** 94 | 95 | - Initial migration effort required 96 | - Temporary import complexity during transition 97 | - Need to update existing code and documentation 98 | 99 | **Neutral:** 100 | 101 | - Larger number of files and directories 102 | - Need for clear naming conventions across domains 103 | 104 | ## More Information 105 | 106 | ### Supporting Evidence from Azul Documentation 107 | 108 | The official Azul documentation clearly organizes services by business domains: 109 | 110 | 1. **Payment Processing**: Core transaction operations (Sale, Hold, Refund, Void, Post) 111 | 2. **Bóveda de Datos (DataVault)**: Card tokenization with methods like ProcessDatavault 112 | 3. **3D Secure**: Complete authentication flows with dedicated endpoints and models 113 | 4. **Payment Page**: Hosted payment integration with specific parameters and responses 114 | 5. **Recurring Payments**: Subscription management with frequency-based models 115 | 6. **Installments (Cuotas)**: Payment plan functionality 116 | 7. **DCC**: Dynamic Currency Conversion with rate inquiry and conversion models 117 | 118 | ### Domain Responsibilities 119 | 120 | **payment/** 121 | 122 | - Core transaction models: SaleTransactionModel, HoldTransactionModel, RefundTransactionModel 123 | - Common response models and error handling 124 | - Base transaction functionality 125 | 126 | **datavault/** 127 | 128 | - DataVaultRequestModel, DataVaultResponseModel 129 | - Token management models 130 | - Card storage and retrieval models 131 | 132 | **three_ds/** 133 | 134 | - 3DS authentication models: SecureSaleRequest, SecureTokenSale 135 | - Challenge flow models: CardHolderInfo, ThreeDSAuth 136 | - Authentication response models 137 | 138 | **payment_page/** 139 | 140 | - PaymentPageModel and related configuration 141 | - Hosted payment form models 142 | - Page-specific response handling 143 | 144 | **verification/** 145 | 146 | - Transaction verification and status models 147 | - Query and lookup functionality 148 | 149 | **recurring/** 150 | 151 | - Subscription models and frequency configurations 152 | - Recurring payment scheduling models 153 | 154 | **installments/** 155 | 156 | - Payment plan models 157 | - Installment configuration and calculation models 158 | 159 | **dcc/** 160 | 161 | - Currency conversion models 162 | - Rate inquiry and conversion response models 163 | 164 | ### References 165 | 166 | - [Azul API Documentation](https://dev.azul.com.do/) - Official service organization 167 | - [Domain-Driven Design by Eric Evans](https://domainlanguage.com/ddd/) - Principles for domain organization 168 | - [Python Package Organization Best Practices](https://docs.python-guide.org/writing/structure/) - Python-specific guidance 169 | - [Pydantic Model Organization](https://pydantic-docs.helpmanual.io/usage/models/#model-signature) - Framework-specific patterns 170 | -------------------------------------------------------------------------------- /docs/decisions/README.md: -------------------------------------------------------------------------------- 1 | # Decision Log 2 | 3 | We capture important decisions with [architectural decision records](https://adr.github.io/). 4 | 5 | These records provide context, trade-offs, and reasoning taken at our community & technical cross-roads. Our goal is to preserve the understanding of the project growth, and capture enough insight to effectively revisit previous decisions. 6 | 7 | Get started created a new decision record with the template: 8 | 9 | ```sh 10 | cp template.md NNNN-title-with-dashes.md 11 | ``` 12 | 13 | For more rational behind this approach, see [Michael Nygard's article](http://thinkrelevance.com/blog/2011/11/15/documenting-architecture-decisions). 14 | 15 | We've inherited MADR [ADR template](https://adr.github.io/madr/), which is a bit more verbose than Nygard's original template. We may simplify it in the future. 16 | 17 | ## Evolving Decisions 18 | 19 | Many decisions build on each other, a driver of iterative change and messiness 20 | in software. By laying out the "story arc" of a particular system within the 21 | application, we hope future maintainers will be able to identify how to rewind 22 | decisions when refactoring the application becomes necessary. 23 | -------------------------------------------------------------------------------- /docs/decisions/example.md: -------------------------------------------------------------------------------- 1 | --- 2 | parent: Architectural Decisions 3 | nav_order: 9 4 | --- 5 | 6 | # Use Plain JUnit5 for advanced test assertions 7 | 8 | ## Context and Problem Statement 9 | 10 | How to write readable test assertions? 11 | How to write readable test assertions for advanced tests? 12 | 13 | ## Considered Options 14 | 15 | - Plain JUnit5 16 | - Hamcrest 17 | - AssertJ 18 | 19 | ## Decision Outcome 20 | 21 | Chosen option: "Plain JUnit5", because comes out best (see "Pros and Cons of the Options" below). 22 | 23 | ### Consequences 24 | 25 | - Good, because tests are more readable 26 | - Good, because more easy to write tests 27 | - Good, because more readable assertions 28 | - Bad, because more complicated testing leads to more complicated assertions 29 | 30 | ### Confirmation 31 | 32 | - Check project dependencies, JUnit5 should appear (and be the only test assertion library). 33 | - Collect experience with JUnit5 in sprint reviews and retrospectives: does the gained experience match the pros and cons evaluation below? 34 | - Decide whether and when to review the decision (this is the 'R' in the [ecADR definition of done] 35 | () for ADs). 36 | 37 | ## Pros and Cons of the Options 38 | 39 | ### Plain JUnit5 40 | 41 | Homepage: 42 | JabRef testing guidelines: 43 | 44 | Example: 45 | 46 | ```java 47 | String actual = markdownFormatter.format(source); 48 | assertTrue(actual.contains("Markup
")); 49 | assertTrue(actual.contains("
  • list item one
  • ")); 50 | assertTrue(actual.contains("
  • list item 2
  • ")); 51 | assertTrue(actual.contains("> rest")); 52 | assertFalse(actual.contains("\n")); 53 | ``` 54 | 55 | - Good, because Junit5 is "common Java knowledge" 56 | - Bad, because complex assertions tend to get hard to read 57 | - Bad, because no fluent API 58 | 59 | ### Hamcrest 60 | 61 | Homepage: 62 | 63 | - Good, because offers advanced matchers (such as `contains`) 64 | - Bad, because not full fluent API 65 | - Bad, because entry barrier is increased 66 | 67 | ### AssertJ 68 | 69 | Homepage: 70 | 71 | Example: 72 | 73 | ```java 74 | assertThat(markdownFormatter.format(source)) 75 | .contains("Markup
    ") 76 | .contains("
  • list item one
  • ") 77 | .contains("
  • list item 2
  • ") 78 | .contains("> rest") 79 | .doesNotContain("\n"); 80 | ``` 81 | 82 | - Good, because offers fluent assertions 83 | - Good, because allows partial string testing to focus on important parts 84 | - Good, because assertions are more readable 85 | - Bad, because not commonly used 86 | - Bad, because newcomers have to learn an additional language to express test cases 87 | - Bad, because entry barrier is increased 88 | - Bad, because expressions of test cases vary from unit test to unit test 89 | 90 | ## More Information 91 | 92 | German comparison between Hamcrest and AssertJ: . 93 | -------------------------------------------------------------------------------- /docs/decisions/template.md: -------------------------------------------------------------------------------- 1 | --- 2 | # status and date are the only required elements. Feel free to remove the rest. 3 | status: {[proposed | rejected | accepted | deprecated | … | superceded by [2021-01-01 Example](2021-01-01-example.md)]} 4 | date: {YYYY-MM-DD when the decision was last updated} 5 | builds-on: {[Short Title](2021-05-15-short-title.md)} 6 | story: {description or link to contextual issue} 7 | --- 8 | 9 | # {short title of solved problem and solution} 10 | 11 | ## Context & Problem Statement 12 | 13 | 2-3 sentences explaining the problem, and what are the forces influencing the decision. 14 | 15 | 16 | 17 | ## Priorities & Constraints 18 | 19 | - {list of concerns} 20 | - {that are influencing the decision} 21 | 22 | ## Considered Options 23 | 24 | - Option 1: Thing 25 | - Option 2: Another 26 | 27 | ## Decision Outcome 28 | 29 | Chosen option [Option 1: Thing] 30 | 31 | [justification] 32 | 33 | ### Expected Consequences 34 | 35 | - List of unrelated outcomes this decision creates. 36 | 37 | 38 | ## More Information 39 | 40 | 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /examples/datavault_flow_example.py: -------------------------------------------------------------------------------- 1 | """Example demonstrating the DataVault tokenization flow with PyAzul.""" 2 | 3 | import asyncio 4 | 5 | from pyazul import PyAzul 6 | from pyazul.models import TokenError, TokenSuccess 7 | 8 | 9 | async def test_datavault_flow(): 10 | """ 11 | Example demonstrating the complete DataVault token lifecycle. 12 | 13 | This example shows how to: 14 | 1. Create a token from card data 15 | 2. Use the token for a payment 16 | 3. Delete the token 17 | 4. Handle token-related errors 18 | 19 | Use case: When you need to securely store card data for future use, 20 | such as: 21 | - Subscription payments 22 | - One-click checkouts 23 | - Recurring billing 24 | 25 | Security note: Using tokens instead of storing card data helps 26 | with PCI compliance by reducing the scope of sensitive data 27 | handling in your system. 28 | """ 29 | # Initialize PyAzul facade 30 | azul = PyAzul() 31 | settings = azul.settings 32 | 33 | # Card data for tokenization 34 | card_details = { 35 | "CardNumber": "5413330089600119", 36 | "Expiration": "202812", 37 | } 38 | common_datavault_params = { 39 | "Channel": "EC", 40 | "Store": settings.MERCHANT_ID, 41 | } 42 | 43 | try: 44 | # 1. Create token 45 | print("\n1. Creating token...") 46 | create_payload_dict = { 47 | **common_datavault_params, 48 | **card_details, 49 | "TrxType": "CREATE", 50 | } 51 | token_response = await azul.create_token(create_payload_dict) 52 | print(f"Full token_response from create_token: {token_response}") 53 | 54 | # Check response type for proper handling 55 | if isinstance(token_response, TokenSuccess): 56 | token = token_response.DataVaultToken 57 | print(f"✅ Token created successfully: {token}") 58 | print(f"🎭 Card Number (masked): {token_response.CardNumber}") 59 | print(f"🏷️ Brand: {token_response.DataVaultBrand}") 60 | print(f"📅 Expiration: {token_response.DataVaultExpiration}") 61 | elif isinstance(token_response, TokenError): 62 | raise Exception(f"Token creation failed: {token_response.ErrorDescription}") 63 | else: 64 | # Fallback for backward compatibility 65 | if token_response.get("ResponseMessage") != "APROBADA": 66 | err_desc = token_response.get( 67 | "ErrorDescription", token_response.get("ResponseMessage") 68 | ) 69 | raise Exception(f"Token creation failed: {err_desc}") 70 | token = token_response.get("DataVaultToken") 71 | print(f"Token created: {token}") 72 | 73 | # 2. Use token for payment 74 | print("\n2. Making payment with token...") 75 | token_sale_data = { 76 | "Channel": "EC", 77 | "Store": settings.MERCHANT_ID, 78 | "PosInputMode": "E-Commerce", 79 | "Amount": "1000", 80 | "Itbis": "180", 81 | "DataVaultToken": token, 82 | "OrderNumber": "003004005006007", 83 | "CVC": "123", 84 | "ForceNo3DS": "1", 85 | } 86 | sale_response = await azul.token_sale(token_sale_data) 87 | print(f"Sale response: {sale_response}") 88 | if sale_response.get("ResponseMessage") != "APROBADA": 89 | err_desc = sale_response.get( 90 | "ErrorDescription", sale_response.get("ResponseMessage") 91 | ) 92 | raise Exception(f"Token Sale failed: {err_desc}") 93 | 94 | # 3. Delete token 95 | print("\n3. Deleting token...") 96 | delete_payload_dict = { 97 | **common_datavault_params, 98 | "DataVaultToken": token, 99 | "TrxType": "DELETE", 100 | } 101 | delete_response = await azul.delete_token(delete_payload_dict) 102 | print(f"Delete response: {delete_response}") 103 | 104 | # Check response type for proper handling 105 | if isinstance(delete_response, TokenSuccess): 106 | print(f"✅ Token deleted successfully: {delete_response.DataVaultToken}") 107 | elif isinstance(delete_response, TokenError): 108 | raise Exception( 109 | f"Token deletion failed: {delete_response.ErrorDescription}" 110 | ) 111 | else: 112 | # Fallback for backward compatibility 113 | if delete_response.get("ResponseMessage") != "APROBADA": 114 | err_desc = delete_response.get( 115 | "ErrorDescription", delete_response.get("ResponseMessage") 116 | ) 117 | raise Exception(f"Token deletion failed: {err_desc}") 118 | 119 | # 4. Verify token is invalid 120 | print("\n4. Verifying deleted token...") 121 | try: 122 | verify_token_sale_data = { 123 | "Channel": "EC", 124 | "Store": settings.MERCHANT_ID, 125 | "PosInputMode": "E-Commerce", 126 | "Amount": "1000", 127 | "Itbis": "180", 128 | "DataVaultToken": token, 129 | "OrderNumber": "003004005006007", 130 | "CVC": "123", 131 | "ForceNo3DS": "1", 132 | } 133 | await azul.token_sale(verify_token_sale_data) 134 | print("Warning: Payment with deleted token succeeded unexpectedly") 135 | except Exception as e: 136 | print(f"Expected error when using deleted token: {str(e)}") 137 | 138 | except Exception as e: 139 | print(f"Error in DataVault flow: {str(e)}") 140 | 141 | 142 | if __name__ == "__main__": 143 | asyncio.run(test_datavault_flow()) 144 | -------------------------------------------------------------------------------- /examples/hold_example.py: -------------------------------------------------------------------------------- 1 | """Example demonstrating how to perform a hold transaction with PyAzul.""" 2 | 3 | import asyncio 4 | 5 | from pyazul import PyAzul 6 | 7 | 8 | async def test_hold(): 9 | """ 10 | Example demonstrating a card authorization hold. 11 | 12 | This example shows how to: 13 | 1. Initialize the transaction service 14 | 2. Create a hold request 15 | 3. Place a hold on a card 16 | 4. Handle the authorization response 17 | 18 | Use case: When you need to verify funds or reserve an amount 19 | without immediate capture, such as: 20 | - Hotel reservations 21 | - Car rentals 22 | - Pre-authorizations 23 | """ 24 | # Initialize PyAzul facade 25 | azul = PyAzul() 26 | settings = azul.settings 27 | 28 | # Hold transaction data 29 | hold_data = { 30 | "Store": settings.MERCHANT_ID, 31 | "Channel": "EC", 32 | "PosInputMode": "E-Commerce", 33 | "Amount": "1000", # Amount to hold 34 | "Itbis": "180", # Tax amount 35 | "CardNumber": "5413330089600119", 36 | "Expiration": "202812", 37 | "CVC": "979", 38 | "OrderNumber": "HOLD-001", 39 | "CustomOrderId": "hold-example-001", 40 | "SaveToDataVault": "0", # Don't save card for holds 41 | } 42 | 43 | try: 44 | # Process hold request 45 | response = await azul.hold(hold_data) 46 | 47 | # Display hold results 48 | print("\nHold Response:") 49 | print("-" * 50) 50 | print(f"ISO Code: {response.get('IsoCode')}") 51 | print(f"Authorization: {response.get('AuthorizationCode')}") 52 | print(f"RRN: {response.get('RRN')}") 53 | print(f"Order ID: {response.get('CustomOrderId')}") 54 | print(f"Azul Order ID: {response.get('AzulOrderId')}") 55 | print("-" * 50) 56 | 57 | except Exception as e: 58 | print(f"Error: {str(e)}") 59 | 60 | 61 | if __name__ == "__main__": 62 | asyncio.run(test_hold()) 63 | -------------------------------------------------------------------------------- /examples/payment_example.py: -------------------------------------------------------------------------------- 1 | """Example demonstrating a standard payment transaction with PyAzul.""" 2 | 3 | import asyncio 4 | 5 | from pyazul import PyAzul 6 | 7 | 8 | async def test_payment(): 9 | """ 10 | Example demonstrating a direct card payment transaction. 11 | 12 | This example shows how to: 13 | 1. Initialize the transaction service 14 | 2. Create a payment request with card data 15 | 3. Process the payment 16 | 4. Handle the response 17 | 18 | Use case: Direct card payment where you need to charge 19 | a customer's card immediately. 20 | """ 21 | # Initialize PyAzul facade 22 | azul = PyAzul() 23 | settings = azul.settings 24 | 25 | # Test payment data 26 | payment_data = { 27 | "Store": settings.MERCHANT_ID, 28 | "Channel": "EC", 29 | "PosInputMode": "E-Commerce", 30 | "Amount": "1000", # $10.00 31 | "Itbis": "180", # $1.80 tax 32 | "CardNumber": "5413330089600119", # Test card 33 | "Expiration": "202812", 34 | "CVC": "979", 35 | "OrderNumber": "SALE-001", 36 | "CustomOrderId": "example-001", 37 | "SaveToDataVault": "1", # Save card for future use 38 | } 39 | 40 | try: 41 | # Create and process payment 42 | response = await azul.sale(payment_data) 43 | 44 | # Display transaction results 45 | print("\nPayment Response:") 46 | print("-" * 50) 47 | print(f"ISO Code: {response.get('IsoCode')}") # '00' means success 48 | print(f"Authorization: {response.get('AuthorizationCode')}") 49 | print(f"RRN: {response.get('RRN')}") # Reference number 50 | print(f"Order ID: {response.get('CustomOrderId')}") 51 | print(f"Azul Order ID: {response.get('AzulOrderId')}") # Keep for refunds 52 | print("-" * 50) 53 | 54 | except Exception as e: 55 | print(f"Error: {str(e)}") 56 | 57 | 58 | if __name__ == "__main__": 59 | asyncio.run(test_payment()) 60 | -------------------------------------------------------------------------------- /examples/payment_page_server.py: -------------------------------------------------------------------------------- 1 | """ 2 | FastAPI server example demonstrating Azul Payment Page integration. 3 | 4 | This example shows how to: 5 | 1. Create a payment page with proper form generation 6 | 2. Handle successful and failed payment responses 7 | 3. Validate payment page data and auth hashes 8 | 9 | Run with: uvicorn examples.payment_page_server:app --reload 10 | """ 11 | 12 | from fastapi import FastAPI, Request 13 | from fastapi.responses import HTMLResponse, JSONResponse 14 | from pydantic import HttpUrl 15 | 16 | from pyazul import PyAzul 17 | from pyazul.models.payment_page import PaymentPage 18 | 19 | # Initialize FastAPI app and payment service 20 | app = FastAPI() 21 | azul = PyAzul() 22 | 23 | 24 | @app.get("/", response_class=HTMLResponse) 25 | async def home(): 26 | """Serve home page with payment options.""" 27 | return """ 28 |
    29 | 30 |
    31 |
    32 |
    33 | 34 |
    35 | """ 36 | 37 | 38 | @app.get("/view-amounts") 39 | async def view_amounts(): 40 | """ 41 | View payment amounts in different formats. 42 | 43 | Shows both the raw amounts (in cents) and formatted amounts (in USD). 44 | """ 45 | payment_request = PaymentPage( 46 | Amount="100000", # $1,000.00 (total amount including ITBIS) 47 | ITBIS="18000", # $180.00 (18% of base amount) 48 | ApprovedUrl=HttpUrl("https://www.instagram.com/progressa.group/#"), 49 | DeclineUrl=HttpUrl("https://www.progressa.group/"), 50 | CancelUrl=HttpUrl("https://www.progressa.group/"), 51 | UseCustomField1="0", 52 | CustomField1Label="", 53 | CustomField1Value="", 54 | AltMerchantName=None, 55 | ) 56 | 57 | return JSONResponse( 58 | { 59 | # Formatted amounts in USD 60 | "string_representation": str(payment_request), 61 | "amount_in_cents": payment_request.Amount, # Raw amount in cents 62 | "itbis_in_cents": payment_request.ITBIS, # Raw ITBIS in cents 63 | } 64 | ) 65 | 66 | 67 | @app.get("/buy-ticket", response_class=HTMLResponse) 68 | async def buy_ticket(request: Request): 69 | """ 70 | Create and return a payment form for Azul Payment Page. 71 | 72 | The form will automatically submit to Azul's payment page where 73 | the user can enter their card details securely. 74 | """ 75 | try: 76 | # Create payment request with: 77 | # - Total amount: $1,000.00 = "100000" cents 78 | # - ITBIS: $180.00 = "18000" cents (18% of base amount) 79 | payment_request = PaymentPage( 80 | Amount="100000", # $1,000.00 81 | ITBIS="18000", # $180.00 82 | ApprovedUrl=HttpUrl("https://www.instagram.com/progressa.group/#"), 83 | DeclineUrl=HttpUrl("https://www.progressa.group/"), 84 | CancelUrl=HttpUrl("https://www.progressa.group/"), 85 | UseCustomField1="0", 86 | CustomField1Label="", 87 | CustomField1Value="", 88 | AltMerchantName=None, 89 | ) 90 | 91 | # Generate and return the HTML form 92 | return azul.payment_page_service.create_payment_form(payment_request) 93 | 94 | except Exception: 95 | return """ 96 |

    Error

    97 |

    Failed to create payment form. Please try again later.

    98 | """ 99 | 100 | 101 | if __name__ == "__main__": 102 | import uvicorn 103 | 104 | uvicorn.run(app, host="0.0.0.0", port=8000) 105 | -------------------------------------------------------------------------------- /examples/post_example.py: -------------------------------------------------------------------------------- 1 | """Example demonstrating how to perform a post-authorization transaction with PyAzul.""" 2 | 3 | import asyncio 4 | 5 | from pyazul import PyAzul 6 | 7 | 8 | async def main(): 9 | """Demonstrates posting a previously authorized transaction.""" 10 | azul = PyAzul() 11 | settings = azul.settings 12 | 13 | # Step 1: Create a hold transaction 14 | hold_transaction_data = { 15 | "Store": settings.MERCHANT_ID, 16 | "Channel": "EC", 17 | "PosInputMode": "E-Commerce", 18 | "CardNumber": "5413330089600119", 19 | "Expiration": "202812", 20 | "CVC": "979", 21 | "Amount": "1000", 22 | "Itbis": "100", 23 | "OrderNumber": "001002003004005", 24 | "CustomOrderId": "hold_test_to_post", 25 | "ForceNo3DS": "1", # bypass 3D Secure 26 | } 27 | hold_result = await azul.hold(hold_transaction_data) 28 | print("Hold Result:", hold_result) 29 | if hold_result.get("ResponseMessage") != "APROBADA": 30 | err_desc = hold_result.get( 31 | "ErrorDescription", hold_result.get("ResponseMessage") 32 | ) 33 | print(f"Hold transaction failed: {err_desc}") 34 | return 35 | azul_order_id = hold_result["AzulOrderId"] 36 | 37 | # Step 2: Use the AzulOrderId from hold for the post transaction 38 | post_transaction_data = { 39 | "Store": settings.MERCHANT_ID, 40 | "Channel": "EC", 41 | "AzulOrderId": azul_order_id, 42 | "Amount": "1000", 43 | "Itbis": "100", 44 | } 45 | post_result = await azul.post_auth(post_transaction_data) 46 | print("Post Result:", post_result) 47 | if post_result.get("ResponseMessage") == "APROBADA": 48 | print("Post transaction processed successfully") 49 | else: 50 | err_desc = post_result.get( 51 | "ErrorDescription", post_result.get("ResponseMessage") 52 | ) 53 | print(f"Post transaction failed: {err_desc}") 54 | 55 | 56 | if __name__ == "__main__": 57 | asyncio.run(main()) 58 | -------------------------------------------------------------------------------- /examples/refund_example.py: -------------------------------------------------------------------------------- 1 | """Example demonstrating how to perform a refund transaction with PyAzul.""" 2 | 3 | import asyncio 4 | 5 | from pyazul import PyAzul 6 | 7 | 8 | async def test_refund(): 9 | """ 10 | Example demonstrating a complete payment and refund flow. 11 | 12 | This example shows how to: 13 | 1. Make an initial payment 14 | 2. Get the AzulOrderId from the payment 15 | 3. Process a refund using that ID 16 | 4. Handle the refund response 17 | 18 | Note: Refunds can only be processed for valid transactions 19 | and the refund amount must match the original payment. 20 | """ 21 | # Initialize PyAzul facade 22 | azul = PyAzul() 23 | settings = azul.settings 24 | 25 | try: 26 | # 1. First make a payment 27 | print("\n1. Making initial payment...") 28 | payment_data = { 29 | "Store": settings.MERCHANT_ID, 30 | "Channel": "EC", 31 | "PosInputMode": "E-Commerce", 32 | "Amount": "1000", 33 | "Itbis": "180", 34 | "CardNumber": "5413330089600119", 35 | "Expiration": "202812", 36 | "CVC": "979", 37 | "OrderNumber": "002003004005006", # Numeric OrderNumber 38 | "CustomOrderId": "refund-test-001", 39 | "SaveToDataVault": "1", 40 | "ForceNo3DS": "1", # Bypass 3D Secure for the initial sale 41 | } 42 | 43 | payment_response = await azul.sale(payment_data) 44 | print(f"Initial Payment Response: {payment_response}") 45 | 46 | if payment_response.get("ResponseMessage") != "APROBADA": 47 | err_desc = payment_response.get( 48 | "ErrorDescription", 49 | payment_response.get( 50 | "ResponseMessage", "Unknown error during payment." 51 | ), 52 | ) 53 | raise Exception(f"Initial payment failed: {err_desc}") 54 | 55 | azul_order_id = payment_response.get("AzulOrderId") 56 | original_order_number = payment_data["OrderNumber"] 57 | if not azul_order_id: 58 | raise Exception("AzulOrderId not found in successful payment response.") 59 | print(f"Payment successful. AzulOrderId: {azul_order_id}") 60 | 61 | # 2. Process refund 62 | print("\n2. Processing refund...") 63 | refund_data = { 64 | "Store": settings.MERCHANT_ID, 65 | "Channel": "EC", 66 | "Amount": "1000", 67 | "Itbis": "180", 68 | "AzulOrderId": azul_order_id, 69 | "OrderNumber": original_order_number, 70 | "PosInputMode": "E-Commerce", 71 | } 72 | 73 | refund_response = await azul.refund(refund_data) 74 | print(f"Refund Attempt Response: {refund_response}") 75 | 76 | if ( 77 | refund_response.get("ResponseMessage") == "APROBADA" 78 | and refund_response.get("IsoCode") == "00" 79 | ): 80 | print("\nRefund Processed Successfully:") 81 | print("-" * 50) 82 | print(f"ISO Code: {refund_response.get('IsoCode')}") 83 | print(f"RRN: {refund_response.get('RRN')}") 84 | print(f"AzulOrderId (Refund): {refund_response.get('AzulOrderId')}") 85 | print("-" * 50) 86 | else: 87 | err_desc_refund = refund_response.get( 88 | "ErrorDescription", 89 | refund_response.get("ResponseMessage", "Unknown error during refund."), 90 | ) 91 | print(f"Refund failed or was not approved. Details: {err_desc_refund}") 92 | print(f"Full refund response: {refund_response}") 93 | 94 | except Exception as e: 95 | print(f"An error occurred: {str(e)}") 96 | # For deeper debugging, you might want to print the full traceback 97 | # import traceback 98 | # traceback.print_exc() 99 | 100 | 101 | if __name__ == "__main__": 102 | asyncio.run(test_refund()) 103 | -------------------------------------------------------------------------------- /examples/templates/error.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Error - Azul Payment Demo 5 | 64 | 65 | 66 |
    67 |
    ⚠️
    68 |

    Payment Process Error

    69 |
    70 | An error occurred while processing your payment. 71 |
    72 |
    73 | Error details:
    74 | {{ error }} 75 |
    76 | Back to Home 77 |
    78 | 79 | 80 | -------------------------------------------------------------------------------- /examples/templates/iframe_3ds.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 3DS Authentication 5 | 6 | 31 | 32 | 33 |
    34 |
    35 |

    Processing authentication...

    36 |
    37 | 38 | 39 | 40 | 160 | 161 | 162 | -------------------------------------------------------------------------------- /examples/templates/processing.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Processing Payment... 5 | 6 | 36 | 37 | 38 |

    Processing Your Payment

    39 | 40 |
    41 |
    42 |

    {{ message or "Processing 3D Secure authentication..." }}

    43 |

    Please wait while we process your payment securely. This may take a few moments.

    44 | {% if secure_id %} 45 |

    Transaction ID: {{ secure_id }}

    46 | {% endif %} 47 |
    48 | 49 | 55 | 56 | 57 | -------------------------------------------------------------------------------- /examples/templates/result.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Payment Result 5 | 6 | 7 | 16 | 17 | 18 |
    19 |

    {{ '✅ Payment Successful!' if success else '❌ Payment Failed' }}

    20 |

    Order ID: {{ order_id }}

    21 |

    Status: {{ status }}

    22 | 23 | {% if not success and status == 'declined' %} 24 |
    25 |

    Reason: Transaction was declined during 3D Secure authentication

    26 |
    27 | {% endif %} 28 | 29 | 32 |
    33 | 34 | 35 | -------------------------------------------------------------------------------- /examples/test_certificates.py: -------------------------------------------------------------------------------- 1 | """Tests for SSL certificate loading functionalities in PyAzul.""" 2 | 3 | import asyncio 4 | import os 5 | 6 | from pyazul.core.config import get_azul_settings 7 | 8 | 9 | def print_separator(): 10 | """Print a separator line to the console.""" 11 | print("\n" + "=" * 60 + "\n") 12 | 13 | 14 | async def test_certificates(): 15 | """Tests the loading and verification of SSL certificates.""" 16 | print_separator() 17 | print("1. Starting certificate test...") 18 | 19 | try: 20 | # First get the configuration to see debug messages 21 | print("\n2. Loading configuration...") 22 | settings = get_azul_settings() 23 | cert_path, key_path = settings._load_certificates() 24 | print("\nCertificates loaded:") 25 | print(f"- Certificate: {cert_path}") 26 | print(f"- Key: {key_path}") 27 | 28 | print("\n3. Verifying files...") 29 | await verify_certificate_loading() 30 | 31 | except Exception as e: 32 | print(f"\nError: {str(e)}") 33 | print("\nError details:") 34 | import traceback 35 | 36 | traceback.print_exc() 37 | raise 38 | 39 | 40 | async def verify_certificate_loading(): 41 | """Verify that certificates are loaded correctly.""" 42 | print("\nVerifying certificate files:") 43 | 44 | # Get certificate paths 45 | settings = get_azul_settings() 46 | cert_path, key_path = settings._load_certificates() 47 | 48 | # Verify existence 49 | print("\nFile existence:") 50 | print(f"- Certificate exists: {os.path.exists(cert_path)}") 51 | print(f"- Key exists: {os.path.exists(key_path)}") 52 | 53 | # Verify permissions 54 | print("\nFile permissions:") 55 | cert_perms = oct(os.stat(cert_path).st_mode)[-3:] 56 | key_perms = oct(os.stat(key_path).st_mode)[-3:] 57 | print(f"- Certificate permissions: {cert_perms}") 58 | print(f"- Key permissions: {key_perms}") 59 | 60 | # Verify content 61 | print("\nCertificate content:") 62 | with open(cert_path, "r") as f: 63 | cert_content = f.read() 64 | print("- Has BEGIN marker:", "-----BEGIN CERTIFICATE-----" in cert_content) 65 | print("- Has END marker:", "-----END CERTIFICATE-----" in cert_content) 66 | print("- Length:", len(cert_content)) 67 | 68 | print("\nKey content:") 69 | with open(key_path, "r") as f: 70 | key_content = f.read() 71 | print("- Has BEGIN marker:", "-----BEGIN PRIVATE KEY-----" in key_content) 72 | print("- Has END marker:", "-----END PRIVATE KEY-----" in key_content) 73 | print("- Length:", len(key_content)) 74 | 75 | 76 | if __name__ == "__main__": 77 | print("\nCertificate Loading Test") 78 | print("=" * 60) 79 | asyncio.run(test_certificates()) 80 | -------------------------------------------------------------------------------- /examples/verify_example.py: -------------------------------------------------------------------------------- 1 | """Example demonstrating how to perform a card verification with PyAzul.""" 2 | 3 | import asyncio 4 | 5 | from pyazul import PyAzul 6 | 7 | 8 | async def main(): 9 | """Perform a card verification transaction.""" 10 | azul = PyAzul() 11 | settings = azul.settings 12 | 13 | # Create a verify transaction data 14 | verify_data = { 15 | "Store": settings.MERCHANT_ID, 16 | "Channel": "EC", 17 | "CustomOrderId": "sale-test-001", # Assumes this CustomOrderId exists 18 | } 19 | try: 20 | verify_result = await azul.verify_transaction(verify_data) 21 | print("Verify Result:", verify_result) 22 | 23 | found_status = verify_result.get("Found") 24 | iso_code = verify_result.get("IsoCode") 25 | 26 | if found_status is True and iso_code == "00": 27 | print("Transaction verified successfully and found.") 28 | print(f" AzulOrderId: {verify_result.get('AzulOrderId')}") 29 | print(f" Amount: {verify_result.get('Amount')}") 30 | print(f" TransactionType: {verify_result.get('TransactionType')}") 31 | elif found_status is True and iso_code != "00": 32 | # Found, but not in an 'approved' state, or some other issue 33 | err_msg = ( 34 | verify_result.get("ErrorDescription") 35 | or verify_result.get("ResponseMessage") 36 | or f"Found but IsoCode is {iso_code}" 37 | ) 38 | print(f"Transaction found but has an issue: {err_msg}") 39 | elif found_status is False or ( 40 | isinstance(found_status, str) and found_status.lower() == "no" 41 | ): 42 | print("Transaction not found.") 43 | else: 44 | # Fallback for unexpected 'Found' status or other errors 45 | err_msg = ( 46 | verify_result.get("ErrorDescription") 47 | or verify_result.get("ResponseMessage") 48 | or "Unknown verification status." 49 | ) 50 | print(f"Transaction verification failed or status unknown: {err_msg}") 51 | print(f"Full Verify Result: {verify_result}") 52 | 53 | except Exception as e: 54 | print(f"An error occurred during verification: {str(e)}") 55 | 56 | 57 | if __name__ == "__main__": 58 | asyncio.run(main()) 59 | -------------------------------------------------------------------------------- /pyazul/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | PyAzul - Python Client for Azul Payment Gateway. 3 | 4 | This package provides a complete interface to interact with Azul services: 5 | - Direct payment processing 6 | - Card tokenization (DataVault) 7 | - Payment Page 8 | - Transaction verification 9 | - Refunds and voids 10 | - Hold/capture of transactions 11 | 12 | Features: 13 | - Direct payments (Sale, Hold) 14 | - Refunds (Refund), Voids (Void), Post-Authorizations (PostAuth) 15 | - Card tokenization (DataVault) 16 | - Hosted payment page generation 17 | 18 | Example: 19 | >>> from pyazul import PyAzul 20 | >>> azul = PyAzul() # Uses environment variables for configuration 21 | >>> 22 | >>> # Process a payment 23 | >>> response = await azul.sale({ 24 | ... "Channel": "EC", 25 | ... "Store": "39038540035", 26 | ... "CardNumber": "4111111111111111", 27 | ... "Expiration": "202812", 28 | ... "CVC": "123", 29 | ... "Amount": "100000" # $1,000.00 30 | ... }) 31 | """ 32 | 33 | from .core.config import AzulSettings, get_azul_settings 34 | from .core.exceptions import AzulError, AzulResponseError 35 | from .index import PyAzul 36 | 37 | # Import models from the centralized pyazul.models package 38 | from .models import ( 39 | AzulBase, 40 | CardHolderInfo, 41 | ChallengeIndicator, 42 | Hold, 43 | PaymentPage, 44 | Post, 45 | Refund, 46 | Sale, 47 | SecureSale, 48 | SecureTokenSale, 49 | ThreeDSAuth, 50 | TokenRequest, 51 | TokenSale, 52 | VerifyTransaction, 53 | Void, 54 | ) 55 | from .services import DataVaultService, PaymentPageService, TransactionService 56 | from .services.secure import SecureService 57 | 58 | __all__ = [ 59 | # Main class 60 | "PyAzul", 61 | # Configuration 62 | "get_azul_settings", 63 | "AzulSettings", 64 | "AzulError", 65 | "AzulResponseError", 66 | # Services 67 | "TransactionService", 68 | "DataVaultService", 69 | "PaymentPageService", 70 | "SecureService", 71 | # Models 72 | "AzulBase", 73 | "Sale", 74 | "Hold", 75 | "Refund", 76 | "TokenRequest", 77 | "TokenSale", 78 | "Post", 79 | "VerifyTransaction", 80 | "Void", 81 | "PaymentPage", 82 | "SecureSale", 83 | "SecureTokenSale", 84 | "CardHolderInfo", 85 | "ThreeDSAuth", 86 | "ChallengeIndicator", 87 | ] 88 | -------------------------------------------------------------------------------- /pyazul/api/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | API module for PyAzul. 3 | 4 | Contains client and constants for API communication. 5 | """ 6 | 7 | from .client import AzulAPI 8 | from .constants import AzulEndpoints, Environment 9 | 10 | __all__ = ["AzulAPI", "Environment", "AzulEndpoints"] 11 | -------------------------------------------------------------------------------- /pyazul/api/constants.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module contains constants and enums used throughout the Azul API client. 3 | 4 | It defines environment types (DEV/PROD) and endpoint URLs for different environments. 5 | The constants are used to configure the API client's behavior and routing. 6 | """ 7 | 8 | from enum import Enum 9 | 10 | 11 | class Environment(str, Enum): 12 | """Represents the API environment (dev or prod).""" 13 | 14 | DEV = "dev" 15 | PROD = "prod" 16 | 17 | 18 | class AzulEndpoints: 19 | """Holds the API endpoint URLs for different environments.""" 20 | 21 | DEV_URL = "https://pruebas.azul.com.do/webservices/JSON/Default.aspx" 22 | PROD_URL = "https://pagos.azul.com.do/webservices/JSON/Default.aspx" 23 | ALT_PROD_URL = "https://contpagos.azul.com.do/Webservices/JSON/default.aspx" 24 | DEV_URL_PAYMEMT = "https://pruebas.azul.com.do/PaymentPage/" 25 | PROD_URL_PAYMEMT = "https://pagos.azul.com.do/PaymentPage/Default.aspx" 26 | ALT_PROD_URL_PAYMEMT = "https://contpagos.azul.com.do/PaymentPage/Default.aspx" 27 | 28 | @classmethod 29 | def get_url(cls, environment: Environment) -> str: 30 | """Get the base URL for the specified environment.""" 31 | return cls.DEV_URL if environment == Environment.DEV else cls.PROD_URL 32 | -------------------------------------------------------------------------------- /pyazul/core/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Core module for PyAzul. 3 | 4 | Contains base classes, configuration, exceptions, and session management. 5 | """ 6 | 7 | from .base import BaseService 8 | from .config import AzulSettings, get_azul_settings 9 | from .exceptions import AzulError 10 | 11 | __all__ = ["get_azul_settings", "AzulSettings", "AzulError", "BaseService"] 12 | -------------------------------------------------------------------------------- /pyazul/core/base.py: -------------------------------------------------------------------------------- 1 | """Base classes and utilities for the PyAzul library.""" 2 | 3 | from ..api.client import AzulAPI 4 | 5 | 6 | class BaseService: 7 | """Base class for all Azul API services.""" 8 | 9 | def __init__(self, api_client: AzulAPI): 10 | """ 11 | Initialize the service with a shared AzulAPI client. 12 | 13 | Args: 14 | api_client (AzulAPI): The shared API client instance. 15 | """ 16 | self.api = api_client 17 | -------------------------------------------------------------------------------- /pyazul/core/exceptions.py: -------------------------------------------------------------------------------- 1 | """ 2 | Custom exceptions for the PyAzul library. 3 | 4 | This module defines a hierarchy of custom exceptions to handle specific 5 | error conditions encountered during API interactions, configuration, or data validation. 6 | """ 7 | 8 | 9 | class AzulError(Exception): 10 | """Base exception for Azul errors.""" 11 | 12 | pass 13 | 14 | 15 | class APIError(AzulError): 16 | """Exception raised for API errors.""" 17 | 18 | def __init__(self, message: str): 19 | """Initialize APIError with a message.""" 20 | self.message = message 21 | super().__init__(self.message) 22 | 23 | 24 | class AzulResponseError(AzulError): 25 | """Exception raised when Azul returns an error response.""" 26 | 27 | def __init__(self, message: str, response_data: dict | None = None): 28 | """Initialize AzulResponseError with a message and response data.""" 29 | self.message = message 30 | self.response_data = response_data or {} 31 | super().__init__(self.message) 32 | 33 | 34 | class ValidationError(AzulError): 35 | """Exception raised for validation errors.""" 36 | 37 | def __init__(self, message: str, errors: dict | None = None): 38 | """Initialize ValidationError with a message and error details.""" 39 | self.message = message 40 | self.errors = errors or {} 41 | super().__init__(self.message) 42 | 43 | 44 | class ConfigurationError(AzulError): 45 | """Exception raised for configuration errors.""" 46 | 47 | pass 48 | 49 | 50 | class SSLError(AzulError): 51 | """Exception raised for SSL configuration errors.""" 52 | 53 | def __init__(self, message: str): 54 | """Initialize SSLError with a message.""" 55 | self.message = message 56 | super().__init__(self.message) 57 | -------------------------------------------------------------------------------- /pyazul/models/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | PyAzul models organized by business domain. 3 | 4 | This module exports all models using clean, Pythonic naming conventions while organizing 5 | them by business domain for better maintainability and developer experience. 6 | 7 | Business Domains: 8 | - payment/: Core payment operations (Sale, Hold, Refund, Void, Post) 9 | - datavault/: Card tokenization and token management 10 | - three_ds/: 3D Secure authentication and challenges 11 | - payment_page/: Azul hosted Payment Page functionality 12 | - verification/: Transaction verification and status checking 13 | 14 | Recommended imports: 15 | from pyazul.models.payment import Sale, Hold, Refund 16 | from pyazul.models.datavault import TokenRequest, TokenSale 17 | from pyazul.models.three_ds import SecureSale, CardHolderInfo 18 | from pyazul.models.payment_page import PaymentPage 19 | from pyazul.models.verification import VerifyTransaction 20 | 21 | Legacy imports (deprecated): 22 | from pyazul.models import SaleTransactionModel # Use Sale instead 23 | """ 24 | 25 | import warnings 26 | 27 | # DataVault domain 28 | from .datavault import TokenError, TokenRequest, TokenResponse, TokenSale, TokenSuccess 29 | 30 | # Payment domain 31 | from .payment import BaseTransaction, CardPayment, Hold, Post, Refund, Sale, Void 32 | 33 | # Payment Page domain 34 | from .payment_page import PaymentPage 35 | 36 | # Base classes and validators from schemas 37 | from .schemas import AzulBase, _validate_amount_field, _validate_itbis_field 38 | 39 | # 3D Secure domain 40 | from .three_ds import ( 41 | CardHolderInfo, 42 | ChallengeIndicator, 43 | ChallengeRequest, 44 | SecureSale, 45 | SecureTokenSale, 46 | SessionID, 47 | ThreeDSAuth, 48 | ) 49 | 50 | # Verification domain 51 | from .verification import VerifyTransaction 52 | 53 | # ======================================== 54 | # Backward Compatibility Aliases (Deprecated) 55 | # ======================================== 56 | 57 | 58 | def _deprecated_alias(new_name: str, old_name: str, obj): 59 | """Create a deprecated alias with warning.""" 60 | 61 | def deprecated_property(): 62 | warnings.warn( 63 | f"'{old_name}' is deprecated. Use '{new_name}' instead.", 64 | DeprecationWarning, 65 | stacklevel=3, 66 | ) 67 | return obj 68 | 69 | return deprecated_property 70 | 71 | 72 | # Create deprecated aliases 73 | def __getattr__(name: str): 74 | """Handle deprecated model name imports with warnings.""" 75 | deprecation_map = { 76 | # Base classes 77 | "AzulBaseModel": ("AzulBase", AzulBase), 78 | "BaseTransactionAttributes": ("BaseTransaction", BaseTransaction), 79 | "CardPaymentAttributes": ("CardPayment", CardPayment), 80 | # Payment models 81 | "SaleTransactionModel": ("Sale", Sale), 82 | "HoldTransactionModel": ("Hold", Hold), 83 | "RefundTransactionModel": ("Refund", Refund), 84 | "VoidTransactionModel": ("Void", Void), 85 | "PostSaleTransactionModel": ("Post", Post), 86 | # DataVault models 87 | "DataVaultRequestModel": ("TokenRequest", TokenRequest), 88 | "DataVaultResponse": ("TokenResponse", TokenResponse), 89 | "DataVaultSuccessResponse": ("TokenSuccess", TokenSuccess), 90 | "DataVaultErrorResponse": ("TokenError", TokenError), 91 | "TokenSaleModel": ("TokenSale", TokenSale), 92 | # 3DS models 93 | "SecureSaleRequest": ("SecureSale", SecureSale), 94 | "SecureSessionID": ("SessionID", SessionID), 95 | "SecureChallengeRequest": ("ChallengeRequest", ChallengeRequest), 96 | # Other models 97 | "PaymentPageModel": ("PaymentPage", PaymentPage), 98 | "VerifyTransactionModel": ("VerifyTransaction", VerifyTransaction), 99 | } 100 | 101 | if name in deprecation_map: 102 | new_name, obj = deprecation_map[name] 103 | warnings.warn( 104 | f"'{name}' is deprecated and will be removed in a future version. " 105 | f"Use '{new_name}' instead.", 106 | DeprecationWarning, 107 | stacklevel=2, 108 | ) 109 | return obj 110 | 111 | raise AttributeError(f"module '{__name__}' has no attribute '{name}'") 112 | 113 | 114 | # Export new clean names 115 | __all__ = [ 116 | # Base classes and validators 117 | "AzulBase", 118 | "BaseTransaction", 119 | "CardPayment", 120 | "_validate_amount_field", 121 | "_validate_itbis_field", 122 | # Payment domain models 123 | "Sale", 124 | "Hold", 125 | "Refund", 126 | "Void", 127 | "Post", 128 | # DataVault domain models 129 | "TokenRequest", 130 | "TokenResponse", 131 | "TokenSuccess", 132 | "TokenError", 133 | "TokenSale", 134 | # 3D Secure domain models 135 | "CardHolderInfo", 136 | "ThreeDSAuth", 137 | "ChallengeIndicator", 138 | "SecureSale", 139 | "SecureTokenSale", 140 | "SessionID", 141 | "ChallengeRequest", 142 | # Payment Page domain models 143 | "PaymentPage", 144 | # Verification domain models 145 | "VerifyTransaction", 146 | ] 147 | -------------------------------------------------------------------------------- /pyazul/models/datavault/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | DataVault domain models for PyAzul. 3 | 4 | This module contains models for card tokenization, token management, 5 | and token-based payment operations. 6 | """ 7 | 8 | from .models import TokenError, TokenRequest, TokenResponse, TokenSale, TokenSuccess 9 | 10 | __all__ = [ 11 | "TokenRequest", 12 | "TokenResponse", 13 | "TokenSuccess", 14 | "TokenError", 15 | "TokenSale", 16 | ] 17 | -------------------------------------------------------------------------------- /pyazul/models/datavault/models.py: -------------------------------------------------------------------------------- 1 | """ 2 | DataVault (tokenization) models for PyAzul. 3 | 4 | This module defines Pydantic models for card tokenization operations, 5 | including token creation, deletion, and token-based payments. 6 | """ 7 | 8 | from typing import Literal, Optional, Union 9 | 10 | from pydantic import BaseModel, Field, field_validator 11 | 12 | from ..payment.models import BaseTransaction 13 | from ..schemas import AzulBase, _validate_amount_field, _validate_itbis_field 14 | 15 | 16 | class TokenRequest(AzulBase): 17 | """Model for DataVault token creation and deletion requests.""" 18 | 19 | TrxType: str = Field( 20 | ..., 21 | pattern="^(CREATE|DELETE)$", 22 | description="Transaction type: 'CREATE' or 'DELETE'.", 23 | ) 24 | CardNumber: Optional[str] = Field( 25 | default=None, 26 | pattern=r"^[0-9]{13,19}$", 27 | description="Card number for CREATE. (N(19))", 28 | ) 29 | Expiration: Optional[str] = Field( 30 | default=None, 31 | pattern=r"^(20[2-9][0-9]|2[1-9][0-9]{2})(0[1-9]|1[0-2])$", 32 | description="Expiration date in YYYYMM format for CREATE. (N(6))", 33 | ) 34 | CVC: Optional[str] = Field( 35 | default=None, 36 | pattern=r"^[0-9]{3,4}$", 37 | description="Card security code for CREATE. (N(3-4))", 38 | ) 39 | DataVaultToken: Optional[str] = Field( 40 | default=None, 41 | pattern=r"^[A-Fa-f0-9\-]{30,40}$", 42 | description="Token to delete for DELETE operations. (A(100))", 43 | ) 44 | 45 | 46 | class TokenSuccess(BaseModel): 47 | """Model for successful DataVault responses.""" 48 | 49 | CardNumber: str = Field(..., description="Masked card number") 50 | DataVaultToken: str = Field(..., description="Generated token") 51 | DataVaultBrand: str = Field(..., description="Card brand (e.g., 'VISA')") 52 | DataVaultExpiration: str = Field(..., description="Token expiration YYYYMM") 53 | ErrorDescription: str = Field("", description="Empty for success") 54 | HasCVV: bool = Field(..., description="Whether token includes CVV") 55 | IsoCode: str = Field(..., description="'00' for success") 56 | ResponseMessage: str = Field(..., description="Success message") 57 | type: Literal["success"] = Field("success", description="Response type indicator") 58 | 59 | @classmethod 60 | def from_api_response(cls, data: dict) -> "TokenSuccess": 61 | """Create a TokenSuccess from API response data.""" 62 | return cls( 63 | CardNumber=data.get("CardNumber", ""), 64 | DataVaultToken=data.get("DataVaultToken", ""), 65 | DataVaultBrand=data.get( 66 | "Brand", "" 67 | ), # API uses 'Brand', not 'DataVaultBrand' 68 | DataVaultExpiration=data.get("Expiration", ""), # API uses 'Expiration' 69 | ErrorDescription="", 70 | HasCVV=data.get("HasCVV", False), 71 | IsoCode=data.get("ISOCode", "00"), 72 | ResponseMessage=data.get("ResponseMessage", ""), 73 | type="success", 74 | ) 75 | 76 | 77 | class TokenError(BaseModel): 78 | """Model for failed DataVault responses.""" 79 | 80 | CardNumber: str = Field("", description="Empty card number for errors") 81 | DataVaultToken: str = Field("", description="Empty token for errors") 82 | DataVaultBrand: str = Field("", description="Empty brand for errors") 83 | DataVaultExpiration: str = Field("", description="Empty expiration for errors") 84 | ErrorDescription: str = Field(..., description="Error description") 85 | HasCVV: bool = Field(False, description="False for errors") 86 | IsoCode: str = Field(..., description="Error ISO code (not '00')") 87 | ResponseMessage: str = Field(..., description="Error response message") 88 | type: Literal["error"] = Field("error", description="Response type indicator") 89 | 90 | @classmethod 91 | def from_api_response(cls, data: dict) -> "TokenError": 92 | """Create a TokenError from API response data.""" 93 | return cls( 94 | CardNumber="", 95 | DataVaultToken="", 96 | DataVaultBrand="", 97 | DataVaultExpiration="", 98 | ErrorDescription=data.get("ErrorDescription", "Unknown error"), 99 | HasCVV=False, 100 | IsoCode=data.get("IsoCode", "99"), 101 | ResponseMessage=data.get("ResponseMessage", "ERROR"), 102 | type="error", 103 | ) 104 | 105 | 106 | # Union type for DataVault responses 107 | TokenResponse = Union[TokenSuccess, TokenError] 108 | 109 | 110 | class TokenSale(BaseTransaction): 111 | """Model for sales transactions using a DataVault token.""" 112 | 113 | Amount: str = Field( # Represented in cents 114 | ..., 115 | pattern=r"^[1-9][0-9]{0,11}$", 116 | description="Total amount in cents (e.g., 1000 for $10.00). Serialized to str.", 117 | ) 118 | Itbis: Optional[str] = Field( # Represented in cents 119 | default="000", 120 | pattern=r"^[0-9]{1,12}$", 121 | description="ITBIS in cents (optional for TokenSale). Serialized to str.", 122 | ) 123 | DataVaultToken: str = Field( 124 | ..., 125 | pattern=r"^[A-Fa-f0-9\-]{30,40}$", 126 | description="DataVault token for the transaction. (A(100))", 127 | ) 128 | TrxType: str = Field( 129 | default="Sale", 130 | pattern="^Sale$", 131 | description="Transaction type, must be 'Sale'. (A(16))", 132 | ) 133 | CVC: Optional[str] = Field( 134 | default=None, 135 | min_length=3, 136 | max_length=4, 137 | pattern=r"^[0-9]{3,4}$", 138 | description="CVC (optional with token, E-comm mandatory). (N(3-4))", 139 | ) 140 | CardNumber: Optional[str] = Field( 141 | default="", description="Card number, empty for token sales. (N(19))" 142 | ) 143 | Expiration: Optional[str] = Field( 144 | default="", 145 | description="Expiration date (YYYYMM), empty for token sales. (N(6))", 146 | ) 147 | 148 | _validate_token_sale_amount_values = field_validator("Amount", mode="before")( 149 | _validate_amount_field 150 | ) 151 | _validate_token_sale_itbis_values = field_validator("Itbis", mode="before")( 152 | _validate_itbis_field 153 | ) 154 | -------------------------------------------------------------------------------- /pyazul/models/payment/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Payment domain models for PyAzul. 3 | 4 | This module contains models for core payment operations including sales, 5 | holds, refunds, voids, and post-authorization transactions. 6 | """ 7 | 8 | from .models import BaseTransaction, CardPayment, Hold, Post, Refund, Sale, Void 9 | 10 | __all__ = [ 11 | "BaseTransaction", 12 | "CardPayment", 13 | "Sale", 14 | "Hold", 15 | "Refund", 16 | "Void", 17 | "Post", 18 | ] 19 | -------------------------------------------------------------------------------- /pyazul/models/payment/models.py: -------------------------------------------------------------------------------- 1 | """ 2 | Core payment models for PyAzul. 3 | 4 | This module defines Pydantic models for basic payment operations with Azul, 5 | including sales, holds, refunds, voids, and post-authorization transactions. 6 | """ 7 | 8 | from typing import Optional 9 | 10 | from pydantic import Field, field_validator 11 | 12 | from ..schemas import AzulBase, _validate_amount_field, _validate_itbis_field 13 | 14 | 15 | class BaseTransaction(AzulBase): 16 | """Base attributes common to many transaction types.""" 17 | 18 | PosInputMode: str = Field( 19 | default="E-Commerce", 20 | description="Transaction entry mode (e.g., 'E-Commerce'), by AZUL. (A(10))", 21 | ) 22 | OrderNumber: str = Field( 23 | ..., description="Merchant order number. Empty if not applicable. (X(15))" 24 | ) 25 | CustomOrderId: Optional[str] = Field( 26 | default=None, 27 | description="Custom merchant order ID. Used for VerifyPayment. (X(75))", 28 | ) 29 | CustomerServicePhone: Optional[str] = Field( 30 | default=None, description="Merchant customer service phone. (X(32))" 31 | ) 32 | ECommerceURL: Optional[str] = Field( 33 | default=None, description="Merchant e-commerce URL. (X(32))" 34 | ) 35 | AltMerchantName: Optional[str] = Field( 36 | default=None, 37 | description="Alternate merchant name for statements (max 25c). (X(30))", 38 | ) 39 | ForceNo3DS: Optional[str] = Field( 40 | default=None, 41 | description="'1' to force no 3DS, '0'/omit to use 3DS if configured. (N(1))", 42 | ) 43 | AcquirerRefData: Optional[str] = Field( 44 | default="1", 45 | description="Acquirer reference. Fixed '1' (AZUL internal use). (N(1))", 46 | ) 47 | 48 | 49 | class CardPayment(BaseTransaction): 50 | """Base attributes for payments made with actual card numbers.""" 51 | 52 | Amount: str = Field( # Represented in cents 53 | ..., 54 | description="Total amount in cents (e.g., 1000 for $10.00). Serialized to str.", 55 | ) 56 | Itbis: str = Field( # Represented in cents 57 | ..., 58 | description="ITBIS tax in cents (e.g., 180 for $1.80, 0 if exempt). To str.", 59 | ) 60 | CardNumber: str = Field( 61 | ..., description="Card number, no special characters. (N(19))" 62 | ) 63 | Expiration: str = Field(..., description="Expiration date in YYYYMM format. (N(6))") 64 | CVC: str = Field(..., description="Card security code (CVV2 or CVC). (N(3))") 65 | SaveToDataVault: Optional[str] = Field( 66 | default="0", description="'1' to save to DataVault, '0' not to. (N(1))" 67 | ) 68 | 69 | _validate_card_amount_values = field_validator("Amount", mode="before")( 70 | _validate_amount_field 71 | ) 72 | _validate_card_itbis_values = field_validator("Itbis", mode="before")( 73 | _validate_itbis_field 74 | ) 75 | 76 | 77 | class Sale(CardPayment): 78 | """Model for sale transactions.""" 79 | 80 | TrxType: str = Field( 81 | default="Sale", 82 | pattern="^Sale$", 83 | description="Transaction type, must be 'Sale'.", 84 | ) 85 | DataVaultToken: Optional[str] = Field( 86 | default=None, 87 | pattern=r"^[A-Fa-f0-9\-]{30,40}$", 88 | description="DataVault token to use instead of PAN/Expiry. (A(100))", 89 | ) 90 | # All other fields inherited from CardPayment 91 | 92 | 93 | class Hold(CardPayment): 94 | """Model for hold transactions.""" 95 | 96 | TrxType: str = Field( 97 | default="Hold", 98 | pattern="^Hold$", 99 | description="Transaction type, must be 'Hold'.", 100 | ) 101 | DataVaultToken: Optional[str] = Field( 102 | default=None, 103 | pattern=r"^[A-Fa-f0-9\-]{30,40}$", 104 | description="DataVault token to use instead of PAN/Expiry. (A(100))", 105 | ) 106 | # All other fields inherited from CardPayment 107 | 108 | 109 | class Refund(BaseTransaction): 110 | """Model for refund transactions.""" 111 | 112 | AzulOrderId: str = Field( 113 | ..., description="AzulOrderId of the original transaction to refund. (N(8))" 114 | ) 115 | TrxType: str = Field( 116 | default="Refund", 117 | pattern="^Refund$", 118 | description="Transaction type, must be 'Refund'.", 119 | ) 120 | Amount: str = Field( # Represented in cents 121 | ..., 122 | description="Total amount in cents (e.g., 1000 for $10.00). Serialized to str.", 123 | ) 124 | Itbis: str = Field( # Represented in cents 125 | ..., 126 | description="ITBIS tax in cents (e.g., 180 for $1.80, 0 if exempt). To str.", 127 | ) 128 | AcquirerRefData: Optional[str] = None # Override to None for refunds 129 | 130 | _validate_refund_amount_values = field_validator("Amount", mode="before")( 131 | _validate_amount_field 132 | ) 133 | _validate_refund_itbis_values = field_validator("Itbis", mode="before")( 134 | _validate_itbis_field 135 | ) 136 | # Inherits OrderNumber, CustomOrderId, etc. from BaseTransaction 137 | 138 | 139 | class Post(AzulBase): 140 | """Model for post-authorization (capture) transactions (ProcessPost).""" 141 | 142 | AzulOrderId: str = Field( 143 | ..., 144 | description="AzulOrderId of the original Hold transaction to capture. (N(8))", 145 | ) 146 | Amount: str = Field( # Represented in cents 147 | ..., 148 | description="Total amount to capture in cents. Serialized to str. (N(12))", 149 | ) 150 | Itbis: str = Field( # Represented in cents 151 | ..., 152 | description="ITBIS of amount to capture in cents. Serialized to str. (N(12))", 153 | ) 154 | 155 | _validate_post_sale_amount_values = field_validator("Amount", mode="before")( 156 | _validate_amount_field 157 | ) 158 | _validate_post_sale_itbis_values = field_validator("Itbis", mode="before")( 159 | _validate_itbis_field 160 | ) 161 | 162 | 163 | class Void(AzulBase): 164 | """Model for void transactions (ProcessVoid).""" 165 | 166 | AzulOrderId: str = Field( 167 | ..., 168 | description="AzulOrderId of the original transaction to void. (N(999))", 169 | ) 170 | -------------------------------------------------------------------------------- /pyazul/models/payment_page/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Payment Page domain models for PyAzul. 3 | 4 | This module contains models for Azul's hosted Payment Page functionality, 5 | including form generation and validation. 6 | """ 7 | 8 | from .models import PaymentPage 9 | 10 | __all__ = [ 11 | "PaymentPage", 12 | ] 13 | -------------------------------------------------------------------------------- /pyazul/models/payment_page/models.py: -------------------------------------------------------------------------------- 1 | """ 2 | Payment Page models for PyAzul. 3 | 4 | This module defines Pydantic models for Azul's hosted Payment Page functionality, 5 | including form generation, validation, and data formatting. 6 | """ 7 | 8 | from datetime import datetime 9 | from typing import Literal, Optional 10 | 11 | from pydantic import BaseModel, ConfigDict, Field, HttpUrl, field_validator 12 | 13 | from ..schemas import _validate_amount_field, _validate_itbis_field 14 | 15 | 16 | class PaymentPage(BaseModel): 17 | """ 18 | Define the model for Azul Payment Page transactions. 19 | 20 | This model handles the payment form data and validation for Azul's Payment Page. 21 | It ensures all data is formatted according to Azul's specifications. 22 | 23 | Amount format: 24 | - All amounts are in cents. 25 | - They will be serialized to strings for the payment page POST. 26 | - Examples: 27 | * 1000 = $10.00 28 | * 1748321 = $17,483.21 29 | 30 | Usage example: 31 | payment = PaymentPage( 32 | Amount=100000, # $1,000.00 (in cents) 33 | ITBIS=18000, # $180.00 (in cents) 34 | ApprovedUrl=HttpUrl("https://example.com/approved"), 35 | DeclineUrl=HttpUrl("https://example.com/declined"), 36 | CancelUrl=HttpUrl("https://example.com/cancel") 37 | ) 38 | """ 39 | 40 | model_config = ConfigDict(validate_assignment=True) 41 | 42 | CurrencyCode: Literal["$"] = "$" 43 | OrderNumber: str = Field( 44 | default_factory=lambda: datetime.now().strftime("%Y%m%d%H%M%S"), 45 | description="Unique order ID. Defaults to current timestamp.", 46 | ) 47 | 48 | Amount: str = Field( # Represented in cents 49 | ..., description="Total amount including taxes, in cents." 50 | ) 51 | ITBIS: str = Field( # Represented in cents 52 | ..., description="Tax amount in cents. Use 0 for zero/exempt." 53 | ) 54 | 55 | ApprovedUrl: HttpUrl 56 | DeclineUrl: HttpUrl 57 | CancelUrl: HttpUrl 58 | 59 | UseCustomField1: Literal["0", "1"] = "0" 60 | CustomField1Label: Optional[str] = "" 61 | CustomField1Value: Optional[str] = "" 62 | UseCustomField2: Literal["0", "1"] = "0" 63 | CustomField2Label: Optional[str] = "" 64 | CustomField2Value: Optional[str] = "" 65 | 66 | ShowTransactionResult: Literal["0", "1"] = "1" 67 | Locale: Literal["ES", "EN"] = "ES" 68 | 69 | SaveToDataVault: Optional[str] = None 70 | DataVaultToken: Optional[str] = None 71 | 72 | AltMerchantName: Optional[str] = Field( 73 | default=None, 74 | max_length=25, 75 | pattern=r"^[a-zA-Z0-9\s.,]*$", 76 | description="Alternate merchant name for display (max 25 chars)", 77 | ) 78 | 79 | _validate_amount_values = field_validator("Amount", mode="before")( 80 | _validate_amount_field 81 | ) 82 | _validate_itbis_values = field_validator("ITBIS", mode="before")( 83 | _validate_itbis_field 84 | ) 85 | 86 | @field_validator("CustomField1Label", "CustomField1Value") 87 | def validate_custom_field1(cls, v: str, info) -> str: 88 | """Validate that custom field 1 values are provided when enabled.""" 89 | if info.data.get("UseCustomField1") == "1" and not v: 90 | field_name = info.field_name 91 | raise ValueError( 92 | f'Custom field 1 {field_name} is required when UseCustomField1 is "1"' 93 | ) 94 | return v 95 | 96 | @field_validator("CustomField2Label", "CustomField2Value") 97 | def validate_custom_field2(cls, v: str, info) -> str: 98 | """Validate that custom field 2 values are provided when enabled.""" 99 | if info.data.get("UseCustomField2") == "1" and not v: 100 | field_name = info.field_name 101 | raise ValueError( 102 | f'Custom field 2 {field_name} is required when UseCustomField2 is "1"' 103 | ) 104 | return v 105 | 106 | def __str__(self) -> str: 107 | """Return a string representation with formatted amounts in USD.""" 108 | amount = int(self.Amount) / 100 109 | itbis = int(self.ITBIS) / 100 110 | return f"Payment Request - Amount: ${amount:.2f}, ITBIS: ${itbis:.2f}" 111 | -------------------------------------------------------------------------------- /pyazul/models/schemas.py: -------------------------------------------------------------------------------- 1 | """ 2 | Core schemas and base models for PyAzul. 3 | 4 | This module defines base classes and validation functions used across 5 | all business domain models. Individual transaction models are organized 6 | in their respective business domain modules. 7 | 8 | For new code, import directly from business domains: 9 | from pyazul.models.payment import Sale 10 | from pyazul.models.datavault import TokenRequest 11 | """ 12 | 13 | from typing import Union 14 | 15 | from pydantic import BaseModel, Field 16 | 17 | 18 | # Helper validator functions 19 | def _validate_amount_field(v: Union[str, int, float], info) -> str: 20 | """ 21 | Validate and normalize amount fields to string representation in cents. 22 | 23 | Accepts int, float, or str input and ensures non-negative integer value. 24 | Converts float/int inputs to string format required by Azul API. 25 | 26 | Args: 27 | v: Input value (str, int, or float representing cents) 28 | info: Pydantic field info 29 | 30 | Returns: 31 | str: Validated amount as string of digits 32 | 33 | Raises: 34 | ValueError: If value is negative or not a valid number 35 | """ 36 | if isinstance(v, float): # Convert float to int (cents) then to str 37 | v = str(int(v)) 38 | elif isinstance(v, int): 39 | v = str(v) 40 | 41 | if not v.isdigit(): 42 | raise ValueError( 43 | f"{info.field_name} ('{v}') must be a string of digits representing cents." 44 | ) 45 | 46 | numeric_val = int(v) 47 | if numeric_val < 0: 48 | raise ValueError(f"{info.field_name} ('{v}') must be non-negative.") 49 | 50 | return str(numeric_val) 51 | 52 | 53 | def _validate_itbis_field(v: Union[str, int, float, None], info) -> str: 54 | """ 55 | Validate and normalize ITBIS fields to string representation in cents. 56 | 57 | Accepts int, float, str, or None input. None values are converted to "0". 58 | Ensures non-negative integer value and converts to string format. 59 | 60 | Args: 61 | v: Input value (str, int, float, or None representing cents) 62 | info: Pydantic field info 63 | 64 | Returns: 65 | str: Validated ITBIS as string of digits 66 | 67 | Raises: 68 | ValueError: If value is negative or not a valid number 69 | """ 70 | if v is None: 71 | return "0" 72 | 73 | if isinstance(v, float): # Convert float to int (cents) then to str 74 | v = str(int(v)) 75 | elif isinstance(v, int): 76 | v = str(v) 77 | 78 | if not v.isdigit(): 79 | raise ValueError( 80 | f"{info.field_name} ('{v}') must be a string of digits representing cents." 81 | ) 82 | 83 | numeric_val = int(v) 84 | if numeric_val < 0: 85 | raise ValueError(f"{info.field_name} ('{v}') must be non-negative.") 86 | 87 | return str(numeric_val) 88 | 89 | 90 | class AzulBase(BaseModel): 91 | """Base model for Azul payment operations.""" 92 | 93 | Channel: str = Field( 94 | default="EC", description="Payment channel. Defaults to 'EC'. (X(3))" 95 | ) 96 | Store: str = Field( 97 | ..., description="Unique merchant ID (MID). Must be provided. (X(11))" 98 | ) 99 | -------------------------------------------------------------------------------- /pyazul/models/three_ds/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3D Secure (3DS) domain models for PyAzul. 3 | 4 | This module contains models for 3D Secure authentication flows, 5 | cardholder information, and challenge handling. 6 | """ 7 | 8 | from .models import ( 9 | CardHolderInfo, 10 | ChallengeIndicator, 11 | ChallengeRequest, 12 | SecureSale, 13 | SecureTokenHold, 14 | SecureTokenSale, 15 | SessionID, 16 | ThreeDSAuth, 17 | ) 18 | 19 | __all__ = [ 20 | "CardHolderInfo", 21 | "ThreeDSAuth", 22 | "ChallengeIndicator", 23 | "SecureSale", 24 | "SecureTokenSale", 25 | "SecureTokenHold", 26 | "SessionID", 27 | "ChallengeRequest", 28 | ] 29 | -------------------------------------------------------------------------------- /pyazul/models/verification/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Verification domain models for PyAzul. 3 | 4 | This module contains models for transaction verification operations, 5 | including checking transaction status and details. 6 | """ 7 | 8 | from .models import VerifyTransaction 9 | 10 | __all__ = [ 11 | "VerifyTransaction", 12 | ] 13 | -------------------------------------------------------------------------------- /pyazul/models/verification/models.py: -------------------------------------------------------------------------------- 1 | """ 2 | Transaction verification models for PyAzul. 3 | 4 | This module defines Pydantic models for verifying existing transactions, 5 | typically used to check payment status or retrieve transaction details. 6 | """ 7 | 8 | from pydantic import Field 9 | 10 | from ..schemas import AzulBase 11 | 12 | 13 | class VerifyTransaction(AzulBase): 14 | """Model for verifying existing transactions using CustomOrderId (VerifyPayment).""" 15 | 16 | CustomOrderId: str = Field( 17 | ..., description="CustomOrderId of the original transaction to verify. (X(75))" 18 | ) 19 | -------------------------------------------------------------------------------- /pyazul/services/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Services module for PyAzul. 3 | 4 | This package contains service classes that encapsulate business logic for interacting 5 | with different aspects of the Azul API, such as transactions, DataVault, and 3D Secure. 6 | """ 7 | 8 | from .datavault import DataVaultService 9 | from .payment_page import PaymentPageService 10 | from .transaction import TransactionService 11 | 12 | __all__ = [ 13 | "TransactionService", 14 | "DataVaultService", 15 | "PaymentPageService", 16 | ] 17 | -------------------------------------------------------------------------------- /pyazul/services/datavault.py: -------------------------------------------------------------------------------- 1 | """ 2 | DataVault service for PyAzul. 3 | 4 | This module provides services for managing card tokenization through Azul's DataVault, 5 | including creating tokens, deleting tokens, and processing token-based payments. 6 | """ 7 | 8 | import logging 9 | from typing import Any, Dict 10 | 11 | from ..api.client import AzulAPI 12 | from ..core.config import AzulSettings 13 | from ..core.exceptions import AzulError 14 | from ..models.datavault import TokenError, TokenRequest, TokenResponse, TokenSuccess 15 | 16 | _logger = logging.getLogger(__name__) 17 | 18 | 19 | class DataVaultService: 20 | """Service for managing DataVault tokenization operations.""" 21 | 22 | def __init__(self, client: AzulAPI, settings: AzulSettings): 23 | """Initialize the DataVault service with API client and settings.""" 24 | self.client = client 25 | self.settings = settings 26 | 27 | async def create_token(self, request: TokenRequest) -> TokenResponse: 28 | """ 29 | Create a new DataVault token for a credit card. 30 | 31 | Args: 32 | request: Token creation request with card details 33 | 34 | Returns: 35 | TokenResponse (either TokenSuccess or TokenError) 36 | 37 | Raises: 38 | AzulError: If token creation fails 39 | """ 40 | try: 41 | _logger.info("Creating DataVault token") 42 | 43 | if request.TrxType != "CREATE": 44 | raise AzulError("TrxType must be CREATE for token creation") 45 | 46 | response = await self.client.post( 47 | "/webservices/JSON/default.aspx?ProcessDatavault", 48 | request.model_dump(exclude_none=True), 49 | ) 50 | 51 | # Parse response based on success/failure 52 | if response.get("IsoCode") == "00": 53 | _logger.info("DataVault token created successfully") 54 | return TokenSuccess.from_api_response(response) 55 | else: 56 | _logger.warning("DataVault token creation failed") 57 | return TokenError.from_api_response(response) 58 | 59 | except Exception as e: 60 | _logger.error(f"DataVault token creation failed: {e}") 61 | raise AzulError(f"DataVault token creation failed: {e}") from e 62 | 63 | async def delete_token(self, request: TokenRequest) -> TokenResponse: 64 | """ 65 | Delete an existing DataVault token. 66 | 67 | Args: 68 | request: Token deletion request with token ID 69 | 70 | Returns: 71 | TokenResponse (either TokenSuccess or TokenError) 72 | 73 | Raises: 74 | AzulError: If token deletion fails 75 | """ 76 | try: 77 | _logger.info("Deleting DataVault token") 78 | 79 | if request.TrxType != "DELETE": 80 | raise AzulError("TrxType must be DELETE for token deletion") 81 | 82 | response = await self.client.post( 83 | "/webservices/JSON/default.aspx?ProcessDatavault", 84 | request.model_dump(exclude_none=True), 85 | ) 86 | 87 | # Parse response based on success/failure 88 | if response.get("IsoCode") == "00": 89 | _logger.info("DataVault token deleted successfully") 90 | return TokenSuccess.from_api_response(response) 91 | else: 92 | _logger.warning("DataVault token deletion failed") 93 | return TokenError.from_api_response(response) 94 | 95 | except Exception as e: 96 | _logger.error(f"DataVault token deletion failed: {e}") 97 | raise AzulError(f"DataVault token deletion failed: {e}") from e 98 | 99 | async def process_datavault_request(self, request: TokenRequest) -> TokenResponse: 100 | """ 101 | Process a DataVault request (CREATE or DELETE). 102 | 103 | Args: 104 | request: DataVault request model 105 | 106 | Returns: 107 | TokenResponse based on operation result 108 | 109 | Raises: 110 | AzulError: If the request processing fails 111 | """ 112 | if request.TrxType == "CREATE": 113 | return await self.create_token(request) 114 | elif request.TrxType == "DELETE": 115 | return await self.delete_token(request) 116 | else: 117 | raise AzulError(f"Unsupported TrxType: {request.TrxType}") 118 | 119 | async def get_token_info(self, token: str) -> Dict[str, Any]: 120 | """ 121 | Get information about a DataVault token. 122 | 123 | Args: 124 | token: The DataVault token to query 125 | 126 | Returns: 127 | Token information dictionary 128 | 129 | Raises: 130 | AzulError: If token query fails 131 | """ 132 | try: 133 | _logger.info("Querying DataVault token information") 134 | # This would be implementation specific based on Azul's API 135 | # Currently using a placeholder implementation 136 | raise NotImplementedError("Token info query not yet implemented") 137 | except Exception as e: 138 | _logger.error(f"DataVault token query failed: {e}") 139 | raise AzulError(f"DataVault token query failed: {e}") from e 140 | -------------------------------------------------------------------------------- /pyazul/services/payment_page.py: -------------------------------------------------------------------------------- 1 | """ 2 | Payment Page service for PyAzul. 3 | 4 | This module provides services for generating Azul's hosted Payment Page, 5 | including HTML form creation, hash generation, and data formatting. 6 | """ 7 | 8 | import hashlib 9 | import logging 10 | from typing import Dict 11 | 12 | from ..core.config import AzulSettings 13 | from ..core.exceptions import AzulError 14 | from ..models.payment_page import PaymentPage 15 | 16 | _logger = logging.getLogger(__name__) 17 | 18 | 19 | class PaymentPageService: 20 | """Service for generating Azul Payment Page forms.""" 21 | 22 | def __init__(self, settings: AzulSettings): 23 | """Initialize the Payment Page service with settings.""" 24 | self.settings = settings 25 | 26 | def generate_payment_form_html(self, payment_data: PaymentPage) -> str: 27 | """ 28 | Generate HTML form for Azul Payment Page. 29 | 30 | Args: 31 | payment_data: Payment page model with transaction details 32 | 33 | Returns: 34 | HTML form string ready for rendering 35 | 36 | Raises: 37 | AzulError: If form generation fails 38 | """ 39 | try: 40 | _logger.info("Generating Payment Page HTML form") 41 | 42 | # Convert model to dictionary for form generation 43 | form_data = payment_data.model_dump() 44 | 45 | # Add merchant configuration 46 | form_data.update( 47 | { 48 | "MerchantId": self.settings.MERCHANT_ID, 49 | "MerchantName": self.settings.MERCHANT_NAME, 50 | "MerchantType": self.settings.MERCHANT_TYPE, 51 | } 52 | ) 53 | 54 | # Generate hash for form authentication 55 | auth_hash = self._generate_auth_hash(form_data) 56 | form_data["AuthHash"] = auth_hash 57 | 58 | # Generate HTML form 59 | html = self._create_form_html(form_data) 60 | 61 | _logger.info("Payment Page HTML form generated successfully") 62 | return html 63 | 64 | except Exception as e: 65 | _logger.error(f"Payment Page form generation failed: {e}") 66 | raise AzulError(f"Payment Page form generation failed: {e}") from e 67 | 68 | def _generate_auth_hash(self, form_data: Dict[str, str]) -> str: 69 | """ 70 | Generate authentication hash for Payment Page. 71 | 72 | Args: 73 | form_data: Dictionary containing form fields 74 | 75 | Returns: 76 | SHA512 hash string for authentication 77 | """ 78 | # Create hash string according to Azul's specification 79 | hash_string = ( 80 | f"{self.settings.AZUL_AUTH_KEY}" 81 | f"{form_data['MerchantId']}" 82 | f"{form_data['OrderNumber']}" 83 | f"{form_data['Amount']}" 84 | f"{form_data['ITBIS']}" 85 | f"{self.settings.AZUL_AUTH_KEY}" 86 | ) 87 | 88 | # Generate SHA512 hash 89 | return hashlib.sha512(hash_string.encode("utf-8")).hexdigest().upper() 90 | 91 | def _create_form_html(self, form_data: Dict[str, str]) -> str: 92 | """ 93 | Create HTML form with all payment data. 94 | 95 | Args: 96 | form_data: Dictionary containing all form fields 97 | 98 | Returns: 99 | Complete HTML form string 100 | """ 101 | payment_page_url = self._get_payment_page_url() 102 | 103 | # Generate form fields 104 | form_fields = "" 105 | for field_name, field_value in form_data.items(): 106 | if field_value is not None: 107 | form_fields += ( 108 | f' \n' 110 | ) 111 | 112 | # Create complete HTML form 113 | html = f""" 114 | 115 | 116 | 117 | 118 | Azul Payment Page 119 | 120 | 121 |
    122 | {form_fields} 123 | 124 |
    125 | 126 | 130 | 131 | 132 | """ 133 | return html 134 | 135 | def _get_payment_page_url(self) -> str: 136 | """ 137 | Get the appropriate Payment Page URL based on environment. 138 | 139 | Returns: 140 | Payment Page URL string 141 | """ 142 | if self.settings.ENVIRONMENT == "prod": 143 | return "https://pagos.azul.com.do/paymentpage/default.aspx" 144 | else: 145 | return "https://pruebas.azul.com.do/paymentpage/default.aspx" 146 | 147 | def create_payment_request( 148 | self, 149 | amount: int, 150 | itbis: int, 151 | order_number: str, 152 | approved_url: str, 153 | decline_url: str, 154 | cancel_url: str, 155 | **kwargs, 156 | ) -> PaymentPage: 157 | """ 158 | Create a payment request model with the provided parameters. 159 | 160 | Args: 161 | amount: Total amount in cents 162 | itbis: Tax amount in cents 163 | order_number: Unique order identifier 164 | approved_url: URL for successful payments 165 | decline_url: URL for declined payments 166 | cancel_url: URL for cancelled payments 167 | **kwargs: Additional optional parameters 168 | 169 | Returns: 170 | PaymentPage model ready for form generation 171 | 172 | Raises: 173 | AzulError: If payment request creation fails 174 | """ 175 | try: 176 | _logger.info("Creating payment request") 177 | 178 | payment_data = { 179 | "Amount": str(amount), 180 | "ITBIS": str(itbis), 181 | "OrderNumber": order_number, 182 | "ApprovedUrl": approved_url, 183 | "DeclineUrl": decline_url, 184 | "CancelUrl": cancel_url, 185 | **kwargs, 186 | } 187 | 188 | payment_request = PaymentPage(**payment_data) 189 | 190 | _logger.info("Payment request created successfully") 191 | return payment_request 192 | 193 | except Exception as e: 194 | _logger.error(f"Payment request creation failed: {e}") 195 | raise AzulError(f"Payment request creation failed: {e}") from e 196 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.2", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "pyazul" 7 | version = "3.1.0" 8 | description = "An Azul Webservices light wrapper for Python." 9 | authors = [{ name = "INDEXA Inc.", email = "info@indexa.do" }] 10 | license = { text = "MIT License" } 11 | classifiers = [ 12 | "Programming Language :: Python :: 3", 13 | "License :: OSI Approved :: MIT License", 14 | "Operating System :: OS Independent", 15 | ] 16 | dependencies = [ 17 | "httpx[http2]>=0.28.1", 18 | "pydantic>=2.11.5", 19 | "pydantic-settings>=2.9.1", 20 | "python-dotenv>=1.1.0", 21 | ] 22 | requires-python = ">=3.12" 23 | readme = "README.md" 24 | 25 | [project.urls] 26 | Homepage = "https://github.com/indexa-git/pyazul/" 27 | 28 | [tool.setuptools.packages.find] 29 | include = ["pyazul", "pyazul.*"] 30 | 31 | [project.optional-dependencies] 32 | dev = [ 33 | "pytest>=8.3.5", 34 | "pytest-dotenv>=0.5.2", 35 | "pytest-cov>=6.1.1", 36 | "pytest-asyncio>=1.0.0", 37 | "python-multipart>=0.0.9", 38 | "jinja2>=3.1.6", 39 | "fastapi>=0.115.12", 40 | "flake8>=7.2.0", 41 | "flake8-docstrings>=1.7.0", 42 | "pydocstyle[toml]>=6.3.0", 43 | "interrogate>=1.7.0", 44 | "black>=25.1.0", 45 | "isort>=6.0.1", 46 | "uvicorn>=0.34.2", 47 | ] 48 | 49 | [tool.pytest.ini_options] 50 | addopts = "-v --import-mode=importlib" 51 | asyncio_mode = "auto" 52 | testpaths = ["tests"] 53 | asyncio_default_fixture_loop_scope = "function" 54 | python_files = ["test_*.py"] 55 | python_classes = ["Test*"] 56 | python_functions = ["test_*"] 57 | 58 | [tool.black] 59 | line-length = 88 60 | target-version = ["py312"] 61 | 62 | [tool.pydocstyle] 63 | convention = "google" 64 | inherit = false 65 | match = "(?!test_).*.py" 66 | add_ignore = "D100,D104,D107" 67 | 68 | [tool.interrogate] 69 | fail-under = 70 70 | verbose = 0 71 | generate-badge = "docs/interrogate_badge.svg" 72 | badge-format = "svg" 73 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | # This file was autogenerated by uv via the following command: 2 | # uv pip compile pyproject.toml --extra dev -o requirements-dev.txt 3 | annotated-types==0.7.0 4 | # via pydantic 5 | anyio==4.9.0 6 | # via 7 | # httpx 8 | # starlette 9 | attrs==25.3.0 10 | # via interrogate 11 | black==25.1.0 12 | # via pyazul (pyproject.toml) 13 | certifi==2025.4.26 14 | # via 15 | # httpcore 16 | # httpx 17 | click==8.2.1 18 | # via 19 | # black 20 | # interrogate 21 | # uvicorn 22 | colorama==0.4.6 23 | # via interrogate 24 | coverage==7.8.2 25 | # via pytest-cov 26 | fastapi==0.115.12 27 | # via pyazul (pyproject.toml) 28 | flake8==7.2.0 29 | # via 30 | # pyazul (pyproject.toml) 31 | # flake8-docstrings 32 | flake8-docstrings==1.7.0 33 | # via pyazul (pyproject.toml) 34 | h11==0.16.0 35 | # via 36 | # httpcore 37 | # uvicorn 38 | h2==4.2.0 39 | # via httpx 40 | hpack==4.1.0 41 | # via h2 42 | httpcore==1.0.9 43 | # via httpx 44 | httpx==0.28.1 45 | # via pyazul (pyproject.toml) 46 | hyperframe==6.1.0 47 | # via h2 48 | idna==3.10 49 | # via 50 | # anyio 51 | # httpx 52 | iniconfig==2.1.0 53 | # via pytest 54 | interrogate==1.7.0 55 | # via pyazul (pyproject.toml) 56 | isort==6.0.1 57 | # via pyazul (pyproject.toml) 58 | jinja2==3.1.6 59 | # via pyazul (pyproject.toml) 60 | markupsafe==3.0.2 61 | # via jinja2 62 | mccabe==0.7.0 63 | # via flake8 64 | mypy-extensions==1.1.0 65 | # via black 66 | packaging==25.0 67 | # via 68 | # black 69 | # pytest 70 | pathspec==0.12.1 71 | # via black 72 | platformdirs==4.3.8 73 | # via black 74 | pluggy==1.6.0 75 | # via pytest 76 | py==1.11.0 77 | # via interrogate 78 | pycodestyle==2.13.0 79 | # via flake8 80 | pydantic==2.11.5 81 | # via 82 | # pyazul (pyproject.toml) 83 | # fastapi 84 | # pydantic-settings 85 | pydantic-core==2.33.2 86 | # via pydantic 87 | pydantic-settings==2.9.1 88 | # via pyazul (pyproject.toml) 89 | pydocstyle==6.3.0 90 | # via 91 | # pyazul (pyproject.toml) 92 | # flake8-docstrings 93 | pyflakes==3.3.2 94 | # via flake8 95 | pytest==8.3.5 96 | # via 97 | # pyazul (pyproject.toml) 98 | # pytest-asyncio 99 | # pytest-cov 100 | # pytest-dotenv 101 | pytest-asyncio==1.0.0 102 | # via pyazul (pyproject.toml) 103 | pytest-cov==6.1.1 104 | # via pyazul (pyproject.toml) 105 | pytest-dotenv==0.5.2 106 | # via pyazul (pyproject.toml) 107 | python-dotenv==1.1.0 108 | # via 109 | # pyazul (pyproject.toml) 110 | # pydantic-settings 111 | # pytest-dotenv 112 | python-multipart==0.0.20 113 | # via pyazul (pyproject.toml) 114 | sniffio==1.3.1 115 | # via anyio 116 | snowballstemmer==3.0.1 117 | # via pydocstyle 118 | starlette==0.46.2 119 | # via fastapi 120 | tabulate==0.9.0 121 | # via interrogate 122 | typing-extensions==4.13.2 123 | # via 124 | # anyio 125 | # fastapi 126 | # pydantic 127 | # pydantic-core 128 | # typing-inspection 129 | typing-inspection==0.4.1 130 | # via 131 | # pydantic 132 | # pydantic-settings 133 | uvicorn==0.34.2 134 | # via pyazul (pyproject.toml) 135 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # This file was autogenerated by uv via the following command: 2 | # uv pip compile pyproject.toml -o requirements.txt 3 | annotated-types==0.7.0 4 | # via pydantic 5 | anyio==4.9.0 6 | # via httpx 7 | certifi==2025.4.26 8 | # via 9 | # httpcore 10 | # httpx 11 | h11==0.16.0 12 | # via httpcore 13 | h2==4.2.0 14 | # via httpx 15 | hpack==4.1.0 16 | # via h2 17 | httpcore==1.0.9 18 | # via httpx 19 | httpx==0.28.1 20 | # via pyazul (pyproject.toml) 21 | hyperframe==6.1.0 22 | # via h2 23 | idna==3.10 24 | # via 25 | # anyio 26 | # httpx 27 | pydantic==2.11.5 28 | # via 29 | # pyazul (pyproject.toml) 30 | # pydantic-settings 31 | pydantic-core==2.33.2 32 | # via pydantic 33 | pydantic-settings==2.9.1 34 | # via pyazul (pyproject.toml) 35 | python-dotenv==1.1.0 36 | # via 37 | # pyazul (pyproject.toml) 38 | # pydantic-settings 39 | sniffio==1.3.1 40 | # via anyio 41 | typing-extensions==4.13.2 42 | # via 43 | # anyio 44 | # pydantic 45 | # pydantic-core 46 | # typing-inspection 47 | typing-inspection==0.4.1 48 | # via 49 | # pydantic 50 | # pydantic-settings 51 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """Pytest configuration and fixtures for PyAzul tests.""" 2 | 3 | import pytest 4 | 5 | from pyazul.core.config import get_azul_settings 6 | 7 | 8 | @pytest.fixture(scope="session") 9 | def settings(): 10 | """Provide AzulSettings for tests, loaded once per session.""" 11 | return get_azul_settings() 12 | -------------------------------------------------------------------------------- /tests/e2e/conftest.py: -------------------------------------------------------------------------------- 1 | """Pytest configuration and fixtures for PyAzul integration tests.""" 2 | 3 | import pytest 4 | 5 | from pyazul.api.client import AzulAPI 6 | from pyazul.core.config import AzulSettings 7 | from pyazul.services.datavault import DataVaultService 8 | from pyazul.services.payment_page import PaymentPageService 9 | from pyazul.services.transaction import TransactionService 10 | 11 | 12 | @pytest.fixture(scope="session") 13 | def transaction_service_integration(settings: AzulSettings) -> TransactionService: 14 | """Provide a TransactionService instance for integration tests.""" 15 | api_client = AzulAPI(settings=settings) 16 | return TransactionService(client=api_client, settings=settings) 17 | 18 | 19 | @pytest.fixture(scope="session") 20 | def datavault_service_integration(settings: AzulSettings) -> DataVaultService: 21 | """Provide a DataVaultService instance for integration tests.""" 22 | api_client = AzulAPI(settings=settings) 23 | return DataVaultService(client=api_client, settings=settings) 24 | 25 | 26 | @pytest.fixture(scope="session") 27 | def payment_page_service_integration(settings: AzulSettings) -> PaymentPageService: 28 | """Provide a PaymentPageService instance for integration tests.""" 29 | return PaymentPageService(settings=settings) 30 | -------------------------------------------------------------------------------- /tests/e2e/services/test_payment_integration.py: -------------------------------------------------------------------------------- 1 | """Integration tests for payment operations.""" 2 | 3 | import pytest 4 | 5 | from pyazul.models.payment import Refund, Sale 6 | from tests.fixtures.cards import get_card 7 | from tests.fixtures.order import generate_order_number 8 | 9 | 10 | @pytest.fixture 11 | def card_payment_data(settings): 12 | """ 13 | Provide standard test data for a card payment. 14 | 15 | Returns: 16 | dict: Data for card payment transaction. 17 | """ 18 | card = get_card("MASTERCARD_2") # Using a standard card 19 | return { 20 | "Store": settings.MERCHANT_ID, 21 | "OrderNumber": generate_order_number(), 22 | "CustomOrderId": f"sale-test-{generate_order_number()}", 23 | "ForceNo3DS": "1", # Test specific 24 | "Amount": "1000", 25 | "Itbis": "180", 26 | "CardNumber": card["number"], 27 | "Expiration": card["expiration"], 28 | "CVC": card["cvv"], 29 | "SaveToDataVault": "1", # Test specific: non-default, save to vault 30 | } 31 | 32 | 33 | @pytest.mark.asyncio 34 | async def test_card_payment(transaction_service_integration, card_payment_data): 35 | """ 36 | Test direct card payment transaction. 37 | 38 | Verifies successful card payment processing, approval, and auth codes. 39 | 40 | Expected outcomes: 41 | - Response IsoCode '00' (success). 42 | - Receive proper authorization codes. 43 | - Transaction amount matches request. 44 | """ 45 | payment = Sale(**card_payment_data) 46 | response = await transaction_service_integration.process_sale(payment) 47 | assert response.get("IsoCode") == "00", "Payment should be approved" 48 | print("Payment Response:", response) 49 | return response 50 | 51 | 52 | @pytest.fixture 53 | async def completed_payment(transaction_service_integration, card_payment_data): 54 | """ 55 | Create a successful payment and return the response. 56 | 57 | Used by refund tests needing a previous successful transaction. 58 | 59 | Returns: 60 | dict: API response with transaction details for refund. 61 | """ 62 | payment = Sale(**card_payment_data) 63 | return await transaction_service_integration.process_sale(payment) 64 | 65 | 66 | @pytest.fixture 67 | def refund_payment_data(completed_payment, card_payment_data, settings): 68 | """ 69 | Provide test data for refund transactions. 70 | 71 | Uses AzulOrderId from a previous successful payment. 72 | 73 | Args: 74 | completed_payment: Response from a successful payment. 75 | card_payment_data: Original card payment data fixture. 76 | settings: The application settings fixture. 77 | 78 | Returns: 79 | dict: Test data with original transaction ref, refund amount, and merchant IDs. 80 | """ 81 | return { 82 | "Store": settings.MERCHANT_ID, 83 | "OrderNumber": card_payment_data.get("OrderNumber"), 84 | "AzulOrderId": completed_payment.get("AzulOrderId"), 85 | "Amount": "1000", 86 | "Itbis": "180", 87 | } 88 | 89 | 90 | @pytest.mark.asyncio 91 | async def test_refund(transaction_service_integration, refund_payment_data): 92 | """ 93 | Test refund transaction for a previous payment. 94 | 95 | Verifies successful refund, approval, and matching amount. 96 | 97 | Expected outcomes: 98 | - Response IsoCode '00' (success). 99 | - Able to refund the full amount. 100 | - Receive proper reference numbers. 101 | 102 | Note: Refunds are for successful transactions and must match the original amount. 103 | """ 104 | payment = Refund(**refund_payment_data) 105 | response = await transaction_service_integration.process_refund(payment) 106 | print("Refund Response:", response) 107 | assert response.get("IsoCode") == "00", "Refund should be approved" 108 | -------------------------------------------------------------------------------- /tests/e2e/services/test_payment_page_integration.py: -------------------------------------------------------------------------------- 1 | """Integration tests for payment page creation and generation.""" 2 | 3 | import pytest 4 | from pydantic import HttpUrl, ValidationError 5 | 6 | from pyazul.models.payment_page import PaymentPage 7 | from tests.fixtures.order import generate_order_number 8 | 9 | 10 | @pytest.fixture 11 | def valid_payment_request(): 12 | """Fixture that provides a valid PaymentPageModel instance.""" 13 | return PaymentPage( 14 | OrderNumber=generate_order_number(), 15 | Amount="100000", # $1,000.00 16 | ITBIS="18000", # $180.00 17 | ApprovedUrl=HttpUrl("https://example.com/approved"), 18 | DeclineUrl=HttpUrl("https://example.com/declined"), 19 | CancelUrl=HttpUrl("https://example.com/cancel"), 20 | ) 21 | 22 | 23 | class TestPaymentPageModel: 24 | """Test cases for PaymentPageModel validation and functionality.""" 25 | 26 | def test_valid_amounts(self): 27 | """Test that valid amounts are accepted.""" 28 | model = PaymentPage( 29 | OrderNumber=generate_order_number(), 30 | Amount="100000", # $1,000.00 31 | ITBIS="18000", # $180.00 32 | ApprovedUrl=HttpUrl("https://example.com/approved"), 33 | DeclineUrl=HttpUrl("https://example.com/declined"), 34 | CancelUrl=HttpUrl("https://example.com/cancel"), 35 | AltMerchantName=None, 36 | ) 37 | assert model.Amount == "100000" 38 | assert model.ITBIS == "18000" 39 | 40 | def test_zero_itbis_format(self): 41 | """Test that zero ITBIS is formatted as '0'.""" 42 | model = PaymentPage( 43 | OrderNumber=generate_order_number(), 44 | Amount="100000", 45 | ITBIS="0", 46 | ApprovedUrl=HttpUrl("https://example.com/approved"), 47 | DeclineUrl=HttpUrl("https://example.com/declined"), 48 | CancelUrl=HttpUrl("https://example.com/cancel"), 49 | AltMerchantName=None, 50 | ) 51 | assert model.ITBIS == "0" 52 | 53 | def test_invalid_amount_format(self): 54 | """Test that invalid amount formats are rejected.""" 55 | with pytest.raises(ValidationError): 56 | PaymentPage( 57 | OrderNumber=generate_order_number(), 58 | Amount="1000.00", # Invalid: contains decimal point 59 | ITBIS="180.00", # Invalid: contains decimal point 60 | ApprovedUrl=HttpUrl("https://example.com/approved"), 61 | DeclineUrl=HttpUrl("https://example.com/declined"), 62 | CancelUrl=HttpUrl("https://example.com/cancel"), 63 | AltMerchantName=None, 64 | ) 65 | 66 | def test_custom_field_validation(self): 67 | """Test that custom field validation works correctly.""" 68 | # When UseCustomField1 is "1", label and value are required 69 | with pytest.raises(ValidationError): 70 | PaymentPage( 71 | OrderNumber=generate_order_number(), 72 | Amount="100000", 73 | ITBIS="18000", 74 | ApprovedUrl=HttpUrl("https://example.com/approved"), 75 | DeclineUrl=HttpUrl("https://example.com/declined"), 76 | CancelUrl=HttpUrl("https://example.com/cancel"), 77 | UseCustomField1="1", # Enabled but no label/value 78 | CustomField1Label="", # Empty label should trigger validation error 79 | CustomField1Value="", # Empty value should trigger validation error 80 | AltMerchantName=None, 81 | ) 82 | 83 | def test_string_representation(self): 84 | """Test the string representation of amounts.""" 85 | order_num = generate_order_number() 86 | model = PaymentPage( 87 | OrderNumber=order_num, 88 | Amount="100000", # $1,000.00 89 | ITBIS="18000", # $180.00 90 | ApprovedUrl=HttpUrl("https://example.com/approved"), 91 | DeclineUrl=HttpUrl("https://example.com/declined"), 92 | CancelUrl=HttpUrl("https://example.com/cancel"), 93 | AltMerchantName=None, 94 | ) 95 | expected = "Payment Request - Amount: $1000.00, ITBIS: $180.00" 96 | assert str(model) == expected 97 | 98 | 99 | class TestPaymentPageService: 100 | """Test cases for PaymentPageService functionality.""" 101 | 102 | def test_form_generation( 103 | self, payment_page_service_integration, valid_payment_request 104 | ): 105 | """Test that a valid HTML form is generated.""" 106 | form = payment_page_service_integration.generate_payment_form_html( 107 | valid_payment_request 108 | ) 109 | 110 | # Check that the form contains essential elements 111 | assert " CardDetails: 114 | """ 115 | Get a test card by key. 116 | 117 | Args: 118 | key: Key of the test card to get. 119 | 120 | Returns: 121 | The requested test card. 122 | """ 123 | return TEST_CARDS[key] 124 | 125 | 126 | def get_random_card(exclude_cards: Optional[List[TestCardKey]] = None) -> CardDetails: 127 | """ 128 | Get a random test card. 129 | 130 | Args: 131 | exclude_cards: Card keys to exclude from selection. 132 | 133 | Returns: 134 | A random test card. 135 | """ 136 | if exclude_cards is None: 137 | exclude_cards = [] 138 | 139 | available_keys = [key for key in TEST_CARDS.keys() if key not in exclude_cards] 140 | if not available_keys: 141 | raise ValueError("No cards available after exclusion.") 142 | 143 | random_key = random.choice(available_keys) 144 | return TEST_CARDS[cast(TestCardKey, random_key)] 145 | -------------------------------------------------------------------------------- /tests/fixtures/order.py: -------------------------------------------------------------------------------- 1 | """Order number generation utility for PyAzul tests.""" 2 | 3 | import datetime 4 | import random 5 | 6 | 7 | def generate_order_number() -> str: 8 | """ 9 | Generate a unique order number with a maximum length of 15 characters. 10 | 11 | The format is: YYMMDD (6 chars) + random numbers (9 chars) = 15 chars total. 12 | Example: 240318123456789. 13 | """ 14 | now = datetime.datetime.now() 15 | date_str = now.strftime("%y%m%d") # YYMMDD 16 | random_num_int = random.randint(0, 999999999) # Generate a 9-digit number 17 | random_num_str = str(random_num_int).zfill(9) # Pad with leading zeros 18 | return f"{date_str}{random_num_str}" 19 | -------------------------------------------------------------------------------- /tests/unit/conftest.py: -------------------------------------------------------------------------------- 1 | """Unit test configurations.""" 2 | 3 | from unittest.mock import AsyncMock, Mock 4 | 5 | import pytest 6 | 7 | from pyazul.api.client import AzulAPI 8 | from pyazul.core.config import AzulSettings 9 | 10 | 11 | @pytest.fixture 12 | def mock_azul_settings() -> AzulSettings: 13 | """Return a mock AzulSettings instance.""" 14 | settings = Mock(spec=AzulSettings) 15 | settings.MERCHANT_ID = "123456789" 16 | settings.AUTH1 = "test_auth1" 17 | settings.AUTH2 = "test_auth2" 18 | settings.AZUL_CERT = "dummy_cert_path.pem" 19 | settings.AZUL_KEY = "dummy_key_path.key" 20 | settings.ENVIRONMENT = "dev" 21 | 22 | settings.CHANNEL = "EC" 23 | # Add other necessary mock attributes as needed 24 | return settings 25 | 26 | 27 | @pytest.fixture 28 | def mock_api_client(mock_azul_settings) -> AzulAPI: 29 | """Return a mock AzulAPI client instance.""" 30 | client = Mock(spec=AzulAPI) 31 | client.settings = mock_azul_settings 32 | # Mock private methods using spec to avoid protected access warnings 33 | client.configure_mock( 34 | **{ 35 | "post.return_value": AsyncMock(), 36 | } 37 | ) 38 | # Set additional attributes that may not be in the spec 39 | client._get_ssl_context = Mock(return_value=None) 40 | client._generate_auth_headers = Mock( 41 | return_value={("Auth1", "test_auth1"), ("Auth2", "test_auth2")} 42 | ) 43 | client._prepare_payload = Mock(side_effect=lambda data, **kwargs: data) 44 | return client 45 | -------------------------------------------------------------------------------- /tests/unit/core/test_config_unit.py: -------------------------------------------------------------------------------- 1 | """Unit tests for pyazul.core.config.""" 2 | 3 | import os 4 | from unittest.mock import patch 5 | 6 | import pytest 7 | 8 | from pyazul.core.config import AzulSettings 9 | 10 | 11 | @pytest.fixture(scope="session", autouse=True) 12 | def mock_load_dotenv_session_wide(): 13 | """Patch dotenv.load_dotenv session-wide to reduce .env influence. 14 | 15 | Note: This might not prevent an initial load if .env is loaded at module import time 16 | before this patch takes effect for all code paths. Tests should be robust to this. 17 | """ 18 | with patch("pyazul.core.config.load_dotenv", return_value=False) as mock_load: 19 | yield mock_load 20 | 21 | 22 | @pytest.fixture 23 | def azul_settings_test_factory(monkeypatch): 24 | """Provide a factory to create AzulSettings for testing. 25 | 26 | Clears OS environ vars. Caller is responsible for passing initial values, 27 | including None for fields intended to be tested as missing. 28 | """ 29 | 30 | def factory(**initial_values_for_test): 31 | vars_to_manage = list(AzulSettings.model_fields.keys()) 32 | original_os_environ = {} 33 | 34 | for var in vars_to_manage: 35 | if var in os.environ: 36 | original_os_environ[var] = os.environ[var] 37 | monkeypatch.delenv(var, raising=False) 38 | 39 | settings = AzulSettings(**initial_values_for_test) 40 | 41 | # Restore original os.environ values 42 | for var, val in original_os_environ.items(): 43 | monkeypatch.setenv(var, val) 44 | for var in vars_to_manage: 45 | if var not in original_os_environ: 46 | monkeypatch.delenv(var, raising=False) 47 | return settings 48 | 49 | return factory 50 | 51 | 52 | def test_azul_settings_model_field_defaults(azul_settings_test_factory): 53 | """Test AzulSettings field defaults when explicitly set to None or not provided.""" 54 | settings = azul_settings_test_factory( 55 | AUTH1="test_auth1", 56 | AUTH2="test_auth2", 57 | MERCHANT_ID="test_merchant_id", 58 | AZUL_CERT="dummy_cert.pem", 59 | AZUL_KEY="dummy_key.key", 60 | ALT_PROD_URL=None, 61 | ALT_PROD_URL_PAYMENT=None, 62 | AZUL_AUTH_KEY=None, 63 | MERCHANT_NAME=None, 64 | MERCHANT_TYPE=None, 65 | CUSTOM_URL=None, 66 | ) 67 | assert settings.ENVIRONMENT == "dev" 68 | assert settings.CHANNEL == "EC" 69 | assert settings.ALT_PROD_URL is None 70 | assert settings.AZUL_AUTH_KEY is None 71 | assert settings.MERCHANT_NAME is None 72 | assert settings.MERCHANT_TYPE is None 73 | assert settings.CUSTOM_URL is None 74 | 75 | 76 | def test_custom_validator_missing_auth_dev_url(azul_settings_test_factory): 77 | """Test custom validator: missing AUTH1/2, M_ID.""" 78 | match_str = "The following required settings are missing or not set" 79 | with pytest.raises(ValueError, match=match_str) as exc_info: 80 | azul_settings_test_factory( 81 | AZUL_CERT="dummy.pem", 82 | AZUL_KEY="dummy.key", 83 | AUTH1=None, 84 | AUTH2=None, 85 | MERCHANT_ID=None, 86 | ENVIRONMENT="dev", 87 | ) 88 | error_msg = str(exc_info.value) 89 | assert "AUTH1" in error_msg 90 | assert "AUTH2" in error_msg 91 | assert "MERCHANT_ID" in error_msg 92 | 93 | 94 | def test_environment_setting_works(azul_settings_test_factory): 95 | """Test that environment setting works correctly.""" 96 | settings = azul_settings_test_factory( 97 | AUTH1="test_auth1", 98 | AUTH2="test_auth2", 99 | MERCHANT_ID="test_merchant_id", 100 | AZUL_CERT="dummy_cert.pem", 101 | AZUL_KEY="dummy_key.key", 102 | ENVIRONMENT="prod", 103 | ) 104 | assert settings.ENVIRONMENT == "prod" 105 | 106 | 107 | def test_alt_prod_url_setting_works(azul_settings_test_factory): 108 | """Test that ALT_PROD_URL setting works correctly.""" 109 | custom_alt_url = "http://custom.alt.url" 110 | 111 | settings = azul_settings_test_factory( 112 | AUTH1="test_auth1", 113 | AUTH2="test_auth2", 114 | MERCHANT_ID="test_merchant_id", 115 | AZUL_CERT="dummy_cert.pem", 116 | AZUL_KEY="dummy_key.key", 117 | ENVIRONMENT="prod", 118 | ALT_PROD_URL=custom_alt_url, 119 | ) 120 | assert settings.ENVIRONMENT == "prod" 121 | assert settings.ALT_PROD_URL == custom_alt_url 122 | -------------------------------------------------------------------------------- /tests/unit/core/test_exceptions_unit.py: -------------------------------------------------------------------------------- 1 | """Unit tests for pyazul.core.exceptions.""" 2 | 3 | import pytest 4 | 5 | from pyazul.core.exceptions import APIError, AzulError, AzulResponseError, SSLError 6 | 7 | 8 | def test_azul_error_raising(): 9 | """Test that AzulError can be raised.""" 10 | with pytest.raises(AzulError, match="This is a base Azul error."): 11 | raise AzulError("This is a base Azul error.") 12 | 13 | 14 | def test_ssl_error_raising(): 15 | """Test that SSLError can be raised.""" 16 | with pytest.raises(SSLError, match="SSL specific error."): 17 | raise SSLError("SSL specific error.") 18 | 19 | 20 | def test_api_error_raising(): 21 | """Test that APIError can be raised.""" 22 | with pytest.raises(APIError, match="Generic API communication error."): 23 | raise APIError("Generic API communication error.") 24 | 25 | 26 | def test_azul_response_error_raising_and_stores_data(): 27 | """Test AzulResponseError can be raised and stores response_data.""" 28 | response_data = {"ErrorCode": "01", "ErrorMessage": "Declined"} 29 | custom_message = "Azul API error: Declined - Code 01" 30 | with pytest.raises(AzulResponseError, match=custom_message) as exc_info: 31 | raise AzulResponseError(custom_message, response_data=response_data) 32 | 33 | assert exc_info.value.response_data == response_data 34 | assert exc_info.value.message == custom_message 35 | 36 | 37 | def test_exception_chaining(): 38 | """Test that exceptions can be chained using 'from'.""" 39 | original_exception = ValueError("Original issue") 40 | with pytest.raises(APIError) as exc_info: 41 | try: 42 | raise original_exception 43 | except ValueError as e: 44 | raise APIError("API failed due to value error") from e 45 | 46 | assert exc_info.value.__cause__ is original_exception 47 | -------------------------------------------------------------------------------- /tests/unit/models/test_schemas_unit.py: -------------------------------------------------------------------------------- 1 | """Unit tests for pyazul.models schema validation and data structures.""" 2 | 3 | import pytest 4 | from pydantic import ValidationError 5 | 6 | from pyazul.models.three_ds import CardHolderInfo, ChallengeIndicator, ThreeDSAuth 7 | 8 | 9 | class TestCardHolderInfo: 10 | """Tests for the CardHolderInfo model.""" 11 | 12 | def test_card_holder_info_valid(self): 13 | """Test successful creation of CardHolderInfo with valid data.""" 14 | data = { 15 | "Name": "Test User", 16 | "Email": "test@example.com", 17 | "BillingAddressLine1": "123 Main St", 18 | "BillingAddressCity": "Anytown", 19 | "BillingAddressState": "CA", 20 | "BillingAddressCountry": "US", 21 | "BillingAddressZip": "90210", 22 | } 23 | info = CardHolderInfo(**data) 24 | assert info.Name == data["Name"] 25 | assert info.Email == data["Email"] 26 | assert info.BillingAddressZip == data["BillingAddressZip"] 27 | 28 | def test_card_holder_info_name_is_required(self): 29 | """Test CardHolderInfo requires Name field, other fields are optional.""" 30 | # Test that Name is required 31 | with pytest.raises(ValidationError) as exc_info: 32 | CardHolderInfo() # type: ignore 33 | assert any(err["loc"] == ("Name",) for err in exc_info.value.errors()) 34 | 35 | # Test that only Name is required, other fields are optional 36 | info = CardHolderInfo(Name="Test User") 37 | assert info.Name == "Test User" 38 | assert info.Email is None 39 | assert info.BillingAddressLine1 is None 40 | # ... can add more checks for other fields if desired 41 | 42 | def test_card_holder_info_optional_fields_provided(self): 43 | """Test CardHolderInfo with optional fields provided.""" 44 | required_data = { 45 | "Name": "Test User", 46 | "Email": "test@example.com", 47 | "BillingAddressLine1": "123 Main St", 48 | "BillingAddressCity": "Anytown", 49 | "BillingAddressState": "CA", 50 | "BillingAddressCountry": "US", 51 | "BillingAddressZip": "90210", 52 | } 53 | # All optional fields are None by default if not provided 54 | info_minimal = CardHolderInfo(**required_data) 55 | assert info_minimal.BillingAddressLine2 is None 56 | assert info_minimal.PhoneHome is None 57 | 58 | info_with_optionals = CardHolderInfo( 59 | **required_data, PhoneHome="555-1234", ShippingAddressLine1="456 Ship Ave" 60 | ) 61 | assert info_with_optionals.PhoneHome == "555-1234" 62 | assert info_with_optionals.ShippingAddressLine1 == "456 Ship Ave" 63 | 64 | 65 | class TestThreeDSAuth: 66 | """Tests for the ThreeDSAuth model.""" 67 | 68 | def test_three_ds_auth_valid(self): 69 | """Test successful creation of ThreeDSAuth with valid data.""" 70 | data = { 71 | "TermUrl": "https://example.com/term", 72 | "MethodNotificationUrl": "https://example.com/method", 73 | "RequestChallengeIndicator": ChallengeIndicator.NO_PREFERENCE, 74 | } 75 | auth = ThreeDSAuth(**data) 76 | assert auth.TermUrl == data["TermUrl"] 77 | assert auth.MethodNotificationUrl == data["MethodNotificationUrl"] 78 | assert auth.RequestChallengeIndicator == data["RequestChallengeIndicator"] 79 | 80 | def test_three_ds_auth_missing_one_required_field_raises_error(self): 81 | """Test ThreeDSAuth raises ValidationError for a missing required field.""" 82 | with pytest.raises(ValidationError) as exc_info: 83 | ThreeDSAuth( # type: ignore 84 | TermUrl="https://example.com/term", 85 | RequestChallengeIndicator=ChallengeIndicator.CHALLENGE, 86 | ) 87 | assert any( 88 | err["loc"] == ("MethodNotificationUrl",) for err in exc_info.value.errors() 89 | ) 90 | 91 | def test_three_ds_auth_invalid_challenge_indicator(self): 92 | """Test ThreeDSAuth with an invalid RequestChallengeIndicator value.""" 93 | with pytest.raises(ValidationError) as exc_info: 94 | ThreeDSAuth( 95 | TermUrl="https://example.com/term", 96 | MethodNotificationUrl="https://example.com/method", 97 | RequestChallengeIndicator="INVALID_VALUE", # type: ignore 98 | ) 99 | assert any( 100 | err["loc"] == ("RequestChallengeIndicator",) 101 | for err in exc_info.value.errors() 102 | ) 103 | 104 | 105 | # Add more model tests here as needed, e.g., for SecureSaleRequest, etc. 106 | -------------------------------------------------------------------------------- /tests/unit/services/test_transaction_service_unit.py: -------------------------------------------------------------------------------- 1 | """Unit tests for transaction service.""" 2 | 3 | from unittest.mock import MagicMock 4 | 5 | import pytest 6 | 7 | from pyazul.api.client import AzulAPI 8 | from pyazul.core.config import AzulSettings 9 | from pyazul.models.payment import Hold, Post, Refund, Sale, Void 10 | from pyazul.services.transaction import TransactionService 11 | 12 | SAMPLE_SALE_VALUES = { 13 | "Store": "12345", 14 | "OrderNumber": "ORDER001", 15 | "Amount": "1000", 16 | "Itbis": "180", 17 | "CardNumber": "4000000000000001", 18 | "Expiration": "202512", 19 | "CVC": "123", 20 | } 21 | 22 | SAMPLE_HOLD_VALUES = { 23 | "Store": "12345", 24 | "OrderNumber": "ORDER001", 25 | "Amount": "1000", 26 | "Itbis": "180", 27 | "CardNumber": "4000000000000001", 28 | "Expiration": "202512", 29 | "CVC": "123", 30 | } 31 | 32 | SAMPLE_REFUND_VALUES = { 33 | "Store": "12345", 34 | "OrderNumber": "ORDER001", 35 | "AzulOrderId": "AZULREF001", 36 | "Amount": "500", 37 | "Itbis": "90", 38 | } 39 | 40 | SAMPLE_VOID_VALUES = {"Store": "12345", "AzulOrderId": "AZULVOID002"} 41 | 42 | SAMPLE_POST_SALE_VALUES = { 43 | "Store": "12345", 44 | "AzulOrderId": "AZULHOLD001", 45 | "Amount": "1000", 46 | "Itbis": "180", 47 | } 48 | 49 | 50 | @pytest.fixture 51 | def mock_settings() -> AzulSettings: 52 | """Return a mock AzulSettings instance.""" 53 | settings = MagicMock(spec=AzulSettings) 54 | settings.MERCHANT_ID = "123456789" 55 | settings.AUTH1 = "test_auth1" 56 | settings.AUTH2 = "test_auth2" 57 | settings.CHANNEL = "EC" 58 | return settings 59 | 60 | 61 | @pytest.fixture 62 | def transaction_service( 63 | mock_api_client: AzulAPI, mock_settings: AzulSettings 64 | ) -> TransactionService: 65 | """Return a TransactionService instance with a mock API client.""" 66 | return TransactionService(client=mock_api_client, settings=mock_settings) 67 | 68 | 69 | class TestTransactionService: 70 | """Tests for the TransactionService.""" 71 | 72 | @pytest.mark.asyncio 73 | async def test_sale( 74 | self, transaction_service: TransactionService, mock_api_client: MagicMock 75 | ): 76 | """Test the process_sale method of TransactionService.""" 77 | sale_request_model = Sale(**SAMPLE_SALE_VALUES) 78 | expected_response = {"ResponseMessage": "APROBADA", "IsoCode": "00"} 79 | mock_api_client.post.return_value = expected_response 80 | 81 | response = await transaction_service.process_sale(sale_request_model) 82 | 83 | assert response == expected_response 84 | actual_call_args = mock_api_client.post.call_args 85 | assert actual_call_args is not None 86 | expected_payload = sale_request_model.model_dump( 87 | exclude_none=True, exclude_defaults=False, exclude_unset=False 88 | ) 89 | assert actual_call_args[0][1] == expected_payload 90 | 91 | @pytest.mark.asyncio 92 | async def test_refund( 93 | self, transaction_service: TransactionService, mock_api_client: MagicMock 94 | ): 95 | """Test the process_refund method of TransactionService.""" 96 | refund_request_model = Refund(**SAMPLE_REFUND_VALUES) 97 | expected_response = {"ResponseMessage": "APROBADA", "IsoCode": "00"} 98 | mock_api_client.post.return_value = expected_response 99 | 100 | response = await transaction_service.process_refund(refund_request_model) 101 | 102 | assert response == expected_response 103 | actual_call_args = mock_api_client.post.call_args 104 | assert actual_call_args is not None 105 | assert actual_call_args[0][1] == refund_request_model.model_dump( 106 | exclude_none=True 107 | ) 108 | 109 | @pytest.mark.asyncio 110 | async def test_void( 111 | self, transaction_service: TransactionService, mock_api_client: MagicMock 112 | ): 113 | """Test the process_void method of TransactionService.""" 114 | void_request_model = Void(**SAMPLE_VOID_VALUES) 115 | expected_response = {"ResponseMessage": "APROBADA", "IsoCode": "00"} 116 | mock_api_client.post.return_value = expected_response 117 | 118 | response = await transaction_service.process_void(void_request_model) 119 | 120 | assert response == expected_response 121 | actual_call_args = mock_api_client.post.call_args 122 | assert actual_call_args is not None 123 | assert actual_call_args[0][1] == void_request_model.model_dump( 124 | exclude_none=True 125 | ) 126 | assert actual_call_args[0][0] == "/webservices/JSON/default.aspx?ProcessVoid" 127 | 128 | @pytest.mark.asyncio 129 | async def test_hold( 130 | self, transaction_service: TransactionService, mock_api_client: MagicMock 131 | ): 132 | """Test the process_hold method of TransactionService.""" 133 | hold_request_model = Hold(**SAMPLE_HOLD_VALUES) 134 | expected_response = {"ResponseMessage": "APROBADA", "IsoCode": "00"} 135 | mock_api_client.post.return_value = expected_response 136 | 137 | response = await transaction_service.process_hold(hold_request_model) 138 | 139 | assert response == expected_response 140 | actual_call_args = mock_api_client.post.call_args 141 | assert actual_call_args is not None 142 | assert actual_call_args[0][1] == hold_request_model.model_dump( 143 | exclude_none=True 144 | ) 145 | 146 | @pytest.mark.asyncio 147 | async def test_post_sale( 148 | self, transaction_service: TransactionService, mock_api_client: MagicMock 149 | ): 150 | """Test the process_post method of TransactionService.""" 151 | post_request_model = Post(**SAMPLE_POST_SALE_VALUES) 152 | expected_response = {"ResponseMessage": "APROBADA", "IsoCode": "00"} 153 | mock_api_client.post.return_value = expected_response 154 | 155 | response = await transaction_service.process_post(post_request_model) 156 | 157 | assert response == expected_response 158 | actual_call_args = mock_api_client.post.call_args 159 | assert actual_call_args is not None 160 | assert actual_call_args[0][1] == post_request_model.model_dump( 161 | exclude_none=True 162 | ) 163 | assert actual_call_args[0][0] == "/webservices/JSON/default.aspx?ProcessPost" 164 | --------------------------------------------------------------------------------