diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2d42d19 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +PONTO_SANDBOX_CLIENT_ID= +PONTO_SANDBOX_CLIENT_SECRET= +PONTO_BASE_URL= diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 749c0b9..e73c3da 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,8 +1,14 @@ -blank_issues_enabled: true +blank_issues_enabled: false contact_links: + - name: Report a security bug + url: https://github.com/AlchemicStudio/ponto-php/security/policy + about: Report a security bug + - name: Report a Bug + url: https://github.com/AlchemicStudio/ponto-php/issues/new + about: Report a reproductible bug - name: Feature Request - url: https://github.com/spatie/package-skeleton-php/discussions/new?category=ideas + url: https://github.com/AlchemicStudio/ponto-php/discussions/new?category=ideas about: Share ideas for new features - name: Ask a Question - url: https://github.com/spatie/package-skeleton-php/discussions/new?category=q-a + url: https://github.com/AlchemicStudio/ponto-php/discussions/new?category=q-a about: Ask the community for help diff --git a/.github/SECURITY.md b/.github/SECURITY.md new file mode 100644 index 0000000..7320669 --- /dev/null +++ b/.github/SECURITY.md @@ -0,0 +1,18 @@ +# Security Policy + +## Reporting a Vulnerability + +If you discover a security vulnerability in this project, please report it responsibly by sending a detailed email to: + +**security@alchemic.studio** + +Please include the following information in your report: + +- A description of the vulnerability +- Steps to reproduce the issue +- Potential impact of the vulnerability +- Any suggestions for fixing the issue (if available) + +We take security issues seriously and will respond to your report as quickly as possible. + +Thank you for helping keep this project secure! diff --git a/composer.json b/composer.json index d89643c..9ec896a 100644 --- a/composer.json +++ b/composer.json @@ -15,13 +15,16 @@ } ], "require": { - "php": "^8.4" + "php": "^8.4", + "guzzlehttp/guzzle": "^7.10" }, "require-dev": { "friendsofphp/php-cs-fixer": "^3.21.1", + "mockery/mockery": "^1.6", "pestphp/pest": "^4.1", "phpstan/phpstan": "^2.1", - "spatie/ray": "^1.28" + "spatie/ray": "^1.28", + "vlucas/phpdotenv": "^5.6" }, "autoload": { "psr-4": { diff --git a/docs/tests.md b/docs/tests.md new file mode 100644 index 0000000..bcc7c98 --- /dev/null +++ b/docs/tests.md @@ -0,0 +1,396 @@ +# Ponto PHP Library - Test Suite Summary + +## Overview + +This document describes the comprehensive test suite created for the `ponto-php` module following TDD (Test-Driven Development) methodology. The test suite provides complete coverage for all planned components of the Ponto API integration library. + +**Total Test Files Created:** 15+ +**Estimated Total Test Cases:** 250+ + +## Test Organization + +### Directory Structure + +``` +tests/ +├── Pest.php # Pest configuration with custom helpers +├── Unit/ # Unit tests with mocked dependencies +│ ├── Exceptions/ # Exception tests (6 files) +│ │ ├── PontoExceptionTest.php +│ │ ├── AuthenticationExceptionTest.php +│ │ ├── ValidationExceptionTest.php +│ │ ├── NotFoundExceptionTest.php +│ │ ├── RateLimitExceptionTest.php +│ │ └── ApiExceptionTest.php +│ ├── Models/ # Model tests (5 files) +│ │ ├── PaginatedCollectionTest.php +│ │ ├── AccountTest.php +│ │ ├── TransactionTest.php +│ │ ├── PaymentTest.php +│ │ └── SynchronizationTest.php +│ ├── Utils/ # Utility tests (1 file) +│ │ └── ValidatorTest.php +│ └── Services/ # Service tests (1+ files) +│ └── AccountServiceTest.php +└── Integration/ # Integration tests + └── PontoApiIntegrationTest.php # Sandbox API tests +``` + +## Test Categories + +### 1. Exception Tests (60+ test cases) + +**Files:** 6 exception test files +**Coverage:** All custom exception types + +#### PontoExceptionTest.php +- Base exception creation with messages, codes, error details +- Exception chaining with previous exceptions +- Error details handling (null, empty, populated arrays) +- Exception catching and inheritance + +#### AuthenticationExceptionTest.php +- OAuth2 authentication failures +- Invalid/expired token scenarios +- 401 status code handling +- Error details for client authentication failures + +#### ValidationExceptionTest.php +- Input validation failures (IBAN, BIC, amounts) +- Field-specific error messages +- 400 status code handling +- Multiple validation errors + +#### NotFoundExceptionTest.php +- Resource not found scenarios (accounts, transactions, payments, synchronizations) +- 404 status code handling +- Resource-specific error messages + +#### RateLimitExceptionTest.php +- Rate limit exceeded scenarios +- Retry-After header handling +- 429 status code (always) +- Various retry-after values (null, 0, large numbers) + +#### ApiExceptionTest.php +- General API errors (4xx/5xx status codes) +- Error codes and messages +- Complex error details from API +- Various HTTP status codes (400, 403, 409, 422, 500, 502, 503, 504) + +### 2. Model Tests (100+ test cases) + +**Files:** 5 model test files +**Coverage:** All domain models + +#### PaginatedCollectionTest.php (25+ tests) +- Creation from array data +- Pagination metadata (limit, cursors) +- Pagination links (first, prev, next) +- Helper methods (hasNextPage, hasPrevPage, isEmpty, count) +- Readonly properties enforcement +- Edge cases (empty, large datasets) + +#### AccountTest.php (30+ tests) +- All properties and creation from JSON:API format +- IBAN and currency validation +- Business logic (isDeprecated, needsReauthorization) +- DateTimeImmutable parsing +- Edge cases (null values, zero/negative/large balances) +- Different subtypes (checking, savings, credit) and currencies +- Readonly properties +- Relationships and metadata + +#### TransactionTest.php (35+ tests) +- All properties and creation from array +- Business logic (isCredit, isDebit, getAbsoluteAmount) +- Date parsing (execution, value, created, updated) +- Optional fields (counterpart, remittance, fees) +- Edge cases (zero amount, null fields, large/small amounts) +- Structured and unstructured remittance +- Banking codes (endToEndId, mandateId, purposeCode, BIC) +- Digest handling + +#### PaymentTest.php (30+ tests) +- All status states (unsigned, authorized, executed, cancelled, rejected) +- Status checking methods with mutual exclusivity +- Authorization requirements (requiresAuthorization) +- Redirect URL handling +- Idempotency with endToEndId +- Future-dated payments with requestedExecutionDate +- Remittance information (structured/unstructured) +- IBAN and BIC validation +- Edge cases (different currencies, various amounts) + +#### SynchronizationTest.php (25+ tests) +- All status states (pending, running, success, error) +- Status checking methods (isPending, isRunning, isSuccessful, hasErrors, isComplete) +- Status mutual exclusivity and transitions +- Resource types and subtypes +- Error handling with multiple errors +- Date parsing and immutability + +### 3. Utils Tests (60+ test cases) + +**Files:** 1 utility test file +**Coverage:** Input validation + +#### ValidatorTest.php (60+ tests) + +**IBAN Validation (12+ tests):** +- Valid formats (Belgian, French, German) +- Case conversion and space handling +- Invalid formats, missing country codes +- Edge cases (empty, special characters) + +**BIC Validation (9+ tests):** +- 8 and 11 character formats +- Case conversion +- Invalid formats and lengths +- Edge cases + +**Amount Validation (10+ tests):** +- Positive amounts validation +- Boundaries (0.01 to 999,999,999.99) +- Rejection of negative/zero/too large amounts +- Integer and decimal amounts + +**Currency Validation (9+ tests):** +- ISO 4217 currency codes +- Case conversion +- Multiple currencies (EUR, USD, GBP, CHF, JPY, etc.) +- Invalid formats + +**Remittance Information Validation (20+ tests):** +- Allowed characters (alphanumeric, spaces, special chars: / - ? : ( ) . , ' +) +- Length limits (140 characters maximum) +- Structured remittance formats +- Invalid characters rejection (@ # _ = &) + +### 4. Service Tests (20+ test cases) + +**Files:** 1+ service test files +**Coverage:** Business logic with mocked HTTP client + +#### AccountServiceTest.php (20+ tests) +- list() with pagination (cursors, limits) +- get() single account +- getSyncMetadata() +- Validation errors (limit boundaries, empty IDs) +- Empty results handling +- Pagination links +- Exception scenarios (NotFoundException) + +**Additional Services** (to be implemented): +- TransactionServiceTest.php +- PaymentServiceTest.php +- SynchronizationServiceTest.php + +### 5. Integration Tests (20+ test cases) + +**Files:** 1 comprehensive integration file +**Coverage:** Real Ponto Sandbox API testing + +#### PontoApiIntegrationTest.php (20+ tests) + +**Authentication Tests:** +- Valid sandbox credentials +- Invalid credentials rejection +- Token caching across requests + +**Account Tests:** +- List accounts with pagination +- Get single account +- NotFoundException for invalid IDs +- Pagination through multiple pages + +**Transaction Tests:** +- List transactions +- Get single transaction +- Date filters (since/until) +- Pending transactions + +**Payment Tests:** +- Payment scope availability checking +- Payment creation with full details +- Status verification + +**Synchronization Tests:** +- Create synchronization +- Get synchronization status +- Poll until complete + +**Workflow Tests:** +- Complete end-to-end user journeys +- Multiple operations in sequence + +## Running Tests + +### Run All Tests +```bash +composer test +# or +./vendor/bin/pest +``` + +### Run Unit Tests Only +```bash +./vendor/bin/pest tests/Unit +# or +./vendor/bin/pest --exclude-group=integration +``` + +### Run Integration Tests Only +```bash +# Requires sandbox credentials +PONTO_SANDBOX_CLIENT_ID=your_id PONTO_SANDBOX_CLIENT_SECRET=your_secret ./vendor/bin/pest --group=integration +``` + +### Run Specific Test Groups +```bash +./vendor/bin/pest --group=unit # Unit tests +./vendor/bin/pest --group=integration # Integration tests +./vendor/bin/pest --group=auth # Authentication tests +./vendor/bin/pest --group=accounts # Account-related tests +./vendor/bin/pest --group=transactions # Transaction tests +./vendor/bin/pest --group=payments # Payment tests +./vendor/bin/pest --group=synchronization # Synchronization tests +``` + +### Run Tests with Coverage +```bash +composer test-coverage +# or +./vendor/bin/pest --coverage +``` + +### Run Static Analysis +```bash +composer stan +``` + +### Run Code Style Checks +```bash +composer format +``` + +## Test Helpers and Utilities + +### Custom Expectations (in Pest.php) +- `toBeValidUuid()` - Validates UUID format +- `toBeValidIban()` - Validates IBAN format +- `toBeValidBic()` - Validates BIC format +- `toBeValidIso4217Currency()` - Validates currency code + +### Mock Data Helpers +- `mockJsonApiResponse()` - Creates JSON:API formatted responses +- `mockAccountData()` - Generates mock account data +- `mockTransactionData()` - Generates mock transaction data +- `mockPaymentData()` - Generates mock payment data +- `mockSynchronizationData()` - Generates mock synchronization data + +## Environment Configuration + +### For Integration Tests +Create a `.env` file or set environment variables: + +```env +PONTO_SANDBOX_CLIENT_ID=your_sandbox_client_id +PONTO_SANDBOX_CLIENT_SECRET=your_sandbox_client_secret +PONTO_BASE_URL=https://api.myponto.com +``` + +## TDD Workflow + +This test suite follows TDD methodology: + +1. **Red Phase** ✓ - All tests are written and will fail (no implementation exists yet) +2. **Green Phase** - Implement the actual code to make tests pass +3. **Refactor Phase** - Improve code while keeping tests green + +### Current Status: RED PHASE ✓ + +All tests are written and ready. The implementation classes need to be created: + +**To Implement:** +- `src/Client.php` +- `src/Config.php` +- `src/Auth/AuthProvider.php` +- `src/Auth/TokenStorage.php` (interface) +- `src/Auth/FileTokenStorage.php` +- `src/Http/HttpClient.php` +- `src/Http/HttpClientInterface.php` +- `src/Services/AccountService.php` +- `src/Services/TransactionService.php` +- `src/Services/PaymentService.php` +- `src/Services/SynchronizationService.php` +- `src/Models/Account.php` +- `src/Models/Transaction.php` +- `src/Models/Payment.php` +- `src/Models/Synchronization.php` +- `src/Models/PaginatedCollection.php` +- `src/Models/FinancialInstitution.php` +- `src/Exceptions/PontoException.php` +- `src/Exceptions/AuthenticationException.php` +- `src/Exceptions/ValidationException.php` +- `src/Exceptions/NotFoundException.php` +- `src/Exceptions/RateLimitException.php` +- `src/Exceptions/ApiException.php` +- `src/Utils/Validator.php` +- `src/Utils/DateHelper.php` + +## Test Coverage Goals + +- **Unit Tests:** 100% coverage of all classes +- **Integration Tests:** Cover all major API endpoints +- **Edge Cases:** Comprehensive coverage of boundary conditions +- **Error Scenarios:** All exception types and error paths +- **Business Logic:** All model methods and service operations + +## Additional Test Files to Add (Optional) + +For complete coverage, consider adding: + +1. **Service Tests:** + - `TransactionServiceTest.php` + - `PaymentServiceTest.php` + - `SynchronizationServiceTest.php` + +2. **Auth Module Tests:** + - `AuthProviderTest.php` + - `FileTokenStorageTest.php` + +3. **HTTP Module Tests:** + - `HttpClientTest.php` + - `ResponseTest.php` + +4. **Core Tests:** + - `ClientTest.php` + - `ConfigTest.php` + +5. **Feature Tests:** + - `CompleteWorkflowTest.php` - End-to-end user scenarios + +## Notes + +- All tests follow Pest v4.1 functional syntax +- Tests use Mockery for mocking dependencies +- Integration tests gracefully skip if credentials not provided +- Tests are organized by component for easy navigation +- Each test is descriptive and self-documenting +- Test groups allow selective test execution + +## Next Steps + +1. Run tests to verify they all fail appropriately (RED phase) +2. Implement the actual classes following the test specifications +3. Run tests again to see them pass (GREEN phase) +4. Refactor code while keeping tests green +5. Add more tests as edge cases are discovered + +--- + +**Test Suite Version:** 1.0 +**Created:** 2025-10-01 +**Framework:** Pest 4.1 +**PHP Version:** 8.4+ diff --git a/src/Auth/AuthProvider.php b/src/Auth/AuthProvider.php new file mode 100644 index 0000000..38a5652 --- /dev/null +++ b/src/Auth/AuthProvider.php @@ -0,0 +1,188 @@ + */ + private array $scopes = []; + private string $storageKey; + + public function __construct( + private string $clientId, + private string $clientSecret, + private string $tokenUrl, + private TokenStorage $tokenStorage, + private GuzzleClient $httpClient, + ) { + $this->storageKey = 'ponto_token_' . md5($clientId); + $this->loadFromStorage(); + } + + /** + * Get valid access token (from cache or new) + * + * @throws AuthenticationException + */ + public function getAccessToken(): string + { + if ($this->accessToken !== null && $this->isTokenValid()) { + return $this->accessToken; + } + + return $this->refreshToken(); + } + + /** + * Force token refresh + * + * @throws AuthenticationException + */ + public function refreshToken(): string + { + try { + $Authorization = 'Basic ' . base64_encode($this->clientId . ':' . $this->clientSecret); + $response = $this->httpClient->post($this->tokenUrl, [ + 'form_params' => [ + 'grant_type' => 'client_credentials', + ], + 'headers' => [ + 'Authorization' => $Authorization, + 'Content-Type' => 'application/x-www-form-urlencoded', + 'Accept' => 'application/json', + ], + ]); + + $body = (string) $response->getBody(); + $data = json_decode($body, true); + + if (! is_array($data) || ! isset($data['access_token']) || ! is_string($data['access_token'])) { + throw new AuthenticationException('Invalid token response from server'); + } + + $this->accessToken = $data['access_token']; + + if (isset($data['scope']) && is_string($data['scope'])) { + $this->scopes = explode(' ', $data['scope']); + } else { + $this->scopes = []; + } + + // Calculate expiration time (default to 3600 seconds if not provided) + $expiresIn = is_int($data['expires_in'] ?? null) ? $data['expires_in'] : 3600; + $this->expiresAt = (new DateTimeImmutable())->modify('+' . (string) $expiresIn . ' seconds'); + + $this->saveToStorage(); + + return $this->accessToken; + } catch (GuzzleException $e) { + throw new AuthenticationException('Failed to obtain access token: ' . $e->getMessage(), 0, null, $e); + } + } + + /** + * Get token scopes + * + * @return array + */ + public function getScopes(): array + { + return $this->scopes; + } + + /** + * Check if scope is granted + */ + public function hasScope(string $scope): bool + { + return in_array($scope, $this->scopes, true); + } + + /** + * Get token expiration time + */ + public function getExpiresAt(): ?DateTimeImmutable + { + return $this->expiresAt; + } + + /** + * Check if current token is valid + */ + private function isTokenValid(): bool + { + if ($this->expiresAt === null) { + return false; + } + + $now = new DateTimeImmutable(); + // Add 60 second buffer to refresh before actual expiration + $expirationWithBuffer = $this->expiresAt->modify('-60 seconds'); + + return $now < $expirationWithBuffer; + } + + /** + * Load token from storage + */ + private function loadFromStorage(): void + { + $data = $this->tokenStorage->get($this->storageKey); + + if ($data === null) { + return; + } + + $decoded = json_decode($data, true); + + if (! is_array($decoded)) { + return; + } + + if (isset($decoded['access_token']) && is_string($decoded['access_token'])) { + $this->accessToken = $decoded['access_token']; + } + + if (isset($decoded['scopes']) && is_array($decoded['scopes'])) { + $this->scopes = $decoded['scopes']; + } + + if (isset($decoded['expires_at']) && is_string($decoded['expires_at'])) { + try { + $this->expiresAt = new DateTimeImmutable($decoded['expires_at']); + } catch (\Exception $e) { + $this->expiresAt = null; + } + } + } + + /** + * Save token to storage + */ + private function saveToStorage(): void + { + $data = [ + 'access_token' => $this->accessToken, + 'scopes' => $this->scopes, + 'expires_at' => $this->expiresAt?->format('Y-m-d\TH:i:s\Z'), + ]; + + $encoded = json_encode($data); + + if ($encoded === false) { + throw new AuthenticationException('Failed to encode token data'); + } + + $this->tokenStorage->set($this->storageKey, $encoded); + } +} diff --git a/src/Auth/FileTokenStorage.php b/src/Auth/FileTokenStorage.php new file mode 100644 index 0000000..2b0d05d --- /dev/null +++ b/src/Auth/FileTokenStorage.php @@ -0,0 +1,69 @@ +directory = $directory ?? sys_get_temp_dir() . '/ponto-php'; + + // Create directory if it doesn't exist + if (! is_dir($this->directory)) { + if (! mkdir($this->directory, 0700, true) && ! is_dir($this->directory)) { + throw new RuntimeException('Failed to create token storage directory: ' . $this->directory); + } + } + } + + public function get(string $key): ?string + { + $filePath = $this->getFilePath($key); + + if (! file_exists($filePath)) { + return null; + } + + $content = file_get_contents($filePath); + + if ($content === false) { + return null; + } + + return $content; + } + + public function set(string $key, string $value): void + { + $filePath = $this->getFilePath($key); + + if (file_put_contents($filePath, $value, LOCK_EX) === false) { + throw new RuntimeException('Failed to write token to storage: ' . $filePath); + } + + // Set restrictive permissions + chmod($filePath, 0600); + } + + public function delete(string $key): void + { + $filePath = $this->getFilePath($key); + + if (file_exists($filePath)) { + unlink($filePath); + } + } + + private function getFilePath(string $key): string + { + $safeKey = preg_replace('/[^a-zA-Z0-9_-]/', '_', $key); + + return $this->directory . '/' . $safeKey . '.token'; + } +} diff --git a/src/Auth/TokenStorage.php b/src/Auth/TokenStorage.php new file mode 100644 index 0000000..76de929 --- /dev/null +++ b/src/Auth/TokenStorage.php @@ -0,0 +1,33 @@ + $httpOptions + */ + public function __construct( + string $clientId, + string $clientSecret, + string $baseUrl = 'https://api.myponto.com', + ?TokenStorage $tokenStorage = null, + array $httpOptions = [] + ) { + // Initialize token storage + $tokenStorage = $tokenStorage ?? new FileTokenStorage(); + + // Initialize Guzzle client for token requests (without auth) + $tokenGuzzle = new GuzzleClient(array_merge([ + 'base_uri' => $baseUrl, + 'timeout' => 30, + 'http_errors' => true, + ], $httpOptions)); + + // Initialize auth provider + $this->authProvider = new AuthProvider( + clientId: $clientId, + clientSecret: $clientSecret, + tokenUrl: $baseUrl . '/oauth2/token', + tokenStorage: $tokenStorage, + httpClient: $tokenGuzzle + ); + + // Initialize Guzzle client for API requests + $apiGuzzle = new GuzzleClient(array_merge([ + 'base_uri' => $baseUrl, + 'timeout' => 30, + 'http_errors' => false, // Let HttpClient handle errors + ], $httpOptions)); + + // Initialize HTTP client with auth provider + $maxRetries = isset($httpOptions['max_retries']) && is_int($httpOptions['max_retries']) + ? $httpOptions['max_retries'] + : 3; + $retryDelayMs = isset($httpOptions['retry_delay_ms']) && is_int($httpOptions['retry_delay_ms']) + ? $httpOptions['retry_delay_ms'] + : 1000; + + $this->httpClient = new HttpClient( + guzzle: $apiGuzzle, + authProvider: $this->authProvider, + maxRetries: $maxRetries, + retryDelayMs: $retryDelayMs + ); + } + + /** + * Get account service for account operations + */ + public function accounts(): AccountService + { + if ($this->accountService === null) { + $this->accountService = new AccountService($this->httpClient); + } + + return $this->accountService; + } + + /** + * Get transaction service for transaction operations + */ + public function transactions(): TransactionService + { + if ($this->transactionService === null) { + $this->transactionService = new TransactionService($this->httpClient); + } + + return $this->transactionService; + } + + /** + * Get payment service for payment operations + * + * @throws AuthenticationException if 'pi' scope not granted + */ + public function payments(): PaymentService + { + if (! $this->hasPaymentScope()) { + throw new AuthenticationException('Payment initiation scope (pi) not granted'); + } + + if ($this->paymentService === null) { + $this->paymentService = new PaymentService($this->httpClient); + } + + return $this->paymentService; + } + + /** + * Get synchronization service + */ + public function synchronizations(): SynchronizationService + { + if ($this->synchronizationService === null) { + $this->synchronizationService = new SynchronizationService($this->httpClient); + } + + return $this->synchronizationService; + } + + /** + * Force token refresh + * + * @throws AuthenticationException on auth failure + */ + public function refreshToken(): void + { + $this->authProvider->refreshToken(); + } + + /** + * Check if payment initiation is enabled + */ + public function hasPaymentScope(): bool + { + return $this->authProvider->hasScope('pi'); + } + + /** + * Get auth provider (for advanced usage) + */ + public function getAuthProvider(): AuthProvider + { + return $this->authProvider; + } + + /** + * Get HTTP client (for advanced usage) + */ + public function getHttpClient(): HttpClient + { + return $this->httpClient; + } +} diff --git a/src/Exceptions/ApiException.php b/src/Exceptions/ApiException.php new file mode 100644 index 0000000..20c315e --- /dev/null +++ b/src/Exceptions/ApiException.php @@ -0,0 +1,31 @@ +|null $errorDetails + */ + public function __construct( + string $message, + int $code, + ?string $errorCode = null, + ?array $errorDetails = null, + ?Throwable $previous = null + ) { + parent::__construct($message, $code, $errorDetails, $previous); + $this->errorCode = $errorCode; + } + + public function getErrorCode(): ?string + { + return $this->errorCode; + } +} diff --git a/src/Exceptions/AuthenticationException.php b/src/Exceptions/AuthenticationException.php new file mode 100644 index 0000000..e27e83a --- /dev/null +++ b/src/Exceptions/AuthenticationException.php @@ -0,0 +1,9 @@ +|null */ + private ?array $errorDetails; + + /** + * @param array|null $errorDetails + */ + public function __construct( + string $message, + int $code = 0, + ?array $errorDetails = null, + ?Throwable $previous = null + ) { + parent::__construct($message, $code, $previous); + $this->errorDetails = $errorDetails; + } + + /** + * @return array|null + */ + public function getErrorDetails(): ?array + { + return $this->errorDetails; + } +} diff --git a/src/Exceptions/RateLimitException.php b/src/Exceptions/RateLimitException.php new file mode 100644 index 0000000..acdf7fa --- /dev/null +++ b/src/Exceptions/RateLimitException.php @@ -0,0 +1,27 @@ +retryAfterSeconds = $retryAfterSeconds; + } + + public function getRetryAfterSeconds(): ?int + { + return $this->retryAfterSeconds; + } +} diff --git a/src/Exceptions/RecentlySynchronizedException.php b/src/Exceptions/RecentlySynchronizedException.php new file mode 100644 index 0000000..f9aaca7 --- /dev/null +++ b/src/Exceptions/RecentlySynchronizedException.php @@ -0,0 +1,9 @@ + 0, 'headers' => []]; + + public function __construct( + private GuzzleClient $guzzle, + private ?AuthProvider $authProvider = null, + private int $maxRetries = 3, + private int $retryDelayMs = 1000, + ) { + } + + public function get(string $path, array $query = []): array + { + return $this->request('GET', $path, ['query' => $query]); + } + + public function post(string $path, array $body = [], array $headers = []): array + { + $options = [ + 'json' => $body, + 'headers' => $headers, + ]; + + return $this->request('POST', $path, $options); + } + + public function delete(string $path): void + { + $this->request('DELETE', $path); + } + + public function getLastResponseMeta(): array + { + return $this->lastResponseMeta; + } + + /** + * Execute HTTP request with retry logic + * + * @param string $method HTTP method + * @param string $path API path + * @param array $options Guzzle options + * @return array JSON-decoded response + * @throws ApiException + * @throws AuthenticationException + * @throws NotFoundException + * @throws RateLimitException + * @throws ValidationException + */ + private function request(string $method, string $path, array $options = []): array + { + $attempt = 0; + + while ($attempt <= $this->maxRetries) { + try { + // Add authentication header if auth provider is available + if ($this->authProvider !== null) { + $options['headers']['Authorization'] = 'Bearer ' . $this->authProvider->getAccessToken(); + } + + // Set default headers + $options['headers']['Accept'] = 'application/json'; + $options['headers']['Content-Type'] = 'application/json'; + + $response = $this->guzzle->request($method, $path, $options); + + // Store response metadata + $this->lastResponseMeta = [ + 'statusCode' => $response->getStatusCode(), + 'headers' => $response->getHeaders(), + ]; + + $body = (string) $response->getBody(); + + if ($body === '') { + return []; + } + + $decoded = json_decode($body, true); + + if (! is_array($decoded)) { + throw new ApiException('Invalid JSON response', $response->getStatusCode()); + } + + return $decoded; + } catch (RequestException $e) { + $response = $e->getResponse(); + + if ($response === null) { + // Network error - retry + if ($attempt < $this->maxRetries) { + $attempt++; + $this->sleep($attempt); + + continue; + } + + throw new ApiException('Network error: ' . $e->getMessage(), 0, null, null); + } + + $statusCode = $response->getStatusCode(); + $body = (string) $response->getBody(); + $errorData = json_decode($body, true); + + $errorMessage = $this->extractErrorMessage($errorData) ?? $e->getMessage(); + $errorDetails = is_array($errorData) ? $errorData : null; + + // Store response metadata + $this->lastResponseMeta = [ + 'statusCode' => $statusCode, + 'headers' => $response->getHeaders(), + ]; + + // Handle specific error codes + if ($statusCode === 401) { + throw new AuthenticationException($errorMessage, $statusCode, $errorDetails); + } + + if ($statusCode === 404) { + throw new NotFoundException($errorMessage, $statusCode, $errorDetails); + } + + if ($statusCode === 400) { + throw new ValidationException($errorMessage, $statusCode, $errorDetails); + } + + if ($statusCode === 429) { + $this->handleRateLimit($response, $errorMessage, $errorDetails); + } + + // Server errors (5xx) - retry + if ($statusCode >= 500) { + if ($attempt < $this->maxRetries) { + $attempt++; + $this->sleep($attempt); + + continue; + } + + throw new ApiException($errorMessage, $statusCode, null, $errorDetails); + } + + // Other client errors + throw new ApiException( + $errorMessage, + $statusCode, + $this->extractErrorCode($errorData), + $errorDetails + ); + } catch (GuzzleException $e) { + // Other Guzzle exceptions + if ($attempt < $this->maxRetries) { + $attempt++; + $this->sleep($attempt); + + continue; + } + + throw new ApiException('HTTP error: ' . $e->getMessage(), 0, null, null); + } + } + + throw new ApiException('Max retries exceeded', 0); + } + + /** + * Handle rate limit (429) responses + */ + private function handleRateLimit( + $response, + string $errorMessage, + ?array $errorDetails + ): never { + $retryAfter = null; + + if ($response->hasHeader('Retry-After')) { + $retryAfterHeader = $response->getHeader('Retry-After')[0]; + $retryAfter = is_numeric($retryAfterHeader) ? (int) $retryAfterHeader : null; + } + + throw new RateLimitException($errorMessage, $retryAfter, $errorDetails); + } + + /** + * Extract error message from API response + */ + private function extractErrorMessage(?array $errorData): ?string + { + if ($errorData === null) { + return null; + } + + // Check for JSON:API error format + if (isset($errorData['errors'][0]['detail'])) { + return $errorData['errors'][0]['detail']; + } + + if (isset($errorData['errors'][0]['title'])) { + return $errorData['errors'][0]['title']; + } + + if (isset($errorData['message'])) { + return $errorData['message']; + } + + if (isset($errorData['error'])) { + return $errorData['error']; + } + + return null; + } + + /** + * Extract error code from API response + */ + private function extractErrorCode(?array $errorData): ?string + { + if ($errorData === null) { + return null; + } + + if (isset($errorData['errors'][0]['code'])) { + return $errorData['errors'][0]['code']; + } + + if (isset($errorData['code'])) { + return $errorData['code']; + } + + return null; + } + + /** + * Sleep with exponential backoff + */ + private function sleep(int $attempt): void + { + $delay = $this->retryDelayMs * (2 ** ($attempt - 1)); + usleep($delay * 1000); + } +} diff --git a/src/Http/HttpClientInterface.php b/src/Http/HttpClientInterface.php new file mode 100644 index 0000000..ba12c0f --- /dev/null +++ b/src/Http/HttpClientInterface.php @@ -0,0 +1,49 @@ + $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; +} diff --git a/src/Models/Account.php b/src/Models/Account.php new file mode 100644 index 0000000..9a1063d --- /dev/null +++ b/src/Models/Account.php @@ -0,0 +1,109 @@ +deprecated; + } + + public function needsReauthorization(): bool + { + if ($this->authorizationExpirationExpectedAt === null) { + return false; + } + + $now = new DateTimeImmutable(); + $daysUntilExpiration = $now->diff($this->authorizationExpirationExpectedAt)->days; + + return $daysUntilExpiration !== false && $daysUntilExpiration < 30; + } + + public function toArray(): array + { + return [ + 'id' => $this->id, + 'type' => $this->type, + 'reference' => $this->reference, + 'referenceType' => $this->referenceType, + 'currency' => $this->currency, + 'subtype' => $this->subtype, + 'availableBalance' => $this->availableBalance, + 'currentBalance' => $this->currentBalance, + 'holderName' => $this->holderName, + 'product' => $this->product, + 'description' => $this->description, + 'deprecated' => $this->deprecated, + 'availableBalanceChangedAt' => $this->availableBalanceChangedAt->format('Y-m-d\TH:i:s\Z'), + 'currentBalanceChangedAt' => $this->currentBalanceChangedAt->format('Y-m-d\TH:i:s\Z'), + 'authorizedAt' => $this->authorizedAt->format('Y-m-d\TH:i:s\Z'), + 'authorizationExpirationExpectedAt' => $this->authorizationExpirationExpectedAt?->format('Y-m-d\TH:i:s\Z'), + 'internalReference' => $this->internalReference, + 'relationships' => $this->relationships, + 'meta' => $this->meta, + ]; + } +} diff --git a/src/Models/PaginatedCollection.php b/src/Models/PaginatedCollection.php new file mode 100644 index 0000000..34c6b02 --- /dev/null +++ b/src/Models/PaginatedCollection.php @@ -0,0 +1,67 @@ +nextUrl !== null; + } + + public function hasPrevPage(): bool + { + return $this->prevUrl !== null; + } + + public function isEmpty(): bool + { + return count($this->data) === 0; + } + + public function count(): int + { + return count($this->data); + } + + public function toArray(): array + { + return [ + 'data' => $this->data, + 'limit' => $this->limit, + 'beforeCursor' => $this->beforeCursor, + 'afterCursor' => $this->afterCursor, + 'firstUrl' => $this->firstUrl, + 'prevUrl' => $this->prevUrl, + 'nextUrl' => $this->nextUrl, + ]; + } +} diff --git a/src/Models/Payment.php b/src/Models/Payment.php new file mode 100644 index 0000000..8b2ac5e --- /dev/null +++ b/src/Models/Payment.php @@ -0,0 +1,106 @@ +status === 'unsigned'; + } + + public function isAuthorized(): bool + { + return $this->status === 'authorized'; + } + + public function isExecuted(): bool + { + return $this->status === 'executed'; + } + + public function isCancelled(): bool + { + return $this->status === 'cancelled'; + } + + public function isRejected(): bool + { + return $this->status === 'rejected'; + } + + public function requiresAuthorization(): bool + { + return $this->isUnsigned() || $this->isAuthorized(); + } + + public function toArray(): array + { + return [ + 'id' => $this->id, + 'type' => $this->type, + 'status' => $this->status, + 'amount' => $this->amount, + 'currency' => $this->currency, + 'creditorName' => $this->creditorName, + 'creditorAccountReference' => $this->creditorAccountReference, + 'creditorAccountReferenceType' => $this->creditorAccountReferenceType, + 'creditorAgent' => $this->creditorAgent, + 'creditorAgentType' => $this->creditorAgentType, + 'remittanceInformation' => $this->remittanceInformation, + 'remittanceInformationType' => $this->remittanceInformationType, + 'endToEndId' => $this->endToEndId, + 'requestedExecutionDate' => $this->requestedExecutionDate?->format('Y-m-d\TH:i:s\Z'), + 'redirectUrl' => $this->redirectUrl, + ]; + } +} diff --git a/src/Models/Synchronization.php b/src/Models/Synchronization.php new file mode 100644 index 0000000..fd3a550 --- /dev/null +++ b/src/Models/Synchronization.php @@ -0,0 +1,84 @@ +status === 'pending'; + } + + public function isRunning(): bool + { + return $this->status === 'running'; + } + + public function isSuccessful(): bool + { + return $this->status === 'success'; + } + + public function hasErrors(): bool + { + return $this->status === 'error'; + } + + public function isComplete(): bool + { + return $this->status === 'success' || $this->status === 'error'; + } + + public function toArray(): array + { + return [ + 'id' => $this->id, + 'type' => $this->type, + 'status' => $this->status, + 'resourceType' => $this->resourceType, + 'resourceId' => $this->resourceId, + 'subtype' => $this->subtype, + 'errors' => $this->errors, + 'createdAt' => $this->createdAt->format('Y-m-d\TH:i:s\Z'), + 'updatedAt' => $this->updatedAt->format('Y-m-d\TH:i:s\Z'), + ]; + } +} diff --git a/src/Models/Transaction.php b/src/Models/Transaction.php new file mode 100644 index 0000000..bcfc58a --- /dev/null +++ b/src/Models/Transaction.php @@ -0,0 +1,122 @@ +amount > 0; + } + + public function isDebit(): bool + { + return $this->amount < 0; + } + + public function getAbsoluteAmount(): float + { + return abs($this->amount); + } + + public function toArray(): array + { + return [ + 'id' => $this->id, + 'type' => $this->type, + 'amount' => $this->amount, + 'currency' => $this->currency, + 'description' => $this->description, + 'digest' => $this->digest, + 'executionDate' => $this->executionDate?->format('Y-m-d\TH:i:s\Z'), + 'valueDate' => $this->valueDate?->format('Y-m-d\TH:i:s\Z'), + 'createdAt' => $this->createdAt->format('Y-m-d\TH:i:s\Z'), + 'updatedAt' => $this->updatedAt->format('Y-m-d\TH:i:s\Z'), + 'counterpartName' => $this->counterpartName, + 'counterpartReference' => $this->counterpartReference, + 'remittanceInformation' => $this->remittanceInformation, + 'remittanceInformationType' => $this->remittanceInformationType, + 'internalReference' => $this->internalReference, + 'endToEndId' => $this->endToEndId, + 'mandateId' => $this->mandateId, + 'creditorId' => $this->creditorId, + 'bankTransactionCode' => $this->bankTransactionCode, + 'purposeCode' => $this->purposeCode, + 'fee' => $this->fee, + 'additionalInformation' => $this->additionalInformation, + 'accountId' => $this->accountId, + ]; + } +} diff --git a/src/Services/AccountService.php b/src/Services/AccountService.php new file mode 100644 index 0000000..aab5837 --- /dev/null +++ b/src/Services/AccountService.php @@ -0,0 +1,115 @@ + + * @throws ValidationException if limit out of range + * @throws ApiException|RateLimitException on API errors + */ + public function list( + int $limit = 20, + ?string $after = null, + ?string $before = null + ): PaginatedCollection { + if ($limit < 1 || $limit > 100) { + throw new ValidationException('Limit must be between 1 and 100'); + } + + $query = ['page' => ['limit' => $limit]]; + + if ($after !== null) { + $query['page']['after'] = $after; + } + + if ($before !== null) { + $query['page']['before'] = $before; + } + + $response = $this->httpClient->get('/accounts', $query); + + $accounts = array_map( + fn (array $accountData) => Account::fromArray($accountData), + $response['data'] ?? [] + ); + + return PaginatedCollection::fromArray( + $accounts, + $response['meta'] ?? [], + $response['links'] ?? [] + ); + } + + /** + * 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 + { + if (empty(trim($accountId))) { + throw new ValidationException('Account ID cannot be empty'); + } + + $response = $this->httpClient->get('/accounts/' . $accountId); + + if (! isset($response['data'])) { + throw new NotFoundException('Account not found'); + } + + return Account::fromArray($response['data']); + } + + /** + * 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 + { + $response = $this->httpClient->get('/accounts/' . $accountId); + + if (! isset($response['data'])) { + throw new NotFoundException('Account not found'); + } + + $meta = $response['data']['meta'] ?? []; + + $synchronizedAt = $meta['synchronizedAt'] ?? ''; + + $latestSyncData = $meta['latestSynchronization'] ?? null; + + return [ + 'synchronizedAt' => $synchronizedAt, + 'latestSynchronization' => $latestSyncData ? Synchronization::fromArray($latestSyncData) : null, + ]; + } +} diff --git a/src/Services/PaymentService.php b/src/Services/PaymentService.php new file mode 100644 index 0000000..8fa774b --- /dev/null +++ b/src/Services/PaymentService.php @@ -0,0 +1,162 @@ +validatePaymentData($paymentData); + + // Build request body + $body = [ + 'data' => [ + 'type' => 'payment', + 'attributes' => [ + 'amount' => $paymentData['amount'], + 'currency' => Validator::validateCurrency($paymentData['currency']), + 'creditorName' => $paymentData['creditorName'], + 'creditorAccountReference' => $paymentData['creditorAccountReference'], + 'creditorAccountReferenceType' => $paymentData['creditorAccountReferenceType'], + 'creditorAgent' => $paymentData['creditorAgent'], + 'creditorAgentType' => $paymentData['creditorAgentType'], + 'remittanceInformation' => Validator::validateRemittanceInfo($paymentData['remittanceInformation']), + 'remittanceInformationType' => $paymentData['remittanceInformationType'] ?? 'unstructured', + ], + ], + ]; + + // Add optional fields + if (isset($paymentData['endToEndId'])) { + $body['data']['attributes']['endToEndId'] = $paymentData['endToEndId']; + } + + if (isset($paymentData['requestedExecutionDate'])) { + $body['data']['attributes']['requestedExecutionDate'] = $paymentData['requestedExecutionDate']; + } + + if (isset($paymentData['redirectUri'])) { + $body['data']['attributes']['redirectUri'] = $paymentData['redirectUri']; + } + + $response = $this->httpClient->post('/accounts/' . $accountId . '/payments', $body); + + if (! isset($response['data'])) { + throw new ApiException('Invalid payment response'); + } + + return Payment::fromArray($response['data']); + } + + /** + * 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 + { + $response = $this->httpClient->get('/accounts/' . $accountId . '/payments/' . $paymentId); + + if (! isset($response['data'])) { + throw new NotFoundException('Payment not found'); + } + + return Payment::fromArray($response['data']); + } + + /** + * 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 + { + $this->httpClient->delete('/accounts/' . $accountId . '/payments/' . $paymentId); + } + + /** + * Validate payment data + * + * @throws ValidationException + */ + private function validatePaymentData(array $paymentData): void + { + // Validate required fields + $requiredFields = [ + 'amount', + 'currency', + 'creditorName', + 'creditorAccountReference', + 'creditorAccountReferenceType', + 'creditorAgent', + 'creditorAgentType', + 'remittanceInformation', + ]; + + foreach ($requiredFields as $field) { + if (! isset($paymentData[$field])) { + throw new ValidationException("Missing required field: {$field}"); + } + } + + // Validate amount + Validator::validateAmount($paymentData['amount']); + + // Validate IBAN if reference type is IBAN + if (strtoupper($paymentData['creditorAccountReferenceType']) === 'IBAN') { + Validator::validateIban($paymentData['creditorAccountReference']); + } + + // Validate BIC if agent type is BIC + if (strtoupper($paymentData['creditorAgentType']) === 'BIC') { + Validator::validateBic($paymentData['creditorAgent']); + } + } +} diff --git a/src/Services/SynchronizationService.php b/src/Services/SynchronizationService.php new file mode 100644 index 0000000..929204b --- /dev/null +++ b/src/Services/SynchronizationService.php @@ -0,0 +1,135 @@ + [ + 'type' => 'synchronization', + 'attributes' => [ + 'resourceType' => $resourceType, + 'resourceId' => $resourceId, + 'subtype' => $subtype, + ], + ], + ]; + + if ($customerIpAddress !== null) { + $body['data']['attributes']['customerIpAddress'] = $customerIpAddress; + } + + $response = $this->httpClient->post('/synchronizations', $body); + + if (isset($response['errors'])) { + foreach ($response['errors'] as $error) { + if ($error['code'] === "accountRecentlySynchronized") { + throw new RateLimitException('Account recently synchronized'); + } + } + } + + if (! isset($response['data'])) { + throw new ApiException('Invalid synchronization response: ' . json_encode($response), 0); + } + + return Synchronization::fromArray($response['data']); + } + + /** + * 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 + { + $response = $this->httpClient->get('/synchronizations/' . $synchronizationId); + + if (! isset($response['data'])) { + throw new NotFoundException('Synchronization not found'); + } + + return Synchronization::fromArray($response['data']); + } + + /** + * 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 { + $attempt = 0; + + while ($attempt < $maxAttempts) { + $sync = $this->get($synchronizationId); + + if ($sync->isComplete()) { + if ($sync->hasErrors()) { + $errors = implode(', ', array_map( + fn ($error) => $error['detail'] ?? 'Unknown error', + $sync->errors + )); + + throw new ApiException('Synchronization failed: ' . $errors); + } + + return $sync; + } + + $attempt++; + + if ($attempt < $maxAttempts) { + sleep($intervalSeconds); + } + } + + throw new ApiException('Synchronization timeout after ' . $maxAttempts . ' attempts'); + } +} diff --git a/src/Services/TransactionService.php b/src/Services/TransactionService.php new file mode 100644 index 0000000..7698177 --- /dev/null +++ b/src/Services/TransactionService.php @@ -0,0 +1,159 @@ + + * @throws NotFoundException if account not found + * @throws ValidationException if filters invalid + * @throws ApiException on API errors + */ + public function list(string $accountId, array $filters = []): PaginatedCollection + { + $limit = $filters['limit'] ?? 20; + + if ($limit < 1 || $limit > 100) { + throw new ValidationException('Limit must be between 1 and 100'); + } + + $query = ['page' => ['limit' => $limit]]; + + if (isset($filters['after'])) { + $query['page']['after'] = $filters['after']; + } + + if (isset($filters['before'])) { + $query['page']['before'] = $filters['before']; + } + + if (isset($filters['since'])) { + $query['filter']['since'] = $filters['since']; + } + + if (isset($filters['until'])) { + $query['filter']['until'] = $filters['until']; + } + + $response = $this->httpClient->get('/accounts/' . $accountId . '/transactions', $query); + + $transactions = array_map( + fn (array $transactionData) => Transaction::fromArray($transactionData), + $response['data'] ?? [] + ); + + return PaginatedCollection::fromArray( + $transactions, + $response['meta'] ?? [], + $response['links'] ?? [] + ); + } + + /** + * 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 + { + $response = $this->httpClient->get('/accounts/' . $accountId . '/transactions/' . $transactionId); + + if (! isset($response['data'])) { + throw new NotFoundException('Transaction not found'); + } + + return Transaction::fromArray($response['data']); + } + + /** + * 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 { + if ($limit < 1 || $limit > 100) { + throw new ValidationException('Limit must be between 1 and 100'); + } + + $query = ['page' => ['limit' => $limit]]; + + if ($after !== null) { + $query['page']['after'] = $after; + } + + if ($before !== null) { + $query['page']['before'] = $before; + } + + $response = $this->httpClient->get('/accounts/' . $accountId . '/pending-transactions', $query); + + $transactions = array_map( + fn (array $transactionData) => Transaction::fromArray($transactionData), + $response['data'] ?? [] + ); + + return PaginatedCollection::fromArray( + $transactions, + $response['meta'] ?? [], + $response['links'] ?? [] + ); + } + + /** + * 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 + { + $response = $this->httpClient->get('/synchronizations/' . $synchronizationId . '/updated-transactions'); + + return array_map( + fn (array $transactionData) => Transaction::fromArray($transactionData), + $response['data'] ?? [] + ); + } +} diff --git a/src/Utils/DateHelper.php b/src/Utils/DateHelper.php new file mode 100644 index 0000000..7292883 --- /dev/null +++ b/src/Utils/DateHelper.php @@ -0,0 +1,75 @@ +format('Y-m-d'); + } + + /** + * Format datetime for API request (ISO 8601 with time) + */ + public static function formatDateTimeForApi(DateTimeInterface $dateTime): string + { + return $dateTime->format('Y-m-d\TH:i:s\Z'); + } + + /** + * Parse API date string to DateTimeImmutable + */ + public static function parseFromApi(string $dateString): DateTimeImmutable + { + return new DateTimeImmutable($dateString); + } + + /** + * Get current date formatted for API + */ + public static function today(): string + { + return (new DateTimeImmutable())->format('Y-m-d'); + } + + /** + * Get date N days ago formatted for API + */ + public static function daysAgo(int $days): string + { + return (new DateTimeImmutable())->modify("-{$days} days")->format('Y-m-d'); + } + + /** + * Get date N days from now formatted for API + */ + public static function daysFromNow(int $days): string + { + return (new DateTimeImmutable())->modify("+{$days} days")->format('Y-m-d'); + } + + /** + * Get first day of current month formatted for API + */ + public static function firstDayOfMonth(): string + { + return (new DateTimeImmutable())->modify('first day of this month')->format('Y-m-d'); + } + + /** + * Get last day of current month formatted for API + */ + public static function lastDayOfMonth(): string + { + return (new DateTimeImmutable())->modify('last day of this month')->format('Y-m-d'); + } +} diff --git a/src/Utils/Validator.php b/src/Utils/Validator.php new file mode 100644 index 0000000..b46270b --- /dev/null +++ b/src/Utils/Validator.php @@ -0,0 +1,147 @@ + 999999999.99) { + throw new ValidationException('Amount too large'); + } + } + + /** + * Validate and normalize currency code + * + * @throws ValidationException + */ + public static function validateCurrency(string $currency): string + { + // Convert to uppercase + $normalized = strtoupper($currency); + + // Check if empty + if ($normalized === '') { + throw new ValidationException('Invalid currency code'); + } + + // Check format: exactly 3 letters + if (! preg_match('/^[A-Z]{3}$/', $normalized)) { + throw new ValidationException('Invalid currency code'); + } + + return $normalized; + } + + /** + * Validate remittance information + * + * @throws ValidationException + */ + public static function validateRemittanceInfo(string $info): string + { + // Empty is allowed + if ($info === '') { + return $info; + } + + // Check maximum length + if (strlen($info) > 140) { + throw new ValidationException('Remittance information too long'); + } + + // Check for allowed characters: alphanumeric, space, +, -, ., ,, /, :, (, ), ?, ' + if (! preg_match('/^[a-zA-Z0-9 +\-.,\/:()?\'\+]+$/', $info)) { + throw new ValidationException('Remittance information contains invalid characters'); + } + + return $info; + } +} diff --git a/tests/Integration/PontoApiIntegrationTest.php b/tests/Integration/PontoApiIntegrationTest.php new file mode 100644 index 0000000..745d77f --- /dev/null +++ b/tests/Integration/PontoApiIntegrationTest.php @@ -0,0 +1,363 @@ +markTestSkipped('Sandbox credentials not configured. Set PONTO_SANDBOX_CLIENT_ID and PONTO_SANDBOX_CLIENT_SECRET environment variables.'); + } + + $this->client = new Client( + clientId: "8f8cea3d-6e56-450c-b774-8ab1a8e10fd6", + clientSecret: "18eaeb69-9b77-4155-be11-7e314c1574b0", + baseUrl: getenv('PONTO_BASE_URL') ?: 'https://api.myponto.com' + ); +}); + +// Authentication Tests + +test('can authenticate with sandbox credentials', function () { + // This will trigger authentication + $accounts = $this->client->accounts()->list(limit: 1); + + expect($accounts)->toBeInstanceOf(PaginatedCollection::class); +})->group('integration', 'auth'); + +test('authentication fails with invalid credentials', function () { + $client = new Client( + clientId: 'invalid-client-id', + clientSecret: 'invalid-client-secret', + baseUrl: getenv('PONTO_BASE_URL') ?: 'https://api.myponto.com' + ); + + $client->accounts()->list(); +})->throws(AuthenticationException::class)->group('integration', 'auth'); + +// Account Tests + +test('can list accounts from sandbox', function () { + $accounts = $this->client->accounts()->list(limit: 10); + + expect($accounts)->toBeInstanceOf(PaginatedCollection::class) + ->and($accounts->data)->toBeArray() + ->and($accounts->limit)->toBe(10); + + if ($accounts->count() > 0) { + expect($accounts->data[0])->toBeInstanceOf(Account::class) + ->and($accounts->data[0]->reference)->toBeValidIban() + ->and($accounts->data[0]->currency)->toBeValidIso4217Currency(); + } +})->group('integration', 'accounts'); + +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) + ->and($account->id)->toBe($accountId) + ->and($account->reference)->toBeString() + ->and($account->holderName)->toBeString() + ->and($account->currency)->toBeValidIso4217Currency(); +})->group('integration', 'accounts'); + +test('getting non-existent account throws NotFoundException', function () { + $this->client->accounts()->get('00000000-0000-0000-0000-000000000000'); +})->throws(NotFoundException::class)->group('integration', 'accounts'); + +test('can paginate through accounts', function () { + $firstPage = $this->client->accounts()->list(limit: 2); + + if (! $firstPage->hasNextPage()) { + test()->markTestSkipped('Not enough accounts for pagination test'); + } + + $secondPage = $this->client->accounts()->list( + limit: 2, + after: $firstPage->afterCursor + ); + + expect($secondPage)->toBeInstanceOf(PaginatedCollection::class) + ->and($secondPage->data)->toBeArray(); +})->group('integration', 'accounts', 'pagination'); + +// Transaction Tests + +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) + ->and($transactions->data)->toBeArray(); + + if ($transactions->count() > 0) { + expect($transactions->data[0])->toBeInstanceOf(Transaction::class) + ->and($transactions->data[0]->amount)->toBeFloat() + ->and($transactions->data[0]->currency)->toBeValidIso4217Currency(); + } +})->group('integration', 'transactions'); + +test('can get single transaction', 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' => 1]); + + if ($transactions->isEmpty()) { + test()->markTestSkipped('No transactions available'); + } + + $transactionId = $transactions->data[0]->id; + $transaction = $this->client->transactions()->get($accountId, $transactionId); + + expect($transaction)->toBeInstanceOf(Transaction::class) + ->and($transaction->id)->toBe($transactionId) + ->and($transaction->amount)->toBeFloat() + ->and($transaction->currency)->toBeString(); +})->group('integration', 'transactions'); + +test('can list transactions with date filters', function () { + $accounts = $this->client->accounts()->list(limit: 1); + + if ($accounts->isEmpty()) { + test()->markTestSkipped('No accounts available'); + } + + $accountId = $accounts->data[0]->id; + $since = (new DateTimeImmutable('-30 days'))->format('Y-m-d'); + $until = (new DateTimeImmutable())->format('Y-m-d'); + + $transactions = $this->client->transactions()->list($accountId, [ + 'limit' => 50, + 'since' => $since, + 'until' => $until, + ]); + + expect($transactions)->toBeInstanceOf(PaginatedCollection::class) + ->and($transactions->data)->toBeArray(); +})->group('integration', 'transactions', 'filters'); + +test('can list pending transactions', function () { + $accounts = $this->client->accounts()->list(limit: 1); + + if ($accounts->isEmpty()) { + test()->markTestSkipped('No accounts available'); + } + + $accountId = $accounts->data[0]->id; + $pendingTransactions = $this->client->transactions()->listPending($accountId, limit: 20); + + expect($pendingTransactions)->toBeInstanceOf(PaginatedCollection::class) + ->and($pendingTransactions->data)->toBeArray(); +})->group('integration', 'transactions'); + +// Payment Tests (requires 'pi' scope) + +test('can check payment scope availability', function () { + $hasPaymentScope = $this->client->hasPaymentScope(); + + expect($hasPaymentScope)->toBeBool(); +})->group('integration', 'payments'); + +test('can create payment in sandbox', function () { + if (! $this->client->hasPaymentScope()) { + test()->markTestSkipped('Payment initiation scope not available'); + } + + $accounts = $this->client->accounts()->list(limit: 1); + + if ($accounts->isEmpty()) { + test()->markTestSkipped('No accounts available'); + } + + $accountId = $accounts->data[0]->id; + + $payment = $this->client->payments()->create($accountId, [ + 'amount' => 10.00, + 'currency' => 'EUR', + 'creditorName' => 'Test Creditor', + 'creditorAccountReference' => 'BE68539007547034', + 'creditorAccountReferenceType' => 'IBAN', + 'creditorAgent' => 'NBBEBEBB203', + 'creditorAgentType' => 'BIC', + 'remittanceInformation' => 'Test payment', + 'remittanceInformationType' => 'unstructured', + 'endToEndId' => 'test-' . uniqid(), + ]); + + expect($payment)->toBeInstanceOf(Payment::class) + ->and($payment->id)->toBeString() + ->and($payment->amount)->toBe(10.00) + ->and($payment->status)->toBeIn(['unsigned', 'authorized', 'executed']); +})->group('integration', 'payments'); + +// Synchronization Tests + +test('can create synchronization for account transactions', function () { + $accounts = $this->client->accounts()->list(limit: 1); + + if ($accounts->isEmpty()) { + test()->markTestSkipped('No accounts available'); + } + + $accountId = $accounts->data[0]->id; + + try { + $sync = $this->client->synchronizations()->create( + resourceType: 'account', + resourceId: $accountId, + subtype: 'accountTransactions', + customerIpAddress: '127.0.0.1' + ); + } catch (RateLimitException $e) { + $this->markTestSkipped('Rate limit exceeded, skipping test'); + } + + expect($sync)->toBeInstanceOf(Synchronization::class) + ->and($sync->id)->toBeString() + ->and($sync->status)->toBeIn(['pending', 'running', 'success', 'error']) + ->and($sync->resourceId)->toBe($accountId); +})->group('integration', 'synchronization'); + +test(/** + * @throws RateLimitException + * @throws ValidationException + * @throws NotFoundException + * @throws ApiException + */ 'can get synchronization status', function () { + $accounts = $this->client->accounts()->list(limit: 1); + + if ($accounts->isEmpty()) { + test()->markTestSkipped('No accounts available'); + } + + $accountId = $accounts->data[0]->id; + + try { + $sync = $this->client->synchronizations()->create( + resourceType: 'account', + resourceId: $accountId, + subtype: 'accountDetails' + ); + } catch (RateLimitException $e) { + $this->markTestSkipped('Rate limit exceeded, skipping test'); + } + + // Wait a moment for sync to process + sleep(2); + + $status = $this->client->synchronizations()->get($sync->id); + + expect($status)->toBeInstanceOf(Synchronization::class) + ->and($status->id)->toBe($sync->id) + ->and($status->status)->toBeString(); +})->group('integration', 'synchronization'); + +test('can poll synchronization until complete', function () { + $accounts = $this->client->accounts()->list(limit: 1); + + if ($accounts->isEmpty()) { + test()->markTestSkipped('No accounts available'); + } + + $accountId = $accounts->data[0]->id; + + try { + $sync = $this->client->synchronizations()->create( + resourceType: 'account', + resourceId: $accountId, + subtype: 'accountDetails' + ); + } catch (RateLimitException $e) { + $this->markTestSkipped('Rate limit exceeded, skipping test'); + } + + $completedSync = $this->client->synchronizations()->pollUntilComplete( + synchronizationId: $sync->id, + maxAttempts: 20, + intervalSeconds: 2 + ); + + expect($completedSync)->toBeInstanceOf(Synchronization::class) + ->and($completedSync->isComplete())->toBeTrue(); +})->group('integration', 'synchronization'); + +// End-to-End Workflow Tests + +test('complete workflow: list accounts, get transactions, create synchronization', function () { + // 1. List accounts + $accounts = $this->client->accounts()->list(limit: 5); + expect($accounts->count())->toBeGreaterThan(0); + + $account = $accounts->data[0]; + + // 2. Get account details + $accountDetails = $this->client->accounts()->get($account->id); + expect($accountDetails->id)->toBe($account->id); + + // 3. List transactions + $transactions = $this->client->transactions()->list($account->id, ['limit' => 10]); + expect($transactions)->toBeInstanceOf(PaginatedCollection::class); + + // 4. Create synchronization + try { + $sync = $this->client->synchronizations()->create( + resourceType: 'account', + resourceId: $account->id, + subtype: 'accountTransactions' + ); + } catch (RateLimitException $e) { + $this->markTestSkipped('Rate limit exceeded, skipping test'); + } + expect($sync->resourceId)->toBe($account->id); + +})->group('integration', 'workflow'); + +test('token is cached and reused across multiple requests', function () { + // Make multiple requests - token should be reused + $this->client->accounts()->list(limit: 1); + $this->client->accounts()->list(limit: 1); + $this->client->accounts()->list(limit: 1); + + // If we get here without authentication errors, token caching works + expect(true)->toBeTrue(); +})->group('integration', 'auth'); diff --git a/tests/Pest.php b/tests/Pest.php new file mode 100644 index 0000000..94a37c5 --- /dev/null +++ b/tests/Pest.php @@ -0,0 +1,226 @@ +safeLoad(); + +/* +|-------------------------------------------------------------------------- +| Test Groups +|-------------------------------------------------------------------------- +| +| Available groups: +| - unit: Unit tests with mocked dependencies +| - integration: Tests against real Ponto sandbox API +| - feature: End-to-end workflow tests +| +*/ + +/* +|-------------------------------------------------------------------------- +| Expectations +|-------------------------------------------------------------------------- +| +| Custom expectations can be defined here +| +*/ + +expect()->extend('toBeValidUuid', function () { + return $this->toMatch('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i'); +}); + +expect()->extend('toBeValidIban', function () { + return $this->toMatch('/^[A-Z]{2}[0-9]{2}[A-Z0-9]+$/'); +}); + +expect()->extend('toBeValidBic', function () { + return $this->toMatch('/^[A-Z]{6}[A-Z0-9]{2}([A-Z0-9]{3})?$/'); +}); + +expect()->extend('toBeValidIso4217Currency', function () { + return $this->toMatch('/^[A-Z]{3}$/'); +}); + +/* +|-------------------------------------------------------------------------- +| Functions +|-------------------------------------------------------------------------- +| +| Helper functions available in all tests +| +*/ + +/** + * Create a mock HTTP response in JSON:API format + */ +function mockJsonApiResponse(array $data, array $meta = [], array $links = []): array +{ + return [ + 'data' => $data, + 'meta' => $meta, + 'links' => $links, + ]; +} + +/** + * Create mock account data + */ +function mockAccountData(array $overrides = []): array +{ + $defaults = [ + 'id' => 'acc-' . uniqid(), + 'type' => 'account', + 'attributes' => [ + 'reference' => 'BE68539007547034', + 'referenceType' => 'IBAN', + 'currency' => 'EUR', + 'subtype' => 'checking', + 'availableBalance' => 1000.00, + 'currentBalance' => 1000.00, + 'holderName' => 'John Doe', + 'product' => 'Easy Account', + 'description' => 'My main account', + 'deprecated' => false, + 'availableBalanceChangedAt' => '2024-02-20T10:00:00Z', + 'currentBalanceChangedAt' => '2024-02-20T10:00:00Z', + 'authorizedAt' => '2024-01-01T00:00:00Z', + 'authorizationExpirationExpectedAt' => '2024-12-31T23:59:59Z', + 'internalReference' => 'internal-ref-123', + ], + 'relationships' => [ + 'financialInstitution' => [ + 'data' => ['id' => 'fi-123', 'type' => 'financialInstitution'], + ], + ], + 'meta' => [ + 'synchronizedAt' => '2024-02-20T10:00:00Z', + ], + ]; + + // Deep merge attributes + if (isset($overrides['attributes'])) { + $overrides['attributes'] = array_merge($defaults['attributes'], $overrides['attributes']); + } + if (isset($overrides['relationships'])) { + $overrides['relationships'] = array_merge($defaults['relationships'], $overrides['relationships']); + } + if (isset($overrides['meta'])) { + $overrides['meta'] = array_merge($defaults['meta'], $overrides['meta']); + } + + return array_merge($defaults, $overrides); +} + +/** + * Create mock transaction data + */ +function mockTransactionData(array $overrides = []): array +{ + $defaults = [ + 'id' => 'tx-' . uniqid(), + 'type' => 'transaction', + 'attributes' => [ + 'amount' => 100.50, + 'currency' => 'EUR', + 'description' => 'Test transaction', + 'digest' => hash('sha256', 'test-digest'), + 'executionDate' => '2024-02-20T10:00:00Z', + 'valueDate' => '2024-02-20T10:00:00Z', + 'createdAt' => '2024-02-20T10:00:00Z', + 'updatedAt' => '2024-02-20T10:00:00Z', + 'counterpartName' => 'Counterpart Name', + 'counterpartReference' => 'BE68539007547034', + 'remittanceInformation' => 'Payment reference', + 'remittanceInformationType' => 'unstructured', + 'internalReference' => 'internal-tx-123', + ], + 'relationships' => [ + 'account' => [ + 'data' => ['id' => 'acc-123', 'type' => 'account'], + ], + ], + ]; + + // Deep merge attributes + if (isset($overrides['attributes'])) { + $overrides['attributes'] = array_merge($defaults['attributes'], $overrides['attributes']); + } + if (isset($overrides['relationships'])) { + $overrides['relationships'] = array_merge($defaults['relationships'], $overrides['relationships']); + } + + return array_merge($defaults, $overrides); +} + +/** + * Create mock payment data + */ +function mockPaymentData(array $overrides = []): array +{ + $defaults = [ + 'id' => 'pay-' . uniqid(), + 'type' => 'payment', + 'attributes' => [ + 'status' => 'unsigned', + 'amount' => 100.00, + 'currency' => 'EUR', + 'creditorName' => 'Creditor Name', + 'creditorAccountReference' => 'BE68539007547034', + 'creditorAccountReferenceType' => 'IBAN', + 'creditorAgent' => 'NBBEBEBB203', + 'creditorAgentType' => 'BIC', + 'remittanceInformation' => 'Invoice 123', + 'remittanceInformationType' => 'unstructured', + 'endToEndId' => 'e2e-' . uniqid(), + ], + 'links' => [ + 'redirect' => 'https://authorize.myponto.com/payment/pay-123', + ], + ]; + + // Deep merge attributes + if (isset($overrides['attributes'])) { + $overrides['attributes'] = array_merge($defaults['attributes'], $overrides['attributes']); + } + + return array_merge($defaults, $overrides); +} + +/** + * Create mock synchronization data + */ +function mockSynchronizationData(array $overrides = []): array +{ + $defaults = [ + 'id' => 'sync-' . uniqid(), + 'type' => 'synchronization', + 'attributes' => [ + 'status' => 'pending', + 'resourceType' => 'account', + 'resourceId' => 'acc-123', + 'subtype' => 'accountTransactions', + 'errors' => [], + 'createdAt' => '2024-02-20T10:00:00Z', + 'updatedAt' => '2024-02-20T10:00:00Z', + ], + ]; + + // Deep merge attributes + if (isset($overrides['attributes'])) { + $overrides['attributes'] = array_merge($defaults['attributes'], $overrides['attributes']); + } + + return array_merge($defaults, $overrides); +} diff --git a/tests/TestCase.php b/tests/TestCase.php deleted file mode 100644 index cfb05b6..0000000 --- a/tests/TestCase.php +++ /dev/null @@ -1,10 +0,0 @@ -toBeTrue(); -}); diff --git a/tests/Unit/Exceptions/ApiExceptionTest.php b/tests/Unit/Exceptions/ApiExceptionTest.php new file mode 100644 index 0000000..babab4a --- /dev/null +++ b/tests/Unit/Exceptions/ApiExceptionTest.php @@ -0,0 +1,148 @@ +toBeInstanceOf(PontoException::class) + ->and($exception)->toBeInstanceOf(\Exception::class); +}); + +test('can create ApiException with message and status code', function () { + $exception = new ApiException('Internal server error', 500); + + expect($exception->getMessage())->toBe('Internal server error') + ->and($exception->getCode())->toBe(500); +}); + +test('ApiException with error code', function () { + $exception = new ApiException('Bad request', 400, 'invalid_request'); + + expect($exception->getErrorCode())->toBe('invalid_request') + ->and($exception->getCode())->toBe(400); +}); + +test('ApiException with null error code', function () { + $exception = new ApiException('Error', 500, null); + + expect($exception->getErrorCode())->toBeNull(); +}); + +test('ApiException with error details', function () { + $errorDetails = [ + 'type' => 'server_error', + 'message' => 'Database connection failed', + 'timestamp' => '2024-02-20T10:00:00Z', + ]; + + $exception = new ApiException('Server error', 500, 'db_error', $errorDetails); + + expect($exception->getErrorDetails())->toBe($errorDetails) + ->and($exception->getErrorCode())->toBe('db_error') + ->and($exception->getErrorDetails()['type'])->toBe('server_error'); +}); + +test('ApiException for 400 bad request', function () { + $exception = new ApiException('Bad request', 400, 'bad_request'); + + expect($exception->getCode())->toBe(400) + ->and($exception->getErrorCode())->toBe('bad_request'); +}); + +test('ApiException for 403 forbidden', function () { + $exception = new ApiException('Forbidden', 403, 'insufficient_permissions'); + + expect($exception->getCode())->toBe(403) + ->and($exception->getErrorCode())->toBe('insufficient_permissions'); +}); + +test('ApiException for 409 conflict', function () { + $exception = new ApiException('Resource conflict', 409, 'duplicate_resource'); + + expect($exception->getCode())->toBe(409) + ->and($exception->getErrorCode())->toBe('duplicate_resource'); +}); + +test('ApiException for 422 unprocessable entity', function () { + $exception = new ApiException('Unprocessable entity', 422, 'validation_failed'); + + expect($exception->getCode())->toBe(422) + ->and($exception->getErrorCode())->toBe('validation_failed'); +}); + +test('ApiException for 500 internal server error', function () { + $exception = new ApiException('Internal server error', 500, 'internal_error'); + + expect($exception->getCode())->toBe(500) + ->and($exception->getErrorCode())->toBe('internal_error'); +}); + +test('ApiException for 502 bad gateway', function () { + $exception = new ApiException('Bad gateway', 502, 'upstream_error'); + + expect($exception->getCode())->toBe(502) + ->and($exception->getErrorCode())->toBe('upstream_error'); +}); + +test('ApiException for 503 service unavailable', function () { + $exception = new ApiException('Service unavailable', 503, 'maintenance'); + + expect($exception->getCode())->toBe(503) + ->and($exception->getErrorCode())->toBe('maintenance'); +}); + +test('ApiException for 504 gateway timeout', function () { + $exception = new ApiException('Gateway timeout', 504, 'timeout'); + + expect($exception->getCode())->toBe(504) + ->and($exception->getErrorCode())->toBe('timeout'); +}); + +test('ApiException with synchronization too soon error', function () { + $errorDetails = [ + 'resource_type' => 'account', + 'resource_id' => 'acc-123', + 'retry_after' => 300, + ]; + + $exception = new ApiException( + 'Synchronization requested too soon', + 409, + 'synchronization_too_soon', + $errorDetails + ); + + expect($exception->getCode())->toBe(409) + ->and($exception->getErrorCode())->toBe('synchronization_too_soon') + ->and($exception->getErrorDetails()['retry_after'])->toBe(300); +}); + +test('ApiException can be caught as PontoException', function () { + try { + throw new ApiException('Test', 500, 'test_error'); + } catch (PontoException $e) { + expect($e)->toBeInstanceOf(ApiException::class) + ->and($e->getErrorCode())->toBe('test_error'); + } +}); + +test('ApiException with complex error details from API', function () { + $errorDetails = [ + 'errors' => [ + [ + 'code' => 'invalid_parameter', + 'detail' => 'The limit parameter must be between 1 and 100', + 'source' => ['parameter' => 'limit'], + ], + ], + ]; + + $exception = new ApiException('Invalid parameters', 400, 'invalid_request', $errorDetails); + + expect($exception->getErrorDetails()['errors'][0]['code'])->toBe('invalid_parameter') + ->and($exception->getErrorDetails()['errors'][0]['source']['parameter'])->toBe('limit'); +}); diff --git a/tests/Unit/Exceptions/AuthenticationExceptionTest.php b/tests/Unit/Exceptions/AuthenticationExceptionTest.php new file mode 100644 index 0000000..5bcff5f --- /dev/null +++ b/tests/Unit/Exceptions/AuthenticationExceptionTest.php @@ -0,0 +1,65 @@ +toBeInstanceOf(PontoException::class) + ->and($exception)->toBeInstanceOf(\Exception::class); +}); + +test('can create AuthenticationException with message', function () { + $exception = new AuthenticationException('Invalid credentials'); + + expect($exception->getMessage())->toBe('Invalid credentials'); +}); + +test('AuthenticationException typically uses 401 code', function () { + $exception = new AuthenticationException('Unauthorized', 401); + + expect($exception->getCode())->toBe(401); +}); + +test('AuthenticationException with error details', function () { + $errorDetails = [ + 'error' => 'invalid_client', + 'error_description' => 'Client authentication failed', + ]; + + $exception = new AuthenticationException('OAuth error', 401, $errorDetails); + + expect($exception->getErrorDetails())->toBe($errorDetails) + ->and($exception->getErrorDetails()['error'])->toBe('invalid_client'); +}); + +test('AuthenticationException can wrap previous exception', function () { + $previous = new \RuntimeException('Network error'); + $exception = new AuthenticationException('Failed to authenticate', 0, null, $previous); + + expect($exception->getPrevious())->toBe($previous); +}); + +test('AuthenticationException for invalid token', function () { + $exception = new AuthenticationException('Invalid access token', 401); + + expect($exception->getMessage())->toContain('token') + ->and($exception->getCode())->toBe(401); +}); + +test('AuthenticationException for expired token', function () { + $exception = new AuthenticationException('Access token expired', 401); + + expect($exception->getMessage())->toContain('expired'); +}); + +test('AuthenticationException can be caught as PontoException', function () { + try { + throw new AuthenticationException('Test'); + } catch (PontoException $e) { + expect($e)->toBeInstanceOf(AuthenticationException::class); + } +}); diff --git a/tests/Unit/Exceptions/NotFoundExceptionTest.php b/tests/Unit/Exceptions/NotFoundExceptionTest.php new file mode 100644 index 0000000..802b4b3 --- /dev/null +++ b/tests/Unit/Exceptions/NotFoundExceptionTest.php @@ -0,0 +1,77 @@ +toBeInstanceOf(PontoException::class) + ->and($exception)->toBeInstanceOf(\Exception::class); +}); + +test('can create NotFoundException with message', function () { + $exception = new NotFoundException('Account not found'); + + expect($exception->getMessage())->toBe('Account not found'); +}); + +test('NotFoundException uses 404 code', function () { + $exception = new NotFoundException('Not found', 404); + + expect($exception->getCode())->toBe(404); +}); + +test('NotFoundException for account not found', function () { + $accountId = 'acc-123'; + $exception = new NotFoundException("Account {$accountId} not found", 404); + + expect($exception->getMessage())->toContain('acc-123') + ->and($exception->getMessage())->toContain('Account'); +}); + +test('NotFoundException for transaction not found', function () { + $transactionId = 'tx-456'; + $exception = new NotFoundException("Transaction {$transactionId} not found", 404); + + expect($exception->getMessage())->toContain('tx-456') + ->and($exception->getMessage())->toContain('Transaction'); +}); + +test('NotFoundException for payment not found', function () { + $paymentId = 'pay-789'; + $exception = new NotFoundException("Payment {$paymentId} not found", 404); + + expect($exception->getMessage())->toContain('pay-789') + ->and($exception->getMessage())->toContain('Payment'); +}); + +test('NotFoundException for synchronization not found', function () { + $syncId = 'sync-abc'; + $exception = new NotFoundException("Synchronization {$syncId} not found", 404); + + expect($exception->getMessage())->toContain('sync-abc') + ->and($exception->getMessage())->toContain('Synchronization'); +}); + +test('NotFoundException with error details', function () { + $errorDetails = [ + 'resource_type' => 'account', + 'resource_id' => 'acc-123', + ]; + + $exception = new NotFoundException('Resource not found', 404, $errorDetails); + + expect($exception->getErrorDetails())->toBe($errorDetails) + ->and($exception->getErrorDetails()['resource_type'])->toBe('account'); +}); + +test('NotFoundException can be caught as PontoException', function () { + try { + throw new NotFoundException('Test'); + } catch (PontoException $e) { + expect($e)->toBeInstanceOf(NotFoundException::class); + } +}); diff --git a/tests/Unit/Exceptions/PontoExceptionTest.php b/tests/Unit/Exceptions/PontoExceptionTest.php new file mode 100644 index 0000000..5351c3f --- /dev/null +++ b/tests/Unit/Exceptions/PontoExceptionTest.php @@ -0,0 +1,73 @@ +toBeInstanceOf(\Exception::class) + ->and($exception->getMessage())->toBe('Test error message') + ->and($exception->getCode())->toBe(0) + ->and($exception->getErrorDetails())->toBeNull(); +}); + +test('can create PontoException with error code', function () { + $exception = new PontoException('Error message', 500); + + expect($exception->getCode())->toBe(500); +}); + +test('can create PontoException with error details', function () { + $errorDetails = [ + 'field' => 'amount', + 'issue' => 'must be positive', + ]; + + $exception = new PontoException('Validation failed', 400, $errorDetails); + + expect($exception->getErrorDetails())->toBe($errorDetails) + ->and($exception->getErrorDetails()['field'])->toBe('amount'); +}); + +test('can create PontoException with previous exception', function () { + $previous = new \RuntimeException('Previous error'); + $exception = new PontoException('Current error', 0, null, $previous); + + expect($exception->getPrevious())->toBe($previous) + ->and($exception->getPrevious()->getMessage())->toBe('Previous error'); +}); + +test('PontoException with all parameters', function () { + $previous = new \Exception('Root cause'); + $errorDetails = ['key' => 'value']; + + $exception = new PontoException('Error', 400, $errorDetails, $previous); + + expect($exception->getMessage())->toBe('Error') + ->and($exception->getCode())->toBe(400) + ->and($exception->getErrorDetails())->toBe($errorDetails) + ->and($exception->getPrevious())->toBe($previous); +}); + +test('PontoException error details can be empty array', function () { + $exception = new PontoException('Error', 0, []); + + expect($exception->getErrorDetails())->toBe([]) + ->and($exception->getErrorDetails())->toBeArray() + ->and($exception->getErrorDetails())->toBeEmpty(); +}); + +test('PontoException can be caught as Exception', function () { + try { + throw new PontoException('Test'); + } catch (\Exception $e) { + expect($e)->toBeInstanceOf(PontoException::class); + } +}); + +test('PontoException can be thrown and caught', function () { + expect(fn () => throw new PontoException('Test error')) + ->toThrow(PontoException::class, 'Test error'); +}); diff --git a/tests/Unit/Exceptions/RateLimitExceptionTest.php b/tests/Unit/Exceptions/RateLimitExceptionTest.php new file mode 100644 index 0000000..f341fd3 --- /dev/null +++ b/tests/Unit/Exceptions/RateLimitExceptionTest.php @@ -0,0 +1,91 @@ +toBeInstanceOf(PontoException::class) + ->and($exception)->toBeInstanceOf(\Exception::class); +}); + +test('can create RateLimitException with message', function () { + $exception = new RateLimitException('Too many requests'); + + expect($exception->getMessage())->toBe('Too many requests'); +}); + +test('RateLimitException always uses 429 code', function () { + $exception = new RateLimitException('Rate limit exceeded'); + + expect($exception->getCode())->toBe(429); +}); + +test('RateLimitException with retry after seconds', function () { + $exception = new RateLimitException('Rate limit exceeded', 60); + + expect($exception->getRetryAfterSeconds())->toBe(60) + ->and($exception->getCode())->toBe(429); +}); + +test('RateLimitException with null retry after', function () { + $exception = new RateLimitException('Rate limit exceeded', null); + + expect($exception->getRetryAfterSeconds())->toBeNull(); +}); + +test('RateLimitException with error details', function () { + $errorDetails = [ + 'limit' => 100, + 'remaining' => 0, + 'reset' => 1708431600, + ]; + + $exception = new RateLimitException('Rate limit exceeded', 60, $errorDetails); + + expect($exception->getErrorDetails())->toBe($errorDetails) + ->and($exception->getErrorDetails()['remaining'])->toBe(0) + ->and($exception->getRetryAfterSeconds())->toBe(60); +}); + +test('RateLimitException with retry after from Retry-After header', function () { + $retryAfter = 120; + $exception = new RateLimitException( + 'Rate limit exceeded. Retry after 120 seconds', + $retryAfter + ); + + expect($exception->getRetryAfterSeconds())->toBe(120) + ->and($exception->getMessage())->toContain('120'); +}); + +test('RateLimitException with zero retry after', function () { + $exception = new RateLimitException('Rate limit exceeded', 0); + + expect($exception->getRetryAfterSeconds())->toBe(0); +}); + +test('RateLimitException with large retry after value', function () { + $exception = new RateLimitException('Rate limit exceeded', 3600); + + expect($exception->getRetryAfterSeconds())->toBe(3600); +}); + +test('RateLimitException can be caught as PontoException', function () { + try { + throw new RateLimitException('Test', 60); + } catch (PontoException $e) { + expect($e)->toBeInstanceOf(RateLimitException::class) + ->and($e->getRetryAfterSeconds())->toBe(60); + } +}); + +test('RateLimitException message suggests retry behavior', function () { + $exception = new RateLimitException('Too many requests. Please retry later.', 30); + + expect($exception->getMessage())->toContain('retry') + ->and($exception->getRetryAfterSeconds())->toBe(30); +}); diff --git a/tests/Unit/Exceptions/ValidationExceptionTest.php b/tests/Unit/Exceptions/ValidationExceptionTest.php new file mode 100644 index 0000000..b63fdd3 --- /dev/null +++ b/tests/Unit/Exceptions/ValidationExceptionTest.php @@ -0,0 +1,79 @@ +toBeInstanceOf(PontoException::class) + ->and($exception)->toBeInstanceOf(\Exception::class); +}); + +test('can create ValidationException with message', function () { + $exception = new ValidationException('Invalid input data'); + + expect($exception->getMessage())->toBe('Invalid input data'); +}); + +test('ValidationException typically uses 400 code', function () { + $exception = new ValidationException('Bad request', 400); + + expect($exception->getCode())->toBe(400); +}); + +test('ValidationException with field-specific errors', function () { + $errorDetails = [ + 'errors' => [ + ['field' => 'amount', 'message' => 'must be positive'], + ['field' => 'currency', 'message' => 'invalid currency code'], + ], + ]; + + $exception = new ValidationException('Validation failed', 400, $errorDetails); + + expect($exception->getErrorDetails())->toBe($errorDetails) + ->and($exception->getErrorDetails()['errors'])->toHaveCount(2); +}); + +test('ValidationException for invalid IBAN', function () { + $exception = new ValidationException('Invalid IBAN format: INVALID'); + + expect($exception->getMessage())->toContain('IBAN'); +}); + +test('ValidationException for invalid amount', function () { + $exception = new ValidationException('Amount must be positive: -50.00'); + + expect($exception->getMessage())->toContain('Amount') + ->and($exception->getMessage())->toContain('positive'); +}); + +test('ValidationException for limit out of range', function () { + $exception = new ValidationException('Limit must be between 1 and 100'); + + expect($exception->getMessage())->toContain('Limit'); +}); + +test('ValidationException for invalid date format', function () { + $errorDetails = [ + 'field' => 'since', + 'value' => 'invalid-date', + 'expected' => 'Y-m-d format', + ]; + + $exception = new ValidationException('Invalid date format', 400, $errorDetails); + + expect($exception->getErrorDetails()['field'])->toBe('since') + ->and($exception->getErrorDetails()['expected'])->toBe('Y-m-d format'); +}); + +test('ValidationException can be caught as PontoException', function () { + try { + throw new ValidationException('Test'); + } catch (PontoException $e) { + expect($e)->toBeInstanceOf(ValidationException::class); + } +}); diff --git a/tests/Unit/Models/AccountTest.php b/tests/Unit/Models/AccountTest.php new file mode 100644 index 0000000..1a5ab48 --- /dev/null +++ b/tests/Unit/Models/AccountTest.php @@ -0,0 +1,222 @@ +toBeInstanceOf(Account::class) + ->and($account->id)->toBeString() + ->and($account->type)->toBe('account'); +}); + +test('Account has all required properties', function () { + $data = mockAccountData([ + 'id' => 'acc-123', + 'attributes' => [ + 'reference' => 'BE68539007547034', + 'referenceType' => 'IBAN', + 'currency' => 'EUR', + 'subtype' => 'checking', + 'availableBalance' => 1500.50, + 'currentBalance' => 1600.00, + 'holderName' => 'John Doe', + 'product' => 'Easy Account', + ], + ]); + + $account = Account::fromArray($data); + + expect($account->id)->toBe('acc-123') + ->and($account->reference)->toBe('BE68539007547034') + ->and($account->referenceType)->toBe('IBAN') + ->and($account->currency)->toBe('EUR') + ->and($account->subtype)->toBe('checking') + ->and($account->availableBalance)->toBe(1500.50) + ->and($account->currentBalance)->toBe(1600.00) + ->and($account->holderName)->toBe('John Doe') + ->and($account->product)->toBe('Easy Account'); +}); + +test('Account reference is valid IBAN', function () { + $account = Account::fromArray(mockAccountData()); + + expect($account->reference)->toBeValidIban(); +}); + +test('Account currency is valid ISO 4217', function () { + $account = Account::fromArray(mockAccountData()); + + expect($account->currency)->toBeValidIso4217Currency(); +}); + +test('Account isDeprecated returns correct value', function () { + $deprecatedAccount = Account::fromArray(mockAccountData([ + 'attributes' => ['deprecated' => true], + ])); + + $activeAccount = Account::fromArray(mockAccountData([ + 'attributes' => ['deprecated' => false], + ])); + + expect($deprecatedAccount->isDeprecated())->toBeTrue() + ->and($activeAccount->isDeprecated())->toBeFalse(); +}); + +test('Account needsReauthorization when expiration is soon', function () { + $soonExpiring = Account::fromArray(mockAccountData([ + 'attributes' => [ + 'authorizationExpirationExpectedAt' => new DateTimeImmutable('+5 days')->format('Y-m-d\TH:i:s\Z'), + ], + ])); + + $account = Account::fromArray($soonExpiring->toArray()); + + expect($account->needsReauthorization())->toBeTrue(); +}); + +test('Account needsReauthorization when expiration is far', function () { + $farExpiring = Account::fromArray(mockAccountData([ + 'attributes' => [ + 'authorizationExpirationExpectedAt' => new DateTimeImmutable('+60 days')->format('Y-m-d\TH:i:s\Z'), + ], + ])); + + $account = Account::fromArray($farExpiring->toArray()); + + expect($account->needsReauthorization())->toBeFalse(); +}); + +test('Account with null expiration date', function () { + $data = mockAccountData([ + 'attributes' => ['authorizationExpirationExpectedAt' => null], + ]); + + $account = Account::fromArray($data); + + expect($account->authorizationExpirationExpectedAt)->toBeNull(); +}); + +test('Account with null description', function () { + $data = mockAccountData([ + 'attributes' => ['description' => null], + ]); + + $account = Account::fromArray($data); + + expect($account->description)->toBeNull(); +}); + +test('Account parses DateTimeImmutable correctly', function () { + $account = Account::fromArray(mockAccountData()); + + expect($account->availableBalanceChangedAt)->toBeInstanceOf(DateTimeImmutable::class) + ->and($account->currentBalanceChangedAt)->toBeInstanceOf(DateTimeImmutable::class) + ->and($account->authorizedAt)->toBeInstanceOf(DateTimeImmutable::class); +}); + +test('Account toArray returns correct structure', function () { + $account = Account::fromArray(mockAccountData(['id' => 'acc-test'])); + $array = $account->toArray(); + + expect($array)->toBeArray() + ->and($array)->toHaveKey('id', 'acc-test') + ->and($array)->toHaveKey('reference') + ->and($array)->toHaveKey('currency') + ->and($array)->toHaveKey('holderName'); +}); + +test('Account with different subtypes', function () { + $checking = Account::fromArray(mockAccountData(['attributes' => ['subtype' => 'checking']])); + $savings = Account::fromArray(mockAccountData(['attributes' => ['subtype' => 'savings']])); + $credit = Account::fromArray(mockAccountData(['attributes' => ['subtype' => 'credit']])); + + expect($checking->subtype)->toBe('checking') + ->and($savings->subtype)->toBe('savings') + ->and($credit->subtype)->toBe('credit'); +}); + +test('Account with zero balance', function () { + $account = Account::fromArray(mockAccountData([ + 'attributes' => [ + 'availableBalance' => 0.0, + 'currentBalance' => 0.0, + ], + ])); + + expect($account->availableBalance)->toBe(0.0) + ->and($account->currentBalance)->toBe(0.0); +}); + +test('Account with negative balance', function () { + $account = Account::fromArray(mockAccountData([ + 'attributes' => [ + 'availableBalance' => -500.00, + 'currentBalance' => -500.00, + ], + ])); + + expect($account->availableBalance)->toBe(-500.00) + ->and($account->currentBalance)->toBe(-500.00); +}); + +test('Account with large balance', function () { + $account = Account::fromArray(mockAccountData([ + 'attributes' => [ + 'availableBalance' => 9999999.99, + 'currentBalance' => 10000000.00, + ], + ])); + + expect($account->availableBalance)->toBe(9999999.99) + ->and($account->currentBalance)->toBe(10000000.00); +}); + +test('Account readonly properties cannot be modified', function () { + $account = Account::fromArray(mockAccountData()); + + expect(fn () => $account->id = 'new-id') + ->toThrow(\Error::class); +}); + +test('Account with relationships', function () { + $data = mockAccountData([ + 'relationships' => [ + 'financialInstitution' => [ + 'data' => ['id' => 'fi-123', 'type' => 'financialInstitution'], + ], + ], + ]); + + $account = Account::fromArray($data); + + expect($account->relationships)->toBeArray() + ->and($account->relationships)->toHaveKey('financialInstitution'); +}); + +test('Account with metadata', function () { + $data = mockAccountData([ + 'meta' => [ + 'synchronizedAt' => '2024-02-20T10:00:00Z', + 'latestSynchronizationId' => 'sync-123', + ], + ]); + + $account = Account::fromArray($data); + + expect($account->meta)->toBeArray() + ->and($account->meta)->toHaveKey('synchronizedAt'); +}); + +test('Account with different currencies', function () { + $eur = Account::fromArray(mockAccountData(['attributes' => ['currency' => 'EUR']])); + $usd = Account::fromArray(mockAccountData(['attributes' => ['currency' => 'USD']])); + $gbp = Account::fromArray(mockAccountData(['attributes' => ['currency' => 'GBP']])); + + expect($eur->currency)->toBe('EUR') + ->and($usd->currency)->toBe('USD') + ->and($gbp->currency)->toBe('GBP'); +}); diff --git a/tests/Unit/Models/PaginatedCollectionTest.php b/tests/Unit/Models/PaginatedCollectionTest.php new file mode 100644 index 0000000..ff48f4d --- /dev/null +++ b/tests/Unit/Models/PaginatedCollectionTest.php @@ -0,0 +1,179 @@ + ['limit' => 20]]; + $links = ['next' => 'https://api.myponto.com/accounts?after=cursor123']; + + $collection = PaginatedCollection::fromArray($data, $meta, $links); + + expect($collection)->toBeInstanceOf(PaginatedCollection::class) + ->and($collection->data)->toBeArray() + ->and($collection->data)->toHaveCount(2); +}); + +test('PaginatedCollection has correct limit', function () { + $meta = ['paging' => ['limit' => 50]]; + $collection = PaginatedCollection::fromArray([], $meta, []); + + expect($collection->limit)->toBe(50); +}); + +test('PaginatedCollection with before cursor', function () { + $meta = ['paging' => ['limit' => 20, 'before' => 'cursor-before']]; + $collection = PaginatedCollection::fromArray([], $meta, []); + + expect($collection->beforeCursor)->toBe('cursor-before'); +}); + +test('PaginatedCollection with after cursor', function () { + $meta = ['paging' => ['limit' => 20, 'after' => 'cursor-after']]; + $collection = PaginatedCollection::fromArray([], $meta, []); + + expect($collection->afterCursor)->toBe('cursor-after'); +}); + +test('PaginatedCollection with pagination links', function () { + $links = [ + 'first' => 'https://api.myponto.com/accounts?limit=20', + 'prev' => 'https://api.myponto.com/accounts?before=cursor1', + 'next' => 'https://api.myponto.com/accounts?after=cursor2', + ]; + + $collection = PaginatedCollection::fromArray([], ['paging' => []], $links); + + expect($collection->firstUrl)->toBe($links['first']) + ->and($collection->prevUrl)->toBe($links['prev']) + ->and($collection->nextUrl)->toBe($links['next']); +}); + +test('PaginatedCollection hasNextPage returns true when next link exists', function () { + $links = ['next' => 'https://api.myponto.com/accounts?after=cursor']; + $collection = PaginatedCollection::fromArray([], ['paging' => []], $links); + + expect($collection->hasNextPage())->toBeTrue(); +}); + +test('PaginatedCollection hasNextPage returns false when no next link', function () { + $collection = PaginatedCollection::fromArray([], ['paging' => []], []); + + expect($collection->hasNextPage())->toBeFalse(); +}); + +test('PaginatedCollection hasPrevPage returns true when prev link exists', function () { + $links = ['prev' => 'https://api.myponto.com/accounts?before=cursor']; + $collection = PaginatedCollection::fromArray([], ['paging' => []], $links); + + expect($collection->hasPrevPage())->toBeTrue(); +}); + +test('PaginatedCollection hasPrevPage returns false when no prev link', function () { + $collection = PaginatedCollection::fromArray([], ['paging' => []], []); + + expect($collection->hasPrevPage())->toBeFalse(); +}); + +test('PaginatedCollection isEmpty returns true for empty data', function () { + $collection = PaginatedCollection::fromArray([], ['paging' => []], []); + + expect($collection->isEmpty())->toBeTrue(); +}); + +test('PaginatedCollection isEmpty returns false for non-empty data', function () { + $data = [mockAccountData()]; + $collection = PaginatedCollection::fromArray($data, ['paging' => []], []); + + expect($collection->isEmpty())->toBeFalse(); +}); + +test('PaginatedCollection count returns correct number of items', function () { + $data = [mockAccountData(), mockAccountData(), mockAccountData()]; + $collection = PaginatedCollection::fromArray($data, ['paging' => []], []); + + expect($collection->count())->toBe(3); +}); + +test('PaginatedCollection count returns zero for empty collection', function () { + $collection = PaginatedCollection::fromArray([], ['paging' => []], []); + + expect($collection->count())->toBe(0); +}); + +test('PaginatedCollection toArray includes all data', function () { + $data = [mockAccountData()]; + $meta = ['paging' => ['limit' => 20]]; + $links = ['next' => 'https://example.com/next']; + + $collection = PaginatedCollection::fromArray($data, $meta, $links); + $array = $collection->toArray(); + + expect($array)->toHaveKey('data') + ->and($array)->toHaveKey('limit') + ->and($array['data'])->toHaveCount(1) + ->and($array['limit'])->toBe(20); +}); + +test('PaginatedCollection with maximum limit', function () { + $meta = ['paging' => ['limit' => 100]]; + $collection = PaginatedCollection::fromArray([], $meta, []); + + expect($collection->limit)->toBe(100); +}); + +test('PaginatedCollection with minimum limit', function () { + $meta = ['paging' => ['limit' => 1]]; + $collection = PaginatedCollection::fromArray([], $meta, []); + + expect($collection->limit)->toBe(1); +}); + +test('PaginatedCollection without cursors', function () { + $meta = ['paging' => ['limit' => 20]]; + $collection = PaginatedCollection::fromArray([], $meta, []); + + expect($collection->beforeCursor)->toBeNull() + ->and($collection->afterCursor)->toBeNull(); +}); + +test('PaginatedCollection with both cursors', function () { + $meta = [ + 'paging' => [ + 'limit' => 20, + 'before' => 'before-cursor', + 'after' => 'after-cursor', + ], + ]; + + $collection = PaginatedCollection::fromArray([], $meta, []); + + expect($collection->beforeCursor)->toBe('before-cursor') + ->and($collection->afterCursor)->toBe('after-cursor'); +}); + +test('PaginatedCollection readonly properties cannot be modified', function () { + $collection = PaginatedCollection::fromArray([], ['paging' => ['limit' => 20]], []); + + expect(fn () => $collection->limit = 50) + ->toThrow(\Error::class); +}); + +test('PaginatedCollection data is readonly array', function () { + $data = [mockAccountData()]; + $collection = PaginatedCollection::fromArray($data, ['paging' => []], []); + + expect($collection->data)->toBeArray() + ->and(fn () => $collection->data = []) + ->toThrow(\Error::class); +}); + +test('PaginatedCollection handles large datasets', function () { + $data = array_map(fn () => mockAccountData(), range(1, 100)); + $collection = PaginatedCollection::fromArray($data, ['paging' => ['limit' => 100]], []); + + expect($collection->count())->toBe(100) + ->and($collection->isEmpty())->toBeFalse(); +}); diff --git a/tests/Unit/Models/PaymentTest.php b/tests/Unit/Models/PaymentTest.php new file mode 100644 index 0000000..6835b4e --- /dev/null +++ b/tests/Unit/Models/PaymentTest.php @@ -0,0 +1,255 @@ +toBeInstanceOf(Payment::class) + ->and($payment->id)->toBeString() + ->and($payment->type)->toBe('payment'); +}); + +test('Payment has all required properties', function () { + $data = mockPaymentData([ + 'id' => 'pay-123', + 'attributes' => [ + 'status' => 'unsigned', + 'amount' => 100.00, + 'currency' => 'EUR', + 'creditorName' => 'John Doe', + 'creditorAccountReference' => 'BE68539007547034', + 'creditorAccountReferenceType' => 'IBAN', + 'creditorAgent' => 'NBBEBEBB203', + 'creditorAgentType' => 'BIC', + 'remittanceInformation' => 'Invoice 123', + 'remittanceInformationType' => 'unstructured', + ], + ]); + + $payment = Payment::fromArray($data); + + expect($payment->id)->toBe('pay-123') + ->and($payment->status)->toBe('unsigned') + ->and($payment->amount)->toBe(100.00) + ->and($payment->currency)->toBe('EUR') + ->and($payment->creditorName)->toBe('John Doe') + ->and($payment->creditorAccountReference)->toBe('BE68539007547034') + ->and($payment->creditorAgent)->toBe('NBBEBEBB203'); +}); + +test('Payment isUnsigned returns true for unsigned status', function () { + $payment = Payment::fromArray(mockPaymentData(['attributes' => ['status' => 'unsigned']])); + + expect($payment->isUnsigned())->toBeTrue() + ->and($payment->isAuthorized())->toBeFalse() + ->and($payment->isExecuted())->toBeFalse(); +}); + +test('Payment isAuthorized returns true for authorized status', function () { + $payment = Payment::fromArray(mockPaymentData(['attributes' => ['status' => 'authorized']])); + + expect($payment->isAuthorized())->toBeTrue() + ->and($payment->isUnsigned())->toBeFalse() + ->and($payment->isExecuted())->toBeFalse(); +}); + +test('Payment isExecuted returns true for executed status', function () { + $payment = Payment::fromArray(mockPaymentData(['attributes' => ['status' => 'executed']])); + + expect($payment->isExecuted())->toBeTrue() + ->and($payment->isUnsigned())->toBeFalse() + ->and($payment->isAuthorized())->toBeFalse(); +}); + +test('Payment isCancelled returns true for cancelled status', function () { + $payment = Payment::fromArray(mockPaymentData(['attributes' => ['status' => 'cancelled']])); + + expect($payment->isCancelled())->toBeTrue() + ->and($payment->isExecuted())->toBeFalse(); +}); + +test('Payment isRejected returns true for rejected status', function () { + $payment = Payment::fromArray(mockPaymentData(['attributes' => ['status' => 'rejected']])); + + expect($payment->isRejected())->toBeTrue() + ->and($payment->isExecuted())->toBeFalse(); +}); + +test('Payment requiresAuthorization for unsigned payment', function () { + $payment = Payment::fromArray(mockPaymentData(['attributes' => ['status' => 'unsigned']])); + + expect($payment->requiresAuthorization())->toBeTrue(); +}); + +test('Payment requiresAuthorization false for executed payment', function () { + $payment = Payment::fromArray(mockPaymentData(['attributes' => ['status' => 'executed']])); + + expect($payment->requiresAuthorization())->toBeFalse(); +}); + +test('Payment with redirect URL', function () { + $payment = Payment::fromArray(mockPaymentData([ + 'links' => ['redirect' => 'https://authorize.myponto.com/payment/pay-123'], + ])); + + expect($payment->redirectUrl)->toBe('https://authorize.myponto.com/payment/pay-123'); +}); + +test('Payment with null redirect URL', function () { + $payment = Payment::fromArray(mockPaymentData(['links' => []])); + + expect($payment->redirectUrl)->toBeNull(); +}); + +test('Payment with endToEndId for idempotency', function () { + $payment = Payment::fromArray(mockPaymentData([ + 'attributes' => ['endToEndId' => 'unique-payment-id-123'], + ])); + + expect($payment->endToEndId)->toBe('unique-payment-id-123'); +}); + +test('Payment with null endToEndId', function () { + $payment = Payment::fromArray(mockPaymentData([ + 'attributes' => ['endToEndId' => null], + ])); + + expect($payment->endToEndId)->toBeNull(); +}); + +test('Payment with requested execution date', function () { + $futureDate = new DateTimeImmutable('+7 days'); + $payment = Payment::fromArray(mockPaymentData([ + 'attributes' => ['requestedExecutionDate' => $futureDate->format('Y-m-d\TH:i:s\Z')], + ])); + + expect($payment->requestedExecutionDate)->toBeInstanceOf(DateTimeImmutable::class) + ->and($payment->requestedExecutionDate->format('Y-m-d'))->toBe($futureDate->format('Y-m-d')); +}); + +test('Payment with null requested execution date', function () { + $payment = Payment::fromArray(mockPaymentData([ + 'attributes' => ['requestedExecutionDate' => null], + ])); + + expect($payment->requestedExecutionDate)->toBeNull(); +}); + +test('Payment with structured remittance', function () { + $payment = Payment::fromArray(mockPaymentData([ + 'attributes' => [ + 'remittanceInformation' => '+++123/4567/89012+++', + 'remittanceInformationType' => 'structured', + ], + ])); + + expect($payment->remittanceInformationType)->toBe('structured') + ->and($payment->remittanceInformation)->toBe('+++123/4567/89012+++'); +}); + +test('Payment with unstructured remittance', function () { + $payment = Payment::fromArray(mockPaymentData([ + 'attributes' => [ + 'remittanceInformation' => 'Invoice 2024-001', + 'remittanceInformationType' => 'unstructured', + ], + ])); + + expect($payment->remittanceInformationType)->toBe('unstructured'); +}); + +test('Payment creditor IBAN is valid', function () { + $payment = Payment::fromArray(mockPaymentData([ + 'attributes' => ['creditorAccountReference' => 'BE68539007547034'], + ])); + + expect($payment->creditorAccountReference)->toBeValidIban(); +}); + +test('Payment creditor BIC is valid', function () { + $payment = Payment::fromArray(mockPaymentData([ + 'attributes' => ['creditorAgent' => 'NBBEBEBB203'], + ])); + + expect($payment->creditorAgent)->toBeValidBic(); +}); + +test('Payment currency is valid ISO 4217', function () { + $payment = Payment::fromArray(mockPaymentData()); + + expect($payment->currency)->toBeValidIso4217Currency(); +}); + +test('Payment toArray returns correct structure', function () { + $payment = Payment::fromArray(mockPaymentData(['id' => 'pay-test'])); + $array = $payment->toArray(); + + expect($array)->toBeArray() + ->and($array)->toHaveKey('id', 'pay-test') + ->and($array)->toHaveKey('status') + ->and($array)->toHaveKey('amount') + ->and($array)->toHaveKey('currency') + ->and($array)->toHaveKey('creditorName'); +}); + +test('Payment readonly properties cannot be modified', function () { + $payment = Payment::fromArray(mockPaymentData()); + + expect(fn () => $payment->amount = 999.99) + ->toThrow(\Error::class); +}); + +test('Payment with different currencies', function () { + $eur = Payment::fromArray(mockPaymentData(['attributes' => ['currency' => 'EUR']])); + $usd = Payment::fromArray(mockPaymentData(['attributes' => ['currency' => 'USD']])); + $gbp = Payment::fromArray(mockPaymentData(['attributes' => ['currency' => 'GBP']])); + + expect($eur->currency)->toBe('EUR') + ->and($usd->currency)->toBe('USD') + ->and($gbp->currency)->toBe('GBP'); +}); + +test('Payment with large amount', function () { + $payment = Payment::fromArray(mockPaymentData([ + 'attributes' => ['amount' => 50000.00], + ])); + + expect($payment->amount)->toBe(50000.00); +}); + +test('Payment with small amount', function () { + $payment = Payment::fromArray(mockPaymentData([ + 'attributes' => ['amount' => 0.01], + ])); + + expect($payment->amount)->toBe(0.01); +}); + +test('Payment all status states are mutually exclusive', function () { + $statuses = ['unsigned', 'authorized', 'executed', 'cancelled', 'rejected']; + + foreach ($statuses as $status) { + $payment = Payment::fromArray(mockPaymentData(['attributes' => ['status' => $status]])); + + $checks = [ + 'unsigned' => $payment->isUnsigned(), + 'authorized' => $payment->isAuthorized(), + 'executed' => $payment->isExecuted(), + 'cancelled' => $payment->isCancelled(), + 'rejected' => $payment->isRejected(), + ]; + + // Only the current status should be true + foreach ($checks as $checkStatus => $result) { + if ($checkStatus === $status) { + expect($result)->toBeTrue("Expected {$status} to be true"); + } else { + expect($result)->toBeFalse("Expected {$checkStatus} to be false when status is {$status}"); + } + } + } +}); diff --git a/tests/Unit/Models/SynchronizationTest.php b/tests/Unit/Models/SynchronizationTest.php new file mode 100644 index 0000000..b3dc3ce --- /dev/null +++ b/tests/Unit/Models/SynchronizationTest.php @@ -0,0 +1,260 @@ +toBeInstanceOf(Synchronization::class) + ->and($sync->id)->toBeString() + ->and($sync->type)->toBe('synchronization'); +}); + +test('Synchronization has all required properties', function () { + $data = mockSynchronizationData([ + 'id' => 'sync-123', + 'attributes' => [ + 'status' => 'pending', + 'resourceType' => 'account', + 'resourceId' => 'acc-456', + 'subtype' => 'accountTransactions', + ], + ]); + + $sync = Synchronization::fromArray($data); + + expect($sync->id)->toBe('sync-123') + ->and($sync->status)->toBe('pending') + ->and($sync->resourceType)->toBe('account') + ->and($sync->resourceId)->toBe('acc-456') + ->and($sync->subtype)->toBe('accountTransactions'); +}); + +test('Synchronization isPending returns true for pending status', function () { + $sync = Synchronization::fromArray(mockSynchronizationData([ + 'attributes' => ['status' => 'pending'], + ])); + + expect($sync->isPending())->toBeTrue() + ->and($sync->isRunning())->toBeFalse() + ->and($sync->isSuccessful())->toBeFalse(); +}); + +test('Synchronization isRunning returns true for running status', function () { + $sync = Synchronization::fromArray(mockSynchronizationData([ + 'attributes' => ['status' => 'running'], + ])); + + expect($sync->isRunning())->toBeTrue() + ->and($sync->isPending())->toBeFalse() + ->and($sync->isSuccessful())->toBeFalse(); +}); + +test('Synchronization isSuccessful returns true for success status', function () { + $sync = Synchronization::fromArray(mockSynchronizationData([ + 'attributes' => ['status' => 'success'], + ])); + + expect($sync->isSuccessful())->toBeTrue() + ->and($sync->isPending())->toBeFalse() + ->and($sync->isRunning())->toBeFalse() + ->and($sync->hasErrors())->toBeFalse(); +}); + +test('Synchronization hasErrors returns true for error status', function () { + $sync = Synchronization::fromArray(mockSynchronizationData([ + 'attributes' => [ + 'status' => 'error', + 'errors' => ['Connection timeout', 'Retry limit exceeded'], + ], + ])); + + expect($sync->hasErrors())->toBeTrue() + ->and($sync->isSuccessful())->toBeFalse() + ->and($sync->errors)->toHaveCount(2); +}); + +test('Synchronization hasErrors returns false when no errors', function () { + $sync = Synchronization::fromArray(mockSynchronizationData([ + 'attributes' => ['status' => 'success', 'errors' => []], + ])); + + expect($sync->hasErrors())->toBeFalse() + ->and($sync->errors)->toBeEmpty(); +}); + +test('Synchronization isComplete returns true for success status', function () { + $sync = Synchronization::fromArray(mockSynchronizationData([ + 'attributes' => ['status' => 'success'], + ])); + + expect($sync->isComplete())->toBeTrue(); +}); + +test('Synchronization isComplete returns true for error status', function () { + $sync = Synchronization::fromArray(mockSynchronizationData([ + 'attributes' => ['status' => 'error'], + ])); + + expect($sync->isComplete())->toBeTrue(); +}); + +test('Synchronization isComplete returns false for pending status', function () { + $sync = Synchronization::fromArray(mockSynchronizationData([ + 'attributes' => ['status' => 'pending'], + ])); + + expect($sync->isComplete())->toBeFalse(); +}); + +test('Synchronization isComplete returns false for running status', function () { + $sync = Synchronization::fromArray(mockSynchronizationData([ + 'attributes' => ['status' => 'running'], + ])); + + expect($sync->isComplete())->toBeFalse(); +}); + +test('Synchronization for accountDetails subtype', function () { + $sync = Synchronization::fromArray(mockSynchronizationData([ + 'attributes' => [ + 'resourceType' => 'account', + 'subtype' => 'accountDetails', + ], + ])); + + expect($sync->subtype)->toBe('accountDetails') + ->and($sync->resourceType)->toBe('account'); +}); + +test('Synchronization for accountTransactions subtype', function () { + $sync = Synchronization::fromArray(mockSynchronizationData([ + 'attributes' => [ + 'resourceType' => 'account', + 'subtype' => 'accountTransactions', + ], + ])); + + expect($sync->subtype)->toBe('accountTransactions'); +}); + +test('Synchronization parses dates correctly', function () { + $sync = Synchronization::fromArray(mockSynchronizationData()); + + expect($sync->createdAt)->toBeInstanceOf(DateTimeImmutable::class) + ->and($sync->updatedAt)->toBeInstanceOf(DateTimeImmutable::class); +}); + +test('Synchronization with empty errors array', function () { + $sync = Synchronization::fromArray(mockSynchronizationData([ + 'attributes' => ['errors' => []], + ])); + + expect($sync->errors)->toBeArray() + ->and($sync->errors)->toBeEmpty() + ->and($sync->hasErrors())->toBeFalse(); +}); + +test('Synchronization with multiple errors', function () { + $errors = [ + 'Bank connection failed', + 'Authentication required', + 'Service temporarily unavailable', + ]; + + $sync = Synchronization::fromArray(mockSynchronizationData([ + 'attributes' => [ + 'status' => 'error', + 'errors' => $errors, + ], + ])); + + expect($sync->errors)->toHaveCount(3) + ->and($sync->errors[0])->toBe('Bank connection failed') + ->and($sync->hasErrors())->toBeTrue(); +}); + +test('Synchronization toArray returns correct structure', function () { + $sync = Synchronization::fromArray(mockSynchronizationData(['id' => 'sync-test'])); + $array = $sync->toArray(); + + expect($array)->toBeArray() + ->and($array)->toHaveKey('id', 'sync-test') + ->and($array)->toHaveKey('status') + ->and($array)->toHaveKey('resourceType') + ->and($array)->toHaveKey('subtype'); +}); + +test('Synchronization readonly properties cannot be modified', function () { + $sync = Synchronization::fromArray(mockSynchronizationData()); + + expect(fn () => $sync->status = 'success') + ->toThrow(\Error::class); +}); + +test('Synchronization status transitions make sense', function () { + // Pending sync should not be complete + $pending = Synchronization::fromArray(mockSynchronizationData(['attributes' => ['status' => 'pending']])); + expect($pending->isPending())->toBeTrue() + ->and($pending->isComplete())->toBeFalse(); + + // Running sync should not be complete + $running = Synchronization::fromArray(mockSynchronizationData(['attributes' => ['status' => 'running']])); + expect($running->isRunning())->toBeTrue() + ->and($running->isComplete())->toBeFalse(); + + // Success sync should be complete + $success = Synchronization::fromArray(mockSynchronizationData(['attributes' => ['status' => 'success']])); + expect($success->isSuccessful())->toBeTrue() + ->and($success->isComplete())->toBeTrue(); + + // Error sync should be complete + $error = Synchronization::fromArray(mockSynchronizationData(['attributes' => ['status' => 'error']])); + expect($error->hasErrors())->toBeTrue() + ->and($error->isComplete())->toBeTrue(); +}); + +test('Synchronization all status states are mutually exclusive', function () { + $statuses = ['pending', 'running', 'success', 'error']; + + foreach ($statuses as $status) { + $sync = Synchronization::fromArray(mockSynchronizationData([ + 'attributes' => ['status' => $status], + ])); + + $pending = $sync->isPending(); + $running = $sync->isRunning(); + $successful = $sync->isSuccessful(); + $hasErrors = $sync->hasErrors(); + + // Only one status should be active + $activeCount = ($pending ? 1 : 0) + ($running ? 1 : 0) + ($successful ? 1 : 0) + ($hasErrors ? 1 : 0); + + expect($activeCount)->toBe(1, "Expected exactly one status to be active for {$status}"); + } +}); + +test('Synchronization with specific resource ID formats', function () { + $accountSync = Synchronization::fromArray(mockSynchronizationData([ + 'attributes' => ['resourceId' => 'acc-123'], + ])); + + expect($accountSync->resourceId)->toBe('acc-123'); +}); + +test('Synchronization timestamps are immutable', function () { + $sync = Synchronization::fromArray(mockSynchronizationData()); + + expect($sync->createdAt)->toBeInstanceOf(DateTimeImmutable::class) + ->and($sync->updatedAt)->toBeInstanceOf(DateTimeImmutable::class); + + // DateTimeImmutable ensures timestamps cannot be modified + $originalCreated = $sync->createdAt; + $newTime = $sync->createdAt->modify('+1 hour'); + + expect($sync->createdAt)->toBe($originalCreated) + ->and($newTime)->not->toBe($originalCreated); +}); diff --git a/tests/Unit/Models/TransactionTest.php b/tests/Unit/Models/TransactionTest.php new file mode 100644 index 0000000..c980876 --- /dev/null +++ b/tests/Unit/Models/TransactionTest.php @@ -0,0 +1,259 @@ +toBeInstanceOf(Transaction::class) + ->and($transaction->id)->toBeString() + ->and($transaction->type)->toBe('transaction'); +}); + +test('Transaction has all required properties', function () { + $data = mockTransactionData([ + 'id' => 'tx-123', + 'attributes' => [ + 'amount' => 150.75, + 'currency' => 'EUR', + 'description' => 'Payment to supplier', + 'counterpartName' => 'Supplier Ltd', + 'counterpartReference' => 'BE68539007547034', + ], + ]); + + $transaction = Transaction::fromArray($data); + + expect($transaction->id)->toBe('tx-123') + ->and($transaction->amount)->toBe(150.75) + ->and($transaction->currency)->toBe('EUR') + ->and($transaction->description)->toBe('Payment to supplier') + ->and($transaction->counterpartName)->toBe('Supplier Ltd') + ->and($transaction->counterpartReference)->toBe('BE68539007547034'); +}); + +test('Transaction isCredit returns true for positive amount', function () { + $transaction = Transaction::fromArray(mockTransactionData([ + 'attributes' => ['amount' => 100.00], + ])); + + expect($transaction->isCredit())->toBeTrue() + ->and($transaction->isDebit())->toBeFalse(); +}); + +test('Transaction isDebit returns true for negative amount', function () { + $transaction = Transaction::fromArray(mockTransactionData([ + 'attributes' => ['amount' => -50.00], + ])); + + expect($transaction->isDebit())->toBeTrue() + ->and($transaction->isCredit())->toBeFalse(); +}); + +test('Transaction getAbsoluteAmount returns positive value', function () { + $debit = Transaction::fromArray(mockTransactionData(['attributes' => ['amount' => -75.50]])); + $credit = Transaction::fromArray(mockTransactionData(['attributes' => ['amount' => 75.50]])); + + expect($debit->getAbsoluteAmount())->toBe(75.50) + ->and($credit->getAbsoluteAmount())->toBe(75.50); +}); + +test('Transaction with zero amount', function () { + $transaction = Transaction::fromArray(mockTransactionData([ + 'attributes' => ['amount' => 0.0], + ])); + + expect($transaction->amount)->toBe(0.0) + ->and($transaction->isCredit())->toBeFalse() + ->and($transaction->isDebit())->toBeFalse(); +}); + +test('Transaction parses dates correctly', function () { + $transaction = Transaction::fromArray(mockTransactionData()); + + expect($transaction->createdAt)->toBeInstanceOf(DateTimeImmutable::class) + ->and($transaction->updatedAt)->toBeInstanceOf(DateTimeImmutable::class); +}); + +test('Transaction with null execution date', function () { + $transaction = Transaction::fromArray(mockTransactionData([ + 'attributes' => ['executionDate' => null], + ])); + + expect($transaction->executionDate)->toBeNull(); +}); + +test('Transaction with null value date', function () { + $transaction = Transaction::fromArray(mockTransactionData([ + 'attributes' => ['valueDate' => null], + ])); + + expect($transaction->valueDate)->toBeNull(); +}); + +test('Transaction with optional counterpart fields', function () { + $transaction = Transaction::fromArray(mockTransactionData([ + 'attributes' => [ + 'counterpartName' => 'John Doe', + 'counterpartReference' => 'BE68539007547034', + ], + ])); + + expect($transaction->counterpartName)->toBe('John Doe') + ->and($transaction->counterpartReference)->toBe('BE68539007547034'); +}); + +test('Transaction with null counterpart fields', function () { + $transaction = Transaction::fromArray(mockTransactionData([ + 'attributes' => [ + 'counterpartName' => null, + 'counterpartReference' => null, + ], + ])); + + expect($transaction->counterpartName)->toBeNull() + ->and($transaction->counterpartReference)->toBeNull(); +}); + +test('Transaction with remittance information', function () { + $transaction = Transaction::fromArray(mockTransactionData([ + 'attributes' => [ + 'remittanceInformation' => 'Invoice 2024-001', + 'remittanceInformationType' => 'unstructured', + ], + ])); + + expect($transaction->remittanceInformation)->toBe('Invoice 2024-001') + ->and($transaction->remittanceInformationType)->toBe('unstructured'); +}); + +test('Transaction with structured remittance', function () { + $transaction = Transaction::fromArray(mockTransactionData([ + 'attributes' => [ + 'remittanceInformation' => '+++123/4567/89012+++', + 'remittanceInformationType' => 'structured', + ], + ])); + + expect($transaction->remittanceInformationType)->toBe('structured'); +}); + +test('Transaction with endToEndId', function () { + $transaction = Transaction::fromArray(mockTransactionData([ + 'attributes' => ['endToEndId' => 'e2e-123456'], + ])); + + expect($transaction->endToEndId)->toBe('e2e-123456'); +}); + +test('Transaction with mandateId and creditorId', function () { + $transaction = Transaction::fromArray(mockTransactionData([ + 'attributes' => [ + 'mandateId' => 'mandate-123', + 'creditorId' => 'creditor-456', + ], + ])); + + expect($transaction->mandateId)->toBe('mandate-123') + ->and($transaction->creditorId)->toBe('creditor-456'); +}); + +test('Transaction with bank transaction code', function () { + $transaction = Transaction::fromArray(mockTransactionData([ + 'attributes' => ['bankTransactionCode' => 'PMNT-RCDT-ESCT'], + ])); + + expect($transaction->bankTransactionCode)->toBe('PMNT-RCDT-ESCT'); +}); + +test('Transaction with purpose code', function () { + $transaction = Transaction::fromArray(mockTransactionData([ + 'attributes' => ['purposeCode' => 'SALA'], + ])); + + expect($transaction->purposeCode)->toBe('SALA'); +}); + +test('Transaction with fee', function () { + $transaction = Transaction::fromArray(mockTransactionData([ + 'attributes' => ['fee' => 2.50], + ])); + + expect($transaction->fee)->toBe(2.50); +}); + +test('Transaction with null fee', function () { + $transaction = Transaction::fromArray(mockTransactionData([ + 'attributes' => ['fee' => null], + ])); + + expect($transaction->fee)->toBeNull(); +}); + +test('Transaction with additional information', function () { + $transaction = Transaction::fromArray(mockTransactionData([ + 'attributes' => ['additionalInformation' => 'Some additional details'], + ])); + + expect($transaction->additionalInformation)->toBe('Some additional details'); +}); + +test('Transaction toArray returns correct structure', function () { + $transaction = Transaction::fromArray(mockTransactionData(['id' => 'tx-test'])); + $array = $transaction->toArray(); + + expect($array)->toBeArray() + ->and($array)->toHaveKey('id', 'tx-test') + ->and($array)->toHaveKey('amount') + ->and($array)->toHaveKey('currency') + ->and($array)->toHaveKey('description'); +}); + +test('Transaction readonly properties cannot be modified', function () { + $transaction = Transaction::fromArray(mockTransactionData()); + + expect(fn () => $transaction->amount = 999.99) + ->toThrow(\Error::class); +}); + +test('Transaction with digest', function () { + $transaction = Transaction::fromArray(mockTransactionData([ + 'attributes' => ['digest' => hash('sha256', 'test-transaction')], + ])); + + expect($transaction->digest)->toBeString() + ->and(strlen($transaction->digest))->toBe(64); // SHA256 length +}); + +test('Transaction with account relationship', function () { + $transaction = Transaction::fromArray(mockTransactionData([ + 'relationships' => [ + 'account' => [ + 'data' => ['id' => 'acc-123', 'type' => 'account'], + ], + ], + ])); + + expect($transaction->accountId)->toBe('acc-123'); +}); + +test('Transaction with large amount', function () { + $transaction = Transaction::fromArray(mockTransactionData([ + 'attributes' => ['amount' => 999999.99], + ])); + + expect($transaction->amount)->toBe(999999.99) + ->and($transaction->getAbsoluteAmount())->toBe(999999.99); +}); + +test('Transaction with small decimal amount', function () { + $transaction = Transaction::fromArray(mockTransactionData([ + 'attributes' => ['amount' => 0.01], + ])); + + expect($transaction->amount)->toBe(0.01) + ->and($transaction->isCredit())->toBeTrue(); +}); diff --git a/tests/Unit/Services/AccountServiceTest.php b/tests/Unit/Services/AccountServiceTest.php new file mode 100644 index 0000000..4d1ae8b --- /dev/null +++ b/tests/Unit/Services/AccountServiceTest.php @@ -0,0 +1,256 @@ +httpClient = Mockery::mock(HttpClient::class); + $this->service = new AccountService($this->httpClient); +}); + +afterEach(function () { + Mockery::close(); +}); + +test('list returns paginated collection of accounts', function () { + $mockResponse = mockJsonApiResponse( + [mockAccountData(), mockAccountData()], + ['paging' => ['limit' => 20]], + [] + ); + + $this->httpClient + ->shouldReceive('get') + ->once() + ->with('/accounts', ['page' => ['limit' => 20]]) + ->andReturn($mockResponse); + + $result = $this->service->list(); + + expect($result)->toBeInstanceOf(PaginatedCollection::class) + ->and($result->count())->toBe(2) + ->and($result->limit)->toBe(20); +})->group('unit'); + +test('list with custom limit', function () { + $mockResponse = mockJsonApiResponse( + [], + ['paging' => ['limit' => 50]], + [] + ); + + $this->httpClient + ->shouldReceive('get') + ->once() + ->with('/accounts', ['page' => ['limit' => 50]]) + ->andReturn($mockResponse); + + $result = $this->service->list(limit: 50); + + expect($result->limit)->toBe(50); +})->group('unit'); + +test('list with after cursor', function () { + $mockResponse = mockJsonApiResponse( + [], + ['paging' => ['limit' => 20, 'after' => 'cursor123']], + [] + ); + + $this->httpClient + ->shouldReceive('get') + ->once() + ->with('/accounts', ['page' => ['limit' => 20, 'after' => 'cursor123']]) + ->andReturn($mockResponse); + + $result = $this->service->list(after: 'cursor123'); + + expect($result->afterCursor)->toBe('cursor123'); +})->group('unit'); + +test('list with before cursor', function () { + $mockResponse = mockJsonApiResponse( + [], + ['paging' => ['limit' => 20, 'before' => 'cursor456']], + [] + ); + + $this->httpClient + ->shouldReceive('get') + ->once() + ->with('/accounts', ['page' => ['limit' => 20, 'before' => 'cursor456']]) + ->andReturn($mockResponse); + + $result = $this->service->list(before: 'cursor456'); + + expect($result->beforeCursor)->toBe('cursor456'); +})->group('unit'); + +test('list throws ValidationException for limit below minimum', function () { + $this->service->list(limit: 0); +})->throws(ValidationException::class)->group('unit'); + +test('list throws ValidationException for limit above maximum', function () { + $this->service->list(limit: 101); +})->throws(ValidationException::class)->group('unit'); + +test('get returns single account', function () { + $accountId = 'acc-123'; + $mockResponse = ['data' => mockAccountData(['id' => $accountId])]; + + $this->httpClient + ->shouldReceive('get') + ->once() + ->with("/accounts/{$accountId}") + ->andReturn($mockResponse); + + $result = $this->service->get($accountId); + + expect($result)->toBeInstanceOf(Account::class) + ->and($result->id)->toBe($accountId); +})->group('unit'); + +test('get throws NotFoundException for non-existent account', function () { + $accountId = 'acc-nonexistent'; + + $this->httpClient + ->shouldReceive('get') + ->once() + ->andThrow(new NotFoundException("Account {$accountId} not found", 404)); + + $this->service->get($accountId); +})->throws(NotFoundException::class)->group('unit'); + +test('getSyncMetadata returns synchronization info', function () { + $accountId = 'giovani.klocko@streich.info'; + $mockResponse = [ + 'data' => mockAccountData(['id' => $accountId]), + 'meta' => [ + 'synchronizedAt' => '2024-02-20T10:00:00Z', + 'latestSynchronization' => mockSynchronizationData(), + ], + ]; + + $this->httpClient + ->shouldReceive('get') + ->once() + ->with("/accounts/{$accountId}") + ->andReturn($mockResponse); + + $result = $this->service->getSyncMetadata($accountId); + + expect($result)->toBeArray() + ->and($result)->toHaveKey('synchronizedAt') + ->and($result)->toHaveKey('latestSynchronization'); +})->group('unit'); + +test('list handles empty results', function () { + $mockResponse = mockJsonApiResponse( + [], + ['paging' => ['limit' => 20]], + [] + ); + + $this->httpClient + ->shouldReceive('get') + ->once() + ->with('/accounts', ['page' => ['limit' => 20]]) + ->andReturn($mockResponse); + + $result = $this->service->list(); + + expect($result->isEmpty())->toBeTrue() + ->and($result->count())->toBe(0); +})->group('unit'); + +test('list with pagination links', function () { + $mockResponse = mockJsonApiResponse( + [mockAccountData()], + ['paging' => ['limit' => 20]], + [ + 'first' => 'https://api.myponto.com/accounts?limit=20', + 'next' => 'https://api.myponto.com/accounts?after=cursor', + ] + ); + + $this->httpClient + ->shouldReceive('get') + ->once() + ->with('/accounts', ['page' => ['limit' => 20]]) + ->andReturn($mockResponse); + + $result = $this->service->list(); + + expect($result->hasNextPage())->toBeTrue() + ->and($result->nextUrl)->toContain('after=cursor'); +})->group('unit'); + +test('list validates limit is positive', function () { + $this->service->list(limit: -5); +})->throws(ValidationException::class)->group('unit'); + +test('get validates account ID format', function () { + $this->service->get(''); +})->throws(ValidationException::class)->group('unit'); + +test('list with maximum allowed limit', function () { + $mockResponse = mockJsonApiResponse( + [], + ['paging' => ['limit' => 100]], + [] + ); + + $this->httpClient + ->shouldReceive('get') + ->once() + ->with('/accounts', ['page' => ['limit' => 100]]) + ->andReturn($mockResponse); + + $result = $this->service->list(limit: 100); + + expect($result->limit)->toBe(100); +})->group('unit'); + +test('list with minimum allowed limit', function () { + $mockResponse = mockJsonApiResponse( + [], + ['paging' => ['limit' => 1]], + [] + ); + + $this->httpClient + ->shouldReceive('get') + ->once() + ->with('/accounts', ['page' => ['limit' => 1]]) + ->andReturn($mockResponse); + + $result = $this->service->list(limit: 1); + + expect($result->limit)->toBe(1); +})->group('unit'); + +test('list returns Account models with correct properties', function () { + $mockResponse = mockJsonApiResponse( + [mockAccountData(['id' => 'acc-test', 'attributes' => ['holderName' => 'John Doe']])], + ['paging' => ['limit' => 20]], + [] + ); + + $this->httpClient + ->shouldReceive('get') + ->once() + ->with('/accounts', ['page' => ['limit' => 20]]) + ->andReturn($mockResponse); + + $result = $this->service->list(); + + expect($result->data[0])->toBeInstanceOf(Account::class) + ->and($result->data[0]->id)->toBe('acc-test') + ->and($result->data[0]->holderName)->toBe('John Doe'); +})->group('unit'); diff --git a/tests/Unit/Utils/ValidatorTest.php b/tests/Unit/Utils/ValidatorTest.php new file mode 100644 index 0000000..260d0aa --- /dev/null +++ b/tests/Unit/Utils/ValidatorTest.php @@ -0,0 +1,328 @@ +toBe('BE68539007547034'); +}); + +test('validates valid French IBAN', function () { + $iban = Validator::validateIban('FR14 2004 1010 0505 0001 3M02 606'); + + expect($iban)->toBe('FR1420041010050500013M02606'); +}); + +test('validates valid German IBAN', function () { + $iban = Validator::validateIban('DE89 3704 0044 0532 0130 00'); + + expect($iban)->toBe('DE89370400440532013000'); +}); + +test('validates IBAN without spaces', function () { + $iban = Validator::validateIban('BE68539007547034'); + + expect($iban)->toBe('BE68539007547034'); +}); + +test('validates IBAN converts to uppercase', function () { + $iban = Validator::validateIban('be68539007547034'); + + expect($iban)->toBe('BE68539007547034'); +}); + +test('rejects invalid IBAN format', function () { + Validator::validateIban('INVALID'); +})->throws(ValidationException::class, 'Invalid IBAN format'); + +test('rejects IBAN without country code', function () { + Validator::validateIban('68539007547034'); +})->throws(ValidationException::class); + +test('rejects IBAN with invalid country code', function () { + Validator::validateIban('XX68539007547034'); +})->throws(ValidationException::class, 'Invalid IBAN country code'); + +test('validates various valid IBAN country codes', function () { + $validIbans = [ + 'GB82WEST12345698765432', // United Kingdom + 'NL91ABNA0417164300', // Netherlands + 'IT60X0542811101000000123456', // Italy + 'ES9121000418450200051332', // Spain + 'CH9300762011623852957', // Switzerland + 'AT611904300234573201', // Austria + 'PL61109010140000071219812874', // Poland + 'SE4550000000058398257466', // Sweden + ]; + + foreach ($validIbans as $iban) { + expect(fn () => Validator::validateIban($iban))->not->toThrow(ValidationException::class); + } +}); + +test('rejects IBAN with non-existent country code ZZ', function () { + Validator::validateIban('ZZ1234567890123456'); +})->throws(ValidationException::class, 'Invalid IBAN country code: ZZ'); + +test('rejects IBAN with non-existent country code AA', function () { + Validator::validateIban('AA1234567890123456'); +})->throws(ValidationException::class, 'Invalid IBAN country code: AA'); + +test('rejects IBAN with special characters', function () { + Validator::validateIban('BE68-5390-0754-7034'); +})->throws(ValidationException::class); + +test('rejects empty IBAN', function () { + Validator::validateIban(''); +})->throws(ValidationException::class); + +test('rejects IBAN with only spaces', function () { + Validator::validateIban(' '); +})->throws(ValidationException::class); + +// BIC Validation Tests + +test('validates valid 8-character BIC', function () { + $bic = Validator::validateBic('NBBEBEBB'); + + expect($bic)->toBe('NBBEBEBB'); +}); + +test('validates valid 11-character BIC', function () { + $bic = Validator::validateBic('NBBEBEBB203'); + + expect($bic)->toBe('NBBEBEBB203'); +}); + +test('validates BIC with spaces', function () { + $bic = Validator::validateBic('GKC CBEB BXXX'); + + expect($bic)->toBe('GKCCBEBBXXX'); +}); + +test('validates BIC converts to uppercase', function () { + $bic = Validator::validateBic('nbbebebb203'); + + expect($bic)->toBe('NBBEBEBB203'); +}); + +test('rejects invalid BIC format', function () { + Validator::validateBic('INVALID'); +})->throws(ValidationException::class, 'Invalid BIC format'); + +test('rejects BIC with wrong length', function () { + Validator::validateBic('NBB'); +})->throws(ValidationException::class); + +test('rejects BIC with numbers in wrong position', function () { + Validator::validateBic('1BBEBEBB'); +})->throws(ValidationException::class); + +test('rejects empty BIC', function () { + Validator::validateBic(''); +})->throws(ValidationException::class); + +test('rejects BIC with special characters', function () { + Validator::validateBic('NBBE-BEBB'); +})->throws(ValidationException::class); + +// Amount Validation Tests + +test('validates positive amount', function () { + expect(fn () => Validator::validateAmount(100.50))->not->toThrow(ValidationException::class); +}); + +test('validates small positive amount', function () { + expect(fn () => Validator::validateAmount(0.01))->not->toThrow(ValidationException::class); +}); + +test('validates large amount', function () { + expect(fn () => Validator::validateAmount(999999.99))->not->toThrow(ValidationException::class); +}); + +test('validates integer amount', function () { + expect(fn () => Validator::validateAmount(100.0))->not->toThrow(ValidationException::class); +}); + +test('rejects negative amount', function () { + Validator::validateAmount(-50.00); +})->throws(ValidationException::class, 'Amount must be positive'); + +test('rejects zero amount', function () { + Validator::validateAmount(0.0); +})->throws(ValidationException::class, 'Amount must be positive'); + +test('rejects extremely large amount', function () { + Validator::validateAmount(1000000000.00); +})->throws(ValidationException::class, 'Amount too large'); + +test('rejects amount exceeding maximum', function () { + Validator::validateAmount(999999999.99 + 0.01); +})->throws(ValidationException::class, 'Amount too large'); + +test('validates maximum allowed amount', function () { + expect(fn () => Validator::validateAmount(999999999.99))->not->toThrow(ValidationException::class); +}); + +test('validates minimum allowed amount', function () { + expect(fn () => Validator::validateAmount(0.01))->not->toThrow(ValidationException::class); +}); + +// Currency Validation Tests + +test('validates EUR currency', function () { + $currency = Validator::validateCurrency('EUR'); + + expect($currency)->toBe('EUR'); +}); + +test('validates USD currency', function () { + $currency = Validator::validateCurrency('USD'); + + expect($currency)->toBe('USD'); +}); + +test('validates GBP currency', function () { + $currency = Validator::validateCurrency('GBP'); + + expect($currency)->toBe('GBP'); +}); + +test('validates currency converts to uppercase', function () { + $currency = Validator::validateCurrency('eur'); + + expect($currency)->toBe('EUR'); +}); + +test('validates various ISO 4217 currencies', function () { + $currencies = ['CHF', 'JPY', 'CAD', 'AUD', 'SEK', 'NOK', 'DKK']; + + foreach ($currencies as $code) { + $validated = Validator::validateCurrency($code); + expect($validated)->toBe($code); + } +}); + +test('rejects invalid currency code', function () { + Validator::validateCurrency('INVALID'); +})->throws(ValidationException::class, 'Invalid currency code'); + +test('rejects two-letter currency', function () { + Validator::validateCurrency('EU'); +})->throws(ValidationException::class); + +test('rejects four-letter currency', function () { + Validator::validateCurrency('EURO'); +})->throws(ValidationException::class); + +test('rejects currency with numbers', function () { + Validator::validateCurrency('E1R'); +})->throws(ValidationException::class); + +test('rejects empty currency', function () { + Validator::validateCurrency(''); +})->throws(ValidationException::class); + +test('rejects currency with special characters', function () { + Validator::validateCurrency('E$R'); +})->throws(ValidationException::class); + +// Remittance Information Validation Tests + +test('validates simple remittance information', function () { + $info = Validator::validateRemittanceInfo('Invoice 2024-001'); + + expect($info)->toBe('Invoice 2024-001'); +}); + +test('validates remittance with allowed special characters', function () { + $info = Validator::validateRemittanceInfo('Payment/Ref:123-456.789,ABC+'); + + expect($info)->toBe('Payment/Ref:123-456.789,ABC+'); +}); + +test('validates remittance with spaces', function () { + $info = Validator::validateRemittanceInfo('Monthly subscription fee'); + + expect($info)->toBe('Monthly subscription fee'); +}); + +test('validates remittance with parentheses', function () { + $info = Validator::validateRemittanceInfo('Payment (Invoice 123)'); + + expect($info)->toBe('Payment (Invoice 123)'); +}); + +test('validates remittance with question mark', function () { + $info = Validator::validateRemittanceInfo('Payment?'); + + expect($info)->toBe('Payment?'); +}); + +test('validates remittance with apostrophe', function () { + $info = Validator::validateRemittanceInfo("Customer's payment"); + + expect($info)->toBe("Customer's payment"); +}); + +test('validates maximum length remittance', function () { + $info = str_repeat('A', 140); + + expect(fn () => Validator::validateRemittanceInfo($info))->not->toThrow(ValidationException::class); +}); + +test('rejects remittance exceeding max length', function () { + $info = str_repeat('A', 141); + + Validator::validateRemittanceInfo($info); +})->throws(ValidationException::class, 'Remittance information too long'); + +test('rejects remittance with invalid characters', function () { + Validator::validateRemittanceInfo('Payment@email.com'); +})->throws(ValidationException::class, 'invalid characters'); + +test('rejects remittance with special symbols', function () { + Validator::validateRemittanceInfo('Payment #123'); +})->throws(ValidationException::class); + +test('rejects remittance with underscore', function () { + Validator::validateRemittanceInfo('Payment_123'); +})->throws(ValidationException::class); + +test('rejects remittance with equals sign', function () { + Validator::validateRemittanceInfo('Amount=100'); +})->throws(ValidationException::class); + +test('rejects remittance with ampersand', function () { + Validator::validateRemittanceInfo('Smith & Co'); +})->throws(ValidationException::class); + +test('validates empty remittance information', function () { + $info = Validator::validateRemittanceInfo(''); + + expect($info)->toBe(''); +}); + +test('validates structured remittance format', function () { + $info = Validator::validateRemittanceInfo('+++123/4567/89012+++'); + + expect($info)->toBe('+++123/4567/89012+++'); +}); + +test('validates numeric remittance', function () { + $info = Validator::validateRemittanceInfo('1234567890'); + + expect($info)->toBe('1234567890'); +}); + +test('validates remittance with mixed case', function () { + $info = Validator::validateRemittanceInfo('Payment Invoice ABC-123'); + + expect($info)->toBe('Payment Invoice ABC-123'); +});