From 3156ff5b5329338af5bae2e7df359edc0932eea6 Mon Sep 17 00:00:00 2001 From: Ryan Yannelli Date: Fri, 13 Mar 2026 13:15:41 -0400 Subject: [PATCH 01/37] refactor(requests): enforce strict type declaration for constants and improve docblock consistency Signed-off-by: Ryan Yannelli --- src/Requests/ClaimRequest.php | 23 ++++++++++++++--------- src/Requests/ERARequest.php | 21 +++++++++++++-------- src/Requests/EligibilityRequest.php | 13 ++++++++----- src/Requests/FileRequest.php | 12 +++++++----- src/Requests/PayerRequest.php | 11 ++++++----- src/Requests/ProviderRequest.php | 14 +++++++------- src/Requests/ResponseRequest.php | 15 ++++++--------- 7 files changed, 61 insertions(+), 48 deletions(-) diff --git a/src/Requests/ClaimRequest.php b/src/Requests/ClaimRequest.php index f8b9ce8..257c014 100644 --- a/src/Requests/ClaimRequest.php +++ b/src/Requests/ClaimRequest.php @@ -8,27 +8,29 @@ /** * Class ClaimRequest - * * Handles various claim-related operations such as archiving, modifications, appeals, and notes. */ class ClaimRequest { - private const ARCHIVE_ENDPOINT = '/services/archive/'; - private const MODIFY_ENDPOINT = '/services/modify/'; - private const APPEAL_ENDPOINT = '/services/appeal/'; - private const NOTES_ENDPOINT = '/services/notes/'; + private const string ARCHIVE_ENDPOINT = '/services/archive/'; + private const string MODIFY_ENDPOINT = '/services/modify/'; + private const string APPEAL_ENDPOINT = '/services/appeal/'; + private const string NOTES_ENDPOINT = '/services/notes/'; /** * ClaimRequest constructor. * * @param Client $client The client used for making HTTP requests. */ - public function __construct(private readonly Client $client) {} + public function __construct(private readonly Client $client) + { + } /** * Archives a claim with the given claim ID. * * @param string $claimId The ID of the claim to be archived. + * * @return array The response from the server after the request is made. * @throws GuzzleException If there's an HTTP request failure. */ @@ -40,9 +42,10 @@ public function archive(string $claimId): array /** * Retrieves a list of modifications based on provided parameters. * - * @param string|null $modId Modification ID to filter the modifications. + * @param string|null $modId Modification ID to filter the modifications. * @param string|null $claimMdId Claim MD ID to filter the modifications. - * @param string|null $field Specific field to filter the modifications. + * @param string|null $field Specific field to filter the modifications. + * * @return array An array of modifications matching the specified criteria. * @throws GuzzleException If there's an HTTP request failure. */ @@ -55,6 +58,7 @@ public function listModifications(?string $modId = null, ?string $claimMdId = nu * Submits an appeal request for a claim. * * @param array|ClaimAppealDTO $claimAppeal Array or Data Transfer Object containing claim appeal details. + * * @return array The response from the appeal endpoint. * @throws GuzzleException If there's an HTTP request failure. */ @@ -69,8 +73,9 @@ public function appeal(array|ClaimAppealDTO $claimAppeal): array /** * Retrieves a list of notes based on provided parameters. * - * @param string|null $noteId Note ID to filter the notes. + * @param string|null $noteId Note ID to filter the notes. * @param string|null $claimMdId Claim MD ID to filter the notes. + * * @return array An array of notes matching the specified criteria. * @throws GuzzleException If there's an HTTP request failure. */ diff --git a/src/Requests/ERARequest.php b/src/Requests/ERARequest.php index fa253f2..1701a05 100644 --- a/src/Requests/ERARequest.php +++ b/src/Requests/ERARequest.php @@ -8,27 +8,29 @@ /** * Class ERARequest - * * This class handles requests related to Electronic Remittance Advice (ERA). */ class ERARequest { - private const ERA_LIST_ENDPOINT = '/services/eralist/'; - private const ERA_835_ENDPOINT = '/services/era835/'; - private const ERA_PDF_ENDPOINT = '/services/erapdf/'; - private const ERA_JSON_ENDPOINT = '/services/eradata/'; + private const string ERA_LIST_ENDPOINT = '/services/eralist/'; + private const string ERA_835_ENDPOINT = '/services/era835/'; + private const string ERA_PDF_ENDPOINT = '/services/erapdf/'; + private const string ERA_JSON_ENDPOINT = '/services/eradata/'; /** * ERARequest constructor. * * @param Client $client The client used to send requests. */ - public function __construct(private readonly Client $client) {} + public function __construct(private readonly Client $client) + { + } /** * Retrieves the JSON representation of an electronic remittance advice. * * @param string $eraId The ID of the electronic remittance advice. + * * @return array The JSON representation of the electronic remittance advice. * @throws GuzzleException If an HTTP request error occurs. */ @@ -40,8 +42,9 @@ public function getJson(string $eraId): array /** * Retrieves a PDF based on the given era ID and optionally a PCN. * - * @param string $eraId The ID of the era to get the PDF for. - * @param string|null $pcn An optional parameter for the PCN. + * @param string $eraId The ID of the era to get the PDF for. + * @param string|null $pcn An optional parameter for the PCN. + * * @return array The response from the client request containing the PDF data (Base64 Encoded). * @throws GuzzleException If an HTTP request error occurs. */ @@ -54,6 +57,7 @@ public function getPDF(string $eraId, ?string $pcn = null): array * Retrieves the 835 ERA (Electronic Remittance Advice) based on the provided ERA ID. * * @param string $eraId The identifier for the ERA to be retrieved. + * * @return array The 835 ERA data as an array. * @throws GuzzleException If an HTTP request error occurs. */ @@ -66,6 +70,7 @@ public function get835(string $eraId): array * Retrieves a list of electronic remittance advices. * * @param array|ERADTO|null $era Optional array or The DTO containing the parameters for the request. + * * @return array The list of electronic remittance advices. * @throws GuzzleException If an HTTP request error occurs. */ diff --git a/src/Requests/EligibilityRequest.php b/src/Requests/EligibilityRequest.php index 0bdcdc0..6ead55b 100644 --- a/src/Requests/EligibilityRequest.php +++ b/src/Requests/EligibilityRequest.php @@ -9,7 +9,6 @@ /** * Class EligibilityRequest - * * Handles eligibility-related requests to the ClaimMD API. */ class EligibilityRequest @@ -17,24 +16,27 @@ class EligibilityRequest /** * Endpoint for eligibility checks using 270/271 files. */ - private const ELIGIBILITY_ENDPOINT = '/services/elig/'; + private const string ELIGIBILITY_ENDPOINT = '/services/elig/'; /** * Endpoint for eligibility checks using JSON data. */ - private const ELIGIBILITY_DATA_ENDPOINT = '/services/eligdata/'; + private const string ELIGIBILITY_DATA_ENDPOINT = '/services/eligdata/'; /** * EligibilityRequest constructor. * * @param Client $client The API client instance. */ - public function __construct(private readonly Client $client) {} + public function __construct(private readonly Client $client) + { + } /** * Check eligibility by submitting eligibility data in JSON format. * * @param array|EligibilityDTO $eligibility Array or The eligibility data transfer object + * * @return array The API response * @throws GuzzleException If an HTTP Request fails */ @@ -50,6 +52,7 @@ public function checkEligibilityJSON(array|EligibilityDTO $eligibility): array * Check eligibility by submitting a 270 file. * * @param resource $file The 270 file resource + * * @return array The API response * @throws InvalidArgumentException If the file is not a valid resource * @throws GuzzleException If an HTTP Request fails @@ -61,7 +64,7 @@ public function checkEligibility270271(mixed $file): array } $data = [ - 'File' => 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..b2f1493 100644 --- a/src/Requests/FileRequest.php +++ b/src/Requests/FileRequest.php @@ -10,13 +10,12 @@ /** * Class FileRequest - * * Handles file-related requests to the Claim.MD service. */ class FileRequest { - private const UPLOAD_ENDPOINT = '/services/upload'; - private const UPLOAD_LIST_ENDPOINT = '/services/uploadlist'; + private const string UPLOAD_ENDPOINT = '/services/upload'; + private const string UPLOAD_LIST_ENDPOINT = '/services/uploadlist'; /** * FileRequest constructor. @@ -30,8 +29,9 @@ public function __construct(private readonly Client $client) /** * Retrieve a list of uploaded files from the Claim.MD service * - * @param int|null $page The page number for paginated results (optional) + * @param int|null $page The page number for paginated results (optional) * @param string|null $uploadDate The upload date filter in format yyyy-mm-dd (optional) + * * @return array The API response * @throws InvalidArgumentException If upload date is not in the format yyyy-mm-dd * @throws GuzzleException HTTP Request Failure @@ -57,8 +57,9 @@ public function getUploadList(?int $page = null, ?string $uploadDate = null): ar /** * Upload a batch file to the Claim.MD service * - * @param resource $file The file to upload (must be a resource) + * @param resource $file The file to upload (must be a resource) * @param string|null $filename The name of the file (optional) + * * @return array The API response * @throws InvalidArgumentException If file is not a valid resource. * @throws GuzzleException HTTP Request Failure @@ -80,6 +81,7 @@ 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. */ diff --git a/src/Requests/PayerRequest.php b/src/Requests/PayerRequest.php index 5a7ea7c..674c1ce 100644 --- a/src/Requests/PayerRequest.php +++ b/src/Requests/PayerRequest.php @@ -7,7 +7,6 @@ /** * Class PayerRequest - * * Handles payer-related requests to the Claim.MD service. */ class PayerRequest @@ -15,23 +14,25 @@ class PayerRequest /** * The endpoint for payer-related requests. */ - private const PAYER_ENDPOINT = '/services/payerlist/'; + private const string PAYER_ENDPOINT = '/services/payerlist/'; /** * PayerRequest constructor. * * @param Client $client The HTTP client for making requests */ - public function __construct(private readonly Client $client) {} + public function __construct(private readonly Client $client) + { + } /** * Get Payer List and available services. - * * This method retrieves a list of payers and their available services from the Claim.MD API. * It can optionally filter the results by payer ID or payer name. * - * @param string|null $payerId Optional payer ID to filter the payers. + * @param string|null $payerId Optional payer ID to filter the payers. * @param string|null $payerName Optional payer name to filter the payers. + * * @return array An array containing the payer list and available services. * @throws GuzzleException If there's an HTTP request failure. */ diff --git a/src/Requests/ProviderRequest.php b/src/Requests/ProviderRequest.php index 4b79dc1..33abae4 100644 --- a/src/Requests/ProviderRequest.php +++ b/src/Requests/ProviderRequest.php @@ -8,7 +8,6 @@ /** * Class ProviderRequest - * * Handles provider-related requests to the Claim.MD service. */ class ProviderRequest @@ -16,27 +15,28 @@ class ProviderRequest /** * The endpoint for provider enrollment requests. */ - private const ENROLLMENT_ENDPOINT = '/services/enroll/'; + private const string ENROLLMENT_ENDPOINT = '/services/enroll/'; /** * ProviderRequest constructor. * * @param Client $client The HTTP client for making requests */ - public function __construct(private readonly Client $client) {} + public function __construct(private readonly Client $client) + { + } /** * Enrolls a provider using the provided enrollment data. - * * This method sends a provider enrollment request to the Claim.MD API. * It accepts either an array of enrollment data or a ProviderEnrollmentDTO object. * * @param array|ProviderEnrollmentDTO $providerEnrollment The enrollment information for the provider. - * If an array is provided, it should contain the necessary enrollment data. - * If a ProviderEnrollmentDTO is provided, it will be converted to an array before sending. + * If an array is provided, it should contain the necessary + * enrollment data. If a ProviderEnrollmentDTO is provided, + * it will be converted to an array before sending. * * @return array The result of the enrollment request as returned by the API. - * * @throws GuzzleException If there's an HTTP request failure during the API call. */ public function enroll(array|ProviderEnrollmentDTO $providerEnrollment): array diff --git a/src/Requests/ResponseRequest.php b/src/Requests/ResponseRequest.php index ff941b6..06ac15b 100644 --- a/src/Requests/ResponseRequest.php +++ b/src/Requests/ResponseRequest.php @@ -9,7 +9,6 @@ /** * Class ResponseRequest - * * Handles response-related requests to the Claim.MD service. */ class ResponseRequest @@ -17,26 +16,26 @@ class ResponseRequest /** * The endpoint for response-related requests. */ - private const RESPONSE_ENDPOINT = '/services/response/'; + private const string RESPONSE_ENDPOINT = '/services/response/'; /** * ResponseRequest constructor. * * @param Client $client The HTTP client for making requests */ - public function __construct(private readonly Client $client) {} + public function __construct(private readonly Client $client) + { + } /** * Fetch responses from the API - * * This method retrieves responses from the Claim.MD API. It can fetch responses * after a specific ResponseID and optionally for a specific claim. * - * @param string $responseId The ResponseID to fetch responses after (use '0' for first request) - * @param string|null $claimId Optional ClaimID to fetch responses for a specific claim + * @param string $responseId The ResponseID to fetch responses after (use '0' for first request) + * @param string|null $claimId Optional ClaimID to fetch responses for a specific claim * * @return array The API response containing the fetched responses - * * @throws InvalidArgumentException If the responseId is empty * @throws GuzzleException If there's an HTTP request failure during the API call */ @@ -59,14 +58,12 @@ public function fetchResponses(string $responseId, ?string $claimId = null): arr /** * Fetch all responses, handling pagination automatically - * * This method retrieves all responses from the Claim.MD API, automatically * handling pagination. It yields each page of responses as they are fetched. * * @param string|null $claimId Optional ClaimID to fetch responses for a specific claim * * @return Generator A generator that yields each page of responses - * * @throws GuzzleException If there's an HTTP request failure during any of the API calls */ public function fetchAllResponses(?string $claimId = null): Generator From 31b9eff480d6a0e09c076b5a08625a6c5c163044 Mon Sep 17 00:00:00 2001 From: Ryan Yannelli Date: Fri, 13 Mar 2026 13:20:36 -0400 Subject: [PATCH 02/37] docs: add CLAUDE.md with project overview and usage guidelines Signed-off-by: Ryan Yannelli --- CLAUDE.md | 56 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 CLAUDE.md 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 From 1a7858bba7d30c459a481bfebdcc85d58fc5fd5e Mon Sep 17 00:00:00 2001 From: Ryan Yannelli Date: Fri, 13 Mar 2026 13:21:15 -0400 Subject: [PATCH 03/37] refactor(dto): update namespace for ClaimAppealDTO Signed-off-by: Ryan Yannelli --- src/DTO/ClaimAppealDTO.php | 2 +- tests/Unit/DTO/ClaimAppealDTOTest.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/DTO/ClaimAppealDTO.php b/src/DTO/ClaimAppealDTO.php index 16ddf3d..2a909cc 100644 --- a/src/DTO/ClaimAppealDTO.php +++ b/src/DTO/ClaimAppealDTO.php @@ -1,6 +1,6 @@ Date: Fri, 13 Mar 2026 13:21:19 -0400 Subject: [PATCH 04/37] refactor(requests): update namespaces to remove "Wrapper" suffix Signed-off-by: Ryan Yannelli --- src/Requests/ClaimRequest.php | 6 +++--- tests/Unit/Requests/ClaimRequestTest.php | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Requests/ClaimRequest.php b/src/Requests/ClaimRequest.php index 257c014..4545c11 100644 --- a/src/Requests/ClaimRequest.php +++ b/src/Requests/ClaimRequest.php @@ -1,10 +1,10 @@ Date: Fri, 13 Mar 2026 13:21:24 -0400 Subject: [PATCH 05/37] refactor(client): update namespaces to remove "Wrapper" suffix Signed-off-by: Ryan Yannelli --- src/Client.php | 4 ++-- tests/Unit/ClientTest.php | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Client.php b/src/Client.php index 42e50a0..e4eb90a 100644 --- a/src/Client.php +++ b/src/Client.php @@ -1,6 +1,6 @@ Date: Fri, 13 Mar 2026 13:21:29 -0400 Subject: [PATCH 06/37] refactor(config): update namespace to remove "Wrapper" suffix Signed-off-by: Ryan Yannelli --- src/Config.php | 2 +- tests/Unit/ConfigTest.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 @@ Date: Fri, 13 Mar 2026 13:21:33 -0400 Subject: [PATCH 07/37] refactor(dto): update namespace for EligibilityDTO Signed-off-by: Ryan Yannelli --- src/DTO/EligibilityDTO.php | 2 +- tests/Unit/DTO/EligibilityDTOTest.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/DTO/EligibilityDTO.php b/src/DTO/EligibilityDTO.php index 3c18b7a..9922817 100644 --- a/src/DTO/EligibilityDTO.php +++ b/src/DTO/EligibilityDTO.php @@ -1,6 +1,6 @@ Date: Fri, 13 Mar 2026 13:21:38 -0400 Subject: [PATCH 08/37] refactor(requests): update namespaces to remove "Wrapper" suffix Signed-off-by: Ryan Yannelli --- src/Requests/EligibilityRequest.php | 6 +++--- tests/Unit/Requests/EligibilityRequestTest.php | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Requests/EligibilityRequest.php b/src/Requests/EligibilityRequest.php index 6ead55b..1400fab 100644 --- a/src/Requests/EligibilityRequest.php +++ b/src/Requests/EligibilityRequest.php @@ -1,11 +1,11 @@ Date: Fri, 13 Mar 2026 13:21:42 -0400 Subject: [PATCH 09/37] refactor(dto): update namespace for ERADTO Signed-off-by: Ryan Yannelli --- src/DTO/ERADTO.php | 2 +- tests/Unit/DTO/ERADTOTest.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/DTO/ERADTO.php b/src/DTO/ERADTO.php index a5d927c..6c9f1a9 100644 --- a/src/DTO/ERADTO.php +++ b/src/DTO/ERADTO.php @@ -1,6 +1,6 @@ Date: Fri, 13 Mar 2026 13:21:47 -0400 Subject: [PATCH 10/37] refactor(requests): update namespaces to remove "Wrapper" suffix Signed-off-by: Ryan Yannelli --- src/Requests/ERARequest.php | 6 +++--- tests/Unit/Requests/ERARequestTest.php | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Requests/ERARequest.php b/src/Requests/ERARequest.php index 1701a05..4d10194 100644 --- a/src/Requests/ERARequest.php +++ b/src/Requests/ERARequest.php @@ -1,10 +1,10 @@ Date: Fri, 13 Mar 2026 13:21:51 -0400 Subject: [PATCH 11/37] refactor(requests): update namespaces to remove "Wrapper" suffix Signed-off-by: Ryan Yannelli --- src/Requests/FileRequest.php | 4 ++-- tests/Unit/Requests/FileRequestTest.php | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Requests/FileRequest.php b/src/Requests/FileRequest.php index b2f1493..10345b4 100644 --- a/src/Requests/FileRequest.php +++ b/src/Requests/FileRequest.php @@ -1,11 +1,11 @@ Date: Fri, 13 Mar 2026 13:21:56 -0400 Subject: [PATCH 12/37] refactor(requests): update namespaces to remove "Wrapper" suffix Signed-off-by: Ryan Yannelli --- src/Requests/PayerRequest.php | 4 ++-- tests/Unit/Requests/PayerRequestTest.php | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Requests/PayerRequest.php b/src/Requests/PayerRequest.php index 674c1ce..9813fbb 100644 --- a/src/Requests/PayerRequest.php +++ b/src/Requests/PayerRequest.php @@ -1,9 +1,9 @@ Date: Fri, 13 Mar 2026 13:23:57 -0400 Subject: [PATCH 13/37] refactor(dto): update namespace for ProviderEnrollmentDTO Signed-off-by: Ryan Yannelli --- src/DTO/ProviderEnrollmentDTO.php | 2 +- tests/Unit/DTO/ProviderEnrollmentDTOTest.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/DTO/ProviderEnrollmentDTO.php b/src/DTO/ProviderEnrollmentDTO.php index 49eb77a..f8a25d3 100644 --- a/src/DTO/ProviderEnrollmentDTO.php +++ b/src/DTO/ProviderEnrollmentDTO.php @@ -1,6 +1,6 @@ Date: Fri, 13 Mar 2026 13:24:01 -0400 Subject: [PATCH 14/37] refactor(requests): update namespaces to remove "Wrapper" suffix Signed-off-by: Ryan Yannelli --- src/Requests/ProviderRequest.php | 6 +++--- tests/Unit/Requests/ProviderRequestTest.php | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Requests/ProviderRequest.php b/src/Requests/ProviderRequest.php index 33abae4..9710842 100644 --- a/src/Requests/ProviderRequest.php +++ b/src/Requests/ProviderRequest.php @@ -1,10 +1,10 @@ Date: Fri, 13 Mar 2026 13:24:05 -0400 Subject: [PATCH 15/37] refactor(requests): update namespaces to remove "Wrapper" suffix Signed-off-by: Ryan Yannelli --- src/Requests/ResponseRequest.php | 4 ++-- tests/Unit/Requests/ResponseRequestTest.php | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Requests/ResponseRequest.php b/src/Requests/ResponseRequest.php index 06ac15b..197577b 100644 --- a/src/Requests/ResponseRequest.php +++ b/src/Requests/ResponseRequest.php @@ -1,11 +1,11 @@ Date: Fri, 13 Mar 2026 13:24:09 -0400 Subject: [PATCH 16/37] refactor(docs): update namespaces and examples to remove "Wrapper" suffix Signed-off-by: Ryan Yannelli --- README.md | 104 ++++++++++++++++++++++++++++++++++++------------------ 1 file changed, 70 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index ef559b5..a4d948b 100644 --- a/README.md +++ b/README.md @@ -4,58 +4,80 @@ ![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. ### [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) ### 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 +96,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 +110,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 +123,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 +145,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 +158,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 +169,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 +180,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 +203,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 +220,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 +229,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 +245,7 @@ $allResponse = $claimRequest->listModifications(); #### Fetch Claim Notes ```php -use Nextvisit\ClaimMDWrapper\Requests\ClaimRequest; +use Nextvisit\ClaimMD\Requests\ClaimRequest; $claimRequest = new ClaimRequest($client); @@ -237,8 +262,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 +274,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 +293,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 +304,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); @@ -302,8 +330,9 @@ $response = $eligibilityRequest->checkEligibilityJSON($eligDto); ### Payers #### List Payers + ```php -use Nextvisit\ClaimMDWrapper\Requests\PayerRequest; +use Nextvisit\ClaimMD\Requests\PayerRequest; $payerRequest = new PayerRequest($client); @@ -324,7 +353,7 @@ $allResponse = $payerRequest->listPayer(); #### ClaimAppealDTO ```php -use Nextvisit\ClaimMDWrapper\DTO\ClaimAppealDTO; +use Nextvisit\ClaimMD\DTO\ClaimAppealDTO; $claimAppealDto = new ClaimAppealDTO( claimId: '12345', @@ -363,7 +392,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 +443,7 @@ $providerEnrollmentDto = ProviderEnrollmentDTO::fromArray($data); #### EligibilityDTO ```php -use Nextvisit\ClaimMDWrapper\DTO\EligibilityDTO; +use Nextvisit\ClaimMD\DTO\EligibilityDTO; $eligibilityDto = new EligibilityDTO( insLastName: 'Doe', @@ -481,7 +510,7 @@ $eligibilityDto = EligibilityDTO::fromArray($data); #### ERADTO ```php -use Nextvisit\ClaimMDWrapper\DTO\ERADTO; +use Nextvisit\ClaimMD\DTO\ERADTO; $eraDto = new ERADTO( checkDate: '09-01-2023', @@ -517,8 +546,15 @@ $eraDto = ERADTO::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 From c1a100ad637a91ed95ef49b4284a0d11640a01c5 Mon Sep 17 00:00:00 2001 From: Ryan Yannelli Date: Fri, 13 Mar 2026 13:24:13 -0400 Subject: [PATCH 17/37] refactor(tests): update namespace to remove "Wrapper" suffix Signed-off-by: Ryan Yannelli --- tests/TestCase.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 @@ Date: Fri, 13 Mar 2026 13:24:16 -0400 Subject: [PATCH 18/37] refactor(tests): update namespace in Pest config Signed-off-by: Ryan Yannelli --- tests/Pest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Pest.php b/tests/Pest.php index aa7eb4a..a48286f 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -11,7 +11,7 @@ | */ -pest()->extend(Nextvisit\ClaimMDWrapper\Tests\TestCase::class)->in('Unit'); +pest()->extend(Nextvisit\ClaimMD\Tests\TestCase::class)->in('Unit'); /* |-------------------------------------------------------------------------- From 5b0943e658a56b338b16a1ddadb288b7c99b1b10 Mon Sep 17 00:00:00 2001 From: Ryan Yannelli Date: Fri, 13 Mar 2026 13:24:19 -0400 Subject: [PATCH 19/37] refactor(deps): bump PHP requirement to 8.3 and update namespaces Signed-off-by: Ryan Yannelli --- composer.json | 116 +++++++++++++++++++++++++------------------------- 1 file changed, 58 insertions(+), 58 deletions(-) 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" } From 3976e37df3522f7832153b30faa1b04b6578593f Mon Sep 17 00:00:00 2001 From: Ryan Yannelli Date: Fri, 13 Mar 2026 13:33:06 -0400 Subject: [PATCH 20/37] feat(exceptions): add custom exceptions for Claim.MD SDK error handling Signed-off-by: Ryan Yannelli --- src/Exceptions/ApiException.php | 40 +++++++++++++++++++++ src/Exceptions/AuthenticationException.php | 25 +++++++++++++ src/Exceptions/ClaimMDException.php | 12 +++++++ src/Exceptions/InvalidResponseException.php | 39 ++++++++++++++++++++ src/Exceptions/NotFoundException.php | 18 ++++++++++ src/Exceptions/RateLimitException.php | 38 ++++++++++++++++++++ src/Exceptions/ServerException.php | 24 +++++++++++++ 7 files changed, 196 insertions(+) create mode 100644 src/Exceptions/ApiException.php create mode 100644 src/Exceptions/AuthenticationException.php create mode 100644 src/Exceptions/ClaimMDException.php create mode 100644 src/Exceptions/InvalidResponseException.php create mode 100644 src/Exceptions/NotFoundException.php create mode 100644 src/Exceptions/RateLimitException.php create mode 100644 src/Exceptions/ServerException.php 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 @@ + Date: Fri, 13 Mar 2026 13:33:15 -0400 Subject: [PATCH 21/37] feat(requests): add ClaimMDException handling to ResponseRequest Signed-off-by: Ryan Yannelli --- src/Requests/ResponseRequest.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Requests/ResponseRequest.php b/src/Requests/ResponseRequest.php index 197577b..4cc285f 100644 --- a/src/Requests/ResponseRequest.php +++ b/src/Requests/ResponseRequest.php @@ -6,6 +6,7 @@ use GuzzleHttp\Exception\GuzzleException; use InvalidArgumentException; use Nextvisit\ClaimMD\Client; +use Nextvisit\ClaimMD\Exceptions\ClaimMDException; /** * Class ResponseRequest @@ -37,6 +38,7 @@ public function __construct(private readonly Client $client) * * @return array The API response containing the fetched responses * @throws InvalidArgumentException If the responseId is empty + * @throws ClaimMDException If the API returns an error response. * @throws GuzzleException If there's an HTTP request failure during the API call */ public function fetchResponses(string $responseId, ?string $claimId = null): array @@ -64,6 +66,7 @@ public function fetchResponses(string $responseId, ?string $claimId = null): arr * @param string|null $claimId Optional ClaimID to fetch responses for a specific claim * * @return Generator A generator that yields each page of responses + * @throws ClaimMDException If the API returns an error response. * @throws GuzzleException If there's an HTTP request failure during any of the API calls */ public function fetchAllResponses(?string $claimId = null): Generator From 11d47c8a37f966c5ad59c2539624069025329699 Mon Sep 17 00:00:00 2001 From: Ryan Yannelli Date: Fri, 13 Mar 2026 13:33:19 -0400 Subject: [PATCH 22/37] feat(requests): add ClaimMDException handling to ProviderRequest Signed-off-by: Ryan Yannelli --- src/Requests/ProviderRequest.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Requests/ProviderRequest.php b/src/Requests/ProviderRequest.php index 9710842..749700b 100644 --- a/src/Requests/ProviderRequest.php +++ b/src/Requests/ProviderRequest.php @@ -5,6 +5,7 @@ use GuzzleHttp\Exception\GuzzleException; use Nextvisit\ClaimMD\Client; use Nextvisit\ClaimMD\DTO\ProviderEnrollmentDTO; +use Nextvisit\ClaimMD\Exceptions\ClaimMDException; /** * Class ProviderRequest @@ -37,6 +38,7 @@ public function __construct(private readonly Client $client) * it will be converted to an array before sending. * * @return array The result of the enrollment request as returned by the API. + * @throws ClaimMDException If the API returns an error response. * @throws GuzzleException If there's an HTTP request failure during the API call. */ public function enroll(array|ProviderEnrollmentDTO $providerEnrollment): array From d3261e83233ca4d487358d8cc82c8f80a2c0fd02 Mon Sep 17 00:00:00 2001 From: Ryan Yannelli Date: Fri, 13 Mar 2026 13:33:23 -0400 Subject: [PATCH 23/37] feat(requests): add ClaimMDException handling to PayerRequest Signed-off-by: Ryan Yannelli --- src/Requests/PayerRequest.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Requests/PayerRequest.php b/src/Requests/PayerRequest.php index 9813fbb..814e5f7 100644 --- a/src/Requests/PayerRequest.php +++ b/src/Requests/PayerRequest.php @@ -4,6 +4,7 @@ use GuzzleHttp\Exception\GuzzleException; use Nextvisit\ClaimMD\Client; +use Nextvisit\ClaimMD\Exceptions\ClaimMDException; /** * Class PayerRequest @@ -34,6 +35,7 @@ public function __construct(private readonly Client $client) * @param string|null $payerName Optional payer name to filter the payers. * * @return array An array containing the payer list and available services. + * @throws ClaimMDException If the API returns an error response. * @throws GuzzleException If there's an HTTP request failure. */ public function listPayer(?string $payerId = null, ?string $payerName = null): array From f53e960c3c44dc65d0604f65dd3d6d93b082c03d Mon Sep 17 00:00:00 2001 From: Ryan Yannelli Date: Fri, 13 Mar 2026 13:33:26 -0400 Subject: [PATCH 24/37] feat(requests): add ClaimMDException handling to FileRequest Signed-off-by: Ryan Yannelli --- src/Requests/FileRequest.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Requests/FileRequest.php b/src/Requests/FileRequest.php index 10345b4..8bb61c0 100644 --- a/src/Requests/FileRequest.php +++ b/src/Requests/FileRequest.php @@ -6,6 +6,7 @@ use GuzzleHttp\Psr7\Utils; use InvalidArgumentException; use Nextvisit\ClaimMD\Client; +use Nextvisit\ClaimMD\Exceptions\ClaimMDException; use Psr\Http\Message\StreamInterface; /** @@ -34,6 +35,7 @@ public function __construct(private readonly Client $client) * * @return array The API response * @throws InvalidArgumentException If upload date is not in the format yyyy-mm-dd + * @throws ClaimMDException If the API returns an error response. * @throws GuzzleException HTTP Request Failure */ public function getUploadList(?int $page = null, ?string $uploadDate = null): array @@ -62,6 +64,7 @@ public function getUploadList(?int $page = null, ?string $uploadDate = null): ar * * @return array The API response * @throws InvalidArgumentException If file is not a valid resource. + * @throws ClaimMDException If the API returns an error response. * @throws GuzzleException HTTP Request Failure */ public function upload($file, ?string $filename = null): array From fdd59293a9e4f354bca584da3fd48d38e6db179e Mon Sep 17 00:00:00 2001 From: Ryan Yannelli Date: Fri, 13 Mar 2026 13:33:30 -0400 Subject: [PATCH 25/37] feat(requests): add ClaimMDException handling to ERARequest Signed-off-by: Ryan Yannelli --- src/Requests/ERARequest.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Requests/ERARequest.php b/src/Requests/ERARequest.php index 4d10194..6131981 100644 --- a/src/Requests/ERARequest.php +++ b/src/Requests/ERARequest.php @@ -5,6 +5,7 @@ use GuzzleHttp\Exception\GuzzleException; use Nextvisit\ClaimMD\Client; use Nextvisit\ClaimMD\DTO\ERADTO; +use Nextvisit\ClaimMD\Exceptions\ClaimMDException; /** * Class ERARequest @@ -32,6 +33,7 @@ public function __construct(private readonly Client $client) * @param string $eraId The ID of the electronic remittance advice. * * @return array The JSON representation of the electronic remittance advice. + * @throws ClaimMDException If the API returns an error response. * @throws GuzzleException If an HTTP request error occurs. */ public function getJson(string $eraId): array @@ -46,6 +48,7 @@ public function getJson(string $eraId): array * @param string|null $pcn An optional parameter for the PCN. * * @return array The response from the client request containing the PDF data (Base64 Encoded). + * @throws ClaimMDException If the API returns an error response. * @throws GuzzleException If an HTTP request error occurs. */ public function getPDF(string $eraId, ?string $pcn = null): array @@ -59,6 +62,7 @@ public function getPDF(string $eraId, ?string $pcn = null): array * @param string $eraId The identifier for the ERA to be retrieved. * * @return array The 835 ERA data as an array. + * @throws ClaimMDException If the API returns an error response. * @throws GuzzleException If an HTTP request error occurs. */ public function get835(string $eraId): array @@ -72,6 +76,7 @@ public function get835(string $eraId): array * @param array|ERADTO|null $era Optional array or The DTO containing the parameters for the request. * * @return array The list of electronic remittance advices. + * @throws ClaimMDException If the API returns an error response. * @throws GuzzleException If an HTTP request error occurs. */ public function getList(array|ERADTO|null $era = []): array From 2c3e274d84810d80e5d90ed05dcf9141a01d00ef Mon Sep 17 00:00:00 2001 From: Ryan Yannelli Date: Fri, 13 Mar 2026 13:33:34 -0400 Subject: [PATCH 26/37] feat(requests): add ClaimMDException handling to EligibilityRequest Signed-off-by: Ryan Yannelli --- src/Requests/EligibilityRequest.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Requests/EligibilityRequest.php b/src/Requests/EligibilityRequest.php index 1400fab..6d0b99d 100644 --- a/src/Requests/EligibilityRequest.php +++ b/src/Requests/EligibilityRequest.php @@ -6,6 +6,7 @@ use InvalidArgumentException; use Nextvisit\ClaimMD\Client; use Nextvisit\ClaimMD\DTO\EligibilityDTO; +use Nextvisit\ClaimMD\Exceptions\ClaimMDException; /** * Class EligibilityRequest @@ -38,6 +39,7 @@ public function __construct(private readonly Client $client) * @param array|EligibilityDTO $eligibility Array or The eligibility data transfer object * * @return array The API response + * @throws ClaimMDException If the API returns an error response. * @throws GuzzleException If an HTTP Request fails */ public function checkEligibilityJSON(array|EligibilityDTO $eligibility): array @@ -55,6 +57,7 @@ public function checkEligibilityJSON(array|EligibilityDTO $eligibility): array * * @return array The API response * @throws InvalidArgumentException If the file is not a valid resource + * @throws ClaimMDException If the API returns an error response. * @throws GuzzleException If an HTTP Request fails */ public function checkEligibility270271(mixed $file): array From 2a9840b69124203e2d1925991788cc19f79236a6 Mon Sep 17 00:00:00 2001 From: Ryan Yannelli Date: Fri, 13 Mar 2026 13:33:39 -0400 Subject: [PATCH 27/37] feat(requests): add custom exception handling to Client requests Extend error handling for Client requests by adding specific exceptions for 4xx and 5xx responses, and handling non-JSON responses. Includes corresponding unit tests for improved coverage. Signed-off-by: Ryan Yannelli --- src/Client.php | 84 +++++++++++++++++++++++-- tests/Unit/ClientTest.php | 126 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 206 insertions(+), 4 deletions(-) diff --git a/src/Client.php b/src/Client.php index e4eb90a..bba77c1 100644 --- a/src/Client.php +++ b/src/Client.php @@ -4,7 +4,14 @@ use GuzzleHttp\Client as GuzzleClient; use GuzzleHttp\Exception\GuzzleException; +use GuzzleHttp\Exception\RequestException; use GuzzleHttp\RequestOptions; +use Nextvisit\ClaimMD\Exceptions\ApiException; +use Nextvisit\ClaimMD\Exceptions\AuthenticationException; +use Nextvisit\ClaimMD\Exceptions\InvalidResponseException; +use Nextvisit\ClaimMD\Exceptions\NotFoundException; +use Nextvisit\ClaimMD\Exceptions\RateLimitException; +use Nextvisit\ClaimMD\Exceptions\ServerException; /** * Class Client @@ -48,6 +55,7 @@ private function createDefaultHttpClient(): GuzzleClient 'headers' => [ '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/tests/Unit/ClientTest.php b/tests/Unit/ClientTest.php index 8c7f99f..0d1f50b 100644 --- a/tests/Unit/ClientTest.php +++ b/tests/Unit/ClientTest.php @@ -7,6 +7,12 @@ use GuzzleHttp\Middleware; use Nextvisit\ClaimMD\Client; use Nextvisit\ClaimMD\Config; +use Nextvisit\ClaimMD\Exceptions\ApiException; +use Nextvisit\ClaimMD\Exceptions\AuthenticationException; +use Nextvisit\ClaimMD\Exceptions\InvalidResponseException; +use Nextvisit\ClaimMD\Exceptions\NotFoundException; +use Nextvisit\ClaimMD\Exceptions\RateLimitException; +use Nextvisit\ClaimMD\Exceptions\ServerException; describe('Client', function () { it('creates a client with account key and config', function () { @@ -114,4 +120,124 @@ $body = (string) $container[0]['request']->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); + } + }); }); From b4b60e4905f3768656af994f2da815978aa2cba7 Mon Sep 17 00:00:00 2001 From: Ryan Yannelli Date: Fri, 13 Mar 2026 13:33:42 -0400 Subject: [PATCH 28/37] feat(requests): add ClaimMDException handling to ClaimRequest Signed-off-by: Ryan Yannelli --- src/Requests/ClaimRequest.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Requests/ClaimRequest.php b/src/Requests/ClaimRequest.php index 4545c11..ccef30b 100644 --- a/src/Requests/ClaimRequest.php +++ b/src/Requests/ClaimRequest.php @@ -5,6 +5,7 @@ use GuzzleHttp\Exception\GuzzleException; use Nextvisit\ClaimMD\Client; use Nextvisit\ClaimMD\DTO\ClaimAppealDTO; +use Nextvisit\ClaimMD\Exceptions\ClaimMDException; /** * Class ClaimRequest @@ -32,6 +33,7 @@ public function __construct(private readonly Client $client) * @param string $claimId The ID of the claim to be archived. * * @return array The response from the server after the request is made. + * @throws ClaimMDException If the API returns an error response. * @throws GuzzleException If there's an HTTP request failure. */ public function archive(string $claimId): array @@ -47,6 +49,7 @@ public function archive(string $claimId): array * @param string|null $field Specific field to filter the modifications. * * @return array An array of modifications matching the specified criteria. + * @throws ClaimMDException If the API returns an error response. * @throws GuzzleException If there's an HTTP request failure. */ public function listModifications(?string $modId = null, ?string $claimMdId = null, ?string $field = null): array @@ -60,6 +63,7 @@ public function listModifications(?string $modId = null, ?string $claimMdId = nu * @param array|ClaimAppealDTO $claimAppeal Array or Data Transfer Object containing claim appeal details. * * @return array The response from the appeal endpoint. + * @throws ClaimMDException If the API returns an error response. * @throws GuzzleException If there's an HTTP request failure. */ public function appeal(array|ClaimAppealDTO $claimAppeal): array @@ -77,6 +81,7 @@ public function appeal(array|ClaimAppealDTO $claimAppeal): array * @param string|null $claimMdId Claim MD ID to filter the notes. * * @return array An array of notes matching the specified criteria. + * @throws ClaimMDException If the API returns an error response. * @throws GuzzleException If there's an HTTP request failure. */ public function notes(?string $noteId = null, ?string $claimMdId = null): array From c5f15f7ad43cb2cda4f5af50d6448a22d871dac5 Mon Sep 17 00:00:00 2001 From: Ryan Yannelli Date: Fri, 13 Mar 2026 13:52:36 -0400 Subject: [PATCH 29/37] fix(dto): adjust date validation error message for flexibility Signed-off-by: Ryan Yannelli --- src/DTO/ERADTO.php | 6 +++++- tests/Unit/DTO/ERADTOTest.php | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/DTO/ERADTO.php b/src/DTO/ERADTO.php index 6c9f1a9..3485650 100644 --- a/src/DTO/ERADTO.php +++ b/src/DTO/ERADTO.php @@ -81,7 +81,11 @@ private function validateDateFormat(string $date, string $fieldName, bool $allow $d = DateTime::createFromFormat('m-d-Y', $date); if (!$d || $d->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/tests/Unit/DTO/ERADTOTest.php b/tests/Unit/DTO/ERADTOTest.php index b23d35c..7f97c10 100644 --- a/tests/Unit/DTO/ERADTOTest.php +++ b/tests/Unit/DTO/ERADTOTest.php @@ -44,7 +44,7 @@ describe('validation', function () { it('throws exception for invalid checkDate format', function () { new ERADTO(checkDate: '2024-01-15'); - })->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'); From a99f4ce5b6d30b05d6a42b315048d830e7e51af1 Mon Sep 17 00:00:00 2001 From: Ryan Yannelli Date: Fri, 13 Mar 2026 13:52:42 -0400 Subject: [PATCH 30/37] fix(requests): correct empty array check in getList method Signed-off-by: Ryan Yannelli --- src/Requests/ERARequest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Requests/ERARequest.php b/src/Requests/ERARequest.php index 6131981..64b4fa5 100644 --- a/src/Requests/ERARequest.php +++ b/src/Requests/ERARequest.php @@ -79,9 +79,9 @@ public function get835(string $eraId): array * @throws ClaimMDException If the API returns an error response. * @throws GuzzleException If an HTTP request error occurs. */ - public function getList(array|ERADTO|null $era = []): array + public function getList(array|ERADTO|null $era = null): array { - if ($era === null || $era === []) { + if ($era === null) { return $this->client->sendRequest('POST', self::ERA_LIST_ENDPOINT); } if ($era instanceof ERADTO) { From 0b71c5fc4265d53651dbb8bee8170d94b048e3e7 Mon Sep 17 00:00:00 2001 From: Ryan Yannelli Date: Fri, 13 Mar 2026 13:52:46 -0400 Subject: [PATCH 31/37] fix(requests): improve upload date validation in FileRequest Signed-off-by: Ryan Yannelli --- src/Requests/FileRequest.php | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/Requests/FileRequest.php b/src/Requests/FileRequest.php index 8bb61c0..f6bfa98 100644 --- a/src/Requests/FileRequest.php +++ b/src/Requests/FileRequest.php @@ -50,6 +50,12 @@ public function getUploadList(?int $page = null, ?string $uploadDate = null): ar if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $uploadDate)) { throw new InvalidArgumentException('Upload date must be in the format yyyy-mm-dd'); } + + $parts = explode('-', $uploadDate); + if (!checkdate((int) $parts[1], (int) $parts[2], (int) $parts[0])) { + throw new InvalidArgumentException('Upload date must be a valid calendar date in the format yyyy-mm-dd'); + } + $data['UploadDate'] = $uploadDate; } @@ -67,7 +73,7 @@ public function getUploadList(?int $page = null, ?string $uploadDate = null): ar * @throws ClaimMDException If the API returns an error response. * @throws GuzzleException HTTP Request Failure */ - public function upload($file, ?string $filename = null): array + public function upload(mixed $file, ?string $filename = null): array { $data = [ 'File' => $this->prepareFile($file), @@ -88,7 +94,7 @@ public function upload($file, ?string $filename = null): array * @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); From 11813d4bc0fc10c9315cf0ddde74afe0fa064563 Mon Sep 17 00:00:00 2001 From: Ryan Yannelli Date: Fri, 13 Mar 2026 13:52:51 -0400 Subject: [PATCH 32/37] fix(dto): improve validation and error handling in DTO classes Refine validation for dates, phone numbers, and state codes. Ensure more precise argument exceptions and consistent error messaging. Signed-off-by: Ryan Yannelli --- src/DTO/ClaimAppealDTO.php | 20 +++++++++++--------- src/DTO/EligibilityDTO.php | 13 ++++++++++--- src/DTO/ProviderEnrollmentDTO.php | 3 +-- src/Requests/EligibilityRequest.php | 1 + 4 files changed, 23 insertions(+), 14 deletions(-) diff --git a/src/DTO/ClaimAppealDTO.php b/src/DTO/ClaimAppealDTO.php index 2a909cc..c4b609e 100644 --- a/src/DTO/ClaimAppealDTO.php +++ b/src/DTO/ClaimAppealDTO.php @@ -2,6 +2,8 @@ namespace Nextvisit\ClaimMD\DTO; +use InvalidArgumentException; + /** * Class ClaimAppealDTO * @@ -30,7 +32,7 @@ * @param string|null $contactState * @param string|null $contactZip * - * @throws \InvalidArgumentException If validation fails + * @throws InvalidArgumentException If validation fails */ public function __construct( public ?string $claimId = null, @@ -56,24 +58,24 @@ public function __construct( /** * Validate that either claimId or remoteClaimId is provided. * - * @throws \InvalidArgumentException If neither claimId nor remoteClaimId is provided + * @throws InvalidArgumentException If neither claimId nor remoteClaimId is provided */ private function validateRequiredFields(): void { if (empty($this->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/EligibilityDTO.php b/src/DTO/EligibilityDTO.php index 9922817..55004cd 100644 --- a/src/DTO/EligibilityDTO.php +++ b/src/DTO/EligibilityDTO.php @@ -111,6 +111,13 @@ private function validateDateFormat(string $date, string $fieldName): void if (!preg_match('/^\d{8}$/', $date)) { throw new InvalidArgumentException("$fieldName must be in yyyymmdd format"); } + + $year = (int) substr($date, 0, 4); + $month = (int) substr($date, 4, 2); + $day = (int) substr($date, 6, 2); + if (!checkdate($month, $day, $year)) { + throw new InvalidArgumentException("$fieldName must be a valid calendar date in yyyymmdd format"); + } } /** @@ -120,7 +127,7 @@ private function validateDateFormat(string $date, string $fieldName): void */ private function validatePatientRelationship(): void { - if (!in_array($this->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 f8a25d3..52764b3 100644 --- a/src/DTO/ProviderEnrollmentDTO.php +++ b/src/DTO/ProviderEnrollmentDTO.php @@ -111,8 +111,7 @@ private function validateSituationalFields(): void if (empty($this->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/Requests/EligibilityRequest.php b/src/Requests/EligibilityRequest.php index 6d0b99d..fd6b59c 100644 --- a/src/Requests/EligibilityRequest.php +++ b/src/Requests/EligibilityRequest.php @@ -3,6 +3,7 @@ namespace Nextvisit\ClaimMD\Requests; use GuzzleHttp\Exception\GuzzleException; +use GuzzleHttp\Psr7\Utils; use InvalidArgumentException; use Nextvisit\ClaimMD\Client; use Nextvisit\ClaimMD\DTO\EligibilityDTO; From c778272c1725da9063169f2de9e874f451671844 Mon Sep 17 00:00:00 2001 From: Ryan Yannelli Date: Fri, 13 Mar 2026 14:03:33 -0400 Subject: [PATCH 33/37] feat(dto): add WebhookEventDTO with validation and serialization Introduce a new Data Transfer Object (WebhookEventDTO) for webhook payload events, including validation logic, array conversion, and unit tests for full coverage. Signed-off-by: Ryan Yannelli --- src/DTO/WebhookEventDTO.php | 111 ++++++++++++++++++ tests/Unit/DTO/WebhookEventDTOTest.php | 149 +++++++++++++++++++++++++ 2 files changed, 260 insertions(+) create mode 100644 src/DTO/WebhookEventDTO.php create mode 100644 tests/Unit/DTO/WebhookEventDTOTest.php 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/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.'); + }); +}); From 209d1822849853555172a2af9bde0d51d7f8f747 Mon Sep 17 00:00:00 2001 From: Ryan Yannelli Date: Fri, 13 Mar 2026 14:03:38 -0400 Subject: [PATCH 34/37] feat(dto): add WebhookPayloadDTO with validation and serialization Introduce WebhookPayloadDTO for managing webhook payloads, including validation, JSON handling, and array conversion. Added comprehensive unit tests for coverage. Signed-off-by: Ryan Yannelli --- src/DTO/WebhookPayloadDTO.php | 110 +++++++++++ tests/Unit/DTO/WebhookPayloadDTOTest.php | 242 +++++++++++++++++++++++ 2 files changed, 352 insertions(+) create mode 100644 src/DTO/WebhookPayloadDTO.php create mode 100644 tests/Unit/DTO/WebhookPayloadDTOTest.php 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/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.'); + }); +}); From 7bfbce84e1f8045c378e019cefcb7cd7ce4a1ef1 Mon Sep 17 00:00:00 2001 From: Ryan Yannelli Date: Fri, 13 Mar 2026 14:03:43 -0400 Subject: [PATCH 35/37] feat(dto): add WebhookEnrollEventDTO with validation and tests Introduce WebhookEnrollEventDTO for managing enrollment event data in webhook payloads. Includes validation logic, array serialization, and comprehensive unit tests for full coverage. Signed-off-by: Ryan Yannelli --- src/DTO/WebhookEnrollEventDTO.php | 113 +++++++++++++++ tests/Unit/DTO/WebhookEnrollEventDTOTest.php | 142 +++++++++++++++++++ 2 files changed, 255 insertions(+) create mode 100644 src/DTO/WebhookEnrollEventDTO.php create mode 100644 tests/Unit/DTO/WebhookEnrollEventDTOTest.php 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/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(); + }); + }); +}); From aff4989525e45a98df3f1ede327d810c4c4ee7df Mon Sep 17 00:00:00 2001 From: Ryan Yannelli Date: Fri, 13 Mar 2026 14:03:48 -0400 Subject: [PATCH 36/37] feat(dto): add WebhookAppealEventDTO with validation and tests Introduce WebhookAppealEventDTO for managing appeal event data in webhook payloads. Includes validation logic, array serialization, and comprehensive unit tests for full coverage. Signed-off-by: Ryan Yannelli --- src/DTO/WebhookAppealEventDTO.php | 122 +++++++++++++++ tests/Unit/DTO/WebhookAppealEventDTOTest.php | 151 +++++++++++++++++++ 2 files changed, 273 insertions(+) create mode 100644 src/DTO/WebhookAppealEventDTO.php create mode 100644 tests/Unit/DTO/WebhookAppealEventDTOTest.php 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/tests/Unit/DTO/WebhookAppealEventDTOTest.php b/tests/Unit/DTO/WebhookAppealEventDTOTest.php new file mode 100644 index 0000000..4cd1c3c --- /dev/null +++ b/tests/Unit/DTO/WebhookAppealEventDTOTest.php @@ -0,0 +1,151 @@ +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(); + }); + }); +}); From ab47e25bf4b273299b308826c2e8be50878c3be0 Mon Sep 17 00:00:00 2001 From: Ryan Yannelli Date: Fri, 13 Mar 2026 14:03:52 -0400 Subject: [PATCH 37/37] feat(readme): add webhook integration and usage examples Include documentation for parsing webhook payloads, accessing event data, and utilizing `WebhookPayloadDTO`. Add examples for enroll and appeal events. Signed-off-by: Ryan Yannelli --- README.md | 85 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/README.md b/README.md index a4d948b..eac9284 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,10 @@ This library provides a range of features to interact with the CLAIM.MD API: - [**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. @@ -75,6 +79,7 @@ This library provides a range of features to interact with the CLAIM.MD API: - [**ProviderEnrollmentDTO**](#providerenrollmentdto) - [**EligibilityDTO**](#eligibilitydto) - [**ERADTO**](#eradto) +- [**WebhookPayloadDTO**](#webhookpayloaddto) ### Utility Features @@ -327,6 +332,51 @@ $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 @@ -544,6 +594,41 @@ $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