diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..e9f1ec6 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,56 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Unofficial PHP SDK for the Claim.MD API — a healthcare/insurance claims processing platform. The library wraps Claim.MD's REST API with typed DTOs, validation, and a thin Guzzle-based HTTP client. + +**Namespace:** `Nextvisit\ClaimMD` (PSR-4 autoloaded from `src/`) + +## Commands + +```bash +# Install dependencies +composer install + +# Run all tests (Pest) +composer test + +# Run tests with coverage +composer test:coverage + +# Run a single test file +./vendor/bin/pest tests/Unit/Requests/ERARequestTest.php + +# Run a specific test by name +./vendor/bin/pest --filter="test name here" +``` + +## Architecture + +**Client + Config** — `Client` wraps Guzzle, auto-injects `AccountKey` into every request, and returns decoded JSON arrays. `Config` holds the base URI (`https://svc.claim.md/`). + +**DTOs** (`src/DTO/`) — Readonly classes with constructor validation. Each DTO: +- Validates required fields and formats in the constructor (throws `InvalidArgumentException`) +- Has `toArray()` that maps camelCase properties to the API's expected field names (varies per endpoint: snake_case, PascalCase, or mixed) +- Has static `fromArray()` factory +- Filters out null values in `toArray()` + +**Request classes** (`src/Requests/`) — One per API domain (ERA, Claim, File, Provider, Eligibility, Response, Payer). Each: +- Accepts `Client` via constructor injection +- Defines endpoint URIs as private constants +- Methods accept either a DTO or a raw array +- Returns raw API response arrays + +**Key patterns:** +- `ResponseRequest::fetchAllResponses()` uses a Generator for auto-paginated iteration via `last_responseid` +- `FileRequest::upload()` and `EligibilityRequest::checkEligibility270271()` use multipart form uploads (accept PHP `resource` handles) +- DTOs handle field name transformation — the Request classes send whatever `toArray()` returns + +## Testing + +- **Framework:** Pest 3.0 with Mockery +- **Structure:** `tests/Unit/DTO/` for DTO validation tests, `tests/Unit/Requests/` for request tests with mocked Client +- **Client tests** use Guzzle's `MockHandler` for HTTP-level mocking; Request tests mock the `Client` class directly with Mockery +- **PHP version:** 8.3+ required \ No newline at end of file diff --git a/README.md b/README.md index ef559b5..eac9284 100644 --- a/README.md +++ b/README.md @@ -4,58 +4,85 @@ ![CLAIM.md](https://cdn.prod.website-files.com/6619250355c3f9e1344f80b5/6619305fba1aef8ce5858ae7_claimmd_glow_120.png) -Welcome to the unofficial PHP SDK for the [CLAIM.MD](https://www.claim.md/) API! 🎉 This library aims to simplify interactions with the official CLAIM.md API, providing a more developer-friendly way to integrate CLAIM.md services into your PHP applications. +Welcome to the unofficial PHP SDK for the [CLAIM.MD](https://www.claim.md/) API! 🎉 This library aims to simplify +interactions with the official CLAIM.md API, providing a more developer-friendly way to integrate CLAIM.md services into +your PHP applications. ## ⚠️ Disclaimer -**Nextvisit Inc. is not affiliated with CLAIM.MD in any way. This package is provided "as is", without warranty of any kind, express or implied, including but not limited to the warranties of merchantability, fitness for a particular purpose, and noninfringement. Use at your own risk.** -That being said, if you encounter any issues or have suggestions for improvement, feel free to open an issue or contribute to the package. 😊 +**Nextvisit Inc. is not affiliated with CLAIM.MD in any way. This package is provided "as is", without warranty of any +kind, express or implied, including but not limited to the warranties of merchantability, fitness for a particular +purpose, and noninfringement. Use at your own risk.** +That being said, if you encounter any issues or have suggestions for improvement, feel free to open an issue or +contribute to the package. 😊 ## 🌟 Features This library provides a range of features to interact with the CLAIM.MD API: + ### [Electronic Remittance Advice (ERA) Management](#electronic-remittance-advice-era) + - [**List Received ERAs**](#get-eras-list): Get a list of all ERAs that have been received. - [**Get ERA 835**](#get-an-era-835): Get a specific ERA in the 835 format. - [**Get ERA PDF**](#get-an-era-pdf): Get a specific ERA in the PDF format. - [**Get ERA PDF**](#get-an-era-pdf): Get a specific ERA in the JSON format. ### [File Management](#file-management) + - [**Upload Files**](#upload-files): Upload batch files to the CLAIM.md service. - [**Get Upload List**](#get-upload-list): Retrieve a list of uploaded files. ### [Provider Management](#provider-management) + - [**Enroll Providers**](#enroll-providers): Enroll providers using detailed enrollment data. -- [**Fetch Provider Enrollment**](#enroll-providers): If a provider is already enrolled, that information will be received from the same `enroll()` method. +- [**Fetch Provider Enrollment**](#enroll-providers): If a provider is already enrolled, that information will be + received from the same `enroll()` method. ### [Claim Management](#claim-management) + - [**Claim Appeal**](#claim-appeal): Submit and manage claim appeals. - [**Fetch Claim Notes**](#fetch-claim-notes): Retrieve notes made on a specific claim. - [**Archive Claim**](#archive-claim): Archive a Claim.MD claim. - [**Claim Modifications**](#list-claim-modifications): Retrieve modifications for claims. ### [Response (Claim Status)](#response-claim-status) + ##### This refers to a claim status response, not an HTTP or protocol response. -- [**Fetch Responses**](#fetch-response): Retrieve claim statuses (referred to as "responses" in the Claim.MD API) from the Claim MD API. + +- [**Fetch Responses**](#fetch-response): Retrieve claim statuses (referred to as "responses" in the Claim.MD API) from + the Claim MD API. - [**Fetch All Responses**](#fetch-all-responses): Automatically handle pagination to retrieve all claim statuses. ### [Eligibility](#eligibility) -- [**Realtime Eligibility X12 270/271**](#realtime-x12-270271-eligibility-check): Validate and check the eligibility of X12 270/271 formatted claim. Receiving the response in the format as well. -- [**Realtime Eligibility JSON**](#realtime-parameter-eligibility-check): Validate and check the eligibility of a claim via parameters. Receiving the response in JSON format. + +- [**Realtime Eligibility X12 270/271**](#realtime-x12-270271-eligibility-check): Validate and check the eligibility of + X12 270/271 formatted claim. Receiving the response in the format as well. +- [**Realtime Eligibility JSON**](#realtime-parameter-eligibility-check): Validate and check the eligibility of a claim + via parameters. Receiving the response in JSON format. + +### [Webhook](#webhook) + +- [**Parse Webhook Payload**](#parse-webhook-payload): Parse inbound webhook events for provider enrollment updates and appeal form creation/updates. ### [Payer](#payers) + - [**Fetch Payers**](#list-payers): Retrieve a list of payers or a specific payer. ### [Data Transfer Objects (DTOs)](#data-transfer-objects-dtos) + #### DTOs can make passing data to and from cleaner in your code. Consider using them over a traditional array. + #### Note: All methods which use a DTO can instead take an array with correct [Claim.MD API](https://api.claim.md) mappings. + - [**ClaimAppealDTO**](#claimappealdto) - [**ProviderEnrollmentDTO**](#providerenrollmentdto) - [**EligibilityDTO**](#eligibilitydto) - [**ERADTO**](#eradto) +- [**WebhookPayloadDTO**](#webhookpayloaddto) ### Utility Features + - **Configuration Handling**: Easily configure the client with account keys and other settings. - **Validation**: Built-in validation for fields like dates, emails, phone numbers, state codes, etc. @@ -74,8 +101,8 @@ composer require nextvisit/claim-md-php First, configure the `Client` with your account key: ```php -use Nextvisit\ClaimMDWrapper\Client; -use Nextvisit\ClaimMDWrapper\Config; +use Nextvisit\ClaimMD\Client; +use Nextvisit\ClaimMD\Config; $accountKey = 'your-account-key'; // Never hardcode your keys! $config = new Config(); @@ -88,8 +115,8 @@ $client = new Client($accountKey, $config); ### Get ERAs List ```php -use Nextvisit\ClaimMDWrapper\Requests\ERARequest; -use Nextvisit\ClaimMDWrapper\DTO\ERADTO; +use Nextvisit\ClaimMD\Requests\ERARequest; +use Nextvisit\ClaimMD\DTO\ERADTO; $eraRequest = new ERARequest($client); @@ -101,16 +128,18 @@ $allResponse = $eraRequest->getList(); ``` ### Get an ERA 835 + ```php -use Nextvisit\ClaimMDWrapper\Requests\ERARequest; +use Nextvisit\ClaimMD\Requests\ERARequest; $eraRequest = new ERARequest($client); $response = $eraRequest->get835('era-id'); ``` ### Get an ERA PDF + ```php -use Nextvisit\ClaimMDWrapper\Requests\ERARequest; +use Nextvisit\ClaimMD\Requests\ERARequest; $eraRequest = new ERARequest($client); @@ -121,8 +150,9 @@ $regularResponse = $eraRequest->getPDF('era-id'); ``` ### Get an ERA JSON + ```php -use Nextvisit\ClaimMDWrapper\Requests\ERARequest; +use Nextvisit\ClaimMD\Requests\ERARequest; $eraRequest = new ERARequest($client); $response = $eraRequest->getJson('era-id'); @@ -133,7 +163,7 @@ $response = $eraRequest->getJson('era-id'); #### Upload Files ```php -use Nextvisit\ClaimMDWrapper\Requests\FileRequest; +use Nextvisit\ClaimMD\Requests\FileRequest; $fileRequest = new FileRequest($client); $response = $fileRequest->upload(fopen('path/to/your/file.txt', 'r')); @@ -144,7 +174,7 @@ print_r($response); #### Get Upload List ```php -use Nextvisit\ClaimMDWrapper\Requests\FileRequest; +use Nextvisit\ClaimMD\Requests\FileRequest; $fileRequest = new FileRequest($client); $response = $fileRequest->getUploadList(); @@ -155,8 +185,8 @@ $response = $fileRequest->getUploadList(); #### Enroll Providers ```php -use Nextvisit\ClaimMDWrapper\Requests\ProviderRequest; -use Nextvisit\ClaimMDWrapper\DTO\ProviderEnrollmentDTO; +use Nextvisit\ClaimMD\Requests\ProviderRequest; +use Nextvisit\ClaimMD\DTO\ProviderEnrollmentDTO; // If this provider is already enrolled, the response will contain that information. $providerEnrollment = new ProviderEnrollmentDTO( @@ -178,8 +208,8 @@ $response = $providerRequest->enroll($providerEnrollment); #### Claim Appeal ```php -use Nextvisit\ClaimMDWrapper\DTO\ClaimAppealDTO; -use Nextvisit\ClaimMDWrapper\Requests\ClaimRequest; +use Nextvisit\ClaimMD\DTO\ClaimAppealDTO; +use Nextvisit\ClaimMD\Requests\ClaimRequest; $appealDto = new ClaimAppealDTO( claimId: 'claim-id', @@ -195,7 +225,7 @@ $response = $claimRequest->appeal($appealDto); #### Archive Claim ```php -use Nextvisit\ClaimMDWrapper\Requests\ClaimRequest; +use Nextvisit\ClaimMD\Requests\ClaimRequest; $claimRequest = new ClaimRequest($client); $response = $claimRequest->archive('claim-id'); @@ -204,7 +234,7 @@ $response = $claimRequest->archive('claim-id'); #### List Claim Modifications ```php -use Nextvisit\ClaimMDWrapper\Requests\ClaimRequest; +use Nextvisit\ClaimMD\Requests\ClaimRequest; $claimRequest = new ClaimRequest($client); @@ -220,7 +250,7 @@ $allResponse = $claimRequest->listModifications(); #### Fetch Claim Notes ```php -use Nextvisit\ClaimMDWrapper\Requests\ClaimRequest; +use Nextvisit\ClaimMD\Requests\ClaimRequest; $claimRequest = new ClaimRequest($client); @@ -237,8 +267,9 @@ $allResponse = $claimRequest->notes(); ### Response (Claim Status) #### Fetch Response + ```php -use Nextvisit\ClaimMDWrapper\Requests\ResponseRequest; +use Nextvisit\ClaimMD\Requests\ResponseRequest; $responseRequest = new ResponseRequest($client); @@ -248,8 +279,9 @@ $recentResponse = $responseRequest->fetchResponses($responseId); ``` #### Fetch All Responses + ```php -use Nextvisit\ClaimMDWrapper\Requests\ResponseRequest; +use Nextvisit\ClaimMD\Requests\ResponseRequest; $responseRequest = new ResponseRequest($client); @@ -266,7 +298,7 @@ foreach ($responseRequest->fetchAllResponses() as $response) { #### Realtime X12 270/271 Eligibility Check ```php -use Nextvisit\ClaimMDWrapper\Requests\EligibilityRequest; +use Nextvisit\ClaimMD\Requests\EligibilityRequest; $eligibilityRequest = new EligibilityRequest($client); @@ -277,9 +309,10 @@ $realtimeResponse = $eligibilityRequest->checkEligibility270271($eligibility270) ``` #### Realtime Parameter Eligibility Check + ```php -use Nextvisit\ClaimMDWrapper\Requests\EligibilityRequest; -use Nextvisit\ClaimMDWrapper\DTO\EligibilityDTO; +use Nextvisit\ClaimMD\Requests\EligibilityRequest; +use Nextvisit\ClaimMD\DTO\EligibilityDTO; $eligibilityRequest = new EligibilityRequest($client); @@ -299,11 +332,57 @@ $eligDto = new EligibilityDTO( $response = $eligibilityRequest->checkEligibilityJSON($eligDto); ``` +### Webhook + +#### Parse Webhook Payload + +```php +use Nextvisit\ClaimMD\DTO\WebhookPayloadDTO; + +// Parse directly from the raw JSON request body +$json = file_get_contents('php://input'); +$webhook = WebhookPayloadDTO::fromJsonString($json); + +// Or from a decoded array +$webhook = WebhookPayloadDTO::fromArray($decodedData); + +// Access top-level fields +$webhook->utcTime; // UTC current time +$webhook->acctNumber; // Claim.MD Account Number +$webhook->remoteAcctNumber; // Customer assigned account number + +// Iterate over events +foreach ($webhook->events as $event) { + $event->eventId; // Unique event identifier + $event->eventType; // "enroll" or "appeal" + $event->eventTime; // UTC time of event + + if ($event->eventType === 'enroll') { + $event->enroll->enrollId; // Enrollment ID + $event->enroll->event; // "enrolled", "received", "completed", "rejected" + $event->enroll->enrollType; // "era", "1500", "ub", "elig", "attach" + $event->enroll->provNpi; // Provider NPI + $event->enroll->payerId; // Payer ID + } + + if ($event->eventType === 'appeal') { + $event->appeal->appealId; // Appeal ID + $event->appeal->event; // "created", "mailed", "update", "faxed", "transmitted", "failure" + $event->appeal->appealType; // "electronic", "mail", "fax", "download" + $event->appeal->claimId; // Associated Claim.MD claim ID + $event->appeal->remoteClaimId; // User-assigned claim ID + $event->appeal->serviceFee; // Service fees + $event->appeal->pages; // Number of pages + } +} +``` + ### Payers #### List Payers + ```php -use Nextvisit\ClaimMDWrapper\Requests\PayerRequest; +use Nextvisit\ClaimMD\Requests\PayerRequest; $payerRequest = new PayerRequest($client); @@ -324,7 +403,7 @@ $allResponse = $payerRequest->listPayer(); #### ClaimAppealDTO ```php -use Nextvisit\ClaimMDWrapper\DTO\ClaimAppealDTO; +use Nextvisit\ClaimMD\DTO\ClaimAppealDTO; $claimAppealDto = new ClaimAppealDTO( claimId: '12345', @@ -363,7 +442,7 @@ $claimAppealDto = ClaimAppealDTO::fromArray($data); #### ProviderEnrollmentDTO ```php -use Nextvisit\ClaimMDWrapper\DTO\ProviderEnrollmentDTO; +use Nextvisit\ClaimMD\DTO\ProviderEnrollmentDTO; $providerEnrollmentDto = new ProviderEnrollmentDTO( payerId: 'payer-123', @@ -414,7 +493,7 @@ $providerEnrollmentDto = ProviderEnrollmentDTO::fromArray($data); #### EligibilityDTO ```php -use Nextvisit\ClaimMDWrapper\DTO\EligibilityDTO; +use Nextvisit\ClaimMD\DTO\EligibilityDTO; $eligibilityDto = new EligibilityDTO( insLastName: 'Doe', @@ -481,7 +560,7 @@ $eligibilityDto = EligibilityDTO::fromArray($data); #### ERADTO ```php -use Nextvisit\ClaimMDWrapper\DTO\ERADTO; +use Nextvisit\ClaimMD\DTO\ERADTO; $eraDto = new ERADTO( checkDate: '09-01-2023', @@ -515,10 +594,52 @@ $data = [ $eraDto = ERADTO::fromArray($data); ``` +#### WebhookPayloadDTO + +```php +use Nextvisit\ClaimMD\DTO\WebhookPayloadDTO; + +// Parse from a raw JSON string (e.g., webhook request body) +$json = file_get_contents('php://input'); +$webhookPayload = WebhookPayloadDTO::fromJsonString($json); + +// Or from a decoded array +$data = [ + 'UTCTime' => '2026-03-13T12:00:00Z', + 'acct_number' => 'ACCT-001', + 'remote_acct_number' => 'REMOTE-001', + 'events' => [ + [ + 'eventid' => 'EVT-001', + 'event_type' => 'enroll', + 'event_time' => '2026-03-13T11:00:00Z', + 'event_data' => [ + 'enroll' => [ + 'enrollid' => 'ENR-001', + 'event' => 'enrolled', + 'enroll_type' => 'era', + 'prov_npi' => '1234567890', + 'payerid' => 'PAYER-001', + ], + ], + ], + ], +]; + +$webhookPayload = WebhookPayloadDTO::fromArray($data); +``` + ## 🤝 Contributing -Contributions are welcome! If you find any issues or have suggestions for improvements, feel free to open an issue or submit a pull request. +Contributions are welcome! If you find any issues or have suggestions for improvements, feel free to open an issue or +submit a pull request. ## 📄 License This package is licensed under the MIT License. See the [LICENSE](LICENSE) file for more information. + +## Credits + +- [Nextvisit AI](https://nextvisit.ai) +- [Ryan Yannelli](https://ryanyannelli.com) +- [Kyle Yannelli](https://github.com/kyleyannelli) \ No newline at end of file diff --git a/composer.json b/composer.json index 1187d64..6edd0ff 100644 --- a/composer.json +++ b/composer.json @@ -1,61 +1,61 @@ { - "name": "nextvisit/claim-md-php", - "description": "An unofficial PHP wrapper for the official Claim.MD API.", - "type": "library", - "license": "MIT", - "require": { - "php": ">=8.2", - "guzzlehttp/guzzle": "^7.9", - "ext-json": "*" + "name": "nextvisit/claim-md-php", + "description": "An unofficial PHP wrapper for the official Claim.MD API.", + "type": "library", + "license": "MIT", + "require": { + "php": ">=8.3", + "guzzlehttp/guzzle": "^7.9", + "ext-json": "*" + }, + "require-dev": { + "mockery/mockery": "^1.6", + "pestphp/pest": "^3.0" + }, + "autoload": { + "psr-4": { + "Nextvisit\\ClaimMD\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Nextvisit\\ClaimMD\\Tests\\": "tests/" + } + }, + "authors": [ + { + "name": "Kyle Yannelli", + "email": "kyleyannelli@gmail.com", + "homepage": "https://github.com/kyleyannelli", + "role": "Developer" }, - "require-dev": { - "mockery/mockery": "^1.6", - "pestphp/pest": "^3.0" - }, - "autoload": { - "psr-4": { - "Nextvisit\\ClaimMDWrapper\\": "src/" - } - }, - "autoload-dev": { - "psr-4": { - "Nextvisit\\ClaimMDWrapper\\Tests\\": "tests/" - } - }, - "authors": [ - { - "name": "Kyle Yannelli", - "email": "kyleyannelli@gmail.com", - "homepage": "https://github.com/kyleyannelli", - "role": "Developer" - }, - { - "name": "Ryan Yannelli", - "email": "ryanyannelli@gmail.com", - "homepage": "https://github.com/yannelli", - "role": "Developer" - } - ], - "keywords": [ - "claimmd", - "api", - "insurance-claims", - "x12", - "php-wrapper" - ], - "homepage": "https://github.com/Nextvisit/claim-md-php", - "support": { - "issues": "https://github.com/Nextvisit/claim-md-php/issues" - }, - "config": { - "sort-packages": true, - "allow-plugins": { - "pestphp/pest-plugin": true - } - }, - "scripts": { - "test": "pest", - "test:coverage": "pest --coverage" - }, - "minimum-stability": "stable" + { + "name": "Ryan Yannelli", + "email": "ryanyannelli@gmail.com", + "homepage": "https://github.com/yannelli", + "role": "Developer" + } + ], + "keywords": [ + "claimmd", + "api", + "insurance-claims", + "x12", + "php-wrapper" + ], + "homepage": "https://github.com/Nextvisit/claim-md-php", + "support": { + "issues": "https://github.com/Nextvisit/claim-md-php/issues" + }, + "config": { + "sort-packages": true, + "allow-plugins": { + "pestphp/pest-plugin": true + } + }, + "scripts": { + "test": "pest", + "test:coverage": "pest --coverage" + }, + "minimum-stability": "stable" } diff --git a/src/Client.php b/src/Client.php index 42e50a0..bba77c1 100644 --- a/src/Client.php +++ b/src/Client.php @@ -1,17 +1,24 @@ [ 'Accept' => 'application/json', ], + 'http_errors' => false, ]); } @@ -62,15 +70,83 @@ private function createDefaultHttpClient(): GuzzleClient * * @return array The API response as an associative array * - * @throws GuzzleException If there's an HTTP request failure + * @throws AuthenticationException If the API returns a 401 response + * @throws RateLimitException If the API rate limit is exceeded (429) + * @throws NotFoundException If the requested resource is not found (404) + * @throws ServerException If the API returns a 5xx response + * @throws ApiException If the API returns any other non-2xx response + * @throws InvalidResponseException If the response body is not valid JSON + * @throws GuzzleException If there's a network-level HTTP request failure */ public function sendRequest(string $method, string $uri, array $data = [], bool $isMultipart = false, array $additionalHeaders = []): array { $options = $this->prepareRequestOptions($data, $isMultipart, $additionalHeaders); - $response = $this->httpClient->request($method, $uri, $options); + try { + $response = $this->httpClient->request($method, $uri, $options); + } catch (RequestException $e) { + $response = $e->getResponse(); - return json_decode($response->getBody()->getContents(), true); + if ($response === null) { + throw $e; + } + + $this->handleErrorResponse( + $response->getStatusCode(), + $response->getBody()->getContents(), + $response->getHeaderLine('Retry-After'), + $e + ); + } + + $statusCode = $response->getStatusCode(); + $rawBody = $response->getBody()->getContents(); + + if ($statusCode >= 400) { + $this->handleErrorResponse( + $statusCode, + $rawBody, + $response->getHeaderLine('Retry-After') + ); + } + + $decoded = json_decode($rawBody, true); + + if (!is_array($decoded)) { + throw new InvalidResponseException($statusCode, $rawBody); + } + + return $decoded; + } + + /** + * Handle an HTTP error response by throwing the appropriate exception. + * + * @throws AuthenticationException + * @throws RateLimitException + * @throws NotFoundException + * @throws ServerException + * @throws ApiException + */ + private function handleErrorResponse( + int $statusCode, + string $rawBody, + string $retryAfterHeader = '', + ?\Throwable $previous = null + ): never { + $responseBody = json_decode($rawBody, true); + + match (true) { + $statusCode === 401 => throw new AuthenticationException($responseBody, $previous), + $statusCode === 404 => throw new NotFoundException($responseBody, $previous), + $statusCode === 429 => throw new RateLimitException( + $responseBody, + $retryAfterHeader !== '' ? (int) $retryAfterHeader : null, + $previous + ), + $statusCode >= 500 => throw new ServerException($statusCode, $responseBody, $previous), + default => throw new ApiException($statusCode, $responseBody, '', $previous), + }; } /** @@ -120,4 +196,4 @@ private function prepareMultipartData(array $data): array } return $multipart; } -} \ No newline at end of file +} diff --git a/src/Config.php b/src/Config.php index 5969b10..9249935 100644 --- a/src/Config.php +++ b/src/Config.php @@ -1,6 +1,6 @@ claimId) && empty($this->remoteClaimId)) { - throw new \InvalidArgumentException('Either claimId or remoteClaimId must be provided.'); + throw new InvalidArgumentException('Either claimId or remoteClaimId must be provided.'); } } /** * Validate the email address. * - * @throws \InvalidArgumentException If the email is invalid + * @throws InvalidArgumentException If the email is invalid */ private function validateEmail(): void { if ($this->contactEmail && !filter_var($this->contactEmail, FILTER_VALIDATE_EMAIL)) { - throw new \InvalidArgumentException('contactEmail must be a valid email address.'); + throw new InvalidArgumentException('contactEmail must be a valid email address.'); } } @@ -83,24 +85,24 @@ private function validateEmail(): void * @param string|null $phoneNumber The phone number to validate * @param string $fieldName The name of the field being validated * - * @throws \InvalidArgumentException If the phone number is invalid + * @throws InvalidArgumentException If the phone number is invalid */ private function validatePhoneNumber(?string $phoneNumber, string $fieldName): void { if ($phoneNumber && !preg_match('/^\+?[0-9\-\(\)\s]+$/', $phoneNumber)) { - throw new \InvalidArgumentException("$fieldName must be a valid phone number."); + throw new InvalidArgumentException("$fieldName must be a valid phone number."); } } /** * Validate the state code. * - * @throws \InvalidArgumentException If the state code is invalid + * @throws InvalidArgumentException If the state code is invalid */ private function validateStateCode(): void { if ($this->contactState && !preg_match('/^[A-Z]{2}$/', $this->contactState)) { - throw new \InvalidArgumentException('contactState must be a valid two-letter state code.'); + throw new InvalidArgumentException('contactState must be a valid two-letter state code.'); } } diff --git a/src/DTO/ERADTO.php b/src/DTO/ERADTO.php index a5d927c..3485650 100644 --- a/src/DTO/ERADTO.php +++ b/src/DTO/ERADTO.php @@ -1,6 +1,6 @@ format('m-d-Y') !== $date) { - throw new InvalidArgumentException("$fieldName must be in mm-dd-yyyy format or 'today'/'yesterday'"); + $message = "$fieldName must be in mm-dd-yyyy format"; + if ($allowTodayYesterday) { + $message .= " or 'today'/'yesterday'"; + } + throw new InvalidArgumentException($message); } } diff --git a/src/DTO/EligibilityDTO.php b/src/DTO/EligibilityDTO.php index 3c18b7a..55004cd 100644 --- a/src/DTO/EligibilityDTO.php +++ b/src/DTO/EligibilityDTO.php @@ -1,6 +1,6 @@ patientRelationship, ['18', 'G8'])) { + if (!in_array($this->patientRelationship, ['18', 'G8'], true)) { throw new InvalidArgumentException("patientRelationship must be either '18' or 'G8'"); } } @@ -134,7 +141,7 @@ private function validatePatientRelationship(): void */ private function validateSex(string $sex, string $fieldName): void { - if (!in_array($sex, ['M', 'F'])) { + if (!in_array($sex, ['M', 'F'], true)) { throw new InvalidArgumentException("$fieldName must be either 'M' or 'F'"); } } @@ -146,7 +153,7 @@ private function validateSex(string $sex, string $fieldName): void */ private function validateProvTaxIdType(): void { - if (!in_array($this->provTaxIdType, ['E', 'S'])) { + if (!in_array($this->provTaxIdType, ['E', 'S'], true)) { throw new InvalidArgumentException("provTaxIdType must be either 'E' or 'S'"); } } diff --git a/src/DTO/ProviderEnrollmentDTO.php b/src/DTO/ProviderEnrollmentDTO.php index 49eb77a..52764b3 100644 --- a/src/DTO/ProviderEnrollmentDTO.php +++ b/src/DTO/ProviderEnrollmentDTO.php @@ -1,6 +1,6 @@ provNameLast)) { throw new InvalidArgumentException('provNameLast is required when provNpi is not provided.'); } - // Assuming provider is an individual if provNameFirst is provided - if ($this->provNameLast && empty($this->provNameFirst)) { + if (empty($this->provNameFirst)) { throw new InvalidArgumentException('provNameFirst is required when provNpi is not provided and the provider is an individual.'); } } diff --git a/src/DTO/WebhookAppealEventDTO.php b/src/DTO/WebhookAppealEventDTO.php new file mode 100644 index 0000000..12ff1c7 --- /dev/null +++ b/src/DTO/WebhookAppealEventDTO.php @@ -0,0 +1,122 @@ +validateRequiredFields(); + $this->validateEvent(); + $this->validateAppealType(); + } + + /** + * @throws InvalidArgumentException + */ + private function validateRequiredFields(): void + { + if (empty($this->appealId)) { + throw new InvalidArgumentException('appealId is required.'); + } + if (empty($this->event)) { + throw new InvalidArgumentException('event is required.'); + } + } + + /** + * @throws InvalidArgumentException + */ + private function validateEvent(): void + { + $validEvents = ['created', 'mailed', 'update', 'faxed', 'transmitted', 'failure']; + if (!in_array($this->event, $validEvents, true)) { + throw new InvalidArgumentException('event must be one of: ' . implode(', ', $validEvents)); + } + } + + /** + * @throws InvalidArgumentException + */ + private function validateAppealType(): void + { + if ($this->appealType === null) { + return; + } + + $validTypes = ['electronic', 'mail', 'fax', 'download']; + if (!in_array($this->appealType, $validTypes, true)) { + throw new InvalidArgumentException('appealType must be one of: ' . implode(', ', $validTypes)); + } + } + + /** + * @return array + */ + public function toArray(): array + { + return array_filter([ + 'appealid' => $this->appealId, + 'event' => $this->event, + 'event_detail' => $this->eventDetail, + 'claimid' => $this->claimId, + 'remote_claimid' => $this->remoteClaimId, + 'appeal_type' => $this->appealType, + 'service_fee' => $this->serviceFee, + 'pages' => $this->pages, + 'formid' => $this->formId, + 'form_name' => $this->formName, + ], fn($value) => $value !== null); + } + + /** + * @param array $data + * @return self + */ + public static function fromArray(array $data): self + { + return new self( + appealId: $data['appealid'] ?? throw new InvalidArgumentException('appealid is required.'), + event: $data['event'] ?? throw new InvalidArgumentException('event is required.'), + eventDetail: $data['event_detail'] ?? null, + claimId: $data['claimid'] ?? null, + remoteClaimId: $data['remote_claimid'] ?? null, + appealType: $data['appeal_type'] ?? null, + serviceFee: $data['service_fee'] ?? null, + pages: $data['pages'] ?? null, + formId: $data['formid'] ?? null, + formName: $data['form_name'] ?? null, + ); + } +} diff --git a/src/DTO/WebhookEnrollEventDTO.php b/src/DTO/WebhookEnrollEventDTO.php new file mode 100644 index 0000000..f82882b --- /dev/null +++ b/src/DTO/WebhookEnrollEventDTO.php @@ -0,0 +1,113 @@ +validateRequiredFields(); + $this->validateEvent(); + $this->validateEnrollType(); + } + + /** + * @throws InvalidArgumentException + */ + private function validateRequiredFields(): void + { + if (empty($this->enrollId)) { + throw new InvalidArgumentException('enrollId is required.'); + } + if (empty($this->event)) { + throw new InvalidArgumentException('event is required.'); + } + if (empty($this->enrollType)) { + throw new InvalidArgumentException('enrollType is required.'); + } + } + + /** + * @throws InvalidArgumentException + */ + private function validateEvent(): void + { + $validEvents = ['enrolled', 'received', 'completed', 'rejected']; + if (!in_array($this->event, $validEvents, true)) { + throw new InvalidArgumentException('event must be one of: ' . implode(', ', $validEvents)); + } + } + + /** + * @throws InvalidArgumentException + */ + private function validateEnrollType(): void + { + $validTypes = ['era', '1500', 'ub', 'elig', 'attach']; + if (!in_array($this->enrollType, $validTypes, true)) { + throw new InvalidArgumentException('enrollType must be one of: ' . implode(', ', $validTypes)); + } + } + + /** + * @return array + */ + public function toArray(): array + { + return array_filter([ + 'enrollid' => $this->enrollId, + 'event' => $this->event, + 'event_detail' => $this->eventDetail, + 'enroll_type' => $this->enrollType, + 'prov_npi' => $this->provNpi, + 'prov_taxid' => $this->provTaxId, + 'prov_id' => $this->provId, + 'payerid' => $this->payerId, + ], fn($value) => $value !== null); + } + + /** + * @param array $data + * @return self + */ + public static function fromArray(array $data): self + { + return new self( + enrollId: $data['enrollid'] ?? throw new InvalidArgumentException('enrollid is required.'), + event: $data['event'] ?? throw new InvalidArgumentException('event is required.'), + enrollType: $data['enroll_type'] ?? throw new InvalidArgumentException('enroll_type is required.'), + eventDetail: $data['event_detail'] ?? null, + provNpi: $data['prov_npi'] ?? null, + provTaxId: $data['prov_taxid'] ?? null, + provId: $data['prov_id'] ?? null, + payerId: $data['payerid'] ?? null, + ); + } +} diff --git a/src/DTO/WebhookEventDTO.php b/src/DTO/WebhookEventDTO.php new file mode 100644 index 0000000..4c3125b --- /dev/null +++ b/src/DTO/WebhookEventDTO.php @@ -0,0 +1,111 @@ +validateRequiredFields(); + $this->validateEventType(); + $this->validateEventData(); + } + + /** + * @throws InvalidArgumentException + */ + private function validateRequiredFields(): void + { + if (empty($this->eventId)) { + throw new InvalidArgumentException('eventId is required.'); + } + if (empty($this->eventType)) { + throw new InvalidArgumentException('eventType is required.'); + } + } + + /** + * @throws InvalidArgumentException + */ + private function validateEventType(): void + { + $validTypes = ['enroll', 'appeal']; + if (!in_array($this->eventType, $validTypes, true)) { + throw new InvalidArgumentException('eventType must be one of: ' . implode(', ', $validTypes)); + } + } + + /** + * @throws InvalidArgumentException + */ + private function validateEventData(): void + { + if ($this->eventType === 'enroll' && $this->enroll === null) { + throw new InvalidArgumentException('enroll event data is required when eventType is "enroll".'); + } + if ($this->eventType === 'appeal' && $this->appeal === null) { + throw new InvalidArgumentException('appeal event data is required when eventType is "appeal".'); + } + } + + /** + * @return array + */ + public function toArray(): array + { + $result = array_filter([ + 'eventid' => $this->eventId, + 'event_type' => $this->eventType, + 'event_time' => $this->eventTime, + ], fn($value) => $value !== null); + + $eventData = array_filter([ + 'enroll' => $this->enroll?->toArray(), + 'appeal' => $this->appeal?->toArray(), + ], fn($value) => $value !== null); + + if (!empty($eventData)) { + $result['event_data'] = $eventData; + } + + return $result; + } + + /** + * @param array $data + * @return self + */ + public static function fromArray(array $data): self + { + $eventData = $data['event_data'] ?? []; + + return new self( + eventId: $data['eventid'] ?? throw new InvalidArgumentException('eventid is required.'), + eventType: $data['event_type'] ?? throw new InvalidArgumentException('event_type is required.'), + eventTime: $data['event_time'] ?? null, + enroll: isset($eventData['enroll']) ? WebhookEnrollEventDTO::fromArray($eventData['enroll']) : null, + appeal: isset($eventData['appeal']) ? WebhookAppealEventDTO::fromArray($eventData['appeal']) : null, + ); + } +} diff --git a/src/DTO/WebhookPayloadDTO.php b/src/DTO/WebhookPayloadDTO.php new file mode 100644 index 0000000..a7f9bd0 --- /dev/null +++ b/src/DTO/WebhookPayloadDTO.php @@ -0,0 +1,110 @@ +validateRequiredFields(); + $this->validateEvents(); + } + + /** + * @throws InvalidArgumentException + */ + private function validateRequiredFields(): void + { + if (empty($this->utcTime)) { + throw new InvalidArgumentException('utcTime is required.'); + } + if (empty($this->acctNumber)) { + throw new InvalidArgumentException('acctNumber is required.'); + } + } + + /** + * @throws InvalidArgumentException + */ + private function validateEvents(): void + { + foreach ($this->events as $index => $event) { + if (!$event instanceof WebhookEventDTO) { + throw new InvalidArgumentException("Event at index $index must be an instance of WebhookEventDTO."); + } + } + } + + /** + * @return array + */ + public function toArray(): array + { + $result = array_filter([ + 'UTCTime' => $this->utcTime, + 'acct_number' => $this->acctNumber, + 'remote_acct_number' => $this->remoteAcctNumber, + ], fn($value) => $value !== null); + + $result['events'] = array_map(fn(WebhookEventDTO $event) => $event->toArray(), $this->events); + + return $result; + } + + /** + * Create a WebhookPayloadDTO from a JSON string. + * + * @param string $json The JSON string to parse + * @return self + * @throws InvalidArgumentException If the JSON is invalid + */ + public static function fromJsonString(string $json): self + { + $data = json_decode($json, true); + + if (!is_array($data)) { + throw new InvalidArgumentException('Invalid JSON string provided.'); + } + + return self::fromArray($data); + } + + /** + * @param array $data + * @return self + */ + public static function fromArray(array $data): self + { + $events = array_map( + fn(array $eventData) => WebhookEventDTO::fromArray($eventData), + $data['events'] ?? [] + ); + + return new self( + utcTime: $data['UTCTime'] ?? throw new InvalidArgumentException('UTCTime is required.'), + acctNumber: $data['acct_number'] ?? throw new InvalidArgumentException('acct_number is required.'), + remoteAcctNumber: $data['remote_acct_number'] ?? null, + events: $events, + ); + } +} diff --git a/src/Exceptions/ApiException.php b/src/Exceptions/ApiException.php new file mode 100644 index 0000000..b876c07 --- /dev/null +++ b/src/Exceptions/ApiException.php @@ -0,0 +1,40 @@ +statusCode = $statusCode; + $this->responseBody = $responseBody; + + if ($message === '') { + $message = "Claim.MD API request failed with status code {$statusCode}"; + } + + parent::__construct($message, $statusCode, $previous); + } + + public function getStatusCode(): int + { + return $this->statusCode; + } + + public function getResponseBody(): ?array + { + return $this->responseBody; + } +} diff --git a/src/Exceptions/AuthenticationException.php b/src/Exceptions/AuthenticationException.php new file mode 100644 index 0000000..8e7559e --- /dev/null +++ b/src/Exceptions/AuthenticationException.php @@ -0,0 +1,25 @@ +statusCode = $statusCode; + $this->rawBody = $rawBody; + + parent::__construct( + "Claim.MD API returned a non-JSON response (HTTP {$statusCode})", + $statusCode, + $previous + ); + } + + public function getStatusCode(): int + { + return $this->statusCode; + } + + public function getRawBody(): string + { + return $this->rawBody; + } +} diff --git a/src/Exceptions/NotFoundException.php b/src/Exceptions/NotFoundException.php new file mode 100644 index 0000000..97a1c07 --- /dev/null +++ b/src/Exceptions/NotFoundException.php @@ -0,0 +1,18 @@ +retryAfter = $retryAfter; + + $message = 'Claim.MD API rate limit exceeded (max 100 requests per minute)'; + if ($retryAfter !== null) { + $message .= ". Retry after {$retryAfter} seconds"; + } + + parent::__construct(429, $responseBody, $message, $previous); + } + + /** + * Seconds to wait before retrying, if provided by the API. + */ + public function getRetryAfter(): ?int + { + return $this->retryAfter; + } +} diff --git a/src/Exceptions/ServerException.php b/src/Exceptions/ServerException.php new file mode 100644 index 0000000..4b8d837 --- /dev/null +++ b/src/Exceptions/ServerException.php @@ -0,0 +1,24 @@ +client->sendRequest('POST', self::ERA_LIST_ENDPOINT); } if ($era instanceof ERADTO) { diff --git a/src/Requests/EligibilityRequest.php b/src/Requests/EligibilityRequest.php index 0bdcdc0..fd6b59c 100644 --- a/src/Requests/EligibilityRequest.php +++ b/src/Requests/EligibilityRequest.php @@ -1,15 +1,16 @@ Utils::streamFor($file) + 'File' => Utils::streamFor($file), ]; return $this->client->sendRequest('POST', self::ELIGIBILITY_ENDPOINT, $data, true); diff --git a/src/Requests/FileRequest.php b/src/Requests/FileRequest.php index 04d92bc..f6bfa98 100644 --- a/src/Requests/FileRequest.php +++ b/src/Requests/FileRequest.php @@ -1,22 +1,22 @@ $this->prepareFile($file), @@ -80,10 +90,11 @@ public function upload($file, ?string $filename = null): array * Prepare the file for upload by converting it to a StreamInterface * * @param resource $file The file resource to prepare + * * @return StreamInterface The prepared file as a StreamInterface * @throws InvalidArgumentException If file is not a valid resource. */ - private function prepareFile($file): StreamInterface + private function prepareFile(mixed $file): StreamInterface { if (is_resource($file)) { return Utils::streamFor($file); diff --git a/src/Requests/PayerRequest.php b/src/Requests/PayerRequest.php index 5a7ea7c..814e5f7 100644 --- a/src/Requests/PayerRequest.php +++ b/src/Requests/PayerRequest.php @@ -1,13 +1,13 @@ extend(Nextvisit\ClaimMDWrapper\Tests\TestCase::class)->in('Unit'); +pest()->extend(Nextvisit\ClaimMD\Tests\TestCase::class)->in('Unit'); /* |-------------------------------------------------------------------------- diff --git a/tests/TestCase.php b/tests/TestCase.php index 37d229b..3901432 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -1,6 +1,6 @@ getBody(); expect($body)->toContain('AccountKey=test-account-key'); }); + + it('throws AuthenticationException on 401 response', function () { + $mock = new MockHandler([ + new Response(401, [], json_encode(['error' => 'Invalid AccountKey'])), + ]); + + $guzzleClient = new GuzzleClient(['handler' => HandlerStack::create($mock)]); + $client = new Client('bad-key', new Config(), $guzzleClient); + + $client->sendRequest('POST', '/test'); + })->throws(AuthenticationException::class, 'authentication failed'); + + it('throws RateLimitException on 429 response', function () { + $mock = new MockHandler([ + new Response(429, ['Retry-After' => '30'], json_encode(['error' => 'Rate limit exceeded'])), + ]); + + $guzzleClient = new GuzzleClient(['handler' => HandlerStack::create($mock)]); + $client = new Client('test-key', new Config(), $guzzleClient); + + $client->sendRequest('POST', '/test'); + })->throws(RateLimitException::class, 'rate limit exceeded'); + + it('includes retry-after seconds on RateLimitException', function () { + $mock = new MockHandler([ + new Response(429, ['Retry-After' => '45'], json_encode(['error' => 'Rate limit'])), + ]); + + $guzzleClient = new GuzzleClient(['handler' => HandlerStack::create($mock)]); + $client = new Client('test-key', new Config(), $guzzleClient); + + try { + $client->sendRequest('POST', '/test'); + } catch (RateLimitException $e) { + expect($e->getRetryAfter())->toBe(45); + expect($e->getStatusCode())->toBe(429); + expect($e->getResponseBody())->toBe(['error' => 'Rate limit']); + } + }); + + it('throws NotFoundException on 404 response', function () { + $mock = new MockHandler([ + new Response(404, [], json_encode(['error' => 'Not found'])), + ]); + + $guzzleClient = new GuzzleClient(['handler' => HandlerStack::create($mock)]); + $client = new Client('test-key', new Config(), $guzzleClient); + + $client->sendRequest('POST', '/test'); + })->throws(NotFoundException::class); + + it('throws ServerException on 500 response', function () { + $mock = new MockHandler([ + new Response(500, [], json_encode(['error' => 'Internal server error'])), + ]); + + $guzzleClient = new GuzzleClient(['handler' => HandlerStack::create($mock)]); + $client = new Client('test-key', new Config(), $guzzleClient); + + $client->sendRequest('POST', '/test'); + })->throws(ServerException::class); + + it('throws ServerException on 503 response', function () { + $mock = new MockHandler([ + new Response(503, [], json_encode(['error' => 'Service unavailable'])), + ]); + + $guzzleClient = new GuzzleClient(['handler' => HandlerStack::create($mock)]); + $client = new Client('test-key', new Config(), $guzzleClient); + + try { + $client->sendRequest('POST', '/test'); + } catch (ServerException $e) { + expect($e->getStatusCode())->toBe(503); + expect($e->getResponseBody())->toBe(['error' => 'Service unavailable']); + } + }); + + it('throws ApiException on other 4xx responses', function () { + $mock = new MockHandler([ + new Response(422, [], json_encode(['error' => 'Validation failed'])), + ]); + + $guzzleClient = new GuzzleClient(['handler' => HandlerStack::create($mock)]); + $client = new Client('test-key', new Config(), $guzzleClient); + + try { + $client->sendRequest('POST', '/test'); + } catch (ApiException $e) { + expect($e->getStatusCode())->toBe(422); + expect($e->getResponseBody())->toBe(['error' => 'Validation failed']); + } + }); + + it('throws InvalidResponseException on non-JSON response', function () { + $mock = new MockHandler([ + new Response(200, [], 'Not JSON'), + ]); + + $guzzleClient = new GuzzleClient(['handler' => HandlerStack::create($mock)]); + $client = new Client('test-key', new Config(), $guzzleClient); + + $client->sendRequest('POST', '/test'); + })->throws(InvalidResponseException::class, 'non-JSON response'); + + it('provides raw body on InvalidResponseException', function () { + $mock = new MockHandler([ + new Response(200, [], 'not json'), + ]); + + $guzzleClient = new GuzzleClient(['handler' => HandlerStack::create($mock)]); + $client = new Client('test-key', new Config(), $guzzleClient); + + try { + $client->sendRequest('POST', '/test'); + } catch (InvalidResponseException $e) { + expect($e->getRawBody())->toBe('not json'); + expect($e->getStatusCode())->toBe(200); + } + }); }); diff --git a/tests/Unit/ConfigTest.php b/tests/Unit/ConfigTest.php index e04f8d0..3f0a72c 100644 --- a/tests/Unit/ConfigTest.php +++ b/tests/Unit/ConfigTest.php @@ -1,6 +1,6 @@ throws(InvalidArgumentException::class, "checkDate must be in mm-dd-yyyy format or 'today'/'yesterday'"); + })->throws(InvalidArgumentException::class, "checkDate must be in mm-dd-yyyy format"); it('throws exception for invalid receivedDate format', function () { new ERADTO(receivedDate: '15-01-2024'); diff --git a/tests/Unit/DTO/EligibilityDTOTest.php b/tests/Unit/DTO/EligibilityDTOTest.php index 034fb12..a6f8d39 100644 --- a/tests/Unit/DTO/EligibilityDTOTest.php +++ b/tests/Unit/DTO/EligibilityDTOTest.php @@ -1,6 +1,6 @@ appealId)->toBe('APL-001'); + expect($dto->event)->toBe('created'); + }); + + it('creates a DTO with all fields', function () { + $dto = new WebhookAppealEventDTO( + appealId: 'APL-001', + event: 'created', + eventDetail: 'Appeal created successfully', + claimId: 'CLM-001', + remoteClaimId: 'RCLM-001', + appealType: 'electronic', + serviceFee: '25.00', + pages: '5', + formId: 'FORM-001', + formName: 'Standard Appeal Form' + ); + + expect($dto->eventDetail)->toBe('Appeal created successfully'); + expect($dto->claimId)->toBe('CLM-001'); + expect($dto->remoteClaimId)->toBe('RCLM-001'); + expect($dto->appealType)->toBe('electronic'); + expect($dto->serviceFee)->toBe('25.00'); + expect($dto->pages)->toBe('5'); + expect($dto->formId)->toBe('FORM-001'); + expect($dto->formName)->toBe('Standard Appeal Form'); + }); + }); + + describe('validation', function () { + it('throws exception when appealId is empty', function () { + new WebhookAppealEventDTO(appealId: '', event: 'created'); + })->throws(InvalidArgumentException::class, 'appealId is required.'); + + it('throws exception when event is empty', function () { + new WebhookAppealEventDTO(appealId: 'APL-001', event: ''); + })->throws(InvalidArgumentException::class, 'event is required.'); + + it('throws exception for invalid event', function () { + new WebhookAppealEventDTO(appealId: 'APL-001', event: 'invalid'); + })->throws(InvalidArgumentException::class, 'event must be one of: created, mailed, update, faxed, transmitted, failure'); + + it('throws exception for invalid appealType', function () { + new WebhookAppealEventDTO(appealId: 'APL-001', event: 'created', appealType: 'invalid'); + })->throws(InvalidArgumentException::class, 'appealType must be one of: electronic, mail, fax, download'); + + it('accepts all valid event values', function () { + foreach (['created', 'mailed', 'update', 'faxed', 'transmitted', 'failure'] as $event) { + $dto = new WebhookAppealEventDTO(appealId: 'APL-001', event: $event); + expect($dto->event)->toBe($event); + } + }); + + it('accepts all valid appealType values', function () { + foreach (['electronic', 'mail', 'fax', 'download'] as $type) { + $dto = new WebhookAppealEventDTO(appealId: 'APL-001', event: 'created', appealType: $type); + expect($dto->appealType)->toBe($type); + } + }); + + it('accepts null appealType', function () { + $dto = new WebhookAppealEventDTO(appealId: 'APL-001', event: 'created'); + expect($dto->appealType)->toBeNull(); + }); + }); + + describe('toArray', function () { + it('converts to array with correct keys', function () { + $dto = new WebhookAppealEventDTO( + appealId: 'APL-001', + event: 'created', + claimId: 'CLM-001', + appealType: 'electronic', + serviceFee: '25.00' + ); + + expect($dto->toArray())->toBe([ + 'appealid' => 'APL-001', + 'event' => 'created', + 'claimid' => 'CLM-001', + 'appeal_type' => 'electronic', + 'service_fee' => '25.00', + ]); + }); + + it('filters out null values', function () { + $dto = new WebhookAppealEventDTO(appealId: 'APL-001', event: 'created'); + + $array = $dto->toArray(); + + expect($array)->not->toHaveKey('event_detail'); + expect($array)->not->toHaveKey('claimid'); + expect($array)->not->toHaveKey('appeal_type'); + }); + }); + + describe('fromArray', function () { + it('creates a DTO from array', function () { + $dto = WebhookAppealEventDTO::fromArray([ + 'appealid' => 'APL-001', + 'event' => 'mailed', + 'event_detail' => 'Mailed successfully', + 'claimid' => 'CLM-001', + 'remote_claimid' => 'RCLM-001', + 'appeal_type' => 'mail', + 'service_fee' => '10.00', + 'pages' => '3', + 'formid' => 'FORM-001', + 'form_name' => 'Standard Form', + ]); + + expect($dto->appealId)->toBe('APL-001'); + expect($dto->event)->toBe('mailed'); + expect($dto->eventDetail)->toBe('Mailed successfully'); + expect($dto->claimId)->toBe('CLM-001'); + expect($dto->remoteClaimId)->toBe('RCLM-001'); + expect($dto->appealType)->toBe('mail'); + expect($dto->serviceFee)->toBe('10.00'); + expect($dto->pages)->toBe('3'); + expect($dto->formId)->toBe('FORM-001'); + expect($dto->formName)->toBe('Standard Form'); + }); + + it('throws exception when appealid is missing', function () { + WebhookAppealEventDTO::fromArray(['event' => 'created']); + })->throws(InvalidArgumentException::class, 'appealid is required.'); + + it('handles missing optional fields', function () { + $dto = WebhookAppealEventDTO::fromArray([ + 'appealid' => 'APL-001', + 'event' => 'created', + ]); + + expect($dto->eventDetail)->toBeNull(); + expect($dto->claimId)->toBeNull(); + expect($dto->appealType)->toBeNull(); + }); + }); +}); diff --git a/tests/Unit/DTO/WebhookEnrollEventDTOTest.php b/tests/Unit/DTO/WebhookEnrollEventDTOTest.php new file mode 100644 index 0000000..f887e6a --- /dev/null +++ b/tests/Unit/DTO/WebhookEnrollEventDTOTest.php @@ -0,0 +1,142 @@ +enrollId)->toBe('ENR-001'); + expect($dto->event)->toBe('enrolled'); + expect($dto->enrollType)->toBe('era'); + }); + + it('creates a DTO with all fields', function () { + $dto = new WebhookEnrollEventDTO( + enrollId: 'ENR-001', + event: 'enrolled', + enrollType: 'era', + eventDetail: 'Enrollment completed successfully', + provNpi: '1234567890', + provTaxId: '123456789', + provId: 'PROV-001', + payerId: 'PAYER-001' + ); + + expect($dto->eventDetail)->toBe('Enrollment completed successfully'); + expect($dto->provNpi)->toBe('1234567890'); + expect($dto->provTaxId)->toBe('123456789'); + expect($dto->provId)->toBe('PROV-001'); + expect($dto->payerId)->toBe('PAYER-001'); + }); + }); + + describe('validation', function () { + it('throws exception when enrollId is empty', function () { + new WebhookEnrollEventDTO(enrollId: '', event: 'enrolled', enrollType: 'era'); + })->throws(InvalidArgumentException::class, 'enrollId is required.'); + + it('throws exception when event is empty', function () { + new WebhookEnrollEventDTO(enrollId: 'ENR-001', event: '', enrollType: 'era'); + })->throws(InvalidArgumentException::class, 'event is required.'); + + it('throws exception when enrollType is empty', function () { + new WebhookEnrollEventDTO(enrollId: 'ENR-001', event: 'enrolled', enrollType: ''); + })->throws(InvalidArgumentException::class, 'enrollType is required.'); + + it('throws exception for invalid event', function () { + new WebhookEnrollEventDTO(enrollId: 'ENR-001', event: 'invalid', enrollType: 'era'); + })->throws(InvalidArgumentException::class, 'event must be one of: enrolled, received, completed, rejected'); + + it('throws exception for invalid enrollType', function () { + new WebhookEnrollEventDTO(enrollId: 'ENR-001', event: 'enrolled', enrollType: 'invalid'); + })->throws(InvalidArgumentException::class, 'enrollType must be one of: era, 1500, ub, elig, attach'); + + it('accepts all valid event values', function () { + foreach (['enrolled', 'received', 'completed', 'rejected'] as $event) { + $dto = new WebhookEnrollEventDTO(enrollId: 'ENR-001', event: $event, enrollType: 'era'); + expect($dto->event)->toBe($event); + } + }); + + it('accepts all valid enrollType values', function () { + foreach (['era', '1500', 'ub', 'elig', 'attach'] as $type) { + $dto = new WebhookEnrollEventDTO(enrollId: 'ENR-001', event: 'enrolled', enrollType: $type); + expect($dto->enrollType)->toBe($type); + } + }); + }); + + describe('toArray', function () { + it('converts to array with correct keys', function () { + $dto = new WebhookEnrollEventDTO( + enrollId: 'ENR-001', + event: 'enrolled', + enrollType: 'era', + provNpi: '1234567890', + payerId: 'PAYER-001' + ); + + expect($dto->toArray())->toBe([ + 'enrollid' => 'ENR-001', + 'event' => 'enrolled', + 'enroll_type' => 'era', + 'prov_npi' => '1234567890', + 'payerid' => 'PAYER-001', + ]); + }); + + it('filters out null values', function () { + $dto = new WebhookEnrollEventDTO(enrollId: 'ENR-001', event: 'enrolled', enrollType: 'era'); + + $array = $dto->toArray(); + + expect($array)->not->toHaveKey('event_detail'); + expect($array)->not->toHaveKey('prov_npi'); + }); + }); + + describe('fromArray', function () { + it('creates a DTO from array', function () { + $dto = WebhookEnrollEventDTO::fromArray([ + 'enrollid' => 'ENR-001', + 'event' => 'enrolled', + 'enroll_type' => 'era', + 'event_detail' => 'Details here', + 'prov_npi' => '1234567890', + 'prov_taxid' => '123456789', + 'prov_id' => 'PROV-001', + 'payerid' => 'PAYER-001', + ]); + + expect($dto->enrollId)->toBe('ENR-001'); + expect($dto->event)->toBe('enrolled'); + expect($dto->enrollType)->toBe('era'); + expect($dto->eventDetail)->toBe('Details here'); + expect($dto->provNpi)->toBe('1234567890'); + expect($dto->provTaxId)->toBe('123456789'); + expect($dto->provId)->toBe('PROV-001'); + expect($dto->payerId)->toBe('PAYER-001'); + }); + + it('throws exception when enrollid is missing', function () { + WebhookEnrollEventDTO::fromArray(['event' => 'enrolled', 'enroll_type' => 'era']); + })->throws(InvalidArgumentException::class, 'enrollid is required.'); + + it('handles missing optional fields', function () { + $dto = WebhookEnrollEventDTO::fromArray([ + 'enrollid' => 'ENR-001', + 'event' => 'enrolled', + 'enroll_type' => 'era', + ]); + + expect($dto->eventDetail)->toBeNull(); + expect($dto->provNpi)->toBeNull(); + }); + }); +}); diff --git a/tests/Unit/DTO/WebhookEventDTOTest.php b/tests/Unit/DTO/WebhookEventDTOTest.php new file mode 100644 index 0000000..4e77e9b --- /dev/null +++ b/tests/Unit/DTO/WebhookEventDTOTest.php @@ -0,0 +1,149 @@ +eventId)->toBe('EVT-001'); + expect($dto->eventType)->toBe('enroll'); + expect($dto->eventTime)->toBe('2026-03-13T12:00:00Z'); + expect($dto->enroll)->toBe($enroll); + expect($dto->appeal)->toBeNull(); + }); + + it('creates an appeal event DTO', function () { + $appeal = new WebhookAppealEventDTO(appealId: 'APL-001', event: 'created'); + $dto = new WebhookEventDTO( + eventId: 'EVT-002', + eventType: 'appeal', + appeal: $appeal + ); + + expect($dto->eventType)->toBe('appeal'); + expect($dto->appeal)->toBe($appeal); + expect($dto->enroll)->toBeNull(); + }); + }); + + describe('validation', function () { + it('throws exception when eventId is empty', function () { + new WebhookEventDTO(eventId: '', eventType: 'enroll'); + })->throws(InvalidArgumentException::class, 'eventId is required.'); + + it('throws exception when eventType is empty', function () { + new WebhookEventDTO(eventId: 'EVT-001', eventType: ''); + })->throws(InvalidArgumentException::class, 'eventType is required.'); + + it('throws exception for invalid eventType', function () { + new WebhookEventDTO(eventId: 'EVT-001', eventType: 'invalid'); + })->throws(InvalidArgumentException::class, 'eventType must be one of: enroll, appeal'); + + it('throws exception when enroll data is missing for enroll event', function () { + new WebhookEventDTO(eventId: 'EVT-001', eventType: 'enroll'); + })->throws(InvalidArgumentException::class, 'enroll event data is required when eventType is "enroll".'); + + it('throws exception when appeal data is missing for appeal event', function () { + new WebhookEventDTO(eventId: 'EVT-001', eventType: 'appeal'); + })->throws(InvalidArgumentException::class, 'appeal event data is required when eventType is "appeal".'); + }); + + describe('toArray', function () { + it('converts enroll event to array', function () { + $enroll = new WebhookEnrollEventDTO(enrollId: 'ENR-001', event: 'enrolled', enrollType: 'era'); + $dto = new WebhookEventDTO( + eventId: 'EVT-001', + eventType: 'enroll', + eventTime: '2026-03-13T12:00:00Z', + enroll: $enroll + ); + + expect($dto->toArray())->toBe([ + 'eventid' => 'EVT-001', + 'event_type' => 'enroll', + 'event_time' => '2026-03-13T12:00:00Z', + 'event_data' => [ + 'enroll' => [ + 'enrollid' => 'ENR-001', + 'event' => 'enrolled', + 'enroll_type' => 'era', + ], + ], + ]); + }); + + it('converts appeal event to array', function () { + $appeal = new WebhookAppealEventDTO(appealId: 'APL-001', event: 'created', appealType: 'electronic'); + $dto = new WebhookEventDTO( + eventId: 'EVT-002', + eventType: 'appeal', + appeal: $appeal + ); + + expect($dto->toArray())->toBe([ + 'eventid' => 'EVT-002', + 'event_type' => 'appeal', + 'event_data' => [ + 'appeal' => [ + 'appealid' => 'APL-001', + 'event' => 'created', + 'appeal_type' => 'electronic', + ], + ], + ]); + }); + }); + + describe('fromArray', function () { + it('creates an enroll event DTO from array', function () { + $dto = WebhookEventDTO::fromArray([ + 'eventid' => 'EVT-001', + 'event_type' => 'enroll', + 'event_time' => '2026-03-13T12:00:00Z', + 'event_data' => [ + 'enroll' => [ + 'enrollid' => 'ENR-001', + 'event' => 'enrolled', + 'enroll_type' => 'era', + ], + ], + ]); + + expect($dto->eventId)->toBe('EVT-001'); + expect($dto->eventType)->toBe('enroll'); + expect($dto->enroll)->toBeInstanceOf(WebhookEnrollEventDTO::class); + expect($dto->enroll->enrollId)->toBe('ENR-001'); + }); + + it('creates an appeal event DTO from array', function () { + $dto = WebhookEventDTO::fromArray([ + 'eventid' => 'EVT-002', + 'event_type' => 'appeal', + 'event_data' => [ + 'appeal' => [ + 'appealid' => 'APL-001', + 'event' => 'created', + ], + ], + ]); + + expect($dto->eventType)->toBe('appeal'); + expect($dto->appeal)->toBeInstanceOf(WebhookAppealEventDTO::class); + expect($dto->appeal->appealId)->toBe('APL-001'); + }); + + it('throws exception when eventid is missing', function () { + WebhookEventDTO::fromArray(['event_type' => 'enroll']); + })->throws(InvalidArgumentException::class, 'eventid is required.'); + }); +}); diff --git a/tests/Unit/DTO/WebhookPayloadDTOTest.php b/tests/Unit/DTO/WebhookPayloadDTOTest.php new file mode 100644 index 0000000..0279bb5 --- /dev/null +++ b/tests/Unit/DTO/WebhookPayloadDTOTest.php @@ -0,0 +1,242 @@ +utcTime)->toBe('2026-03-13T12:00:00Z'); + expect($dto->acctNumber)->toBe('ACCT-001'); + expect($dto->remoteAcctNumber)->toBeNull(); + expect($dto->events)->toBe([]); + }); + + it('creates a DTO with all fields', function () { + $enroll = new WebhookEnrollEventDTO(enrollId: 'ENR-001', event: 'enrolled', enrollType: 'era'); + $event = new WebhookEventDTO(eventId: 'EVT-001', eventType: 'enroll', enroll: $enroll); + + $dto = new WebhookPayloadDTO( + utcTime: '2026-03-13T12:00:00Z', + acctNumber: 'ACCT-001', + remoteAcctNumber: 'REMOTE-001', + events: [$event] + ); + + expect($dto->remoteAcctNumber)->toBe('REMOTE-001'); + expect($dto->events)->toHaveCount(1); + expect($dto->events[0])->toBe($event); + }); + }); + + describe('validation', function () { + it('throws exception when utcTime is empty', function () { + new WebhookPayloadDTO(utcTime: '', acctNumber: 'ACCT-001'); + })->throws(InvalidArgumentException::class, 'utcTime is required.'); + + it('throws exception when acctNumber is empty', function () { + new WebhookPayloadDTO(utcTime: '2026-03-13T12:00:00Z', acctNumber: ''); + })->throws(InvalidArgumentException::class, 'acctNumber is required.'); + + it('throws exception when events array contains non-WebhookEventDTO', function () { + new WebhookPayloadDTO( + utcTime: '2026-03-13T12:00:00Z', + acctNumber: 'ACCT-001', + events: ['not-a-dto'] + ); + })->throws(InvalidArgumentException::class, 'Event at index 0 must be an instance of WebhookEventDTO.'); + }); + + describe('toArray', function () { + it('converts to array with correct keys', function () { + $enroll = new WebhookEnrollEventDTO( + enrollId: 'ENR-001', + event: 'enrolled', + enrollType: 'era', + provNpi: '1234567890', + payerId: 'PAYER-001' + ); + $event = new WebhookEventDTO( + eventId: 'EVT-001', + eventType: 'enroll', + eventTime: '2026-03-13T12:00:00Z', + enroll: $enroll + ); + + $dto = new WebhookPayloadDTO( + utcTime: '2026-03-13T12:00:00Z', + acctNumber: 'ACCT-001', + remoteAcctNumber: 'REMOTE-001', + events: [$event] + ); + + expect($dto->toArray())->toBe([ + 'UTCTime' => '2026-03-13T12:00:00Z', + 'acct_number' => 'ACCT-001', + 'remote_acct_number' => 'REMOTE-001', + 'events' => [ + [ + 'eventid' => 'EVT-001', + 'event_type' => 'enroll', + 'event_time' => '2026-03-13T12:00:00Z', + 'event_data' => [ + 'enroll' => [ + 'enrollid' => 'ENR-001', + 'event' => 'enrolled', + 'enroll_type' => 'era', + 'prov_npi' => '1234567890', + 'payerid' => 'PAYER-001', + ], + ], + ], + ], + ]); + }); + + it('filters out null remoteAcctNumber', function () { + $dto = new WebhookPayloadDTO( + utcTime: '2026-03-13T12:00:00Z', + acctNumber: 'ACCT-001' + ); + + $array = $dto->toArray(); + + expect($array)->not->toHaveKey('remote_acct_number'); + expect($array['events'])->toBe([]); + }); + }); + + describe('fromArray', function () { + it('creates a DTO from a full webhook payload', function () { + $data = [ + 'UTCTime' => '2026-03-13T12:00:00Z', + 'acct_number' => 'ACCT-001', + 'remote_acct_number' => 'REMOTE-001', + 'events' => [ + [ + 'eventid' => 'EVT-001', + 'event_type' => 'enroll', + 'event_time' => '2026-03-13T11:00:00Z', + 'event_data' => [ + 'enroll' => [ + 'enrollid' => 'ENR-001', + 'event' => 'enrolled', + 'enroll_type' => 'era', + 'prov_npi' => '1234567890', + 'payerid' => 'PAYER-001', + ], + ], + ], + [ + 'eventid' => 'EVT-002', + 'event_type' => 'appeal', + 'event_time' => '2026-03-13T11:30:00Z', + 'event_data' => [ + 'appeal' => [ + 'appealid' => 'APL-001', + 'event' => 'created', + 'appeal_type' => 'electronic', + 'claimid' => 'CLM-001', + ], + ], + ], + ], + ]; + + $dto = WebhookPayloadDTO::fromArray($data); + + expect($dto->utcTime)->toBe('2026-03-13T12:00:00Z'); + expect($dto->acctNumber)->toBe('ACCT-001'); + expect($dto->remoteAcctNumber)->toBe('REMOTE-001'); + expect($dto->events)->toHaveCount(2); + + expect($dto->events[0])->toBeInstanceOf(WebhookEventDTO::class); + expect($dto->events[0]->eventType)->toBe('enroll'); + expect($dto->events[0]->enroll)->toBeInstanceOf(WebhookEnrollEventDTO::class); + expect($dto->events[0]->enroll->enrollId)->toBe('ENR-001'); + + expect($dto->events[1])->toBeInstanceOf(WebhookEventDTO::class); + expect($dto->events[1]->eventType)->toBe('appeal'); + expect($dto->events[1]->appeal)->toBeInstanceOf(WebhookAppealEventDTO::class); + expect($dto->events[1]->appeal->appealId)->toBe('APL-001'); + }); + + it('throws exception when UTCTime is missing', function () { + WebhookPayloadDTO::fromArray(['acct_number' => 'ACCT-001']); + })->throws(InvalidArgumentException::class, 'UTCTime is required.'); + + it('throws exception when acct_number is missing', function () { + WebhookPayloadDTO::fromArray(['UTCTime' => '2026-03-13T12:00:00Z']); + })->throws(InvalidArgumentException::class, 'acct_number is required.'); + + it('handles empty events array', function () { + $dto = WebhookPayloadDTO::fromArray([ + 'UTCTime' => '2026-03-13T12:00:00Z', + 'acct_number' => 'ACCT-001', + 'events' => [], + ]); + + expect($dto->events)->toBe([]); + }); + + it('handles missing events key', function () { + $dto = WebhookPayloadDTO::fromArray([ + 'UTCTime' => '2026-03-13T12:00:00Z', + 'acct_number' => 'ACCT-001', + ]); + + expect($dto->events)->toBe([]); + }); + }); + + describe('fromJsonString', function () { + it('creates a DTO from a JSON string', function () { + $json = json_encode([ + 'UTCTime' => '2026-03-13T12:00:00Z', + 'acct_number' => 'ACCT-001', + 'remote_acct_number' => 'REMOTE-001', + 'events' => [ + [ + 'eventid' => 'EVT-001', + 'event_type' => 'enroll', + 'event_time' => '2026-03-13T11:00:00Z', + 'event_data' => [ + 'enroll' => [ + 'enrollid' => 'ENR-001', + 'event' => 'enrolled', + 'enroll_type' => 'era', + ], + ], + ], + ], + ]); + + $dto = WebhookPayloadDTO::fromJsonString($json); + + expect($dto->utcTime)->toBe('2026-03-13T12:00:00Z'); + expect($dto->acctNumber)->toBe('ACCT-001'); + expect($dto->remoteAcctNumber)->toBe('REMOTE-001'); + expect($dto->events)->toHaveCount(1); + expect($dto->events[0]->enroll->enrollId)->toBe('ENR-001'); + }); + + it('throws exception for invalid JSON', function () { + WebhookPayloadDTO::fromJsonString('not valid json'); + })->throws(InvalidArgumentException::class, 'Invalid JSON string provided.'); + + it('throws exception for non-object JSON', function () { + WebhookPayloadDTO::fromJsonString('"just a string"'); + })->throws(InvalidArgumentException::class, 'Invalid JSON string provided.'); + + it('throws exception when required fields are missing in JSON', function () { + WebhookPayloadDTO::fromJsonString('{"acct_number": "ACCT-001"}'); + })->throws(InvalidArgumentException::class, 'UTCTime is required.'); + }); +}); diff --git a/tests/Unit/Requests/ClaimRequestTest.php b/tests/Unit/Requests/ClaimRequestTest.php index 5d45cc9..0c2c8c0 100644 --- a/tests/Unit/Requests/ClaimRequestTest.php +++ b/tests/Unit/Requests/ClaimRequestTest.php @@ -1,8 +1,8 @@