diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..61c4615 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,57 @@ +# Contributing + +Contributions are **welcome** and will be fully **credited**. + +Please read and understand the contribution guide before creating an issue or pull request. + +## Etiquette + +This project is open source, and as such, the maintainers give their free time to build and maintain the source code +held within. They make the code freely available in the hope that it will be of use to other developers. It would be +extremely unfair for them to suffer abuse or anger for their hard work. + +Please be considerate towards maintainers when raising issues or presenting pull requests. Let's show the +world that developers are civilized and selfless people. + +It's the duty of the maintainer to ensure that all submissions to the project are of sufficient +quality to benefit the project. Many developers have different skillsets, strengths, and weaknesses. Respect the maintainer's decision, and do not be upset or abusive if your submission is not used. + +## Viability + +When requesting or submitting new features, first consider whether it might be useful to others. Open +source projects are used by many developers, who may have entirely different needs to your own. Think about +whether or not your feature is likely to be used by other users of the project. + +## Procedure + +Before filing an issue: + +- Attempt to replicate the problem, to ensure that it wasn't a coincidental incident. +- Check to make sure your feature suggestion isn't already present within the project. +- Check the pull requests tab to ensure that the bug doesn't have a fix in progress. +- Check the pull requests tab to ensure that the feature isn't already in progress. + +Before submitting a pull request: + +- Check the codebase to ensure that your feature doesn't already exist. +- Check the pull requests to ensure that another person hasn't already submitted the feature or fix. + +## Requirements + +If the project maintainer has any additional requirements, you will find them listed here. + +- **[PSR-12 Coding Standard](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-12-extended-coding-style-guide.md)** - The easiest way to apply the conventions is to install [PHP Code Sniffer](http://pear.php.net/package/PHP_CodeSniffer). + +- **PHPStan level 10** - Your code should pass [PHPStan](https://github.com/phpstan/phpstan) level 10. + +- **Add tests!** - Your patch won't be accepted if it doesn't have tests. + +- **Document any change in behaviour** - Make sure the `README.md` and any other relevant documentation are kept up-to-date. + +- **Consider our release cycle** - We try to follow [SemVer v2.0.0](http://semver.org/). Randomly breaking public APIs is not an option. + +- **One pull request per feature** - If you want to do more than one thing, send multiple pull requests. + +- **Send coherent history** - Make sure each individual commit in your pull request is meaningful. If you had to make multiple intermediate commits while developing, please [squash them](http://www.git-scm.com/book/en/v2/Git-Tools-Rewriting-History#Changing-Multiple-Commit-Messages) before submitting. + +**Happy coding**! diff --git a/README.md b/README.md index f4c388c..bb5f9ed 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,11 @@ Implementation of the [Ponto API](https://documentation.myponto.com/1/api/curl) in PHP +## /!\ This package is still in development. /!\ + +You can find the development documentation [here](docs/developpement.md). + + ## Installation You can install the package via composer: @@ -23,17 +28,27 @@ echo $skeleton->echoPhrase('Hello, AlchemicStudio!'); ## Testing +The project uses Pest for testing. You can run the tests with: + ```bash composer test ``` +## PHPStan + +The project uses PHPStan for static analysis. You can run the tests with: + +```bash +composer stan +``` + ## Changelog Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently. ## Contributing -Please see [CONTRIBUTING](https://github.com/spatie/.github/blob/main/CONTRIBUTING.md) for details. +Please see [CONTRIBUTING](CONTRIBUTING.md) for details. ## Security Vulnerabilities diff --git a/composer.json b/composer.json index 913a6f5..d89643c 100644 --- a/composer.json +++ b/composer.json @@ -18,8 +18,9 @@ "php": "^8.4" }, "require-dev": { - "pestphp/pest": "^4.0", "friendsofphp/php-cs-fixer": "^3.21.1", + "pestphp/pest": "^4.1", + "phpstan/phpstan": "^2.1", "spatie/ray": "^1.28" }, "autoload": { @@ -34,8 +35,9 @@ }, "scripts": { "test": "vendor/bin/pest", - "test-coverage": "vendor/bin/pest --coverage", - "format": "vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php --allow-risky=yes" + "test-coverage": "vendor/bin/pest --coverage", + "format": "vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php --allow-risky=yes", + "stan": "vendor/bin/phpstan analyse src tests --level=max" }, "config": { "sort-packages": true, diff --git a/docs/developpement.md b/docs/developpement.md new file mode 100644 index 0000000..279a257 --- /dev/null +++ b/docs/developpement.md @@ -0,0 +1,3155 @@ +# Ponto PHP Library - Development Documentation + +## Overview and Goals + +The **ponto-php** library is a modern PHP SDK for integrating with the Ponto API, which provides: +- **Account Information Services (AIS)**: Retrieve account details, balances, and transaction history +- **Payment Initiation Services (PIS)**: Create and manage single payments, bulk payments, and payment requests + +### Key Features +- Full OAuth2 client credentials authentication with automatic token management +- Type-safe models and responses (PHP 8.4+) +- PSR-compliant architecture (PSR-4 autoloading, PSR-12 code style) +- Comprehensive error handling with custom exceptions +- Built-in retry logic with exponential backoff +- Support for pagination, filtering, and synchronization +- Idempotency support for payment operations +- Sandbox and production environment support + +### Design Principles +- **Developer Experience**: Intuitive API with fluent interfaces +- **Type Safety**: Full PHP 8.4 type declarations +- **Standards Compliance**: PSR-4, PSR-12, and REST best practices +- **Security First**: No credential logging, secure token storage +- **Testability**: Dependency injection and mockable interfaces + +--- + +## Prerequisites + +### System Requirements +- **PHP**: 8.4 or higher +- **Extensions**: + - `ext-json` (JSON encoding/decoding) + - `ext-mbstring` (String manipulation) + - `ext-curl` or `ext-openssl` (HTTPS requests) + +### Dependencies +- **Guzzle HTTP Client**: ^7.8 (HTTP requests with retry support) +- **PSR-7/PSR-18**: HTTP message interfaces + +### Development Tools +- **Composer**: 2.x for dependency management +- **Pest**: 4.1 for testing +- **PHPStan**: 2.1 for static analysis (level max) +- **PHP CS Fixer**: 3.21 for code style + +--- + +## Installation + +### Via Composer + +```bash +composer require alchemic-studio/ponto-php +``` + +### Manual Installation + +Add to your `composer.json`: + +```json +{ + "require": { + "php": "^8.4", + "alchemic-studio/ponto-php": "^1.0", + "guzzlehttp/guzzle": "^7.8" + }, + "require-dev": { + "pestphp/pest": "^4.1", + "phpstan/phpstan": "^2.1", + "friendsofphp/php-cs-fixer": "^3.21" + }, + "autoload": { + "psr-4": { + "AlchemicStudio\\Ponto\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "AlchemicStudio\\Ponto\\Tests\\": "tests/" + } + }, + "scripts": { + "test": "pest", + "test-coverage": "pest --coverage", + "stan": "phpstan analyse src tests --level=max", + "format": "php-cs-fixer fix" + }, + "config": { + "sort-packages": true, + "allow-plugins": { + "pestphp/pest-plugin": true + } + }, + "minimum-stability": "dev", + "prefer-stable": true +} +``` + +Then run: + +```bash +composer install +``` + +--- + +## Project Layout + +### Recommended Directory Structure + +``` +your-project/ +├── src/ +│ ├── Client.php # Main client entry point +│ ├── Config.php # Configuration container +│ ├── Auth/ +│ │ ├── AuthProvider.php # OAuth2 token management +│ │ ├── TokenStorage.php # Token storage interface +│ │ └── FileTokenStorage.php # File-based token cache +│ ├── Http/ +│ │ ├── HttpClient.php # HTTP client wrapper +│ │ ├── HttpClientInterface.php # Client contract +│ │ └── Response.php # HTTP response wrapper +│ ├── Services/ +│ │ ├── AccountService.php # Account operations +│ │ ├── TransactionService.php # Transaction operations +│ │ ├── PaymentService.php # Payment operations +│ │ └── SynchronizationService.php # Sync operations +│ ├── Models/ +│ │ ├── Account.php # Account resource +│ │ ├── Transaction.php # Transaction resource +│ │ ├── Payment.php # Payment resource +│ │ ├── Synchronization.php # Synchronization resource +│ │ ├── FinancialInstitution.php # Financial institution +│ │ └── PaginatedCollection.php # Paginated results +│ ├── Exceptions/ +│ │ ├── PontoException.php # Base exception +│ │ ├── AuthenticationException.php # Auth failures +│ │ ├── ValidationException.php # Validation errors +│ │ ├── NotFoundException.php # 404 errors +│ │ ├── RateLimitException.php # Rate limit errors +│ │ └── ApiException.php # API errors +│ └── Utils/ +│ ├── Validator.php # Input validation +│ └── DateHelper.php # Date formatting +├── tests/ +│ ├── Unit/ # Unit tests +│ ├── Integration/ # Integration tests +│ └── Pest.php # Pest configuration +├── examples/ +│ ├── authenticate.php +│ ├── list-accounts.php +│ ├── list-transactions.php +│ ├── create-payment.php +│ └── handle-synchronization.php +├── docs/ + └── developpement.md # This file +``` + +--- +## Module Breakdown + +### 1. Authentication Module (`Auth/`) +Handles OAuth2 client credentials flow, token lifecycle management, and secure storage. + +**Components:** +- `AuthProvider`: Manages token acquisition and refresh +- `TokenStorage`: Interface for token persistence +- `FileTokenStorage`: File-based token cache implementation + +**Responsibilities:** +- Obtain access tokens via OAuth2 +- Cache valid tokens to minimize API calls +- Automatically refresh expired tokens +- Secure token storage + +### 2. HTTP Client Module (`Http/`) +Abstracts HTTP communication with retry logic, error handling, and rate limiting. + +**Components:** +- `HttpClient`: Guzzle-based HTTP client wrapper +- `HttpClientInterface`: Contract for HTTP operations +- `Response`: Normalized response object + +**Responsibilities:** +- Execute HTTP requests (GET, POST, DELETE) +- Handle authentication headers +- Implement retry with exponential backoff +- Parse JSON:API responses +- Rate limit handling + +### 3. Services Module (`Services/`) +High-level business logic for interacting with Ponto API resources. + +**Components:** +- `AccountService`: Account listing and retrieval +- `TransactionService`: Transaction querying with filters +- `PaymentService`: Payment creation and management +- `SynchronizationService`: Data synchronization orchestration + +**Responsibilities:** +- Resource-specific operations +- Parameter validation +- Result mapping to models +- Pagination handling + +### 4. Models Module (`Models/`) +Immutable value objects representing API resources. + +**Components:** +- `Account`: Bank account with balances +- `Transaction`: Financial transaction +- `Payment`: Payment instruction +- `Synchronization`: Sync job status +- `FinancialInstitution`: Bank details +- `PaginatedCollection`: Collection with pagination metadata + +**Responsibilities:** +- Type-safe data containers +- JSON deserialization +- Business logic methods (e.g., `isSuccessful()`, `isPending()`) + +### 5. Exceptions Module (`Exceptions/`) +Hierarchy of exceptions for comprehensive error handling. + +**Components:** +- `PontoException`: Base exception +- `AuthenticationException`: OAuth/token errors +- `ValidationException`: Input validation failures +- `NotFoundException`: Resource not found (404) +- `RateLimitException`: Rate limit exceeded (429) +- `ApiException`: General API errors (4xx/5xx) + +### 6. Utilities Module (`Utils/`) +Helper classes for common operations. + +**Components:** +- `Validator`: Input validation (IBAN, BIC, amounts) +- `DateHelper`: Date formatting for API requests + +--- + +## Core Classes - Detailed Specifications + +### 1. Client Class + +**Namespace:** `AlchemicStudio\Ponto` +**Purpose:** Main entry point for the Ponto SDK + +#### Properties +```php +private Config $config; +private AuthProvider $authProvider; +private HttpClient $httpClient; +private ?AccountService $accountService = null; +private ?TransactionService $transactionService = null; +private ?PaymentService $paymentService = null; +private ?SynchronizationService $synchronizationService = null; +``` + +#### Constructor +```php +public function __construct( + string $clientId, + string $clientSecret, + string $baseUrl = 'https://api.myponto.com', + ?TokenStorage $tokenStorage = null, + array $httpOptions = [] +) +``` + +**Parameters:** +- `$clientId`: OAuth2 client ID from Ponto dashboard +- `$clientSecret`: OAuth2 client secret +- `$baseUrl`: API base URL (production or sandbox) +- `$tokenStorage`: Optional custom token storage (defaults to FileTokenStorage) +- `$httpOptions`: Guzzle HTTP client options + +#### Public Methods + +```php +/** + * Get account service for account operations + * + * @return AccountService + */ +public function accounts(): AccountService + +/** + * Get transaction service for transaction operations + * + * @return TransactionService + */ +public function transactions(): TransactionService + +/** + * Get payment service for payment operations + * + * @return PaymentService + * @throws AuthenticationException if 'pi' scope not granted + */ +public function payments(): PaymentService + +/** + * Get synchronization service + * + * @return SynchronizationService + */ +public function synchronizations(): SynchronizationService + +/** + * Force token refresh + * + * @return void + * @throws AuthenticationException on auth failure + */ +public function refreshToken(): void + +/** + * Check if payment initiation is enabled + * + * @return bool + */ +public function hasPaymentScope(): bool +``` + +**Thrown Exceptions:** +- `AuthenticationException`: Authentication failures +- `ApiException`: API communication errors + +--- + +### 2. AccountService Class + +**Namespace:** `AlchemicStudio\Ponto\Services` +**Purpose:** Manage account operations + +#### Constructor +```php +public function __construct( + private HttpClient $httpClient +) +``` + +#### Public Methods + +```php +/** + * List all accounts + * + * @param int $limit Number of results per page (1-100) + * @param string|null $after Cursor for next page + * @param string|null $before Cursor for previous page + * @return PaginatedCollection + * @throws ValidationException if limit out of range + * @throws ApiException on API errors + */ +public function list( + int $limit = 20, + ?string $after = null, + ?string $before = null +): PaginatedCollection + +/** + * Get single account by ID + * + * @param string $accountId UUID of the account + * @return Account + * @throws NotFoundException if account not found + * @throws ApiException on API errors + */ +public function get(string $accountId): Account + +/** + * Get synchronization metadata for an account + * + * @param string $accountId + * @return array{synchronizedAt: string, latestSynchronization: Synchronization} + * @throws NotFoundException if account not found + */ +public function getSyncMetadata(string $accountId): array +``` + +**Thrown Exceptions:** +- `ValidationException`: Invalid parameters +- `NotFoundException`: Account not found +- `ApiException`: API errors + +--- + +### 3. TransactionService Class + +**Namespace:** `AlchemicStudio\Ponto\Services` +**Purpose:** Query transactions with filtering and pagination + +#### Constructor +```php +public function __construct( + private HttpClient $httpClient +) +``` + +#### Public Methods + +```php +/** + * List transactions for an account + * + * @param string $accountId Account UUID + * @param array{ + * limit?: int, + * after?: string, + * before?: string, + * since?: string, + * until?: string + * } $filters Optional filters + * @return PaginatedCollection + * @throws NotFoundException if account not found + * @throws ValidationException if filters invalid + * @throws ApiException on API errors + */ +public function list(string $accountId, array $filters = []): PaginatedCollection + +/** + * Get single transaction + * + * @param string $accountId Account UUID + * @param string $transactionId Transaction UUID + * @return Transaction + * @throws NotFoundException if not found + * @throws ApiException on API errors + */ +public function get(string $accountId, string $transactionId): Transaction + +/** + * List pending transactions + * + * @param string $accountId Account UUID + * @param int $limit Results per page (1-100) + * @param string|null $after Cursor for next page + * @param string|null $before Cursor for previous page + * @return PaginatedCollection + * @throws NotFoundException if account not found + * @throws ApiException on API errors + */ +public function listPending( + string $accountId, + int $limit = 20, + ?string $after = null, + ?string $before = null +): PaginatedCollection + +/** + * Get updated transactions from a synchronization + * + * @param string $synchronizationId Synchronization UUID + * @return array + * @throws NotFoundException if sync not found + * @throws ApiException on API errors + */ +public function getUpdatedFromSync(string $synchronizationId): array +``` + +**Thrown Exceptions:** +- `ValidationException`: Invalid filters +- `NotFoundException`: Resource not found +- `ApiException`: API errors + +--- + +### 4. PaymentService Class + +**Namespace:** `AlchemicStudio\Ponto\Services` +**Purpose:** Create and manage payments + +#### Constructor +```php +public function __construct( + private HttpClient $httpClient +) +``` + +#### Public Methods + +```php +/** + * Create a payment + * + * @param string $accountId Debtor account UUID + * @param array{ + * amount: float, + * currency: string, + * creditorName: string, + * creditorAccountReference: string, + * creditorAccountReferenceType: string, + * creditorAgent: string, + * creditorAgentType: string, + * remittanceInformation: string, + * remittanceInformationType?: string, + * endToEndId?: string, + * requestedExecutionDate?: string, + * redirectUri?: string + * } $paymentData Payment details + * @return Payment + * @throws ValidationException if data invalid + * @throws AuthenticationException if 'pi' scope missing + * @throws ApiException on API errors + */ +public function create(string $accountId, array $paymentData): Payment + +/** + * Get payment details + * + * @param string $accountId Account UUID + * @param string $paymentId Payment UUID + * @return Payment + * @throws NotFoundException if not found + * @throws ApiException on API errors + */ +public function get(string $accountId, string $paymentId): Payment + +/** + * Delete (cancel) a payment + * + * @param string $accountId Account UUID + * @param string $paymentId Payment UUID + * @return void + * @throws NotFoundException if not found + * @throws ApiException on API errors + */ +public function delete(string $accountId, string $paymentId): void +``` + +**Thrown Exceptions:** +- `ValidationException`: Invalid payment data +- `AuthenticationException`: Missing payment scope +- `NotFoundException`: Resource not found +- `ApiException`: API errors + +--- + +### 5. SynchronizationService Class + +**Namespace:** `AlchemicStudio\Ponto\Services` +**Purpose:** Manage data synchronization + +#### Constructor +```php +public function __construct( + private HttpClient $httpClient +) +``` + +#### Public Methods + +```php +/** + * Create synchronization job + * + * @param string $resourceType 'account' or 'transaction' + * @param string $resourceId UUID of resource to sync + * @param string $subtype 'accountDetails', 'accountTransactions', etc. + * @param string|null $customerIpAddress Client IP for audit + * @return Synchronization + * @throws ValidationException if invalid parameters + * @throws ApiException on API errors (e.g., sync too soon) + */ +public function create( + string $resourceType, + string $resourceId, + string $subtype, + ?string $customerIpAddress = null +): Synchronization + +/** + * Get synchronization status + * + * @param string $synchronizationId Sync UUID + * @return Synchronization + * @throws NotFoundException if not found + * @throws ApiException on API errors + */ +public function get(string $synchronizationId): Synchronization + +/** + * Poll synchronization until complete or timeout + * + * @param string $synchronizationId Sync UUID + * @param int $maxAttempts Maximum polling attempts + * @param int $intervalSeconds Seconds between polls + * @return Synchronization + * @throws ApiException if timeout or sync fails + */ +public function pollUntilComplete( + string $synchronizationId, + int $maxAttempts = 20, + int $intervalSeconds = 3 +): Synchronization +``` + +**Thrown Exceptions:** +- `ValidationException`: Invalid parameters +- `NotFoundException`: Sync not found +- `ApiException`: API errors or sync failures + +--- +### 6. Model Classes + +#### Account Model + +**Namespace:** `AlchemicStudio\Ponto\Models` + +**Properties:** +```php +public readonly string $id; +public readonly string $type; // 'account' +public readonly string $reference; // IBAN +public readonly string $referenceType; // 'IBAN' +public readonly string $currency; // ISO 4217 +public readonly string $subtype; // 'checking', 'savings', etc. +public readonly float $availableBalance; +public readonly float $currentBalance; +public readonly string $holderName; +public readonly string $product; +public readonly ?string $description; +public readonly bool $deprecated; +public readonly \DateTimeImmutable $availableBalanceChangedAt; +public readonly \DateTimeImmutable $currentBalanceChangedAt; +public readonly \DateTimeImmutable $authorizedAt; +public readonly ?\DateTimeImmutable $authorizationExpirationExpectedAt; +public readonly string $internalReference; +public readonly array $meta; // Sync metadata +public readonly array $relationships; // Related resources +``` + +**Methods:** +```php +public static function fromArray(array $data): self +public function isDeprecated(): bool +public function needsReauthorization(): bool +public function toArray(): array +``` + +--- + +#### Transaction Model + +**Namespace:** `AlchemicStudio\Ponto\Models` + +**Properties:** +```php +public readonly string $id; +public readonly string $type; // 'transaction' +public readonly string $accountId; // Parent account +public readonly float $amount; +public readonly string $currency; +public readonly string $description; +public readonly string $digest; +public readonly ?\DateTimeImmutable $executionDate; +public readonly ?\DateTimeImmutable $valueDate; +public readonly \DateTimeImmutable $createdAt; +public readonly \DateTimeImmutable $updatedAt; +public readonly ?string $counterpartName; +public readonly ?string $counterpartReference; +public readonly ?string $remittanceInformation; +public readonly ?string $remittanceInformationType; +public readonly ?string $bankTransactionCode; +public readonly ?string $endToEndId; +public readonly ?string $mandateId; +public readonly ?string $creditorId; +public readonly ?string $purposeCode; +public readonly ?float $fee; +public readonly ?string $additionalInformation; +public readonly string $internalReference; +``` + +**Methods:** +```php +public static function fromArray(array $data): self +public function isCredit(): bool // amount > 0 +public function isDebit(): bool // amount < 0 +public function getAbsoluteAmount(): float +public function toArray(): array +``` + +--- + +#### Payment Model + +**Namespace:** `AlchemicStudio\Ponto\Models` + +**Properties:** +```php +public readonly string $id; +public readonly string $type; // 'payment' +public readonly string $status; // 'unsigned', 'authorized', 'executed', 'cancelled', 'rejected' +public readonly float $amount; +public readonly string $currency; +public readonly string $creditorName; +public readonly string $creditorAccountReference; +public readonly string $creditorAccountReferenceType; +public readonly string $creditorAgent; +public readonly string $creditorAgentType; +public readonly string $remittanceInformation; +public readonly string $remittanceInformationType; +public readonly ?string $endToEndId; +public readonly ?\DateTimeImmutable $requestedExecutionDate; +public readonly ?string $redirectUrl; // Authorization URL +``` + +**Methods:** +```php +public static function fromArray(array $data): self +public function isUnsigned(): bool +public function isAuthorized(): bool +public function isExecuted(): bool +public function isCancelled(): bool +public function isRejected(): bool +public function requiresAuthorization(): bool +public function toArray(): array +``` + +--- + +#### Synchronization Model + +**Namespace:** `AlchemicStudio\Ponto\Models` + +**Properties:** +```php +public readonly string $id; +public readonly string $type; // 'synchronization' +public readonly string $status; // 'pending', 'running', 'success', 'error' +public readonly string $resourceType; // 'account' +public readonly string $resourceId; +public readonly string $subtype; // 'accountDetails', 'accountTransactions' +public readonly array $errors; +public readonly \DateTimeImmutable $createdAt; +public readonly \DateTimeImmutable $updatedAt; +``` + +**Methods:** +```php +public static function fromArray(array $data): self +public function isPending(): bool +public function isRunning(): bool +public function isSuccessful(): bool +public function hasErrors(): bool +public function isComplete(): bool // success or error +public function toArray(): array +``` + +--- + +#### PaginatedCollection Model + +**Namespace:** `AlchemicStudio\Ponto\Models` + +**Type Parameters:** `@template T` + +**Properties:** +```php +/** @var array */ +public readonly array $data; +public readonly int $limit; +public readonly ?string $beforeCursor; +public readonly ?string $afterCursor; +public readonly ?string $firstUrl; +public readonly ?string $prevUrl; +public readonly ?string $nextUrl; +``` + +**Methods:** +```php +/** + * @param array $data + */ +public static function fromArray(array $data, array $meta, array $links): self +public function hasNextPage(): bool +public function hasPrevPage(): bool +public function isEmpty(): bool +public function count(): int +public function toArray(): array +``` + +--- + +### 7. AuthProvider Class + +**Namespace:** `AlchemicStudio\Ponto\Auth` +**Purpose:** OAuth2 token management + +#### Constructor +```php +public function __construct( + private string $clientId, + private string $clientSecret, + private string $tokenUrl, + private TokenStorage $tokenStorage, + private HttpClient $httpClient +) +``` + +#### Public Methods + +```php +/** + * Get valid access token (from cache or new) + * + * @return string Access token + * @throws AuthenticationException on auth failure + */ +public function getAccessToken(): string + +/** + * Force token refresh + * + * @return string New access token + * @throws AuthenticationException on auth failure + */ +public function refreshToken(): string + +/** + * Get token scopes + * + * @return array Scopes (e.g., ['ai', 'pi']) + */ +public function getScopes(): array + +/** + * Check if scope is granted + * + * @param string $scope Scope to check (e.g., 'pi') + * @return bool + */ +public function hasScope(string $scope): bool + +/** + * Get token expiration time + * + * @return \DateTimeImmutable|null + */ +public function getExpiresAt(): ?\DateTimeImmutable +``` + +**Thrown Exceptions:** +- `AuthenticationException`: OAuth failures, invalid credentials + +--- + +### 8. HttpClient Class + +**Namespace:** `AlchemicStudio\Ponto\Http` +**Purpose:** HTTP communication with retry logic + +#### Constructor +```php +public function __construct( + private \GuzzleHttp\Client $guzzle, + private AuthProvider $authProvider, + private int $maxRetries = 3, + private int $retryDelayMs = 1000 +) +``` + +#### Public Methods + +```php +/** + * Execute GET request + * + * @param string $path API path (e.g., '/accounts') + * @param array $query Query parameters + * @return array JSON-decoded response + * @throws ApiException on API errors + * @throws RateLimitException on 429 + */ +public function get(string $path, array $query = []): array + +/** + * Execute POST request + * + * @param string $path API path + * @param array $body Request body + * @param array $headers Additional headers + * @return array JSON-decoded response + * @throws ApiException on API errors + */ +public function post(string $path, array $body = [], array $headers = []): array + +/** + * Execute DELETE request + * + * @param string $path API path + * @return void + * @throws ApiException on API errors + */ +public function delete(string $path): void + +/** + * Get last response metadata + * + * @return array{statusCode: int, headers: array} + */ +public function getLastResponseMeta(): array +``` + +**Retry Logic:** +- Retries on network errors and 5xx responses +- Exponential backoff: 1s, 2s, 4s +- Does NOT retry 4xx errors (except 429 with Retry-After) +- Respects Retry-After header for 429 responses + +**Thrown Exceptions:** +- `AuthenticationException`: 401 responses +- `ValidationException`: 400 responses +- `NotFoundException`: 404 responses +- `RateLimitException`: 429 responses +- `ApiException`: Other 4xx/5xx responses + +--- + +### 9. Exception Hierarchy + +**Base Exception:** +```php +namespace AlchemicStudio\Ponto\Exceptions; + +class PontoException extends \Exception +{ + public function __construct( + string $message = '', + int $code = 0, + private ?array $errorDetails = null, + ?\Throwable $previous = null + ) { + parent::__construct($message, $code, $previous); + } + + public function getErrorDetails(): ?array + { + return $this->errorDetails; + } +} +``` + +**Specific Exceptions:** + +```php +// Authentication failures (401, invalid tokens) +class AuthenticationException extends PontoException {} + +// Validation errors (400, invalid input) +class ValidationException extends PontoException {} + +// Resource not found (404) +class NotFoundException extends PontoException {} + +// Rate limit exceeded (429) +class RateLimitException extends PontoException +{ + public function __construct( + string $message, + private ?int $retryAfterSeconds = null, + ?array $errorDetails = null + ) { + parent::__construct($message, 429, $errorDetails); + } + + public function getRetryAfterSeconds(): ?int + { + return $this->retryAfterSeconds; + } +} + +// General API errors (4xx/5xx) +class ApiException extends PontoException +{ + public function __construct( + string $message, + int $statusCode, + private ?string $errorCode = null, + ?array $errorDetails = null + ) { + parent::__construct($message, $statusCode, $errorDetails); + } + + public function getErrorCode(): ?string + { + return $this->errorCode; + } +} +``` + +--- +## Complete Usage Examples + +### 1. Authentication + +**Basic Client Initialization:** + +```php +accounts()->list(); + echo "Authenticated successfully!\n"; +} catch (AuthenticationException $e) { + echo "Authentication failed: " . $e->getMessage() . "\n"; + exit(1); +} +``` + +**Custom Token Storage:** + +```php +redis->get("ponto:token:{$key}"); + return $value !== false ? $value : null; + } + + public function set(string $key, string $value, int $ttl): void + { + $this->redis->setex("ponto:token:{$key}", $ttl, $value); + } + + public function delete(string $key): void + { + $this->redis->del("ponto:token:{$key}"); + } +} + +// Use custom storage +$redis = new \Redis(); +$redis->connect('127.0.0.1', 6379); + +$client = new Client( + clientId: $_ENV['PONTO_CLIENT_ID'], + clientSecret: $_ENV['PONTO_CLIENT_SECRET'], + tokenStorage: new RedisTokenStorage($redis) +); +``` + +--- + +### 2. Fetching Accounts + +**List All Accounts:** + +```php +accounts()->list(limit: 20); + +foreach ($accountsPage->data as $account) { + echo sprintf( + "Account: %s (%s)\n", + $account->reference, + $account->holderName + ); + echo sprintf( + " Available Balance: %.2f %s\n", + $account->availableBalance, + $account->currency + ); + echo sprintf( + " Current Balance: %.2f %s\n", + $account->currentBalance, + $account->currency + ); + echo "\n"; +} + +// Pagination +if ($accountsPage->hasNextPage()) { + $nextPage = $client->accounts()->list( + limit: 20, + after: $accountsPage->afterCursor + ); + echo "Loaded next page with " . count($nextPage->data) . " accounts\n"; +} +``` + +**Get Single Account:** + +```php +accounts()->get($accountId); + + echo "Account Details:\n"; + echo " IBAN: {$account->reference}\n"; + echo " Holder: {$account->holderName}\n"; + echo " Product: {$account->product}\n"; + echo " Available: {$account->availableBalance} {$account->currency}\n"; + + if ($account->needsReauthorization()) { + echo " ⚠️ Authorization expires soon!\n"; + } +} catch (NotFoundException $e) { + echo "Account not found\n"; +} +``` + +--- + +### 3. Fetching Transactions + +**List Transactions with Filters:** + +```php +format('Y-m-d'); +$until = (new \DateTimeImmutable())->format('Y-m-d'); + +$transactions = $client->transactions()->list( + accountId: $accountId, + filters: [ + 'limit' => 50, + 'since' => $since, + 'until' => $until, + ] +); + +echo "Found {$transactions->count()} transactions:\n\n"; + +foreach ($transactions->data as $tx) { + $type = $tx->isCredit() ? '💰 Credit' : '💸 Debit'; + echo sprintf( + "%s | %s | %.2f %s | %s\n", + $tx->executionDate?->format('Y-m-d') ?? 'N/A', + $type, + $tx->getAbsoluteAmount(), + $tx->currency, + $tx->description + ); + + if ($tx->counterpartName) { + echo " Counterpart: {$tx->counterpartName} ({$tx->counterpartReference})\n"; + } + if ($tx->remittanceInformation) { + echo " Reference: {$tx->remittanceInformation}\n"; + } + echo "\n"; +} + +// Pagination through all transactions +$allTransactions = []; +$currentPage = $transactions; + +do { + $allTransactions = array_merge($allTransactions, $currentPage->data); + + if ($currentPage->hasNextPage()) { + $currentPage = $client->transactions()->list( + accountId: $accountId, + filters: [ + 'limit' => 50, + 'after' => $currentPage->afterCursor, + 'since' => $since, + 'until' => $until, + ] + ); + } else { + break; + } +} while (true); + +echo "Total transactions loaded: " . count($allTransactions) . "\n"; +``` + +**Get Single Transaction:** + +```php +transactions()->get($accountId, $transactionId); + + echo "Transaction Details:\n"; + echo " ID: {$tx->id}\n"; + echo " Amount: {$tx->amount} {$tx->currency}\n"; + echo " Description: {$tx->description}\n"; + echo " Execution Date: " . $tx->executionDate?->format('Y-m-d H:i:s') . "\n"; + echo " Value Date: " . $tx->valueDate?->format('Y-m-d H:i:s') . "\n"; + + if ($tx->endToEndId) { + echo " End-to-End ID: {$tx->endToEndId}\n"; + } +} catch (NotFoundException $e) { + echo "Transaction not found\n"; +} +``` + +--- + +### 4. Creating Payments + +**Create Simple Payment:** + +```php +hasPaymentScope()) { + echo "Payment initiation not enabled for this integration\n"; + exit(1); +} + +$accountId = '2ca42e3a-357b-4dc4-a22f-197d049cd2fa'; + +try { + $payment = $client->payments()->create( + accountId: $accountId, + paymentData: [ + 'amount' => 59.00, + 'currency' => 'EUR', + 'creditorName' => 'Alex Creditor', + 'creditorAccountReference' => 'BE55732022998044', + 'creditorAccountReferenceType' => 'IBAN', + 'creditorAgent' => 'NBBEBEBB203', + 'creditorAgentType' => 'BIC', + 'remittanceInformation' => 'Invoice 2024-001', + 'remittanceInformationType' => 'unstructured', + 'endToEndId' => uniqid('payment_'), // Idempotency key + 'redirectUri' => 'https://your-app.com/payment-callback', + ] + ); + + echo "Payment created successfully!\n"; + echo " Payment ID: {$payment->id}\n"; + echo " Status: {$payment->status}\n"; + echo " Amount: {$payment->amount} {$payment->currency}\n"; + + if ($payment->requiresAuthorization()) { + echo "\n⚠️ Payment requires authorization\n"; + echo "Redirect user to: {$payment->redirectUrl}\n"; + } + +} catch (ValidationException $e) { + echo "Invalid payment data: " . $e->getMessage() . "\n"; + print_r($e->getErrorDetails()); +} catch (AuthenticationException $e) { + echo "Authentication error: " . $e->getMessage() . "\n"; +} +``` + +**Create Future-Dated Payment:** + +```php +format('Y-m-d'); + +$payment = $client->payments()->create( + accountId: $accountId, + paymentData: [ + 'amount' => 150.00, + 'currency' => 'EUR', + 'creditorName' => 'Supplier Ltd', + 'creditorAccountReference' => 'BE68539007547034', + 'creditorAccountReferenceType' => 'IBAN', + 'creditorAgent' => 'NBBEBEBB203', + 'creditorAgentType' => 'BIC', + 'remittanceInformation' => 'Monthly subscription', + 'requestedExecutionDate' => $futureDate, // Future-dated + ] +); + +echo "Future payment scheduled for {$futureDate}\n"; +``` + +**Check Payment Status:** + +```php +payments()->get($accountId, $paymentId); + +echo "Payment Status: {$payment->status}\n"; + +if ($payment->isExecuted()) { + echo "✅ Payment has been executed\n"; +} elseif ($payment->isAuthorized()) { + echo "⏳ Payment authorized, awaiting execution\n"; +} elseif ($payment->isUnsigned()) { + echo "⚠️ Payment awaiting authorization\n"; +} elseif ($payment->isRejected()) { + echo "❌ Payment rejected\n"; +} elseif ($payment->isCancelled()) { + echo "🚫 Payment cancelled\n"; +} +``` + +--- + +### 5. Synchronization + +**Sync Account Transactions:** + +```php +synchronizations()->create( + resourceType: 'account', + resourceId: $accountId, + subtype: 'accountTransactions', + customerIpAddress: $_SERVER['REMOTE_ADDR'] ?? null +); + +echo "Synchronization started: {$sync->id}\n"; +echo "Status: {$sync->status}\n"; + +// Poll until complete (max 60 seconds) +try { + $completedSync = $client->synchronizations()->pollUntilComplete( + synchronizationId: $sync->id, + maxAttempts: 20, + intervalSeconds: 3 + ); + + if ($completedSync->isSuccessful()) { + echo "✅ Synchronization completed successfully\n"; + + // Get updated transactions + $transactions = $client->transactions()->getUpdatedFromSync($sync->id); + echo "Retrieved " . count($transactions) . " updated transactions\n"; + + // Now fetch the latest transactions + $latestTransactions = $client->transactions()->list($accountId, ['limit' => 100]); + echo "Latest transaction count: {$latestTransactions->count()}\n"; + } else { + echo "❌ Synchronization failed\n"; + print_r($completedSync->errors); + } +} catch (\AlchemicStudio\Ponto\Exceptions\ApiException $e) { + echo "Synchronization error: " . $e->getMessage() . "\n"; +} +``` + +**Sync Account Details:** + +```php +synchronizations()->create( + resourceType: 'account', + resourceId: $accountId, + subtype: 'accountDetails' +); + +// Poll with custom intervals +$attempt = 0; +$maxAttempts = 10; + +while ($attempt < $maxAttempts) { + sleep(2); + $attempt++; + + $status = $client->synchronizations()->get($sync->id); + + echo "Attempt {$attempt}: {$status->status}\n"; + + if ($status->isComplete()) { + if ($status->isSuccessful()) { + echo "Account details synchronized\n"; + + // Fetch updated account + $account = $client->accounts()->get($accountId); + echo "Updated balance: {$account->availableBalance} {$account->currency}\n"; + } else { + echo "Sync failed: " . implode(', ', $status->errors) . "\n"; + } + break; + } +} +``` + +--- + +### 6. Error Handling & Retry Strategy + +**Comprehensive Error Handling:** + +```php +getRetryAfterSeconds() ?? 60; + echo "Rate limited. Retrying after {$retryAfter}s...\n"; + sleep($retryAfter); + $attempt++; + + } catch (AuthenticationException $e) { + echo "Authentication failed: " . $e->getMessage() . "\n"; + // Don't retry auth errors - fix credentials + throw $e; + + } catch (ValidationException $e) { + echo "Validation error: " . $e->getMessage() . "\n"; + print_r($e->getErrorDetails()); + // Don't retry validation errors - fix input + throw $e; + + } catch (NotFoundException $e) { + echo "Resource not found: " . $e->getMessage() . "\n"; + // Don't retry 404s + throw $e; + + } catch (ApiException $e) { + echo "API error ({$e->getCode()}): " . $e->getMessage() . "\n"; + + if ($e->getCode() >= 500) { + // Retry server errors with exponential backoff + $backoff = min(60, pow(2, $attempt)); + echo "Retrying in {$backoff}s...\n"; + sleep($backoff); + $attempt++; + } else { + // Don't retry 4xx errors + throw $e; + } + + } catch (PontoException $e) { + echo "Ponto error: " . $e->getMessage() . "\n"; + throw $e; + } + } + + throw new \RuntimeException("Max retries exceeded"); +} + +// Usage +try { + $accounts = safeApiCall(fn() => $client->accounts()->list()); + echo "Retrieved " . $accounts->count() . " accounts\n"; +} catch (\Throwable $e) { + echo "Failed to retrieve accounts: " . $e->getMessage() . "\n"; + // Log error, notify admin, etc. +} +``` + +--- + +### 7. Idempotency for Payments + +**Using endToEndId for Idempotency:** + +```php +payments()->create( + accountId: $accountId, + paymentData: [ + 'amount' => 100.00, + 'currency' => 'EUR', + 'creditorName' => 'Merchant', + 'creditorAccountReference' => 'BE68539007547034', + 'creditorAccountReferenceType' => 'IBAN', + 'creditorAgent' => 'NBBEBEBB203', + 'creditorAgentType' => 'BIC', + 'remittanceInformation' => 'Invoice 12345', + 'endToEndId' => $idempotencyKey, // Ensures uniqueness + ] + ); + + echo "Payment created: {$payment->id}\n"; + + // Store payment ID in your database + // savePaymentId($idempotencyKey, $payment->id); + + } catch (\AlchemicStudio\Ponto\Exceptions\ValidationException $e) { + // If duplicate endToEndId, payment may already exist + echo "Payment may already exist: " . $e->getMessage() . "\n"; + } +} + +// Safe to call multiple times - same endToEndId prevents duplicates +createPaymentIdempotent($client, $accountId, $idempotencyKey); +``` + +--- +## Configuration and Environment Variables + +### Environment Variables + +Create a `.env` file in your project root: + +```env +# Ponto API Credentials +PONTO_CLIENT_ID=8f0e9068-48f5-4b45-84ee-1e8adb386c0d +PONTO_CLIENT_SECRET=a9e726f9-8078-4f70-8570-e3c8600d7816 + +# Environment (production or sandbox) +PONTO_BASE_URL=https://api.myponto.com + +# Optional: Token storage +PONTO_TOKEN_STORAGE_PATH=/tmp/ponto_tokens + +# Optional: HTTP Client Configuration +PONTO_HTTP_TIMEOUT=30 +PONTO_HTTP_CONNECT_TIMEOUT=10 +PONTO_MAX_RETRIES=3 +PONTO_RETRY_DELAY_MS=1000 +``` + +### Configuration Class + +**`src/Config.php`:** + +```php +clientId, 'sandbox_'); + } + + public function getTokenUrl(): string + { + return $this->baseUrl . '/oauth2/token'; + } +} +``` + +**Usage:** + +```php +clientId, + clientSecret: $config->clientSecret, + baseUrl: $config->baseUrl, + httpOptions: [ + 'timeout' => $config->httpTimeout, + 'connect_timeout' => $config->connectTimeout, + ] +); + +// Or use Config directly in Client (alternative design) +// $client = Client::fromConfig($config); +``` + +--- + +## Authentication Strategy and Token Refresh + +### OAuth2 Flow + +Ponto uses the **Client Credentials Grant** (OAuth2): + +1. **Initial Authentication:** + ``` + POST /oauth2/token + Authorization: Basic base64(client_id:client_secret) + Content-Type: application/x-www-form-urlencoded + + grant_type=client_credentials + ``` + +2. **Response:** + ```json + { + "access_token": "sandbox_CSmOh54ps-...", + "expires_in": 1799, + "scope": "ai pi", + "token_type": "bearer" + } + ``` + +3. **Token Usage:** + ``` + GET /accounts + Authorization: Bearer sandbox_CSmOh54ps-... + ``` + +### Token Lifecycle + +- **Expiration:** Tokens expire after ~30 minutes (1799 seconds) +- **Caching:** Store tokens to avoid unnecessary OAuth requests +- **Refresh Strategy:** Refresh 5 minutes before expiration +- **Automatic Refresh:** On 401 responses, attempt token refresh once + +**Implementation in AuthProvider:** + +```php +cachedToken && $this->isTokenValid()) { + return $this->cachedToken; + } + + // Try token from storage + $storedToken = $this->tokenStorage->get($this->getStorageKey()); + if ($storedToken) { + $tokenData = json_decode($storedToken, true); + $expiresAt = new \DateTimeImmutable($tokenData['expires_at']); + + if ($expiresAt > new \DateTimeImmutable('+5 minutes')) { + $this->cachedToken = $tokenData['access_token']; + $this->expiresAt = $expiresAt; + $this->scopes = $tokenData['scopes'] ?? []; + return $this->cachedToken; + } + } + + // Fetch new token + return $this->refreshToken(); + } + + public function refreshToken(): string + { + $credentials = base64_encode("{$this->clientId}:{$this->clientSecret}"); + + try { + $response = $this->httpClient->postRaw( + url: $this->tokenUrl, + body: ['grant_type' => 'client_credentials'], + headers: [ + 'Authorization' => "Basic {$credentials}", + 'Content-Type' => 'application/x-www-form-urlencoded', + ] + ); + + $this->cachedToken = $response['access_token']; + $this->expiresAt = (new \DateTimeImmutable())->modify("+{$response['expires_in']} seconds"); + $this->scopes = explode(' ', $response['scope'] ?? ''); + + // Store token + $this->tokenStorage->set( + key: $this->getStorageKey(), + value: json_encode([ + 'access_token' => $this->cachedToken, + 'expires_at' => $this->expiresAt->format(\DateTimeInterface::ATOM), + 'scopes' => $this->scopes, + ]), + ttl: $response['expires_in'] + ); + + return $this->cachedToken; + + } catch (\Exception $e) { + throw new AuthenticationException( + "Failed to obtain access token: {$e->getMessage()}", + previous: $e + ); + } + } + + public function hasScope(string $scope): bool + { + return in_array($scope, $this->scopes, true); + } + + public function getScopes(): array + { + return $this->scopes; + } + + private function isTokenValid(): bool + { + if (/home/sebastien/Codes/ponto-phpthis->expiresAt) { + return false; + } + + // Consider token invalid 5 minutes before expiration + return $this->expiresAt > new \DateTimeImmutable('+5 minutes'); + } + + private function getStorageKey(): string + { + return 'access_token_' . substr(hash('sha256', $this->clientId), 0, 16); + } +} +``` + +--- + +## Security Best Practices + +### 1. Credential Management + +**❌ Don't:** +```php +// Hardcoded credentials in source code +$client = new Client('my-client-id', 'my-secret'); +``` + +**✅ Do:** +```php +// Use environment variables +$client = new Client( + clientId: $_ENV['PONTO_CLIENT_ID'], + clientSecret: $_ENV['PONTO_CLIENT_SECRET'] +); +``` + +### 2. Secure Token Storage + +**File Storage Permissions:** +```php +storagePath)) { + mkdir($this->storagePath, 0700, true); // Owner-only permissions + } + } + + public function set(string $key, string $value, int $ttl): void + { + $filePath = $this->getFilePath($key); + $data = [ + 'value' => $value, + 'expires_at' => time() + $ttl, + ]; + + file_put_contents($filePath, json_encode($data), LOCK_EX); + chmod($filePath, 0600); // Owner read/write only + } + + public function get(string $key): ?string + { + $filePath = $this->getFilePath($key); + + if (!file_exists($filePath)) { + return null; + } + + $data = json_decode(file_get_contents($filePath), true); + + if ($data['expires_at'] < time()) { + $this->delete($key); + return null; + } + + return $data['value']; + } + + public function delete(string $key): void + { + $filePath = $this->getFilePath($key); + if (file_exists($filePath)) { + unlink($filePath); + } + } + + private function getFilePath(string $key): string + { + return $this->storagePath . '/' . hash('sha256', $key) . '.json'; + } +} +``` + +### 3. No Credential Logging + +**❌ Don't:** +```php +// Never log credentials or tokens +error_log("Token: " . $accessToken); +$logger->info("Secret: {$clientSecret}"); +``` + +**✅ Do:** +```php +// Log operations without sensitive data +$logger->info('Authentication successful', [ + 'client_id' => substr($clientId, 0, 8) . '...', + 'scopes' => $scopes, +]); +``` + +### 4. TLS/HTTPS Only + +```php +// Enforce HTTPS +if (parse_url($baseUrl, PHP_URL_SCHEME) !== 'https') { + throw new \InvalidArgumentException('Ponto API requires HTTPS'); +} + +// Guzzle HTTP client with TLS verification +$guzzle = new \GuzzleHttp\Client([ + 'base_uri' => $baseUrl, + 'verify' => true, // Verify SSL certificates + 'timeout' => 30, +]); +``` + +### 5. Idempotency Keys + +Always use `endToEndId` for payments to prevent duplicate transactions: + +```php +// Generate unique but deterministic idempotency key +$idempotencyKey = hash('sha256', implode('|', [ + $invoiceId, + $amount, + $currency, + $creditorIban, + date('Y-m-d'), +])); + +$payment = $client->payments()->create($accountId, [ + 'endToEndId' => $idempotencyKey, + // ... other fields +]); +``` + +### 6. Input Validation + +**Validation Helper:** + +```php + 999999999.99) { + throw new ValidationException("Amount too large: {$amount}"); + } + } + + public static function validateCurrency(string $currency): string + { + $currency = strtoupper($currency); + + if (!preg_match('/^[A-Z]{3}$/', $currency)) { + throw new ValidationException("Invalid currency code: {$currency}"); + } + + return $currency; + } + + public static function validateRemittanceInfo(string $info): string + { + $maxLength = 140; + $allowedChars = '/^[a-zA-Z0-9\\/\\-\\?:\\(\\)\\.\\,\'\\+ ]+$/'; + + if (strlen($info) > $maxLength) { + throw new ValidationException("Remittance information too long (max {$maxLength} chars)"); + } + + if (!preg_match($allowedChars, $info)) { + throw new ValidationException("Remittance information contains invalid characters"); + } + + return $info; + } +} +``` + +--- +## Testing Approach + +### Testing Strategy + +The library uses **Pest v4.1** for testing with a functional approach. + +**Test Types:** +1. **Unit Tests**: Test individual classes in isolation with mocks +2. **Integration Tests**: Test against Ponto sandbox API +3. **Feature Tests**: Test complete user workflows + +### Unit Tests + +**Example: Testing TransactionService** + +**`tests/Unit/Services/TransactionServiceTest.php`:** + +```php +httpClient = Mockery::mock(HttpClient::class); + $this->service = new TransactionService($this->httpClient); +}); + +afterEach(function () { + Mockery::close(); +}); + +test('list transactions returns paginated collection', function () { + $accountId = 'acc-123'; + $mockResponse = [ + 'data' => [ + [ + 'id' => 'tx-1', + 'type' => 'transaction', + 'attributes' => [ + 'amount' => 100.50, + 'currency' => 'EUR', + 'description' => 'Test transaction', + 'executionDate' => '2024-02-20T10:00:00Z', + 'createdAt' => '2024-02-20T10:00:00Z', + 'updatedAt' => '2024-02-20T10:00:00Z', + ], + 'relationships' => [ + 'account' => [ + 'data' => ['id' => $accountId, 'type' => 'account'], + ], + ], + ], + ], + 'meta' => [ + 'paging' => [ + 'limit' => 20, + ], + ], + 'links' => [], + ]; + + $this->httpClient + ->shouldReceive('get') + ->once() + ->with("/accounts/{$accountId}/transactions", ['limit' => 20]) + ->andReturn($mockResponse); + + $result = $this->service->list($accountId); + + expect($result)->toBeInstanceOf(PaginatedCollection::class); + expect($result->count())->toBe(1); + expect($result->data[0])->toBeInstanceOf(Transaction::class); + expect($result->data[0]->amount)->toBe(100.50); +}); + +test('list transactions with filters applies correct query parameters', function () { + $accountId = 'acc-123'; + $filters = [ + 'limit' => 50, + 'since' => '2024-01-01', + 'until' => '2024-12-31', + 'after' => 'cursor-123', + ]; + + $this->httpClient + ->shouldReceive('get') + ->once() + ->with("/accounts/{$accountId}/transactions", $filters) + ->andReturn(['data' => [], 'meta' => ['paging' => []], 'links' => []]); + + $this->service->list($accountId, $filters); +}); + +test('get transaction by id returns single transaction', function () { + $accountId = 'acc-123'; + $transactionId = 'tx-456'; + + $mockResponse = [ + 'data' => [ + 'id' => $transactionId, + 'type' => 'transaction', + 'attributes' => [ + 'amount' => -50.00, + 'currency' => 'EUR', + 'description' => 'Payment', + 'executionDate' => '2024-02-20T10:00:00Z', + 'createdAt' => '2024-02-20T10:00:00Z', + 'updatedAt' => '2024-02-20T10:00:00Z', + ], + 'relationships' => [ + 'account' => [ + 'data' => ['id' => $accountId, 'type' => 'account'], + ], + ], + ], + ]; + + $this->httpClient + ->shouldReceive('get') + ->once() + ->with("/accounts/{$accountId}/transactions/{$transactionId}", []) + ->andReturn($mockResponse); + + $transaction = $this->service->get($accountId, $transactionId); + + expect($transaction)->toBeInstanceOf(Transaction::class); + expect($transaction->id)->toBe($transactionId); + expect($transaction->isDebit())->toBeTrue(); +}); + +test('get non-existent transaction throws NotFoundException', function () { + $accountId = 'acc-123'; + $transactionId = 'tx-nonexistent'; + + $this->httpClient + ->shouldReceive('get') + ->once() + ->andThrow(new NotFoundException("Transaction not found")); + + $this->service->get($accountId, $transactionId); +})->throws(NotFoundException::class); +``` + +**Example: Testing Payment Model** + +**`tests/Unit/Models/PaymentTest.php`:** + +```php + 'pay-123', + 'type' => 'payment', + 'attributes' => [ + 'status' => 'unsigned', + 'amount' => 100.00, + 'currency' => 'EUR', + 'creditorName' => 'John Doe', + 'creditorAccountReference' => 'BE68539007547034', + 'creditorAccountReferenceType' => 'IBAN', + 'creditorAgent' => 'NBBEBEBB203', + 'creditorAgentType' => 'BIC', + 'remittanceInformation' => 'Invoice 123', + 'remittanceInformationType' => 'unstructured', + ], + 'links' => [ + 'redirect' => 'https://authorize.myponto.com/payment/pay-123', + ], + ]; + + $payment = Payment::fromArray($data); + + expect($payment->id)->toBe('pay-123'); + expect($payment->status)->toBe('unsigned'); + expect($payment->amount)->toBe(100.00); + expect($payment->creditorName)->toBe('John Doe'); + expect($payment->redirectUrl)->toBe('https://authorize.myponto.com/payment/pay-123'); +}); + +test('payment status checks work correctly', function () { + $createPayment = fn($status) => Payment::fromArray([ + 'id' => 'pay-123', + 'type' => 'payment', + 'attributes' => [ + 'status' => $status, + 'amount' => 100, + 'currency' => 'EUR', + 'creditorName' => 'Test', + 'creditorAccountReference' => 'BE68539007547034', + 'creditorAccountReferenceType' => 'IBAN', + 'creditorAgent' => 'NBBEBEBB203', + 'creditorAgentType' => 'BIC', + 'remittanceInformation' => 'Test', + 'remittanceInformationType' => 'unstructured', + ], + ]); + + expect($createPayment('unsigned')->isUnsigned())->toBeTrue(); + expect($createPayment('authorized')->isAuthorized())->toBeTrue(); + expect($createPayment('executed')->isExecuted())->toBeTrue(); + expect($createPayment('cancelled')->isCancelled())->toBeTrue(); + expect($createPayment('rejected')->isRejected())->toBeTrue(); + + expect($createPayment('unsigned')->requiresAuthorization())->toBeTrue(); + expect($createPayment('executed')->requiresAuthorization())->toBeFalse(); +}); + +test('payment converts to array correctly', function () { + $payment = Payment::fromArray([ + 'id' => 'pay-123', + 'type' => 'payment', + 'attributes' => [ + 'status' => 'executed', + 'amount' => 50.50, + 'currency' => 'EUR', + 'creditorName' => 'Test', + 'creditorAccountReference' => 'BE68539007547034', + 'creditorAccountReferenceType' => 'IBAN', + 'creditorAgent' => 'NBBEBEBB203', + 'creditorAgentType' => 'BIC', + 'remittanceInformation' => 'Test payment', + 'remittanceInformationType' => 'unstructured', + ], + ]); + + $array = $payment->toArray(); + + expect($array)->toHaveKey('id', 'pay-123'); + expect($array)->toHaveKey('status', 'executed'); + expect($array)->toHaveKey('amount', 50.50); +}); +``` + +**Example: Testing Validator** + +**`tests/Unit/Utils/ValidatorTest.php`:** + +```php +toBe('BE68539007547034'); +}); + +test('rejects invalid IBAN', function () { + Validator::validateIban('INVALID'); +})->throws(ValidationException::class); + +test('validates valid BIC', function () { + $bic = Validator::validateBic('NBBEBEBB203'); + expect($bic)->toBe('NBBEBEBB203'); +}); + +test('rejects invalid BIC', function () { + Validator::validateBic('INVALID'); +})->throws(ValidationException::class); + +test('validates positive amounts', function () { + expect(fn() => Validator::validateAmount(100.50))->not->toThrow(ValidationException::class); +}); + +test('rejects negative amounts', function () { + Validator::validateAmount(-50.00); +})->throws(ValidationException::class); + +test('rejects zero amount', function () { + Validator::validateAmount(0); +})->throws(ValidationException::class); + +test('validates currency codes', function () { + $currency = Validator::validateCurrency('eur'); + expect($currency)->toBe('EUR'); +}); + +test('rejects invalid currency', function () { + Validator::validateCurrency('INVALID'); +})->throws(ValidationException::class); +``` + +### Integration Tests + +**Example: Sandbox Integration Test** + +**`tests/Integration/AccountServiceIntegrationTest.php`:** + +```php +markTestSkipped('Sandbox credentials not configured'); + } + + $this->client = new Client( + clientId: getenv('PONTO_SANDBOX_CLIENT_ID'), + clientSecret: getenv('PONTO_SANDBOX_CLIENT_SECRET'), + baseUrl: 'https://api.myponto.com' + ); +}); + +test('can list accounts from sandbox', function () { + $accounts = $this->client->accounts()->list(limit: 10); + + expect($accounts)->toBeInstanceOf(PaginatedCollection::class); + expect($accounts->data)->toBeArray(); + + if ($accounts->count() > 0) { + expect($accounts->data[0])->toBeInstanceOf(Account::class); + expect($accounts->data[0]->currency)->toBeString(); + } +})->group('integration', 'sandbox'); + +test('can get single account from sandbox', function () { + $accounts = $this->client->accounts()->list(limit: 1); + + if ($accounts->isEmpty()) { + test()->markTestSkipped('No accounts available in sandbox'); + } + + $accountId = $accounts->data[0]->id; + $account = $this->client->accounts()->get($accountId); + + expect($account)->toBeInstanceOf(Account::class); + expect($account->id)->toBe($accountId); + expect($account->reference)->toBeString(); + expect($account->holderName)->toBeString(); +})->group('integration', 'sandbox'); + +test('can list transactions from sandbox account', function () { + $accounts = $this->client->accounts()->list(limit: 1); + + if ($accounts->isEmpty()) { + test()->markTestSkipped('No accounts available'); + } + + $accountId = $accounts->data[0]->id; + $transactions = $this->client->transactions()->list($accountId, ['limit' => 20]); + + expect($transactions)->toBeInstanceOf(PaginatedCollection::class); + expect($transactions->data)->toBeArray(); +})->group('integration', 'sandbox'); +``` + +### Running Tests + +```bash +# Run all tests +composer test + +# Run only unit tests +./vendor/bin/pest --filter Unit + +# Run only integration tests (requires sandbox credentials) +PONTO_SANDBOX_CLIENT_ID=your_id PONTO_SANDBOX_CLIENT_SECRET=your_secret \ +./vendor/bin/pest --group=integration + +# Run with coverage +composer test-coverage + +# Run specific test file +./vendor/bin/pest tests/Unit/Models/PaymentTest.php +``` + +--- + +## Framework Integration + +### Laravel Integration + +**Service Provider:** + +**`src/Laravel/PontoServiceProvider.php`:** + +```php +mergeConfigFrom(__DIR__ . '/../../config/ponto.php', 'ponto'); + + $this->app->singleton(Client::class, function ($app) { + $config = $app['config']['ponto']; + + return new Client( + clientId: $config['client_id'], + clientSecret: $config['client_secret'], + baseUrl: $config['base_url'], + tokenStorage: new FileTokenStorage($config['token_storage_path']), + httpOptions: [ + 'timeout' => $config['http_timeout'], + 'connect_timeout' => $config['connect_timeout'], + ] + ); + }); + + $this->app->alias(Client::class, 'ponto'); + } + + public function boot(): void + { + if ($this->app->runningInConsole()) { + $this->publishes([ + __DIR__ . '/../../config/ponto.php' => config_path('ponto.php'), + ], 'ponto-config'); + } + } + + public function provides(): array + { + return [Client::class, 'ponto']; + } +} +``` + +**Configuration File:** + +**`config/ponto.php`:** + +```php + env('PONTO_CLIENT_ID'), + 'client_secret' => env('PONTO_CLIENT_SECRET'), + 'base_url' => env('PONTO_BASE_URL', 'https://api.myponto.com'), + 'token_storage_path' => env('PONTO_TOKEN_STORAGE_PATH', storage_path('app/ponto')), + 'http_timeout' => env('PONTO_HTTP_TIMEOUT', 30), + 'connect_timeout' => env('PONTO_HTTP_CONNECT_TIMEOUT', 10), +]; +``` + +**Facade:** + +**`src/Laravel/Facades/Ponto.php`:** + +```php +list(limit: 50); + + return view('accounts.index', ['accounts' => $accounts->data]); + } + + public function transactions(string $accountId) + { + $transactions = Ponto::transactions()->list($accountId, [ + 'limit' => 100, + 'since' => now()->subDays(30)->format('Y-m-d'), + ]); + + return view('transactions.index', [ + 'transactions' => $transactions->data, + 'pagination' => $transactions, + ]); + } +} +``` + +**Artisan Command Example:** + +```php +argument('accountId'); + + $this->info("Starting synchronization for account {$accountId}..."); + + $sync = $ponto->synchronizations()->create( + resourceType: 'account', + resourceId: $accountId, + subtype: 'accountTransactions' + ); + + $this->info("Synchronization created: {$sync->id}"); + + $completed = $ponto->synchronizations()->pollUntilComplete($sync->id); + + if ($completed->isSuccessful()) { + $this->info('✅ Synchronization completed successfully'); + + $transactions = $ponto->transactions()->getUpdatedFromSync($sync->id); + $this->info("Retrieved " . count($transactions) . " updated transactions"); + + return Command::SUCCESS; + } + + $this->error('❌ Synchronization failed'); + return Command::FAILURE; + } +} +``` + +--- +## Compliance Checklist + +### API Endpoints Coverage + +| Endpoint | Method | Status | Implementation | +|----------|--------|--------|----------------| +| `/oauth2/token` | POST | ✅ Complete | `AuthProvider::refreshToken()` | +| `/accounts` | GET | ✅ Complete | `AccountService::list()` | +| `/accounts/{id}` | GET | ✅ Complete | `AccountService::get()` | +| `/accounts/{id}/transactions` | GET | ✅ Complete | `TransactionService::list()` | +| `/accounts/{id}/transactions/{id}` | GET | ✅ Complete | `TransactionService::get()` | +| `/accounts/{id}/pending-transactions` | GET | ✅ Complete | `TransactionService::listPending()` | +| `/accounts/{id}/payments` | POST | ✅ Complete | `PaymentService::create()` | +| `/accounts/{id}/payments/{id}` | GET | ✅ Complete | `PaymentService::get()` | +| `/accounts/{id}/payments/{id}` | DELETE | ✅ Complete | `PaymentService::delete()` | +| `/accounts/{id}/bulk-payments` | POST | 🟡 Planned | Future enhancement | +| `/accounts/{id}/payment-requests` | POST | 🟡 Planned | Future enhancement | +| `/synchronizations` | POST | ✅ Complete | `SynchronizationService::create()` | +| `/synchronizations/{id}` | GET | ✅ Complete | `SynchronizationService::get()` | +| `/synchronizations/{id}/updated-transactions` | GET | ✅ Complete | `TransactionService::getUpdatedFromSync()` | +| `/financial-institutions` | GET | 🟡 Planned | Future enhancement | + +### Fields Supported + +#### Account Model +- ✅ `id`, `type`, `reference`, `referenceType` +- ✅ `currency`, `subtype`, `product`, `description` +- ✅ `availableBalance`, `currentBalance` +- ✅ `holderName`, `deprecated` +- ✅ `authorizedAt`, `authorizationExpirationExpectedAt` +- ✅ `internalReference` +- ✅ Synchronization metadata + +#### Transaction Model +- ✅ `id`, `type`, `amount`, `currency`, `description` +- ✅ `digest`, `executionDate`, `valueDate` +- ✅ `counterpartName`, `counterpartReference` +- ✅ `remittanceInformation`, `remittanceInformationType` +- ✅ `bankTransactionCode`, `endToEndId` +- ✅ `mandateId`, `creditorId`, `purposeCode` +- ✅ `fee`, `additionalInformation` +- ✅ `createdAt`, `updatedAt`, `internalReference` + +#### Payment Model +- ✅ `id`, `type`, `status` +- ✅ `amount`, `currency` +- ✅ `creditorName`, `creditorAccountReference`, `creditorAccountReferenceType` +- ✅ `creditorAgent`, `creditorAgentType` +- ✅ `remittanceInformation`, `remittanceInformationType` +- ✅ `endToEndId`, `requestedExecutionDate` +- ✅ `redirectUrl` for authorization flow + +#### Synchronization Model +- ✅ `id`, `type`, `status` +- ✅ `resourceType`, `resourceId`, `subtype` +- ✅ `errors`, `createdAt`, `updatedAt` + +### Features Implemented + +| Feature | Status | Notes | +|---------|--------|-------| +| OAuth2 Client Credentials | ✅ Complete | With token caching and refresh | +| Token Storage Interface | ✅ Complete | File-based implementation | +| Account Information | ✅ Complete | List and get accounts | +| Transaction Retrieval | ✅ Complete | With filtering and pagination | +| Payment Initiation | ✅ Complete | Single payments with authorization | +| Future-Dated Payments | ✅ Complete | Via `requestedExecutionDate` | +| Synchronization | ✅ Complete | With polling helper | +| Pagination | ✅ Complete | Cursor-based navigation | +| Error Handling | ✅ Complete | Comprehensive exception hierarchy | +| Retry Logic | ✅ Complete | Exponential backoff | +| Rate Limiting | ✅ Complete | Respects `Retry-After` header | +| Idempotency | ✅ Complete | Via `endToEndId` | +| Input Validation | ✅ Complete | IBAN, BIC, amounts, etc. | +| Type Safety | ✅ Complete | Full PHP 8.4 types | +| Sandbox Support | ✅ Complete | Same API, different credentials | +| Laravel Integration | ✅ Complete | ServiceProvider and Facade | + +### Known Limitations + +| Limitation | Workaround | Priority | +|------------|------------|----------| +| Bulk Payments Not Implemented | Use multiple single payments | Medium | +| Payment Requests Not Implemented | Use standard payments | Low | +| Financial Institution Listing Not Implemented | Use dashboard for linking | Low | +| [ASSUMPTION] No webhooks support | Poll synchronizations | Low | +| [ASSUMPTION] No payment status webhooks | Poll payment status | Medium | +| Synchronization rate limit (30 min) | Cache results, plan syncs carefully | High | + +### Assumptions and Safe Defaults + +1. **[ASSUMPTION]** Token expiration buffer: 5 minutes before actual expiration +2. **[ASSUMPTION]** Default pagination limit: 20 items (max 100) +3. **[ASSUMPTION]** HTTP timeout: 30 seconds (configurable) +4. **[ASSUMPTION]** Max retries: 3 attempts with exponential backoff +5. **[ASSUMPTION]** Synchronization polling: 20 attempts at 3-second intervals (60s total) +6. **[ASSUMPTION]** File token storage default path: `/tmp/ponto_tokens` +7. **[ASSUMPTION]** IBAN/BIC validation: Basic format checks (not checksum validation) +8. **[ASSUMPTION]** Currency validation: ISO 4217 format check only + +--- + +## Versioning and Continuous Integration + +### Semantic Versioning + +Follow [SemVer 2.0.0](https://semver.org/): + +- **MAJOR** version: Breaking changes (e.g., 1.0.0 → 2.0.0) +- **MINOR** version: New features, backward compatible (e.g., 1.0.0 → 1.1.0) +- **PATCH** version: Bug fixes (e.g., 1.0.0 → 1.0.1) + +**Example Version History:** +``` +v1.0.0 - Initial release with core features +v1.1.0 - Add bulk payments support +v1.1.1 - Fix token refresh edge case +v2.0.0 - Refactor to use PSR-18 HTTP client interface +``` + +### Git Workflow + +```bash +# Feature branches +git checkout -b feature/bulk-payments +git commit -m "feat: add bulk payment support" +git push origin feature/bulk-payments + +# Hotfix branches +git checkout -b hotfix/token-expiration +git commit -m "fix: token expiration edge case" +git push origin hotfix/token-expiration + +# Release tagging +git tag -a v1.0.0 -m "Release version 1.0.0" +git push origin v1.0.0 +``` + +### Continuous Integration (GitHub Actions) + +**`.github/workflows/tests.yml`:** + +```yaml +name: Tests + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main, develop ] + +jobs: + test: + runs-on: ubuntu-latest + + strategy: + matrix: + php-version: ['8.4'] + + steps: + - uses: actions/checkout@v3 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + extensions: json, mbstring, curl + coverage: xdebug + + - name: Validate composer.json + run: composer validate --strict + + - name: Cache Composer packages + uses: actions/cache@v3 + with: + path: vendor + key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }} + restore-keys: | + ${{ runner.os }}-php- + + - name: Install dependencies + run: composer install --prefer-dist --no-progress + + - name: Run PHPStan + run: composer stan + + - name: Run PHP CS Fixer (check) + run: vendor/bin/php-cs-fixer fix --dry-run --diff + + - name: Run Pest Tests + run: composer test + + - name: Run Pest Tests with Coverage + run: composer test-coverage + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v3 + with: + files: ./coverage.xml + flags: unittests + name: codecov-umbrella + + integration: + runs-on: ubuntu-latest + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + + steps: + - uses: actions/checkout@v3 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + extensions: json, mbstring, curl + + - name: Install dependencies + run: composer install --prefer-dist --no-progress + + - name: Run Integration Tests + env: + PONTO_SANDBOX_CLIENT_ID: ${{ secrets.PONTO_SANDBOX_CLIENT_ID }} + PONTO_SANDBOX_CLIENT_SECRET: ${{ secrets.PONTO_SANDBOX_CLIENT_SECRET }} + run: ./vendor/bin/pest --group=integration +``` + +**`.github/workflows/release.yml`:** + +```yaml +name: Release + +on: + push: + tags: + - 'v*' + +jobs: + release: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + + - name: Create Release + uses: actions/create-release@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: ${{ github.ref }} + release_name: Release ${{ github.ref }} + draft: false + prerelease: false +``` + +### Quality Gates + +Before merging to main: +1. ✅ All tests pass (unit + integration) +2. ✅ PHPStan level max passes +3. ✅ PHP CS Fixer formatting applied +4. ✅ Code coverage ≥ 80% +5. ✅ Documentation updated +6. ✅ CHANGELOG.md updated + +### Pre-commit Hook + +**`.git/hooks/pre-commit`:** + +```bash +#!/bin/bash + +echo "Running pre-commit checks..." + +# Format code +composer format + +# Static analysis +composer stan +if [ $? -ne 0 ]; then + echo "❌ PHPStan failed" + exit 1 +fi + +# Tests +composer test +if [ $? -ne 0 ]; then + echo "❌ Tests failed" + exit 1 +fi + +echo "✅ All checks passed" +exit 0 +``` + +--- + +## Complete Implementation Example + +### Minimal Working Client + +**`src/Client.php`:** + +```php + $baseUrl, + 'timeout' => 30, + 'connect_timeout' => 10, + 'verify' => true, + ], $httpOptions)); + + $this->httpClient = new HttpClient($guzzle); + + $this->authProvider = new AuthProvider( + clientId: $clientId, + clientSecret: $clientSecret, + tokenUrl: $baseUrl . '/oauth2/token', + tokenStorage: $tokenStorage, + httpClient: $this->httpClient + ); + + $this->httpClient->setAuthProvider($this->authProvider); + } + + public function accounts(): AccountService + { + return $this->accountService ??= new AccountService($this->httpClient); + } + + public function transactions(): TransactionService + { + return $this->transactionService ??= new TransactionService($this->httpClient); + } + + public function payments(): PaymentService + { + if (/home/sebastien/Codes/ponto-phpthis->hasPaymentScope()) { + throw new AuthenticationException('Payment initiation scope (pi) not granted'); + } + + return $this->paymentService ??= new PaymentService($this->httpClient); + } + + public function synchronizations(): SynchronizationService + { + return $this->synchronizationService ??= new SynchronizationService($this->httpClient); + } + + public function refreshToken(): void + { + $this->authProvider->refreshToken(); + } + + public function hasPaymentScope(): bool + { + return $this->authProvider->hasScope('pi'); + } +} +``` + +### Quick Start Example + +**`examples/quickstart.php`:** + +```php +accounts()->list(limit: 10); +echo " Found {$accounts->count()} accounts\n\n"; + +foreach ($accounts->data as $account) { + echo " - {$account->reference} ({$account->holderName})\n"; + echo " Balance: {$account->availableBalance} {$account->currency}\n"; +} + +// 2. Get transactions for first account +if ($accounts->count() > 0) { + $accountId = $accounts->data[0]->id; + + echo "\n2. Fetching transactions...\n"; + $transactions = $client->transactions()->list($accountId, ['limit' => 5]); + echo " Found {$transactions->count()} recent transactions\n\n"; + + foreach ($transactions->data as $tx) { + $type = $tx->isCredit() ? '💰' : '💸'; + echo " {$type} {$tx->amount} {$tx->currency} - {$tx->description}\n"; + } +} + +// 3. Create a payment (if scope available) +if ($client->hasPaymentScope() && $accounts->count() > 0) { + echo "\n3. Creating test payment...\n"; + + try { + $payment = $client->payments()->create($accountId, [ + 'amount' => 10.00, + 'currency' => 'EUR', + 'creditorName' => 'Test Recipient', + 'creditorAccountReference' => 'BE68539007547034', + 'creditorAccountReferenceType' => 'IBAN', + 'creditorAgent' => 'NBBEBEBB203', + 'creditorAgentType' => 'BIC', + 'remittanceInformation' => 'Test payment from PHP library', + 'endToEndId' => uniqid('test_'), + ]); + + echo " Payment created: {$payment->id}\n"; + echo " Status: {$payment->status}\n"; + + if ($payment->redirectUrl) { + echo " Authorize at: {$payment->redirectUrl}\n"; + } + } catch (\Exception $e) { + echo " Payment failed: {$e->getMessage()}\n"; + } +} + +echo "\n=== Done ===\n"; +``` + +--- + +## Summary + +This documentation provides a comprehensive guide to implementing the **ponto-php** library for integrating with the Ponto API. + +**Key Takeaways:** + +1. **Architecture**: Modular design with Services, Models, Auth, and HTTP layers +2. **Type Safety**: Full PHP 8.4 type declarations throughout +3. **Standards Compliance**: PSR-4, PSR-12, and REST best practices +4. **Security**: Environment-based configuration, secure token storage, TLS enforcement +5. **Error Handling**: Comprehensive exception hierarchy with retry logic +6. **Testing**: Pest-based unit and integration tests +7. **Framework Support**: Laravel ServiceProvider and Facade included +8. **Production Ready**: Token management, rate limiting, idempotency support + +**Next Steps:** + +1. Review the Ponto API documentation at https://documentation.myponto.com +2. Obtain sandbox credentials from https://myponto.com +3. Implement the core classes following the specifications above +4. Write comprehensive tests (aim for 80%+ coverage) +5. Add examples directory with working code samples +6. Set up CI/CD pipeline with GitHub Actions +7. Publish to Packagist for easy installation + +**Support:** + +- GitHub Issues: For bug reports and feature requests +- Pull Requests: Contributions welcome following PSR-12 +- Documentation: Keep this file updated with API changes +- Changelog: Maintain CHANGELOG.md following Keep a Changelog format + +--- + +*Document Version: 1.0.0* +*Last Updated: 2024-02-20* +*Compatible with: Ponto API v1, PHP 8.4+* diff --git a/docs/developpement_part1.md b/docs/developpement_part1.md new file mode 100644 index 0000000..acb3242 --- /dev/null +++ b/docs/developpement_part1.md @@ -0,0 +1,161 @@ +# Ponto PHP Library - Development Documentation + +## Overview and Goals + +The **ponto-php** library is a modern PHP SDK for integrating with the Ponto API, which provides: +- **Account Information Services (AIS)**: Retrieve account details, balances, and transaction history +- **Payment Initiation Services (PIS)**: Create and manage single payments, bulk payments, and payment requests + +### Key Features +- Full OAuth2 client credentials authentication with automatic token management +- Type-safe models and responses (PHP 8.4+) +- PSR-compliant architecture (PSR-4 autoloading, PSR-12 code style) +- Comprehensive error handling with custom exceptions +- Built-in retry logic with exponential backoff +- Support for pagination, filtering, and synchronization +- Idempotency support for payment operations +- Sandbox and production environment support + +### Design Principles +- **Developer Experience**: Intuitive API with fluent interfaces +- **Type Safety**: Full PHP 8.4 type declarations +- **Standards Compliance**: PSR-4, PSR-12, and REST best practices +- **Security First**: No credential logging, secure token storage +- **Testability**: Dependency injection and mockable interfaces + +--- + +## Prerequisites + +### System Requirements +- **PHP**: 8.4 or higher +- **Extensions**: + - `ext-json` (JSON encoding/decoding) + - `ext-mbstring` (String manipulation) + - `ext-curl` or `ext-openssl` (HTTPS requests) + +### Dependencies +- **Guzzle HTTP Client**: ^7.8 (HTTP requests with retry support) +- **PSR-7/PSR-18**: HTTP message interfaces + +### Development Tools +- **Composer**: 2.x for dependency management +- **Pest**: 4.1 for testing +- **PHPStan**: 2.1 for static analysis (level max) +- **PHP CS Fixer**: 3.21 for code style + +--- + +## Installation + +### Via Composer + +```bash +composer require alchemic-studio/ponto-php +``` + +### Manual Installation + +Add to your `composer.json`: + +```json +{ + "require": { + "php": "^8.4", + "alchemic-studio/ponto-php": "^1.0", + "guzzlehttp/guzzle": "^7.8" + }, + "require-dev": { + "pestphp/pest": "^4.1", + "phpstan/phpstan": "^2.1", + "friendsofphp/php-cs-fixer": "^3.21" + }, + "autoload": { + "psr-4": { + "AlchemicStudio\\Ponto\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "AlchemicStudio\\Ponto\\Tests\\": "tests/" + } + }, + "scripts": { + "test": "pest", + "test-coverage": "pest --coverage", + "stan": "phpstan analyse src tests --level=max", + "format": "php-cs-fixer fix" + }, + "config": { + "sort-packages": true, + "allow-plugins": { + "pestphp/pest-plugin": true + } + }, + "minimum-stability": "dev", + "prefer-stable": true +} +``` + +Then run: + +```bash +composer install +``` + +--- + +## Project Layout + +### Directory Structure + +``` +ponto-php/ +├── src/ +│ ├── Client.php # Main client entry point +│ ├── Config.php # Configuration container +│ ├── Auth/ +│ │ ├── AuthProvider.php # OAuth2 token management +│ │ ├── TokenStorage.php # Token storage interface +│ │ └── FileTokenStorage.php # File-based token cache +│ ├── Http/ +│ │ ├── HttpClient.php # HTTP client wrapper +│ │ ├── HttpClientInterface.php # Client contract +│ │ └── Response.php # HTTP response wrapper +│ ├── Services/ +│ │ ├── AccountService.php # Account operations +│ │ ├── TransactionService.php # Transaction operations +│ │ ├── PaymentService.php # Payment operations +│ │ └── SynchronizationService.php # Sync operations +│ ├── Models/ +│ │ ├── Account.php # Account resource +│ │ ├── Transaction.php # Transaction resource +│ │ ├── Payment.php # Payment resource +│ │ ├── Synchronization.php # Synchronization resource +│ │ ├── FinancialInstitution.php # Financial institution +│ │ └── PaginatedCollection.php # Paginated results +│ ├── Exceptions/ +│ │ ├── PontoException.php # Base exception +│ │ ├── AuthenticationException.php # Auth failures +│ │ ├── ValidationException.php # Validation errors +│ │ ├── NotFoundException.php # 404 errors +│ │ ├── RateLimitException.php # Rate limit errors +│ │ └── ApiException.php # API errors +│ └── Utils/ +│ ├── Validator.php # Input validation +│ └── DateHelper.php # Date formatting +├── tests/ +│ ├── Unit/ # Unit tests +│ ├── Integration/ # Integration tests +│ └── Pest.php # Pest configuration +├── examples/ +│ ├── authenticate.php +│ ├── list-accounts.php +│ ├── list-transactions.php +│ ├── create-payment.php +│ └── handle-synchronization.php +├── docs/ + └── developpement.md # This file +``` + +--- diff --git a/docs/developpement_part2.md b/docs/developpement_part2.md new file mode 100644 index 0000000..0aa0cbb --- /dev/null +++ b/docs/developpement_part2.md @@ -0,0 +1,446 @@ +## Module Breakdown + +### 1. Authentication Module (`Auth/`) +Handles OAuth2 client credentials flow, token lifecycle management, and secure storage. + +**Components:** +- `AuthProvider`: Manages token acquisition and refresh +- `TokenStorage`: Interface for token persistence +- `FileTokenStorage`: File-based token cache implementation + +**Responsibilities:** +- Obtain access tokens via OAuth2 +- Cache valid tokens to minimize API calls +- Automatically refresh expired tokens +- Secure token storage + +### 2. HTTP Client Module (`Http/`) +Abstracts HTTP communication with retry logic, error handling, and rate limiting. + +**Components:** +- `HttpClient`: Guzzle-based HTTP client wrapper +- `HttpClientInterface`: Contract for HTTP operations +- `Response`: Normalized response object + +**Responsibilities:** +- Execute HTTP requests (GET, POST, DELETE) +- Handle authentication headers +- Implement retry with exponential backoff +- Parse JSON:API responses +- Rate limit handling + +### 3. Services Module (`Services/`) +High-level business logic for interacting with Ponto API resources. + +**Components:** +- `AccountService`: Account listing and retrieval +- `TransactionService`: Transaction querying with filters +- `PaymentService`: Payment creation and management +- `SynchronizationService`: Data synchronization orchestration + +**Responsibilities:** +- Resource-specific operations +- Parameter validation +- Result mapping to models +- Pagination handling + +### 4. Models Module (`Models/`) +Immutable value objects representing API resources. + +**Components:** +- `Account`: Bank account with balances +- `Transaction`: Financial transaction +- `Payment`: Payment instruction +- `Synchronization`: Sync job status +- `FinancialInstitution`: Bank details +- `PaginatedCollection`: Collection with pagination metadata + +**Responsibilities:** +- Type-safe data containers +- JSON deserialization +- Business logic methods (e.g., `isSuccessful()`, `isPending()`) + +### 5. Exceptions Module (`Exceptions/`) +Hierarchy of exceptions for comprehensive error handling. + +**Components:** +- `PontoException`: Base exception +- `AuthenticationException`: OAuth/token errors +- `ValidationException`: Input validation failures +- `NotFoundException`: Resource not found (404) +- `RateLimitException`: Rate limit exceeded (429) +- `ApiException`: General API errors (4xx/5xx) + +### 6. Utilities Module (`Utils/`) +Helper classes for common operations. + +**Components:** +- `Validator`: Input validation (IBAN, BIC, amounts) +- `DateHelper`: Date formatting for API requests + +--- + +## Core Classes - Detailed Specifications + +### 1. Client Class + +**Namespace:** `AlchemicStudio\Ponto` +**Purpose:** Main entry point for the Ponto SDK + +#### Properties +```php +private Config $config; +private AuthProvider $authProvider; +private HttpClient $httpClient; +private ?AccountService $accountService = null; +private ?TransactionService $transactionService = null; +private ?PaymentService $paymentService = null; +private ?SynchronizationService $synchronizationService = null; +``` + +#### Constructor +```php +public function __construct( + string $clientId, + string $clientSecret, + string $baseUrl = 'https://api.myponto.com', + ?TokenStorage $tokenStorage = null, + array $httpOptions = [] +) +``` + +**Parameters:** +- `$clientId`: OAuth2 client ID from Ponto dashboard +- `$clientSecret`: OAuth2 client secret +- `$baseUrl`: API base URL (production or sandbox) +- `$tokenStorage`: Optional custom token storage (defaults to FileTokenStorage) +- `$httpOptions`: Guzzle HTTP client options + +#### Public Methods + +```php +/** + * Get account service for account operations + * + * @return AccountService + */ +public function accounts(): AccountService + +/** + * Get transaction service for transaction operations + * + * @return TransactionService + */ +public function transactions(): TransactionService + +/** + * Get payment service for payment operations + * + * @return PaymentService + * @throws AuthenticationException if 'pi' scope not granted + */ +public function payments(): PaymentService + +/** + * Get synchronization service + * + * @return SynchronizationService + */ +public function synchronizations(): SynchronizationService + +/** + * Force token refresh + * + * @return void + * @throws AuthenticationException on auth failure + */ +public function refreshToken(): void + +/** + * Check if payment initiation is enabled + * + * @return bool + */ +public function hasPaymentScope(): bool +``` + +**Thrown Exceptions:** +- `AuthenticationException`: Authentication failures +- `ApiException`: API communication errors + +--- + +### 2. AccountService Class + +**Namespace:** `AlchemicStudio\Ponto\Services` +**Purpose:** Manage account operations + +#### Constructor +```php +public function __construct( + private HttpClient $httpClient +) +``` + +#### Public Methods + +```php +/** + * List all accounts + * + * @param int $limit Number of results per page (1-100) + * @param string|null $after Cursor for next page + * @param string|null $before Cursor for previous page + * @return PaginatedCollection + * @throws ValidationException if limit out of range + * @throws ApiException on API errors + */ +public function list( + int $limit = 20, + ?string $after = null, + ?string $before = null +): PaginatedCollection + +/** + * Get single account by ID + * + * @param string $accountId UUID of the account + * @return Account + * @throws NotFoundException if account not found + * @throws ApiException on API errors + */ +public function get(string $accountId): Account + +/** + * Get synchronization metadata for an account + * + * @param string $accountId + * @return array{synchronizedAt: string, latestSynchronization: Synchronization} + * @throws NotFoundException if account not found + */ +public function getSyncMetadata(string $accountId): array +``` + +**Thrown Exceptions:** +- `ValidationException`: Invalid parameters +- `NotFoundException`: Account not found +- `ApiException`: API errors + +--- + +### 3. TransactionService Class + +**Namespace:** `AlchemicStudio\Ponto\Services` +**Purpose:** Query transactions with filtering and pagination + +#### Constructor +```php +public function __construct( + private HttpClient $httpClient +) +``` + +#### Public Methods + +```php +/** + * List transactions for an account + * + * @param string $accountId Account UUID + * @param array{ + * limit?: int, + * after?: string, + * before?: string, + * since?: string, + * until?: string + * } $filters Optional filters + * @return PaginatedCollection + * @throws NotFoundException if account not found + * @throws ValidationException if filters invalid + * @throws ApiException on API errors + */ +public function list(string $accountId, array $filters = []): PaginatedCollection + +/** + * Get single transaction + * + * @param string $accountId Account UUID + * @param string $transactionId Transaction UUID + * @return Transaction + * @throws NotFoundException if not found + * @throws ApiException on API errors + */ +public function get(string $accountId, string $transactionId): Transaction + +/** + * List pending transactions + * + * @param string $accountId Account UUID + * @param int $limit Results per page (1-100) + * @param string|null $after Cursor for next page + * @param string|null $before Cursor for previous page + * @return PaginatedCollection + * @throws NotFoundException if account not found + * @throws ApiException on API errors + */ +public function listPending( + string $accountId, + int $limit = 20, + ?string $after = null, + ?string $before = null +): PaginatedCollection + +/** + * Get updated transactions from a synchronization + * + * @param string $synchronizationId Synchronization UUID + * @return array + * @throws NotFoundException if sync not found + * @throws ApiException on API errors + */ +public function getUpdatedFromSync(string $synchronizationId): array +``` + +**Thrown Exceptions:** +- `ValidationException`: Invalid filters +- `NotFoundException`: Resource not found +- `ApiException`: API errors + +--- + +### 4. PaymentService Class + +**Namespace:** `AlchemicStudio\Ponto\Services` +**Purpose:** Create and manage payments + +#### Constructor +```php +public function __construct( + private HttpClient $httpClient +) +``` + +#### Public Methods + +```php +/** + * Create a payment + * + * @param string $accountId Debtor account UUID + * @param array{ + * amount: float, + * currency: string, + * creditorName: string, + * creditorAccountReference: string, + * creditorAccountReferenceType: string, + * creditorAgent: string, + * creditorAgentType: string, + * remittanceInformation: string, + * remittanceInformationType?: string, + * endToEndId?: string, + * requestedExecutionDate?: string, + * redirectUri?: string + * } $paymentData Payment details + * @return Payment + * @throws ValidationException if data invalid + * @throws AuthenticationException if 'pi' scope missing + * @throws ApiException on API errors + */ +public function create(string $accountId, array $paymentData): Payment + +/** + * Get payment details + * + * @param string $accountId Account UUID + * @param string $paymentId Payment UUID + * @return Payment + * @throws NotFoundException if not found + * @throws ApiException on API errors + */ +public function get(string $accountId, string $paymentId): Payment + +/** + * Delete (cancel) a payment + * + * @param string $accountId Account UUID + * @param string $paymentId Payment UUID + * @return void + * @throws NotFoundException if not found + * @throws ApiException on API errors + */ +public function delete(string $accountId, string $paymentId): void +``` + +**Thrown Exceptions:** +- `ValidationException`: Invalid payment data +- `AuthenticationException`: Missing payment scope +- `NotFoundException`: Resource not found +- `ApiException`: API errors + +--- + +### 5. SynchronizationService Class + +**Namespace:** `AlchemicStudio\Ponto\Services` +**Purpose:** Manage data synchronization + +#### Constructor +```php +public function __construct( + private HttpClient $httpClient +) +``` + +#### Public Methods + +```php +/** + * Create synchronization job + * + * @param string $resourceType 'account' or 'transaction' + * @param string $resourceId UUID of resource to sync + * @param string $subtype 'accountDetails', 'accountTransactions', etc. + * @param string|null $customerIpAddress Client IP for audit + * @return Synchronization + * @throws ValidationException if invalid parameters + * @throws ApiException on API errors (e.g., sync too soon) + */ +public function create( + string $resourceType, + string $resourceId, + string $subtype, + ?string $customerIpAddress = null +): Synchronization + +/** + * Get synchronization status + * + * @param string $synchronizationId Sync UUID + * @return Synchronization + * @throws NotFoundException if not found + * @throws ApiException on API errors + */ +public function get(string $synchronizationId): Synchronization + +/** + * Poll synchronization until complete or timeout + * + * @param string $synchronizationId Sync UUID + * @param int $maxAttempts Maximum polling attempts + * @param int $intervalSeconds Seconds between polls + * @return Synchronization + * @throws ApiException if timeout or sync fails + */ +public function pollUntilComplete( + string $synchronizationId, + int $maxAttempts = 20, + int $intervalSeconds = 3 +): Synchronization +``` + +**Thrown Exceptions:** +- `ValidationException`: Invalid parameters +- `NotFoundException`: Sync not found +- `ApiException`: API errors or sync failures + +--- diff --git a/docs/developpement_part3.md b/docs/developpement_part3.md new file mode 100644 index 0000000..10549c7 --- /dev/null +++ b/docs/developpement_part3.md @@ -0,0 +1,389 @@ +### 6. Model Classes + +#### Account Model + +**Namespace:** `AlchemicStudio\Ponto\Models` + +**Properties:** +```php +public readonly string $id; +public readonly string $type; // 'account' +public readonly string $reference; // IBAN +public readonly string $referenceType; // 'IBAN' +public readonly string $currency; // ISO 4217 +public readonly string $subtype; // 'checking', 'savings', etc. +public readonly float $availableBalance; +public readonly float $currentBalance; +public readonly string $holderName; +public readonly string $product; +public readonly ?string $description; +public readonly bool $deprecated; +public readonly \DateTimeImmutable $availableBalanceChangedAt; +public readonly \DateTimeImmutable $currentBalanceChangedAt; +public readonly \DateTimeImmutable $authorizedAt; +public readonly ?\DateTimeImmutable $authorizationExpirationExpectedAt; +public readonly string $internalReference; +public readonly array $meta; // Sync metadata +public readonly array $relationships; // Related resources +``` + +**Methods:** +```php +public static function fromArray(array $data): self +public function isDeprecated(): bool +public function needsReauthorization(): bool +public function toArray(): array +``` + +--- + +#### Transaction Model + +**Namespace:** `AlchemicStudio\Ponto\Models` + +**Properties:** +```php +public readonly string $id; +public readonly string $type; // 'transaction' +public readonly string $accountId; // Parent account +public readonly float $amount; +public readonly string $currency; +public readonly string $description; +public readonly string $digest; +public readonly ?\DateTimeImmutable $executionDate; +public readonly ?\DateTimeImmutable $valueDate; +public readonly \DateTimeImmutable $createdAt; +public readonly \DateTimeImmutable $updatedAt; +public readonly ?string $counterpartName; +public readonly ?string $counterpartReference; +public readonly ?string $remittanceInformation; +public readonly ?string $remittanceInformationType; +public readonly ?string $bankTransactionCode; +public readonly ?string $endToEndId; +public readonly ?string $mandateId; +public readonly ?string $creditorId; +public readonly ?string $purposeCode; +public readonly ?float $fee; +public readonly ?string $additionalInformation; +public readonly string $internalReference; +``` + +**Methods:** +```php +public static function fromArray(array $data): self +public function isCredit(): bool // amount > 0 +public function isDebit(): bool // amount < 0 +public function getAbsoluteAmount(): float +public function toArray(): array +``` + +--- + +#### Payment Model + +**Namespace:** `AlchemicStudio\Ponto\Models` + +**Properties:** +```php +public readonly string $id; +public readonly string $type; // 'payment' +public readonly string $status; // 'unsigned', 'authorized', 'executed', 'cancelled', 'rejected' +public readonly float $amount; +public readonly string $currency; +public readonly string $creditorName; +public readonly string $creditorAccountReference; +public readonly string $creditorAccountReferenceType; +public readonly string $creditorAgent; +public readonly string $creditorAgentType; +public readonly string $remittanceInformation; +public readonly string $remittanceInformationType; +public readonly ?string $endToEndId; +public readonly ?\DateTimeImmutable $requestedExecutionDate; +public readonly ?string $redirectUrl; // Authorization URL +``` + +**Methods:** +```php +public static function fromArray(array $data): self +public function isUnsigned(): bool +public function isAuthorized(): bool +public function isExecuted(): bool +public function isCancelled(): bool +public function isRejected(): bool +public function requiresAuthorization(): bool +public function toArray(): array +``` + +--- + +#### Synchronization Model + +**Namespace:** `AlchemicStudio\Ponto\Models` + +**Properties:** +```php +public readonly string $id; +public readonly string $type; // 'synchronization' +public readonly string $status; // 'pending', 'running', 'success', 'error' +public readonly string $resourceType; // 'account' +public readonly string $resourceId; +public readonly string $subtype; // 'accountDetails', 'accountTransactions' +public readonly array $errors; +public readonly \DateTimeImmutable $createdAt; +public readonly \DateTimeImmutable $updatedAt; +``` + +**Methods:** +```php +public static function fromArray(array $data): self +public function isPending(): bool +public function isRunning(): bool +public function isSuccessful(): bool +public function hasErrors(): bool +public function isComplete(): bool // success or error +public function toArray(): array +``` + +--- + +#### PaginatedCollection Model + +**Namespace:** `AlchemicStudio\Ponto\Models` + +**Type Parameters:** `@template T` + +**Properties:** +```php +/** @var array */ +public readonly array $data; +public readonly int $limit; +public readonly ?string $beforeCursor; +public readonly ?string $afterCursor; +public readonly ?string $firstUrl; +public readonly ?string $prevUrl; +public readonly ?string $nextUrl; +``` + +**Methods:** +```php +/** + * @param array $data + */ +public static function fromArray(array $data, array $meta, array $links): self +public function hasNextPage(): bool +public function hasPrevPage(): bool +public function isEmpty(): bool +public function count(): int +public function toArray(): array +``` + +--- + +### 7. AuthProvider Class + +**Namespace:** `AlchemicStudio\Ponto\Auth` +**Purpose:** OAuth2 token management + +#### Constructor +```php +public function __construct( + private string $clientId, + private string $clientSecret, + private string $tokenUrl, + private TokenStorage $tokenStorage, + private HttpClient $httpClient +) +``` + +#### Public Methods + +```php +/** + * Get valid access token (from cache or new) + * + * @return string Access token + * @throws AuthenticationException on auth failure + */ +public function getAccessToken(): string + +/** + * Force token refresh + * + * @return string New access token + * @throws AuthenticationException on auth failure + */ +public function refreshToken(): string + +/** + * Get token scopes + * + * @return array Scopes (e.g., ['ai', 'pi']) + */ +public function getScopes(): array + +/** + * Check if scope is granted + * + * @param string $scope Scope to check (e.g., 'pi') + * @return bool + */ +public function hasScope(string $scope): bool + +/** + * Get token expiration time + * + * @return \DateTimeImmutable|null + */ +public function getExpiresAt(): ?\DateTimeImmutable +``` + +**Thrown Exceptions:** +- `AuthenticationException`: OAuth failures, invalid credentials + +--- + +### 8. HttpClient Class + +**Namespace:** `AlchemicStudio\Ponto\Http` +**Purpose:** HTTP communication with retry logic + +#### Constructor +```php +public function __construct( + private \GuzzleHttp\Client $guzzle, + private AuthProvider $authProvider, + private int $maxRetries = 3, + private int $retryDelayMs = 1000 +) +``` + +#### Public Methods + +```php +/** + * Execute GET request + * + * @param string $path API path (e.g., '/accounts') + * @param array $query Query parameters + * @return array JSON-decoded response + * @throws ApiException on API errors + * @throws RateLimitException on 429 + */ +public function get(string $path, array $query = []): array + +/** + * Execute POST request + * + * @param string $path API path + * @param array $body Request body + * @param array $headers Additional headers + * @return array JSON-decoded response + * @throws ApiException on API errors + */ +public function post(string $path, array $body = [], array $headers = []): array + +/** + * Execute DELETE request + * + * @param string $path API path + * @return void + * @throws ApiException on API errors + */ +public function delete(string $path): void + +/** + * Get last response metadata + * + * @return array{statusCode: int, headers: array} + */ +public function getLastResponseMeta(): array +``` + +**Retry Logic:** +- Retries on network errors and 5xx responses +- Exponential backoff: 1s, 2s, 4s +- Does NOT retry 4xx errors (except 429 with Retry-After) +- Respects Retry-After header for 429 responses + +**Thrown Exceptions:** +- `AuthenticationException`: 401 responses +- `ValidationException`: 400 responses +- `NotFoundException`: 404 responses +- `RateLimitException`: 429 responses +- `ApiException`: Other 4xx/5xx responses + +--- + +### 9. Exception Hierarchy + +**Base Exception:** +```php +namespace AlchemicStudio\Ponto\Exceptions; + +class PontoException extends \Exception +{ + public function __construct( + string $message = '', + int $code = 0, + private ?array $errorDetails = null, + ?\Throwable $previous = null + ) { + parent::__construct($message, $code, $previous); + } + + public function getErrorDetails(): ?array + { + return $this->errorDetails; + } +} +``` + +**Specific Exceptions:** + +```php +// Authentication failures (401, invalid tokens) +class AuthenticationException extends PontoException {} + +// Validation errors (400, invalid input) +class ValidationException extends PontoException {} + +// Resource not found (404) +class NotFoundException extends PontoException {} + +// Rate limit exceeded (429) +class RateLimitException extends PontoException +{ + public function __construct( + string $message, + private ?int $retryAfterSeconds = null, + ?array $errorDetails = null + ) { + parent::__construct($message, 429, $errorDetails); + } + + public function getRetryAfterSeconds(): ?int + { + return $this->retryAfterSeconds; + } +} + +// General API errors (4xx/5xx) +class ApiException extends PontoException +{ + public function __construct( + string $message, + int $statusCode, + private ?string $errorCode = null, + ?array $errorDetails = null + ) { + parent::__construct($message, $statusCode, $errorDetails); + } + + public function getErrorCode(): ?string + { + return $this->errorCode; + } +} +``` + +--- diff --git a/docs/developpement_part4.md b/docs/developpement_part4.md new file mode 100644 index 0000000..bc70951 --- /dev/null +++ b/docs/developpement_part4.md @@ -0,0 +1,601 @@ +## Complete Usage Examples + +### 1. Authentication + +**Basic Client Initialization:** + +```php +accounts()->list(); + echo "Authenticated successfully!\n"; +} catch (AuthenticationException $e) { + echo "Authentication failed: " . $e->getMessage() . "\n"; + exit(1); +} +``` + +**Custom Token Storage:** + +```php +redis->get("ponto:token:{$key}"); + return $value !== false ? $value : null; + } + + public function set(string $key, string $value, int $ttl): void + { + $this->redis->setex("ponto:token:{$key}", $ttl, $value); + } + + public function delete(string $key): void + { + $this->redis->del("ponto:token:{$key}"); + } +} + +// Use custom storage +$redis = new \Redis(); +$redis->connect('127.0.0.1', 6379); + +$client = new Client( + clientId: $_ENV['PONTO_CLIENT_ID'], + clientSecret: $_ENV['PONTO_CLIENT_SECRET'], + tokenStorage: new RedisTokenStorage($redis) +); +``` + +--- + +### 2. Fetching Accounts + +**List All Accounts:** + +```php +accounts()->list(limit: 20); + +foreach ($accountsPage->data as $account) { + echo sprintf( + "Account: %s (%s)\n", + $account->reference, + $account->holderName + ); + echo sprintf( + " Available Balance: %.2f %s\n", + $account->availableBalance, + $account->currency + ); + echo sprintf( + " Current Balance: %.2f %s\n", + $account->currentBalance, + $account->currency + ); + echo "\n"; +} + +// Pagination +if ($accountsPage->hasNextPage()) { + $nextPage = $client->accounts()->list( + limit: 20, + after: $accountsPage->afterCursor + ); + echo "Loaded next page with " . count($nextPage->data) . " accounts\n"; +} +``` + +**Get Single Account:** + +```php +accounts()->get($accountId); + + echo "Account Details:\n"; + echo " IBAN: {$account->reference}\n"; + echo " Holder: {$account->holderName}\n"; + echo " Product: {$account->product}\n"; + echo " Available: {$account->availableBalance} {$account->currency}\n"; + + if ($account->needsReauthorization()) { + echo " ⚠️ Authorization expires soon!\n"; + } +} catch (NotFoundException $e) { + echo "Account not found\n"; +} +``` + +--- + +### 3. Fetching Transactions + +**List Transactions with Filters:** + +```php +format('Y-m-d'); +$until = (new \DateTimeImmutable())->format('Y-m-d'); + +$transactions = $client->transactions()->list( + accountId: $accountId, + filters: [ + 'limit' => 50, + 'since' => $since, + 'until' => $until, + ] +); + +echo "Found {$transactions->count()} transactions:\n\n"; + +foreach ($transactions->data as $tx) { + $type = $tx->isCredit() ? '💰 Credit' : '💸 Debit'; + echo sprintf( + "%s | %s | %.2f %s | %s\n", + $tx->executionDate?->format('Y-m-d') ?? 'N/A', + $type, + $tx->getAbsoluteAmount(), + $tx->currency, + $tx->description + ); + + if ($tx->counterpartName) { + echo " Counterpart: {$tx->counterpartName} ({$tx->counterpartReference})\n"; + } + if ($tx->remittanceInformation) { + echo " Reference: {$tx->remittanceInformation}\n"; + } + echo "\n"; +} + +// Pagination through all transactions +$allTransactions = []; +$currentPage = $transactions; + +do { + $allTransactions = array_merge($allTransactions, $currentPage->data); + + if ($currentPage->hasNextPage()) { + $currentPage = $client->transactions()->list( + accountId: $accountId, + filters: [ + 'limit' => 50, + 'after' => $currentPage->afterCursor, + 'since' => $since, + 'until' => $until, + ] + ); + } else { + break; + } +} while (true); + +echo "Total transactions loaded: " . count($allTransactions) . "\n"; +``` + +**Get Single Transaction:** + +```php +transactions()->get($accountId, $transactionId); + + echo "Transaction Details:\n"; + echo " ID: {$tx->id}\n"; + echo " Amount: {$tx->amount} {$tx->currency}\n"; + echo " Description: {$tx->description}\n"; + echo " Execution Date: " . $tx->executionDate?->format('Y-m-d H:i:s') . "\n"; + echo " Value Date: " . $tx->valueDate?->format('Y-m-d H:i:s') . "\n"; + + if ($tx->endToEndId) { + echo " End-to-End ID: {$tx->endToEndId}\n"; + } +} catch (NotFoundException $e) { + echo "Transaction not found\n"; +} +``` + +--- + +### 4. Creating Payments + +**Create Simple Payment:** + +```php +hasPaymentScope()) { + echo "Payment initiation not enabled for this integration\n"; + exit(1); +} + +$accountId = '2ca42e3a-357b-4dc4-a22f-197d049cd2fa'; + +try { + $payment = $client->payments()->create( + accountId: $accountId, + paymentData: [ + 'amount' => 59.00, + 'currency' => 'EUR', + 'creditorName' => 'Alex Creditor', + 'creditorAccountReference' => 'BE55732022998044', + 'creditorAccountReferenceType' => 'IBAN', + 'creditorAgent' => 'NBBEBEBB203', + 'creditorAgentType' => 'BIC', + 'remittanceInformation' => 'Invoice 2024-001', + 'remittanceInformationType' => 'unstructured', + 'endToEndId' => uniqid('payment_'), // Idempotency key + 'redirectUri' => 'https://your-app.com/payment-callback', + ] + ); + + echo "Payment created successfully!\n"; + echo " Payment ID: {$payment->id}\n"; + echo " Status: {$payment->status}\n"; + echo " Amount: {$payment->amount} {$payment->currency}\n"; + + if ($payment->requiresAuthorization()) { + echo "\n⚠️ Payment requires authorization\n"; + echo "Redirect user to: {$payment->redirectUrl}\n"; + } + +} catch (ValidationException $e) { + echo "Invalid payment data: " . $e->getMessage() . "\n"; + print_r($e->getErrorDetails()); +} catch (AuthenticationException $e) { + echo "Authentication error: " . $e->getMessage() . "\n"; +} +``` + +**Create Future-Dated Payment:** + +```php +format('Y-m-d'); + +$payment = $client->payments()->create( + accountId: $accountId, + paymentData: [ + 'amount' => 150.00, + 'currency' => 'EUR', + 'creditorName' => 'Supplier Ltd', + 'creditorAccountReference' => 'BE68539007547034', + 'creditorAccountReferenceType' => 'IBAN', + 'creditorAgent' => 'NBBEBEBB203', + 'creditorAgentType' => 'BIC', + 'remittanceInformation' => 'Monthly subscription', + 'requestedExecutionDate' => $futureDate, // Future-dated + ] +); + +echo "Future payment scheduled for {$futureDate}\n"; +``` + +**Check Payment Status:** + +```php +payments()->get($accountId, $paymentId); + +echo "Payment Status: {$payment->status}\n"; + +if ($payment->isExecuted()) { + echo "✅ Payment has been executed\n"; +} elseif ($payment->isAuthorized()) { + echo "⏳ Payment authorized, awaiting execution\n"; +} elseif ($payment->isUnsigned()) { + echo "⚠️ Payment awaiting authorization\n"; +} elseif ($payment->isRejected()) { + echo "❌ Payment rejected\n"; +} elseif ($payment->isCancelled()) { + echo "🚫 Payment cancelled\n"; +} +``` + +--- + +### 5. Synchronization + +**Sync Account Transactions:** + +```php +synchronizations()->create( + resourceType: 'account', + resourceId: $accountId, + subtype: 'accountTransactions', + customerIpAddress: $_SERVER['REMOTE_ADDR'] ?? null +); + +echo "Synchronization started: {$sync->id}\n"; +echo "Status: {$sync->status}\n"; + +// Poll until complete (max 60 seconds) +try { + $completedSync = $client->synchronizations()->pollUntilComplete( + synchronizationId: $sync->id, + maxAttempts: 20, + intervalSeconds: 3 + ); + + if ($completedSync->isSuccessful()) { + echo "✅ Synchronization completed successfully\n"; + + // Get updated transactions + $transactions = $client->transactions()->getUpdatedFromSync($sync->id); + echo "Retrieved " . count($transactions) . " updated transactions\n"; + + // Now fetch the latest transactions + $latestTransactions = $client->transactions()->list($accountId, ['limit' => 100]); + echo "Latest transaction count: {$latestTransactions->count()}\n"; + } else { + echo "❌ Synchronization failed\n"; + print_r($completedSync->errors); + } +} catch (\AlchemicStudio\Ponto\Exceptions\ApiException $e) { + echo "Synchronization error: " . $e->getMessage() . "\n"; +} +``` + +**Sync Account Details:** + +```php +synchronizations()->create( + resourceType: 'account', + resourceId: $accountId, + subtype: 'accountDetails' +); + +// Poll with custom intervals +$attempt = 0; +$maxAttempts = 10; + +while ($attempt < $maxAttempts) { + sleep(2); + $attempt++; + + $status = $client->synchronizations()->get($sync->id); + + echo "Attempt {$attempt}: {$status->status}\n"; + + if ($status->isComplete()) { + if ($status->isSuccessful()) { + echo "Account details synchronized\n"; + + // Fetch updated account + $account = $client->accounts()->get($accountId); + echo "Updated balance: {$account->availableBalance} {$account->currency}\n"; + } else { + echo "Sync failed: " . implode(', ', $status->errors) . "\n"; + } + break; + } +} +``` + +--- + +### 6. Error Handling & Retry Strategy + +**Comprehensive Error Handling:** + +```php +getRetryAfterSeconds() ?? 60; + echo "Rate limited. Retrying after {$retryAfter}s...\n"; + sleep($retryAfter); + $attempt++; + + } catch (AuthenticationException $e) { + echo "Authentication failed: " . $e->getMessage() . "\n"; + // Don't retry auth errors - fix credentials + throw $e; + + } catch (ValidationException $e) { + echo "Validation error: " . $e->getMessage() . "\n"; + print_r($e->getErrorDetails()); + // Don't retry validation errors - fix input + throw $e; + + } catch (NotFoundException $e) { + echo "Resource not found: " . $e->getMessage() . "\n"; + // Don't retry 404s + throw $e; + + } catch (ApiException $e) { + echo "API error ({$e->getCode()}): " . $e->getMessage() . "\n"; + + if ($e->getCode() >= 500) { + // Retry server errors with exponential backoff + $backoff = min(60, pow(2, $attempt)); + echo "Retrying in {$backoff}s...\n"; + sleep($backoff); + $attempt++; + } else { + // Don't retry 4xx errors + throw $e; + } + + } catch (PontoException $e) { + echo "Ponto error: " . $e->getMessage() . "\n"; + throw $e; + } + } + + throw new \RuntimeException("Max retries exceeded"); +} + +// Usage +try { + $accounts = safeApiCall(fn() => $client->accounts()->list()); + echo "Retrieved " . $accounts->count() . " accounts\n"; +} catch (\Throwable $e) { + echo "Failed to retrieve accounts: " . $e->getMessage() . "\n"; + // Log error, notify admin, etc. +} +``` + +--- + +### 7. Idempotency for Payments + +**Using endToEndId for Idempotency:** + +```php +payments()->create( + accountId: $accountId, + paymentData: [ + 'amount' => 100.00, + 'currency' => 'EUR', + 'creditorName' => 'Merchant', + 'creditorAccountReference' => 'BE68539007547034', + 'creditorAccountReferenceType' => 'IBAN', + 'creditorAgent' => 'NBBEBEBB203', + 'creditorAgentType' => 'BIC', + 'remittanceInformation' => 'Invoice 12345', + 'endToEndId' => $idempotencyKey, // Ensures uniqueness + ] + ); + + echo "Payment created: {$payment->id}\n"; + + // Store payment ID in your database + // savePaymentId($idempotencyKey, $payment->id); + + } catch (\AlchemicStudio\Ponto\Exceptions\ValidationException $e) { + // If duplicate endToEndId, payment may already exist + echo "Payment may already exist: " . $e->getMessage() . "\n"; + } +} + +// Safe to call multiple times - same endToEndId prevents duplicates +createPaymentIdempotent($client, $accountId, $idempotencyKey); +``` + +--- diff --git a/docs/developpement_part5.md b/docs/developpement_part5.md new file mode 100644 index 0000000..abecc03 --- /dev/null +++ b/docs/developpement_part5.md @@ -0,0 +1,482 @@ +## Configuration and Environment Variables + +### Environment Variables + +Create a `.env` file in your project root: + +```env +# Ponto API Credentials +PONTO_CLIENT_ID=8f0e9068-48f5-4b45-84ee-1e8adb386c0d +PONTO_CLIENT_SECRET=a9e726f9-8078-4f70-8570-e3c8600d7816 + +# Environment (production or sandbox) +PONTO_BASE_URL=https://api.myponto.com + +# Optional: Token storage +PONTO_TOKEN_STORAGE_PATH=/tmp/ponto_tokens + +# Optional: HTTP Client Configuration +PONTO_HTTP_TIMEOUT=30 +PONTO_HTTP_CONNECT_TIMEOUT=10 +PONTO_MAX_RETRIES=3 +PONTO_RETRY_DELAY_MS=1000 +``` + +### Configuration Class + +**`src/Config.php`:** + +```php +clientId, 'sandbox_'); + } + + public function getTokenUrl(): string + { + return $this->baseUrl . '/oauth2/token'; + } +} +``` + +**Usage:** + +```php +clientId, + clientSecret: $config->clientSecret, + baseUrl: $config->baseUrl, + httpOptions: [ + 'timeout' => $config->httpTimeout, + 'connect_timeout' => $config->connectTimeout, + ] +); + +// Or use Config directly in Client (alternative design) +// $client = Client::fromConfig($config); +``` + +--- + +## Authentication Strategy and Token Refresh + +### OAuth2 Flow + +Ponto uses the **Client Credentials Grant** (OAuth2): + +1. **Initial Authentication:** + ``` + POST /oauth2/token + Authorization: Basic base64(client_id:client_secret) + Content-Type: application/x-www-form-urlencoded + + grant_type=client_credentials + ``` + +2. **Response:** + ```json + { + "access_token": "sandbox_CSmOh54ps-...", + "expires_in": 1799, + "scope": "ai pi", + "token_type": "bearer" + } + ``` + +3. **Token Usage:** + ``` + GET /accounts + Authorization: Bearer sandbox_CSmOh54ps-... + ``` + +### Token Lifecycle + +- **Expiration:** Tokens expire after ~30 minutes (1799 seconds) +- **Caching:** Store tokens to avoid unnecessary OAuth requests +- **Refresh Strategy:** Refresh 5 minutes before expiration +- **Automatic Refresh:** On 401 responses, attempt token refresh once + +**Implementation in AuthProvider:** + +```php +cachedToken && $this->isTokenValid()) { + return $this->cachedToken; + } + + // Try token from storage + $storedToken = $this->tokenStorage->get($this->getStorageKey()); + if ($storedToken) { + $tokenData = json_decode($storedToken, true); + $expiresAt = new \DateTimeImmutable($tokenData['expires_at']); + + if ($expiresAt > new \DateTimeImmutable('+5 minutes')) { + $this->cachedToken = $tokenData['access_token']; + $this->expiresAt = $expiresAt; + $this->scopes = $tokenData['scopes'] ?? []; + return $this->cachedToken; + } + } + + // Fetch new token + return $this->refreshToken(); + } + + public function refreshToken(): string + { + $credentials = base64_encode("{$this->clientId}:{$this->clientSecret}"); + + try { + $response = $this->httpClient->postRaw( + url: $this->tokenUrl, + body: ['grant_type' => 'client_credentials'], + headers: [ + 'Authorization' => "Basic {$credentials}", + 'Content-Type' => 'application/x-www-form-urlencoded', + ] + ); + + $this->cachedToken = $response['access_token']; + $this->expiresAt = (new \DateTimeImmutable())->modify("+{$response['expires_in']} seconds"); + $this->scopes = explode(' ', $response['scope'] ?? ''); + + // Store token + $this->tokenStorage->set( + key: $this->getStorageKey(), + value: json_encode([ + 'access_token' => $this->cachedToken, + 'expires_at' => $this->expiresAt->format(\DateTimeInterface::ATOM), + 'scopes' => $this->scopes, + ]), + ttl: $response['expires_in'] + ); + + return $this->cachedToken; + + } catch (\Exception $e) { + throw new AuthenticationException( + "Failed to obtain access token: {$e->getMessage()}", + previous: $e + ); + } + } + + public function hasScope(string $scope): bool + { + return in_array($scope, $this->scopes, true); + } + + public function getScopes(): array + { + return $this->scopes; + } + + private function isTokenValid(): bool + { + if (/home/sebastien/Codes/ponto-phpthis->expiresAt) { + return false; + } + + // Consider token invalid 5 minutes before expiration + return $this->expiresAt > new \DateTimeImmutable('+5 minutes'); + } + + private function getStorageKey(): string + { + return 'access_token_' . substr(hash('sha256', $this->clientId), 0, 16); + } +} +``` + +--- + +## Security Best Practices + +### 1. Credential Management + +**❌ Don't:** +```php +// Hardcoded credentials in source code +$client = new Client('my-client-id', 'my-secret'); +``` + +**✅ Do:** +```php +// Use environment variables +$client = new Client( + clientId: $_ENV['PONTO_CLIENT_ID'], + clientSecret: $_ENV['PONTO_CLIENT_SECRET'] +); +``` + +### 2. Secure Token Storage + +**File Storage Permissions:** +```php +storagePath)) { + mkdir($this->storagePath, 0700, true); // Owner-only permissions + } + } + + public function set(string $key, string $value, int $ttl): void + { + $filePath = $this->getFilePath($key); + $data = [ + 'value' => $value, + 'expires_at' => time() + $ttl, + ]; + + file_put_contents($filePath, json_encode($data), LOCK_EX); + chmod($filePath, 0600); // Owner read/write only + } + + public function get(string $key): ?string + { + $filePath = $this->getFilePath($key); + + if (!file_exists($filePath)) { + return null; + } + + $data = json_decode(file_get_contents($filePath), true); + + if ($data['expires_at'] < time()) { + $this->delete($key); + return null; + } + + return $data['value']; + } + + public function delete(string $key): void + { + $filePath = $this->getFilePath($key); + if (file_exists($filePath)) { + unlink($filePath); + } + } + + private function getFilePath(string $key): string + { + return $this->storagePath . '/' . hash('sha256', $key) . '.json'; + } +} +``` + +### 3. No Credential Logging + +**❌ Don't:** +```php +// Never log credentials or tokens +error_log("Token: " . $accessToken); +$logger->info("Secret: {$clientSecret}"); +``` + +**✅ Do:** +```php +// Log operations without sensitive data +$logger->info('Authentication successful', [ + 'client_id' => substr($clientId, 0, 8) . '...', + 'scopes' => $scopes, +]); +``` + +### 4. TLS/HTTPS Only + +```php +// Enforce HTTPS +if (parse_url($baseUrl, PHP_URL_SCHEME) !== 'https') { + throw new \InvalidArgumentException('Ponto API requires HTTPS'); +} + +// Guzzle HTTP client with TLS verification +$guzzle = new \GuzzleHttp\Client([ + 'base_uri' => $baseUrl, + 'verify' => true, // Verify SSL certificates + 'timeout' => 30, +]); +``` + +### 5. Idempotency Keys + +Always use `endToEndId` for payments to prevent duplicate transactions: + +```php +// Generate unique but deterministic idempotency key +$idempotencyKey = hash('sha256', implode('|', [ + $invoiceId, + $amount, + $currency, + $creditorIban, + date('Y-m-d'), +])); + +$payment = $client->payments()->create($accountId, [ + 'endToEndId' => $idempotencyKey, + // ... other fields +]); +``` + +### 6. Input Validation + +**Validation Helper:** + +```php + 999999999.99) { + throw new ValidationException("Amount too large: {$amount}"); + } + } + + public static function validateCurrency(string $currency): string + { + $currency = strtoupper($currency); + + if (!preg_match('/^[A-Z]{3}$/', $currency)) { + throw new ValidationException("Invalid currency code: {$currency}"); + } + + return $currency; + } + + public static function validateRemittanceInfo(string $info): string + { + $maxLength = 140; + $allowedChars = '/^[a-zA-Z0-9\\/\\-\\?:\\(\\)\\.\\,\'\\+ ]+$/'; + + if (strlen($info) > $maxLength) { + throw new ValidationException("Remittance information too long (max {$maxLength} chars)"); + } + + if (!preg_match($allowedChars, $info)) { + throw new ValidationException("Remittance information contains invalid characters"); + } + + return $info; + } +} +``` + +--- diff --git a/docs/developpement_part6.md b/docs/developpement_part6.md new file mode 100644 index 0000000..f46706e --- /dev/null +++ b/docs/developpement_part6.md @@ -0,0 +1,571 @@ +## Testing Approach + +### Testing Strategy + +The library uses **Pest v4.1** for testing with a functional approach. + +**Test Types:** +1. **Unit Tests**: Test individual classes in isolation with mocks +2. **Integration Tests**: Test against Ponto sandbox API +3. **Feature Tests**: Test complete user workflows + +### Unit Tests + +**Example: Testing TransactionService** + +**`tests/Unit/Services/TransactionServiceTest.php`:** + +```php +httpClient = Mockery::mock(HttpClient::class); + $this->service = new TransactionService($this->httpClient); +}); + +afterEach(function () { + Mockery::close(); +}); + +test('list transactions returns paginated collection', function () { + $accountId = 'acc-123'; + $mockResponse = [ + 'data' => [ + [ + 'id' => 'tx-1', + 'type' => 'transaction', + 'attributes' => [ + 'amount' => 100.50, + 'currency' => 'EUR', + 'description' => 'Test transaction', + 'executionDate' => '2024-02-20T10:00:00Z', + 'createdAt' => '2024-02-20T10:00:00Z', + 'updatedAt' => '2024-02-20T10:00:00Z', + ], + 'relationships' => [ + 'account' => [ + 'data' => ['id' => $accountId, 'type' => 'account'], + ], + ], + ], + ], + 'meta' => [ + 'paging' => [ + 'limit' => 20, + ], + ], + 'links' => [], + ]; + + $this->httpClient + ->shouldReceive('get') + ->once() + ->with("/accounts/{$accountId}/transactions", ['limit' => 20]) + ->andReturn($mockResponse); + + $result = $this->service->list($accountId); + + expect($result)->toBeInstanceOf(PaginatedCollection::class); + expect($result->count())->toBe(1); + expect($result->data[0])->toBeInstanceOf(Transaction::class); + expect($result->data[0]->amount)->toBe(100.50); +}); + +test('list transactions with filters applies correct query parameters', function () { + $accountId = 'acc-123'; + $filters = [ + 'limit' => 50, + 'since' => '2024-01-01', + 'until' => '2024-12-31', + 'after' => 'cursor-123', + ]; + + $this->httpClient + ->shouldReceive('get') + ->once() + ->with("/accounts/{$accountId}/transactions", $filters) + ->andReturn(['data' => [], 'meta' => ['paging' => []], 'links' => []]); + + $this->service->list($accountId, $filters); +}); + +test('get transaction by id returns single transaction', function () { + $accountId = 'acc-123'; + $transactionId = 'tx-456'; + + $mockResponse = [ + 'data' => [ + 'id' => $transactionId, + 'type' => 'transaction', + 'attributes' => [ + 'amount' => -50.00, + 'currency' => 'EUR', + 'description' => 'Payment', + 'executionDate' => '2024-02-20T10:00:00Z', + 'createdAt' => '2024-02-20T10:00:00Z', + 'updatedAt' => '2024-02-20T10:00:00Z', + ], + 'relationships' => [ + 'account' => [ + 'data' => ['id' => $accountId, 'type' => 'account'], + ], + ], + ], + ]; + + $this->httpClient + ->shouldReceive('get') + ->once() + ->with("/accounts/{$accountId}/transactions/{$transactionId}", []) + ->andReturn($mockResponse); + + $transaction = $this->service->get($accountId, $transactionId); + + expect($transaction)->toBeInstanceOf(Transaction::class); + expect($transaction->id)->toBe($transactionId); + expect($transaction->isDebit())->toBeTrue(); +}); + +test('get non-existent transaction throws NotFoundException', function () { + $accountId = 'acc-123'; + $transactionId = 'tx-nonexistent'; + + $this->httpClient + ->shouldReceive('get') + ->once() + ->andThrow(new NotFoundException("Transaction not found")); + + $this->service->get($accountId, $transactionId); +})->throws(NotFoundException::class); +``` + +**Example: Testing Payment Model** + +**`tests/Unit/Models/PaymentTest.php`:** + +```php + 'pay-123', + 'type' => 'payment', + 'attributes' => [ + 'status' => 'unsigned', + 'amount' => 100.00, + 'currency' => 'EUR', + 'creditorName' => 'John Doe', + 'creditorAccountReference' => 'BE68539007547034', + 'creditorAccountReferenceType' => 'IBAN', + 'creditorAgent' => 'NBBEBEBB203', + 'creditorAgentType' => 'BIC', + 'remittanceInformation' => 'Invoice 123', + 'remittanceInformationType' => 'unstructured', + ], + 'links' => [ + 'redirect' => 'https://authorize.myponto.com/payment/pay-123', + ], + ]; + + $payment = Payment::fromArray($data); + + expect($payment->id)->toBe('pay-123'); + expect($payment->status)->toBe('unsigned'); + expect($payment->amount)->toBe(100.00); + expect($payment->creditorName)->toBe('John Doe'); + expect($payment->redirectUrl)->toBe('https://authorize.myponto.com/payment/pay-123'); +}); + +test('payment status checks work correctly', function () { + $createPayment = fn($status) => Payment::fromArray([ + 'id' => 'pay-123', + 'type' => 'payment', + 'attributes' => [ + 'status' => $status, + 'amount' => 100, + 'currency' => 'EUR', + 'creditorName' => 'Test', + 'creditorAccountReference' => 'BE68539007547034', + 'creditorAccountReferenceType' => 'IBAN', + 'creditorAgent' => 'NBBEBEBB203', + 'creditorAgentType' => 'BIC', + 'remittanceInformation' => 'Test', + 'remittanceInformationType' => 'unstructured', + ], + ]); + + expect($createPayment('unsigned')->isUnsigned())->toBeTrue(); + expect($createPayment('authorized')->isAuthorized())->toBeTrue(); + expect($createPayment('executed')->isExecuted())->toBeTrue(); + expect($createPayment('cancelled')->isCancelled())->toBeTrue(); + expect($createPayment('rejected')->isRejected())->toBeTrue(); + + expect($createPayment('unsigned')->requiresAuthorization())->toBeTrue(); + expect($createPayment('executed')->requiresAuthorization())->toBeFalse(); +}); + +test('payment converts to array correctly', function () { + $payment = Payment::fromArray([ + 'id' => 'pay-123', + 'type' => 'payment', + 'attributes' => [ + 'status' => 'executed', + 'amount' => 50.50, + 'currency' => 'EUR', + 'creditorName' => 'Test', + 'creditorAccountReference' => 'BE68539007547034', + 'creditorAccountReferenceType' => 'IBAN', + 'creditorAgent' => 'NBBEBEBB203', + 'creditorAgentType' => 'BIC', + 'remittanceInformation' => 'Test payment', + 'remittanceInformationType' => 'unstructured', + ], + ]); + + $array = $payment->toArray(); + + expect($array)->toHaveKey('id', 'pay-123'); + expect($array)->toHaveKey('status', 'executed'); + expect($array)->toHaveKey('amount', 50.50); +}); +``` + +**Example: Testing Validator** + +**`tests/Unit/Utils/ValidatorTest.php`:** + +```php +toBe('BE68539007547034'); +}); + +test('rejects invalid IBAN', function () { + Validator::validateIban('INVALID'); +})->throws(ValidationException::class); + +test('validates valid BIC', function () { + $bic = Validator::validateBic('NBBEBEBB203'); + expect($bic)->toBe('NBBEBEBB203'); +}); + +test('rejects invalid BIC', function () { + Validator::validateBic('INVALID'); +})->throws(ValidationException::class); + +test('validates positive amounts', function () { + expect(fn() => Validator::validateAmount(100.50))->not->toThrow(ValidationException::class); +}); + +test('rejects negative amounts', function () { + Validator::validateAmount(-50.00); +})->throws(ValidationException::class); + +test('rejects zero amount', function () { + Validator::validateAmount(0); +})->throws(ValidationException::class); + +test('validates currency codes', function () { + $currency = Validator::validateCurrency('eur'); + expect($currency)->toBe('EUR'); +}); + +test('rejects invalid currency', function () { + Validator::validateCurrency('INVALID'); +})->throws(ValidationException::class); +``` + +### Integration Tests + +**Example: Sandbox Integration Test** + +**`tests/Integration/AccountServiceIntegrationTest.php`:** + +```php +markTestSkipped('Sandbox credentials not configured'); + } + + $this->client = new Client( + clientId: getenv('PONTO_SANDBOX_CLIENT_ID'), + clientSecret: getenv('PONTO_SANDBOX_CLIENT_SECRET'), + baseUrl: 'https://api.myponto.com' + ); +}); + +test('can list accounts from sandbox', function () { + $accounts = $this->client->accounts()->list(limit: 10); + + expect($accounts)->toBeInstanceOf(PaginatedCollection::class); + expect($accounts->data)->toBeArray(); + + if ($accounts->count() > 0) { + expect($accounts->data[0])->toBeInstanceOf(Account::class); + expect($accounts->data[0]->currency)->toBeString(); + } +})->group('integration', 'sandbox'); + +test('can get single account from sandbox', function () { + $accounts = $this->client->accounts()->list(limit: 1); + + if ($accounts->isEmpty()) { + test()->markTestSkipped('No accounts available in sandbox'); + } + + $accountId = $accounts->data[0]->id; + $account = $this->client->accounts()->get($accountId); + + expect($account)->toBeInstanceOf(Account::class); + expect($account->id)->toBe($accountId); + expect($account->reference)->toBeString(); + expect($account->holderName)->toBeString(); +})->group('integration', 'sandbox'); + +test('can list transactions from sandbox account', function () { + $accounts = $this->client->accounts()->list(limit: 1); + + if ($accounts->isEmpty()) { + test()->markTestSkipped('No accounts available'); + } + + $accountId = $accounts->data[0]->id; + $transactions = $this->client->transactions()->list($accountId, ['limit' => 20]); + + expect($transactions)->toBeInstanceOf(PaginatedCollection::class); + expect($transactions->data)->toBeArray(); +})->group('integration', 'sandbox'); +``` + +### Running Tests + +```bash +# Run all tests +composer test + +# Run only unit tests +./vendor/bin/pest --filter Unit + +# Run only integration tests (requires sandbox credentials) +PONTO_SANDBOX_CLIENT_ID=your_id PONTO_SANDBOX_CLIENT_SECRET=your_secret \ +./vendor/bin/pest --group=integration + +# Run with coverage +composer test-coverage + +# Run specific test file +./vendor/bin/pest tests/Unit/Models/PaymentTest.php +``` + +--- + +## Framework Integration + +### Laravel Integration + +**Service Provider:** + +**`src/Laravel/PontoServiceProvider.php`:** + +```php +mergeConfigFrom(__DIR__ . '/../../config/ponto.php', 'ponto'); + + $this->app->singleton(Client::class, function ($app) { + $config = $app['config']['ponto']; + + return new Client( + clientId: $config['client_id'], + clientSecret: $config['client_secret'], + baseUrl: $config['base_url'], + tokenStorage: new FileTokenStorage($config['token_storage_path']), + httpOptions: [ + 'timeout' => $config['http_timeout'], + 'connect_timeout' => $config['connect_timeout'], + ] + ); + }); + + $this->app->alias(Client::class, 'ponto'); + } + + public function boot(): void + { + if ($this->app->runningInConsole()) { + $this->publishes([ + __DIR__ . '/../../config/ponto.php' => config_path('ponto.php'), + ], 'ponto-config'); + } + } + + public function provides(): array + { + return [Client::class, 'ponto']; + } +} +``` + +**Configuration File:** + +**`config/ponto.php`:** + +```php + env('PONTO_CLIENT_ID'), + 'client_secret' => env('PONTO_CLIENT_SECRET'), + 'base_url' => env('PONTO_BASE_URL', 'https://api.myponto.com'), + 'token_storage_path' => env('PONTO_TOKEN_STORAGE_PATH', storage_path('app/ponto')), + 'http_timeout' => env('PONTO_HTTP_TIMEOUT', 30), + 'connect_timeout' => env('PONTO_HTTP_CONNECT_TIMEOUT', 10), +]; +``` + +**Facade:** + +**`src/Laravel/Facades/Ponto.php`:** + +```php +list(limit: 50); + + return view('accounts.index', ['accounts' => $accounts->data]); + } + + public function transactions(string $accountId) + { + $transactions = Ponto::transactions()->list($accountId, [ + 'limit' => 100, + 'since' => now()->subDays(30)->format('Y-m-d'), + ]); + + return view('transactions.index', [ + 'transactions' => $transactions->data, + 'pagination' => $transactions, + ]); + } +} +``` + +**Artisan Command Example:** + +```php +argument('accountId'); + + $this->info("Starting synchronization for account {$accountId}..."); + + $sync = $ponto->synchronizations()->create( + resourceType: 'account', + resourceId: $accountId, + subtype: 'accountTransactions' + ); + + $this->info("Synchronization created: {$sync->id}"); + + $completed = $ponto->synchronizations()->pollUntilComplete($sync->id); + + if ($completed->isSuccessful()) { + $this->info('✅ Synchronization completed successfully'); + + $transactions = $ponto->transactions()->getUpdatedFromSync($sync->id); + $this->info("Retrieved " . count($transactions) . " updated transactions"); + + return Command::SUCCESS; + } + + $this->error('❌ Synchronization failed'); + return Command::FAILURE; + } +} +``` + +--- diff --git a/docs/developpement_part7.md b/docs/developpement_part7.md new file mode 100644 index 0000000..fa1cc13 --- /dev/null +++ b/docs/developpement_part7.md @@ -0,0 +1,505 @@ +## Compliance Checklist + +### API Endpoints Coverage + +| Endpoint | Method | Status | Implementation | +|----------|--------|--------|----------------| +| `/oauth2/token` | POST | ✅ Complete | `AuthProvider::refreshToken()` | +| `/accounts` | GET | ✅ Complete | `AccountService::list()` | +| `/accounts/{id}` | GET | ✅ Complete | `AccountService::get()` | +| `/accounts/{id}/transactions` | GET | ✅ Complete | `TransactionService::list()` | +| `/accounts/{id}/transactions/{id}` | GET | ✅ Complete | `TransactionService::get()` | +| `/accounts/{id}/pending-transactions` | GET | ✅ Complete | `TransactionService::listPending()` | +| `/accounts/{id}/payments` | POST | ✅ Complete | `PaymentService::create()` | +| `/accounts/{id}/payments/{id}` | GET | ✅ Complete | `PaymentService::get()` | +| `/accounts/{id}/payments/{id}` | DELETE | ✅ Complete | `PaymentService::delete()` | +| `/accounts/{id}/bulk-payments` | POST | 🟡 Planned | Future enhancement | +| `/accounts/{id}/payment-requests` | POST | 🟡 Planned | Future enhancement | +| `/synchronizations` | POST | ✅ Complete | `SynchronizationService::create()` | +| `/synchronizations/{id}` | GET | ✅ Complete | `SynchronizationService::get()` | +| `/synchronizations/{id}/updated-transactions` | GET | ✅ Complete | `TransactionService::getUpdatedFromSync()` | +| `/financial-institutions` | GET | 🟡 Planned | Future enhancement | + +### Fields Supported + +#### Account Model +- ✅ `id`, `type`, `reference`, `referenceType` +- ✅ `currency`, `subtype`, `product`, `description` +- ✅ `availableBalance`, `currentBalance` +- ✅ `holderName`, `deprecated` +- ✅ `authorizedAt`, `authorizationExpirationExpectedAt` +- ✅ `internalReference` +- ✅ Synchronization metadata + +#### Transaction Model +- ✅ `id`, `type`, `amount`, `currency`, `description` +- ✅ `digest`, `executionDate`, `valueDate` +- ✅ `counterpartName`, `counterpartReference` +- ✅ `remittanceInformation`, `remittanceInformationType` +- ✅ `bankTransactionCode`, `endToEndId` +- ✅ `mandateId`, `creditorId`, `purposeCode` +- ✅ `fee`, `additionalInformation` +- ✅ `createdAt`, `updatedAt`, `internalReference` + +#### Payment Model +- ✅ `id`, `type`, `status` +- ✅ `amount`, `currency` +- ✅ `creditorName`, `creditorAccountReference`, `creditorAccountReferenceType` +- ✅ `creditorAgent`, `creditorAgentType` +- ✅ `remittanceInformation`, `remittanceInformationType` +- ✅ `endToEndId`, `requestedExecutionDate` +- ✅ `redirectUrl` for authorization flow + +#### Synchronization Model +- ✅ `id`, `type`, `status` +- ✅ `resourceType`, `resourceId`, `subtype` +- ✅ `errors`, `createdAt`, `updatedAt` + +### Features Implemented + +| Feature | Status | Notes | +|---------|--------|-------| +| OAuth2 Client Credentials | ✅ Complete | With token caching and refresh | +| Token Storage Interface | ✅ Complete | File-based implementation | +| Account Information | ✅ Complete | List and get accounts | +| Transaction Retrieval | ✅ Complete | With filtering and pagination | +| Payment Initiation | ✅ Complete | Single payments with authorization | +| Future-Dated Payments | ✅ Complete | Via `requestedExecutionDate` | +| Synchronization | ✅ Complete | With polling helper | +| Pagination | ✅ Complete | Cursor-based navigation | +| Error Handling | ✅ Complete | Comprehensive exception hierarchy | +| Retry Logic | ✅ Complete | Exponential backoff | +| Rate Limiting | ✅ Complete | Respects `Retry-After` header | +| Idempotency | ✅ Complete | Via `endToEndId` | +| Input Validation | ✅ Complete | IBAN, BIC, amounts, etc. | +| Type Safety | ✅ Complete | Full PHP 8.4 types | +| Sandbox Support | ✅ Complete | Same API, different credentials | +| Laravel Integration | ✅ Complete | ServiceProvider and Facade | + +### Known Limitations + +| Limitation | Workaround | Priority | +|------------|------------|----------| +| Bulk Payments Not Implemented | Use multiple single payments | Medium | +| Payment Requests Not Implemented | Use standard payments | Low | +| Financial Institution Listing Not Implemented | Use dashboard for linking | Low | +| [ASSUMPTION] No webhooks support | Poll synchronizations | Low | +| [ASSUMPTION] No payment status webhooks | Poll payment status | Medium | +| Synchronization rate limit (30 min) | Cache results, plan syncs carefully | High | + +### Assumptions and Safe Defaults + +1. **[ASSUMPTION]** Token expiration buffer: 5 minutes before actual expiration +2. **[ASSUMPTION]** Default pagination limit: 20 items (max 100) +3. **[ASSUMPTION]** HTTP timeout: 30 seconds (configurable) +4. **[ASSUMPTION]** Max retries: 3 attempts with exponential backoff +5. **[ASSUMPTION]** Synchronization polling: 20 attempts at 3-second intervals (60s total) +6. **[ASSUMPTION]** File token storage default path: `/tmp/ponto_tokens` +7. **[ASSUMPTION]** IBAN/BIC validation: Basic format checks (not checksum validation) +8. **[ASSUMPTION]** Currency validation: ISO 4217 format check only + +--- + +## Versioning and Continuous Integration + +### Semantic Versioning + +Follow [SemVer 2.0.0](https://semver.org/): + +- **MAJOR** version: Breaking changes (e.g., 1.0.0 → 2.0.0) +- **MINOR** version: New features, backward compatible (e.g., 1.0.0 → 1.1.0) +- **PATCH** version: Bug fixes (e.g., 1.0.0 → 1.0.1) + +**Example Version History:** +``` +v1.0.0 - Initial release with core features +v1.1.0 - Add bulk payments support +v1.1.1 - Fix token refresh edge case +v2.0.0 - Refactor to use PSR-18 HTTP client interface +``` + +### Git Workflow + +```bash +# Feature branches +git checkout -b feature/bulk-payments +git commit -m "feat: add bulk payment support" +git push origin feature/bulk-payments + +# Hotfix branches +git checkout -b hotfix/token-expiration +git commit -m "fix: token expiration edge case" +git push origin hotfix/token-expiration + +# Release tagging +git tag -a v1.0.0 -m "Release version 1.0.0" +git push origin v1.0.0 +``` + +### Continuous Integration (GitHub Actions) + +**`.github/workflows/tests.yml`:** + +```yaml +name: Tests + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main, develop ] + +jobs: + test: + runs-on: ubuntu-latest + + strategy: + matrix: + php-version: ['8.4'] + + steps: + - uses: actions/checkout@v3 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + extensions: json, mbstring, curl + coverage: xdebug + + - name: Validate composer.json + run: composer validate --strict + + - name: Cache Composer packages + uses: actions/cache@v3 + with: + path: vendor + key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }} + restore-keys: | + ${{ runner.os }}-php- + + - name: Install dependencies + run: composer install --prefer-dist --no-progress + + - name: Run PHPStan + run: composer stan + + - name: Run PHP CS Fixer (check) + run: vendor/bin/php-cs-fixer fix --dry-run --diff + + - name: Run Pest Tests + run: composer test + + - name: Run Pest Tests with Coverage + run: composer test-coverage + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v3 + with: + files: ./coverage.xml + flags: unittests + name: codecov-umbrella + + integration: + runs-on: ubuntu-latest + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + + steps: + - uses: actions/checkout@v3 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + extensions: json, mbstring, curl + + - name: Install dependencies + run: composer install --prefer-dist --no-progress + + - name: Run Integration Tests + env: + PONTO_SANDBOX_CLIENT_ID: ${{ secrets.PONTO_SANDBOX_CLIENT_ID }} + PONTO_SANDBOX_CLIENT_SECRET: ${{ secrets.PONTO_SANDBOX_CLIENT_SECRET }} + run: ./vendor/bin/pest --group=integration +``` + +**`.github/workflows/release.yml`:** + +```yaml +name: Release + +on: + push: + tags: + - 'v*' + +jobs: + release: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + + - name: Create Release + uses: actions/create-release@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: ${{ github.ref }} + release_name: Release ${{ github.ref }} + draft: false + prerelease: false +``` + +### Quality Gates + +Before merging to main: +1. ✅ All tests pass (unit + integration) +2. ✅ PHPStan level max passes +3. ✅ PHP CS Fixer formatting applied +4. ✅ Code coverage ≥ 80% +5. ✅ Documentation updated +6. ✅ CHANGELOG.md updated + +### Pre-commit Hook + +**`.git/hooks/pre-commit`:** + +```bash +#!/bin/bash + +echo "Running pre-commit checks..." + +# Format code +composer format + +# Static analysis +composer stan +if [ $? -ne 0 ]; then + echo "❌ PHPStan failed" + exit 1 +fi + +# Tests +composer test +if [ $? -ne 0 ]; then + echo "❌ Tests failed" + exit 1 +fi + +echo "✅ All checks passed" +exit 0 +``` + +--- + +## Complete Implementation Example + +### Minimal Working Client + +**`src/Client.php`:** + +```php + $baseUrl, + 'timeout' => 30, + 'connect_timeout' => 10, + 'verify' => true, + ], $httpOptions)); + + $this->httpClient = new HttpClient($guzzle); + + $this->authProvider = new AuthProvider( + clientId: $clientId, + clientSecret: $clientSecret, + tokenUrl: $baseUrl . '/oauth2/token', + tokenStorage: $tokenStorage, + httpClient: $this->httpClient + ); + + $this->httpClient->setAuthProvider($this->authProvider); + } + + public function accounts(): AccountService + { + return $this->accountService ??= new AccountService($this->httpClient); + } + + public function transactions(): TransactionService + { + return $this->transactionService ??= new TransactionService($this->httpClient); + } + + public function payments(): PaymentService + { + if (/home/sebastien/Codes/ponto-phpthis->hasPaymentScope()) { + throw new AuthenticationException('Payment initiation scope (pi) not granted'); + } + + return $this->paymentService ??= new PaymentService($this->httpClient); + } + + public function synchronizations(): SynchronizationService + { + return $this->synchronizationService ??= new SynchronizationService($this->httpClient); + } + + public function refreshToken(): void + { + $this->authProvider->refreshToken(); + } + + public function hasPaymentScope(): bool + { + return $this->authProvider->hasScope('pi'); + } +} +``` + +### Quick Start Example + +**`examples/quickstart.php`:** + +```php +accounts()->list(limit: 10); +echo " Found {$accounts->count()} accounts\n\n"; + +foreach ($accounts->data as $account) { + echo " - {$account->reference} ({$account->holderName})\n"; + echo " Balance: {$account->availableBalance} {$account->currency}\n"; +} + +// 2. Get transactions for first account +if ($accounts->count() > 0) { + $accountId = $accounts->data[0]->id; + + echo "\n2. Fetching transactions...\n"; + $transactions = $client->transactions()->list($accountId, ['limit' => 5]); + echo " Found {$transactions->count()} recent transactions\n\n"; + + foreach ($transactions->data as $tx) { + $type = $tx->isCredit() ? '💰' : '💸'; + echo " {$type} {$tx->amount} {$tx->currency} - {$tx->description}\n"; + } +} + +// 3. Create a payment (if scope available) +if ($client->hasPaymentScope() && $accounts->count() > 0) { + echo "\n3. Creating test payment...\n"; + + try { + $payment = $client->payments()->create($accountId, [ + 'amount' => 10.00, + 'currency' => 'EUR', + 'creditorName' => 'Test Recipient', + 'creditorAccountReference' => 'BE68539007547034', + 'creditorAccountReferenceType' => 'IBAN', + 'creditorAgent' => 'NBBEBEBB203', + 'creditorAgentType' => 'BIC', + 'remittanceInformation' => 'Test payment from PHP library', + 'endToEndId' => uniqid('test_'), + ]); + + echo " Payment created: {$payment->id}\n"; + echo " Status: {$payment->status}\n"; + + if ($payment->redirectUrl) { + echo " Authorize at: {$payment->redirectUrl}\n"; + } + } catch (\Exception $e) { + echo " Payment failed: {$e->getMessage()}\n"; + } +} + +echo "\n=== Done ===\n"; +``` + +--- + +## Summary + +This documentation provides a comprehensive guide to implementing the **ponto-php** library for integrating with the Ponto API. + +**Key Takeaways:** + +1. **Architecture**: Modular design with Services, Models, Auth, and HTTP layers +2. **Type Safety**: Full PHP 8.4 type declarations throughout +3. **Standards Compliance**: PSR-4, PSR-12, and REST best practices +4. **Security**: Environment-based configuration, secure token storage, TLS enforcement +5. **Error Handling**: Comprehensive exception hierarchy with retry logic +6. **Testing**: Pest-based unit and integration tests +7. **Framework Support**: Laravel ServiceProvider and Facade included +8. **Production Ready**: Token management, rate limiting, idempotency support + +**Next Steps:** + +1. Review the Ponto API documentation at https://documentation.myponto.com +2. Obtain sandbox credentials from https://myponto.com +3. Implement the core classes following the specifications above +4. Write comprehensive tests (aim for 80%+ coverage) +5. Add examples directory with working code samples +6. Set up CI/CD pipeline with GitHub Actions +7. Publish to Packagist for easy installation + +**Support:** + +- GitHub Issues: For bug reports and feature requests +- Pull Requests: Contributions welcome following PSR-12 +- Documentation: Keep this file updated with API changes +- Changelog: Maintain CHANGELOG.md following Keep a Changelog format + +--- + +*Document Version: 1.0.0* +*Last Updated: 2024-02-20* +*Compatible with: Ponto API v1, PHP 8.4+* diff --git a/src/PontoClass.php b/src/Ponto.php similarity index 72% rename from src/PontoClass.php rename to src/Ponto.php index 4407017..43e31f5 100755 --- a/src/PontoClass.php +++ b/src/Ponto.php @@ -2,6 +2,6 @@ namespace AlchemicStudio\Ponto; -class PontoClass +class Ponto { } diff --git a/tests/ArchTest.php b/tests/ArchTest.php deleted file mode 100644 index fd6a1e4..0000000 --- a/tests/ArchTest.php +++ /dev/null @@ -1,5 +0,0 @@ -expect(['dd', 'dump', 'ray']) - ->not->toBeUsed(); diff --git a/tests/Pest.php b/tests/Pest.php deleted file mode 100644 index b3d9bbc..0000000 --- a/tests/Pest.php +++ /dev/null @@ -1 +0,0 @@ -toBeTrue(); });