diff --git a/.github/scripts/release.py b/.github/scripts/release.py new file mode 100644 index 0000000..f50c1bc --- /dev/null +++ b/.github/scripts/release.py @@ -0,0 +1,170 @@ +import argparse +from datetime import datetime, timezone +import json +import os +from pathlib import Path +import re +import subprocess + + +VERSION = re.compile(r"v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)") +COMMIT = re.compile(r"(?P[a-z]+)(?:\((?P[^()\r\n]+)\))?(?P!)?: (?P.+)", re.IGNORECASE) +BREAKING = re.compile(r"^BREAKING(?: CHANGE|-CHANGE):\s*\S", re.MULTILINE) + + +def run(*args, check=True, input=None): + result = subprocess.run(args, input=input, capture_output=True, text=True) + if check and result.returncode: + raise RuntimeError(result.stderr.strip() or result.stdout.strip() or f"{args[0]} failed") + return result + + +def git(*args, **kwargs): + return run("git", *args, **kwargs).stdout.strip() + + +def stable_tags(ref): + tags = [] + for tag in git("tag", "--merged", ref).splitlines(): + match = VERSION.fullmatch(tag) + if match: + tags.append((tuple(map(int, match.groups())), tag)) + return sorted(tags, reverse=True) + + +def parse_commit(sha, message): + subject = message.splitlines()[0] + match = COMMIT.fullmatch(subject) + breaking = bool(BREAKING.search(message)) or bool(match and match["breaking"]) + if not match and not breaking: + return None + kind = match["type"].lower() if match else "other" + level = 3 if breaking else 2 if kind == "feat" else 1 if kind in {"fix", "perf", "revert"} else 0 + description = match["description"] if match else subject + if match and match["scope"]: + description = f'{match["scope"]}: {description}' + return {"sha": sha, "type": kind, "breaking": breaking, "level": level, "description": description} + + +def next_version(version, level): + major, minor, patch = version + if level == 3: + return major + 1, 0, 0 + if level == 2: + return major, minor + 1, 0 + return major, minor, patch + 1 + + +def release_notes(previous, tag, commits, repository, date): + url = f"https://github.com/{repository}" + link = f"{url}/compare/{previous}...{tag}" if previous else f"{url}/releases/tag/{tag}" + lines = [f"## [{tag[1:]}]({link}) ({date})"] + groups = {} + titles = {"feat": "Features", "fix": "Fixes", "perf": "Performance", "revert": "Reverts"} + for commit in commits: + title = "Breaking changes" if commit["breaking"] else titles.get(commit["type"], "Maintenance") + description = re.sub(r"([\\`*_[\]<>])", r"\\\1", commit["description"]) + groups.setdefault(title, []).append(f'- {description} ([{commit["sha"][:7]}]({url}/commit/{commit["sha"]}))') + for title in ["Breaking changes", "Features", "Fixes", "Performance", "Reverts", "Maintenance"]: + if title in groups: + lines.extend(["", f"### {title}", "", *groups[title]]) + return "\n".join(lines) + "\n" + + +def plan_release(source, repository): + tags = stable_tags(source) + version, previous = tags[0] if tags else ((0, 0, 0), None) + revision = f"{previous}..{source}" if previous else source + records = git("log", "--format=%H%x00%B%x00", revision).split("\0") + commits = [] + for index in range(0, len(records) - 1, 2): + commit = parse_commit(records[index].strip(), records[index + 1].strip()) + if commit: + commits.append(commit) + level = max((commit["level"] for commit in commits), default=0) + if not level: + return None + version = ".".join(map(str, next_version(version, level))) + tag = f"v{version}" + date = datetime.now(timezone.utc).date().isoformat() + return { + "version": version, + "tag": tag, + "previous_tag": previous, + "bump": {1: "patch", 2: "minor", 3: "major"}[level], + "notes": release_notes(previous, tag, commits, repository, date), + } + + +def update_changelog(notes): + path = Path("CHANGELOG.md") + existing = path.read_text() if path.exists() else "" + existing = existing.removeprefix("# Changelog\n").lstrip("\n") + path.write_text("# Changelog\n\n" + notes.rstrip() + "\n\n" + existing) + + +def release_subject(tag): + return f"chore(release): {tag} [skip ci]" + + +def publish_github_release(tag, repository, latest): + existing = run("gh", "release", "view", tag, "--repo", repository, "--json", "url", "--jq", ".url", check=False) + if existing.returncode == 0: + return existing.stdout.strip() + return run( + "gh", "release", "create", tag, "--repo", repository, "--verify-tag", + "--title", tag, "--notes-from-tag", "--latest" if latest else "--latest=false", + ).stdout.strip() + + +def publish(source, repository): + if git("status", "--porcelain"): + raise RuntimeError("Release requires a clean working tree") + if git("rev-parse", "HEAD") != source: + raise RuntimeError("Checkout does not match the tested commit") + git("fetch", "origin", "main", "--tags") + remote_head = git("rev-parse", "refs/remotes/origin/main") + tags = stable_tags(remote_head) + for _, tag in tags: + if git("log", "-1", "--format=%s", tag) != release_subject(tag): + continue + commit = git("rev-parse", f"{tag}^{{commit}}") + parent = git("log", "-1", "--format=%P", tag) + if commit == source or parent == source: + url = publish_github_release(tag, repository, tag == tags[0][1]) + return {"status": "released", "tag": tag, "url": url} + if remote_head != source: + return {"status": "skipped", "reason": "main advanced beyond the tested commit"} + plan = plan_release(source, repository) + if plan is None: + return {"status": "skipped", "reason": "No release commits since the latest stable tag"} + tag = plan["tag"] + if run("git", "show-ref", "--verify", "--quiet", f"refs/tags/{tag}", check=False).returncode == 0: + raise RuntimeError(f"Tag {tag} already exists") + update_changelog(plan["notes"]) + git("add", "--", "CHANGELOG.md") + git("commit", "-m", release_subject(tag)) + git("tag", "-a", tag, "-F", "-", input=plan["notes"]) + git("push", "--atomic", "origin", "HEAD:refs/heads/main", f"refs/tags/{tag}:refs/tags/{tag}") + url = publish_github_release(tag, repository, latest=True) + return {"status": "released", "tag": tag, "url": url} + + +def main(): + parser = argparse.ArgumentParser() + mode = parser.add_mutually_exclusive_group() + mode.add_argument("--publish", action="store_true") + mode.add_argument("--dry-run", action="store_true") + parser.add_argument("--expected-head") + parser.add_argument("--repository", default=os.environ.get("GITHUB_REPOSITORY", "Nextvisit/claim-md-php")) + args = parser.parse_args() + if args.publish and not args.expected_head: + parser.error("--publish requires --expected-head") + os.chdir(Path(__file__).resolve().parents[2]) + source = git("rev-parse", "--verify", f"{args.expected_head or 'HEAD'}^{{commit}}") + result = publish(source, args.repository) if args.publish else plan_release(source, args.repository) + print(json.dumps(result or {"status": "skipped", "reason": "No release commits since the latest stable tag"}, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/.github/scripts/test_release.py b/.github/scripts/test_release.py new file mode 100644 index 0000000..9f0a17d --- /dev/null +++ b/.github/scripts/test_release.py @@ -0,0 +1,250 @@ +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch + +import release + + +class VersionTests(unittest.TestCase): + def test_commit_types_and_breaking_markers(self): + cases = { + "fix: handle errors": 1, + "perf(client): reduce allocations": 1, + "revert: restore behavior": 1, + "feat(claims): add downloads": 2, + "fix!: throw API errors": 3, + "feat(client)!: change the response type": 3, + "chore: update dependencies\n\nBREAKING CHANGE: PHP 8.3 is needed": 3, + "fix: change output\n\nBREAKING-CHANGE: output is now an array": 3, + "docs: add examples": 0, + "ci: add release workflow": 0, + "chore(deps): update tools": 0, + } + for message, level in cases.items(): + with self.subTest(message=message): + self.assertEqual(release.parse_commit("a" * 40, message)["level"], level) + self.assertIsNone(release.parse_commit("a" * 40, "Update the README")) + + def test_semver_resets_lower_components(self): + self.assertEqual(release.next_version((2, 9, 4), 1), (2, 9, 5)) + self.assertEqual(release.next_version((2, 9, 4), 2), (2, 10, 0)) + self.assertEqual(release.next_version((2, 9, 4), 3), (3, 0, 0)) + self.assertEqual(release.next_version((0, 9, 4), 3), (1, 0, 0)) + + +class RepositoryTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix="claimmd-release-test-") + self.addCleanup(self.temp.cleanup) + self.addCleanup(os.chdir, Path.cwd()) + root = Path(self.temp.name) + self.repo = root / "repo" + self.remote = root / "remote.git" + self.repo.mkdir() + scripts = self.repo / ".github" / "scripts" + scripts.mkdir(parents=True) + shutil.copy2(Path(release.__file__), scripts / "release.py") + os.chdir(self.repo) + self.real_run = release.run + release.git("init", "--bare", "--initial-branch=main", str(self.remote)) + release.git("init", "--initial-branch=main") + release.git("config", "user.name", "Release Test") + release.git("config", "user.email", "release@example.com") + Path("CHANGELOG.md").write_text("# Changelog\n\n## 2.0.0\n\nPrevious release notes.\n") + release.git("add", ".") + release.git("commit", "-m", "Initial release") + release.git("tag", "v2.0.0") + release.git("remote", "add", "origin", str(self.remote)) + release.git("push", "-u", "origin", "main", "--tags") + self.gh_calls = [] + self.released = set() + self.fail_github = False + self.race = False + mock = patch.object(release, "run", side_effect=self.runner) + mock.start() + self.addCleanup(mock.stop) + + def runner(self, *args, **kwargs): + if args[0] == "gh": + self.gh_calls.append(args) + tag = args[3] + url = f"https://github.com/example/package/releases/tag/{tag}" + if args[2] == "view": + return subprocess.CompletedProcess(args, 0 if tag in self.released else 1, url, "") + if self.fail_github: + raise RuntimeError("GitHub unavailable") + self.released.add(tag) + return subprocess.CompletedProcess(args, 0, url, "") + if self.race and args[:3] == ("git", "push", "--atomic"): + other = str(Path(self.temp.name) / "concurrent") + self.real_run("git", "clone", str(self.remote), other) + self.real_run("git", "-C", other, "config", "user.name", "Concurrent Test") + self.real_run("git", "-C", other, "config", "user.email", "concurrent@example.com") + self.real_run("git", "-C", other, "commit", "--allow-empty", "-m", "fix: concurrent change") + self.real_run("git", "-C", other, "push", "origin", "main") + self.race = False + return self.real_run(*args, **kwargs) + + def commit(self, message): + release.git("commit", "--allow-empty", "-m", message) + return release.git("rev-parse", "HEAD") + + def push_source(self, message): + source = self.commit(message) + release.git("push", "origin", "HEAD:refs/heads/main") + return source + + def remote_git(self, *args): + return release.git("--git-dir", str(self.remote), *args) + + def test_highest_bump_wins_and_notes_keep_history(self): + self.commit("fix: handle upload errors") + self.commit("feat(claims): add downloads") + source = self.commit("fix!: change error responses") + plan = release.plan_release(source, "example/package") + self.assertEqual(plan["tag"], "v3.0.0") + self.assertEqual(plan["bump"], "major") + self.assertIn("### Breaking changes", plan["notes"]) + self.assertIn("### Features", plan["notes"]) + self.assertIn("### Fixes", plan["notes"]) + release.update_changelog(plan["notes"]) + changelog = Path("CHANGELOG.md").read_text() + self.assertEqual(changelog.count("# Changelog\n"), 1) + self.assertIn("Previous release notes.", changelog) + self.assertLess(changelog.index("[3.0.0]"), changelog.index("## 2.0.0")) + + def test_maintenance_commits_do_not_release(self): + source = self.push_source("docs: document releases") + self.assertIsNone(release.plan_release(source, "example/package")) + self.assertEqual(release.publish(source, "example/package")["status"], "skipped") + self.assertEqual(self.gh_calls, []) + self.assertEqual(release.git("status", "--porcelain"), "") + + def test_versions_sort_numerically_and_ignore_prereleases(self): + for tag in ["v2.9.0", "v2.10.0", "v3.0.0-beta.1"]: + release.git("tag", tag) + plan = release.plan_release(self.commit("fix: correct encoding"), "example/package") + self.assertEqual(plan["previous_tag"], "v2.10.0") + self.assertEqual(plan["tag"], "v2.10.1") + + def test_tags_on_other_branches_do_not_set_the_version(self): + release.git("checkout", "-b", "other") + self.commit("feat: other branch") + release.git("tag", "v9.0.0") + release.git("checkout", "main") + plan = release.plan_release(self.commit("fix: main change"), "example/package") + self.assertEqual(plan["tag"], "v2.0.1") + + def test_dry_run_leaves_files_and_refs_unchanged(self): + source = self.commit("feat: add claim downloads") + refs = release.git("show-ref") + result = self.real_run(sys.executable, ".github/scripts/release.py", "--dry-run") + self.assertEqual(json.loads(result.stdout)["tag"], "v2.1.0") + self.assertEqual(release.git("show-ref"), refs) + self.assertEqual(release.git("rev-parse", "HEAD"), source) + self.assertEqual(release.git("status", "--porcelain"), "") + + def test_publishing_requires_an_explicit_tested_commit(self): + result = self.real_run(sys.executable, ".github/scripts/release.py", "--publish", check=False) + self.assertNotEqual(result.returncode, 0) + self.assertIn("--publish requires --expected-head", result.stderr) + + def test_publish_pushes_changelog_and_annotated_tag(self): + source = self.push_source("feat: add claim downloads") + result = release.publish(source, "example/package") + self.assertEqual(result["tag"], "v2.1.0") + target = self.remote_git("rev-parse", "main") + self.assertEqual(self.remote_git("rev-parse", "v2.1.0^{commit}"), target) + self.assertEqual(self.remote_git("rev-parse", f"{target}^"), source) + self.assertEqual(self.remote_git("cat-file", "-t", "v2.1.0"), "tag") + self.assertEqual(self.remote_git("diff", "--name-only", source, target), "CHANGELOG.md") + self.assertIn("add claim downloads", self.remote_git("cat-file", "-p", "v2.1.0")) + create = self.gh_calls[-1] + self.assertIn("--verify-tag", create) + self.assertIn("--notes-from-tag", create) + self.assertIn("--latest", create) + + def test_retries_finish_publication_without_another_bump(self): + source = self.push_source("fix: correct multipart uploads") + self.fail_github = True + with self.assertRaisesRegex(RuntimeError, "GitHub unavailable"): + release.publish(source, "example/package") + target = self.remote_git("rev-parse", "main") + release.git("checkout", "--detach", source) + self.fail_github = False + result = release.publish(source, "example/package") + self.assertEqual(result["tag"], "v2.0.1") + self.assertEqual(self.remote_git("rev-parse", "main"), target) + self.assertEqual(self.remote_git("tag", "--list"), "v2.0.0\nv2.0.1") + release.publish(source, "example/package") + self.assertEqual(len([call for call in self.gh_calls if call[2] == "create"]), 2) + + def test_manual_retry_accepts_the_tagged_release_commit(self): + source = self.push_source("fix: correct encoding") + release.publish(source, "example/package") + result = release.publish(release.git("rev-parse", "HEAD"), "example/package") + self.assertEqual(result["tag"], "v2.0.1") + self.assertEqual(len([call for call in self.gh_calls if call[2] == "create"]), 1) + + def test_older_release_recovery_does_not_replace_latest(self): + source = self.push_source("fix: correct encoding") + self.fail_github = True + with self.assertRaises(RuntimeError): + release.publish(source, "example/package") + newer = self.push_source("feat: add downloads") + self.fail_github = False + release.publish(newer, "example/package") + release.git("checkout", "--detach", source) + release.publish(source, "example/package") + self.assertIn("--latest=false", self.gh_calls[-1]) + + def test_stale_runs_skip_publishing(self): + source = self.push_source("feat: add downloads") + newer = self.push_source("fix: newer change") + release.git("checkout", "--detach", source) + self.assertEqual(release.publish(source, "example/package")["status"], "skipped") + self.assertEqual(self.remote_git("rev-parse", "main"), newer) + self.assertEqual(self.gh_calls, []) + + def test_atomic_push_rejects_a_concurrent_main_update(self): + source = self.push_source("feat: add downloads") + self.race = True + with self.assertRaises(RuntimeError): + release.publish(source, "example/package") + self.assertEqual(self.remote_git("tag", "--list"), "v2.0.0") + self.assertEqual(self.remote_git("log", "-1", "--format=%s", "main"), "fix: concurrent change") + self.assertEqual(self.gh_calls, []) + + def test_dirty_working_trees_are_rejected(self): + source = self.push_source("fix: correct encoding") + Path("CHANGELOG.md").write_text("Local edits\n") + with self.assertRaisesRegex(RuntimeError, "clean working tree"): + release.publish(source, "example/package") + self.assertEqual(self.remote_git("rev-parse", "main"), source) + + def test_checkout_must_match_the_tested_commit(self): + source = self.push_source("fix: correct encoding") + self.commit("feat: untested change") + with self.assertRaisesRegex(RuntimeError, "tested commit"): + release.publish(source, "example/package") + + def test_existing_tags_are_not_overwritten(self): + release.git("checkout", "-b", "other") + self.commit("fix: another release") + release.git("tag", "v2.0.1") + release.git("checkout", "main") + source = self.push_source("fix: main change") + with self.assertRaisesRegex(RuntimeError, "already exists"): + release.publish(source, "example/package") + self.assertEqual(release.git("status", "--porcelain"), "") + self.assertEqual(release.git("rev-parse", "HEAD"), source) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..64662af --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,42 @@ +name: Release + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: release-main + cancel-in-progress: false + +jobs: + tests: + uses: ./.github/workflows/tests.yml + + release: + needs: tests + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + steps: + - name: Checkout release source + uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + + - name: Configure release author + run: | + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + + - name: Publish release + env: + GH_TOKEN: ${{ github.token }} + RELEASE_SOURCE_SHA: ${{ github.sha }} + run: python3 .github/scripts/release.py --publish --expected-head "$RELEASE_SOURCE_SHA" diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 5ce4033..0a0f8cc 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -2,9 +2,13 @@ name: Tests on: push: - branches: [main, master] + branches: [master] pull_request: branches: [main, master] + workflow_call: + +permissions: + contents: read jobs: tests: @@ -47,3 +51,6 @@ jobs: - name: Run tests run: composer test + + - name: Run release tests + run: python3 -B -m unittest discover -s .github/scripts -p 'test_*.py' diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..2d8bc7a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,8 @@ +# Changelog + +## [2.0.0](https://github.com/Nextvisit/claim-md-php/releases/tag/v2.0.0) (2026-03-13) + +- Changed the namespace from `Nextvisit\ClaimMDWrapper` to `Nextvisit\ClaimMD`. +- Raised the minimum PHP version to 8.3. +- Added enrollment and appeal webhook DTOs. +- Added SDK exceptions and expanded request validation. diff --git a/CLAUDE.md b/CLAUDE.md index e9f1ec6..f42204b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,6 +8,11 @@ Unofficial PHP SDK for the Claim.MD API — a healthcare/insurance claims proces **Namespace:** `Nextvisit\ClaimMD` (PSR-4 autoloaded from `src/`) +**API reference:** [Claim.MD v1.19](https://api.claim.md/). All 16 service endpoints have wrappers, with DTOs for webhook payloads. + +**Dependencies:** PHP 8.3+, Guzzle `^7.15.5 || ^8.1`, Pest `^4.7.8`, and Mockery `^1.6.15`. +Composer's `config.platform.php` is `8.3.0` so updates select dependencies compatible with the minimum PHP version. + ## Commands ```bash @@ -25,11 +30,26 @@ composer test:coverage # Run a specific test by name ./vendor/bin/pest --filter="test name here" + +composer validate --strict +composer check-platform-reqs +composer audit +php -l src/Client.php +git diff --check +python3 -B -m unittest discover -s .github/scripts -p 'test_*.py' +python3 .github/scripts/release.py --dry-run ``` ## 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/`). +**Client + Config** — `Client` wraps Guzzle and injects `AccountKey` into every request. `Config` holds the base URI (`https://svc.claim.md/`). +`sendRequest()` returns decoded JSON arrays. `sendX12Request()` requests JSON error bodies and returns `application/edi-x12` bodies unchanged. + +**Errors** — Non-empty top-level `error` elements throw `ApiException`, including HTTP 200 responses. +`getApiErrors()` returns object/list errors as a list. `getApiErrorCodes()` returns string codes separately from `getStatusCode()`. +`getResponseBody()` returns the complete decoded response. Messages accept `error_mesg` and `error_message`. +HTTP 401, 404, 429, and 5xx keep their specific exception classes. Per-claim status messages remain response data. +`InvalidResponseException` stores the raw body when the response format is unexpected. **DTOs** (`src/DTO/`) — Readonly classes with constructor validation. Each DTO: - Validates required fields and formats in the constructor (throws `InvalidArgumentException`) @@ -41,16 +61,31 @@ composer test:coverage - 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 +- Returns API response arrays, except claim downloads, which return X12 strings or a generator of strings **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) +- `FileRequest::upload()` and `EligibilityRequest::checkEligibility270271()` use multipart form uploads (accept PHP `resource` handles). Guzzle generates the content type with its boundary. +- `ClaimRequest::archive()` accepts a string or a non-empty list of strings. Bulk calls enable `repeatFormFields` to send repeated `claimid` keys. +- `ClaimRequest::downloadTransmittedClaims()` maps `transmit_date`, `claim_form`, `bill_npi`, `bill_taxid`, `payerid`, and `pg` to `/services/claimdata/`. Dates must be valid calendar dates; forms are `1500`, `ub`, or `dental`; pages start at zero. +- `downloadAllTransmittedClaims()` yields complete 837 pages and stops on HTTP 200 with API error code `711`. Other errors are thrown. +- `ProviderEnrollmentDTO` accepts an SDK-only `isOrganization` flag in its constructor and `fromArray()`. Without an NPI, organizations require a name and individuals require first and last names. The flag is excluded from `toArray()`. - DTOs handle field name transformation — the Request classes send whatever `toArray()` returns ## Testing -- **Framework:** Pest 3.0 with Mockery +- Complete code, dependency, and documentation updates before adding or running tests and checks. +- **Framework:** Pest 4 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 +- **PHP version:** 8.3+ required + +## Releases + +- Pushes to `main` and manual runs on `main` use `.github/workflows/release.yml`. Its release job depends on the reusable Tests workflow. +- `.github/scripts/release.py` uses Python's standard library, Git, and the GitHub CLI. Publishing uses the built-in `GITHUB_TOKEN`. +- Use Conventional Commits. `fix`, `perf`, and `revert` select patch; `feat` selects minor; `!` or a `BREAKING CHANGE:` / `BREAKING-CHANGE:` footer selects major. Other types do not release on their own. +- Squash PR titles must carry the commit type and any breaking-change marker. +- Versions come from stable `vX.Y.Z` Git tags. Composer reads the version from the tag; keep the `version` field out of `composer.json`. +- Automation updates `CHANGELOG.md`, commits it, pushes the commit and tag atomically, and publishes the GitHub release. Generated release commits use `[skip ci]`. +- A changed `main` head skips a stale release run. Retries can finish GitHub publication for a tag already pushed by the same run. diff --git a/README.md b/README.md index 48419e4..037ca68 100644 --- a/README.md +++ b/README.md @@ -19,14 +19,14 @@ contribute to the package. 😊 ## 🌟 Features -This library provides a range of features to interact with the CLAIM.MD API: +This library wraps all 16 service endpoints in [Claim.MD API v1.19](https://api.claim.md/) and parses webhook payloads. ### [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. +- [**Get ERA JSON**](#get-an-era-json): Get a specific ERA in the JSON format. ### [File Management](#file-management) @@ -43,7 +43,8 @@ This library provides a range of features to interact with the CLAIM.MD API: - [**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. +- [**Archive Claim**](#archive-claim): Archive one or more Claim.MD claims. +- [**Download Claims as 837**](#download-claims-as-837): Download claims by transmission date with page iteration. - [**Claim Modifications**](#list-claim-modifications): Retrieve modifications for claims. ### [Response (Claim Status)](#response-claim-status) @@ -88,6 +89,8 @@ This library provides a range of features to interact with the CLAIM.MD API: ## 📦 Installation +Requires PHP 8.3 or later with the JSON extension. Composer accepts Guzzle `^7.15.5 || ^8.1`. + You can install the package via Composer: ```bash @@ -110,6 +113,33 @@ $config = new Config(); $client = new Client($accountKey, $config); ``` +### Error Handling + +HTTP 200 responses with a non-empty top-level `error` now throw `ApiException`. +The exception stores the HTTP status and complete response body. Claim.MD error codes are returned separately as strings. +Both object and list errors are supported, including `error_mesg` and `error_message`. + +```php +use Nextvisit\ClaimMD\Exceptions\ApiException; +use Nextvisit\ClaimMD\Requests\ClaimRequest; + +$claimRequest = new ClaimRequest($client); + +try { + $response = $claimRequest->archive('claim-id'); +} catch (ApiException $e) { + $httpStatus = $e->getStatusCode(); + $apiCodes = $e->getApiErrorCodes(); + $errors = $e->getApiErrors(); + $responseBody = $e->getResponseBody(); + $message = $e->getMessage(); +} +``` + +HTTP 401, 404, 429, and 5xx responses keep their specific exception classes. +Network failures throw Guzzle exceptions. Per-claim status messages remain part of the returned data. +`sendRequest()` returns JSON arrays. `sendX12Request()` returns X12 bytes and handles JSON error bodies through the same exceptions. + ### Electronic Remittance Advice (ERA) ### Get ERAs List @@ -203,6 +233,23 @@ $providerRequest = new ProviderRequest($client); $response = $providerRequest->enroll($providerEnrollment); ``` +For an organization without an NPI, supply its name and set `isOrganization: true`: + +```php +$organization = new ProviderEnrollmentDTO( + payerId: 'payer-id', + enrollType: 'era', + provTaxId: '123456789', + provNameLast: 'Example Clinic', + isOrganization: true +); + +$response = $providerRequest->enroll($organization); +``` + +The flag defaults to `false` and is excluded from the API payload. Individuals without an NPI still need both names. +For an API field array, use `ProviderEnrollmentDTO::fromArray($data, isOrganization: true)`. + ### Claim Management #### Claim Appeal @@ -229,8 +276,40 @@ use Nextvisit\ClaimMD\Requests\ClaimRequest; $claimRequest = new ClaimRequest($client); $response = $claimRequest->archive('claim-id'); +$bulkResponse = $claimRequest->archive(['claim-id-1', 'claim-id-2']); ``` +Bulk requests send repeated `claimid` fields. Pass a non-empty list of non-empty strings. + +#### Download Claims as 837 + +Use `1500` for 837P, `ub` for 837I, or `dental` for 837D. Dates use `YYYY-MM-DD` and pages start at zero. +Each request returns up to 1,000 claims as X12 bytes. The optional billing NPI, tax ID, and payer fields filter the results. + +```php +use Nextvisit\ClaimMD\Requests\ClaimRequest; + +$claimRequest = new ClaimRequest($client); +$x12 = $claimRequest->downloadTransmittedClaims( + transmitDate: '2026-08-19', + claimForm: '1500', + billNpi: '1234567890', + billTaxId: '123456789', + payerId: 'payer-id', + page: 0 +); + +file_put_contents('claims.837', $x12); + +foreach ($claimRequest->downloadAllTransmittedClaims('2026-08-19', '1500') as $page => $x12) { + file_put_contents("claims-{$page}.837", $x12); +} +``` + +The single-page method throws `ApiException` for code `711` when no claims remain. +The generator stops on that code and throws other errors. Each yielded value is a complete 837 file. +Claim.MD generates files from current claim data, so later edits can appear in the download. [Claim download documentation](https://api.claim.md/#/paths/~1services~1claimdata~1/post) + #### List Claim Modifications ```php @@ -304,7 +383,7 @@ $eligibilityRequest = new EligibilityRequest($client); $eligibility270 = fopen('path/to/your/file.270', 'r'); -// the response will contain a 270 file +// The response array contains the 271 response. $realtimeResponse = $eligibilityRequest->checkEligibility270271($eligibility270); ``` @@ -392,7 +471,7 @@ $specifiedPayerResponse = $payerRequest->listPayer(payerId: $payerId); // specify a general payer name search $payerName = 'payer-name'; -$searchResponse = $payerRequest->listPayer($payerName: $payerName); +$searchResponse = $payerRequest->listPayer(payerName: $payerName); // get all payers $allResponse = $payerRequest->listPayer(); @@ -629,8 +708,46 @@ $data = [ $webhookPayload = WebhookPayloadDTO::fromArray($data); ``` +## Releases + +Pushes to `main` run the PHP suite and release tests. After they pass, the Release workflow reads +[Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) since the latest stable tag and selects the largest SemVer bump: + +| Commit | Bump | +| --- | --- | +| `fix:`, `perf:`, `revert:` | Patch | +| `feat:` | Minor | +| `!` after the type/scope, or a `BREAKING CHANGE:` / `BREAKING-CHANGE:` footer | Major | +| `docs:`, `chore:`, `ci:`, and other types without a breaking change | No release | + +Use a Conventional Commit title when squash-merging a pull request. For example, +`feat: add 837 downloads` selects a minor bump and `fix!: throw exceptions for API errors` selects a major bump. + +The workflow commits [CHANGELOG.md](CHANGELOG.md), pushes an annotated `vX.Y.Z` tag, and publishes a GitHub release with the same notes. +Composer reads package versions from Git tags. [Packagist](https://packagist.org/packages/nextvisit/claim-md-php) has automatic updates enabled for this package. + +The workflow uses the built-in `GITHUB_TOKEN`. Runs are serialized and skip a source commit if `main` has advanced. +Re-running a failed job completes a missing GitHub release after a successful tag push. The Actions **Run workflow** button also runs tests before publishing from `main`. + +Preview the next release from committed history without changing files or publishing: + +```bash +python3 .github/scripts/release.py --dry-run +``` + ## 🤝 Contributing +Development uses Pest 4 and Mockery 1.6. Composer resolves the lockfile against PHP 8.3 to keep it installable on the minimum supported version. +After code, dependency, and documentation changes are complete, add the affected tests and run: + +```bash +composer test +composer validate --strict +composer check-platform-reqs +composer audit +python3 -B -m unittest discover -s .github/scripts -p 'test_*.py' +``` + Contributions are welcome! If you find any issues or have suggestions for improvements, feel free to open an issue or submit a pull request. diff --git a/composer.json b/composer.json index 6edd0ff..9b6a2e7 100644 --- a/composer.json +++ b/composer.json @@ -5,12 +5,12 @@ "license": "MIT", "require": { "php": ">=8.3", - "guzzlehttp/guzzle": "^7.9", + "guzzlehttp/guzzle": "^7.15.5 || ^8.1", "ext-json": "*" }, "require-dev": { - "mockery/mockery": "^1.6", - "pestphp/pest": "^3.0" + "mockery/mockery": "^1.6.15", + "pestphp/pest": "^4.7.8" }, "autoload": { "psr-4": { @@ -48,6 +48,9 @@ "issues": "https://github.com/Nextvisit/claim-md-php/issues" }, "config": { + "platform": { + "php": "8.3.0" + }, "sort-packages": true, "allow-plugins": { "pestphp/pest-plugin": true diff --git a/src/Client.php b/src/Client.php index bba77c1..ef7800d 100644 --- a/src/Client.php +++ b/src/Client.php @@ -5,6 +5,7 @@ use GuzzleHttp\Client as GuzzleClient; use GuzzleHttp\Exception\GuzzleException; use GuzzleHttp\Exception\RequestException; +use GuzzleHttp\Psr7\Query; use GuzzleHttp\RequestOptions; use Nextvisit\ClaimMD\Exceptions\ApiException; use Nextvisit\ClaimMD\Exceptions\AuthenticationException; @@ -12,6 +13,7 @@ use Nextvisit\ClaimMD\Exceptions\NotFoundException; use Nextvisit\ClaimMD\Exceptions\RateLimitException; use Nextvisit\ClaimMD\Exceptions\ServerException; +use Psr\Http\Message\ResponseInterface; /** * Class Client @@ -67,6 +69,7 @@ private function createDefaultHttpClient(): GuzzleClient * @param array $data Request data * @param bool $isMultipart Whether the request contains multipart data * @param array $additionalHeaders Additional headers to include in the request + * @param bool $repeatFormFields Encode array values as repeated form fields * * @return array The API response as an associative array * @@ -74,18 +77,50 @@ private function createDefaultHttpClient(): GuzzleClient * @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 ApiException If the API returns an HTTP error or a top-level error element * @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 + public function sendRequest(string $method, string $uri, array $data = [], bool $isMultipart = false, array $additionalHeaders = [], bool $repeatFormFields = false): array { - $options = $this->prepareRequestOptions($data, $isMultipart, $additionalHeaders); + $options = $this->prepareRequestOptions($data, $isMultipart, $additionalHeaders, $repeatFormFields); + $response = $this->request($method, $uri, $options); + $rawBody = (string) $response->getBody(); + $decoded = json_decode($rawBody, true); + + if (!is_array($decoded)) { + throw new InvalidResponseException($response->getStatusCode(), $rawBody); + } + + $this->handleApiErrors($response->getStatusCode(), $decoded); + + return $decoded; + } + + public function sendX12Request(string $method, string $uri, array $data = []): string + { + $response = $this->request($method, $uri, $this->prepareRequestOptions($data, false, [])); + $rawBody = (string) $response->getBody(); + $decoded = json_decode($rawBody, true); + if (is_array($decoded)) { + $this->handleApiErrors($response->getStatusCode(), $decoded); + } + + $contentType = strtolower(trim(explode(';', $response->getHeaderLine('Content-Type'))[0])); + if ($contentType !== 'application/edi-x12') { + throw new InvalidResponseException($response->getStatusCode(), $rawBody, expectedFormat: 'X12'); + } + + return $rawBody; + } + + private function request(string $method, string $uri, array $options): ResponseInterface + { try { $response = $this->httpClient->request($method, $uri, $options); } catch (RequestException $e) { - $response = $e->getResponse(); + $response = method_exists($e, 'getResponse') ? $e->getResponse() : null; if ($response === null) { throw $e; @@ -93,30 +128,29 @@ public function sendRequest(string $method, string $uri, array $data = [], bool $this->handleErrorResponse( $response->getStatusCode(), - $response->getBody()->getContents(), + (string) $response->getBody(), $response->getHeaderLine('Retry-After'), $e ); } $statusCode = $response->getStatusCode(); - $rawBody = $response->getBody()->getContents(); - if ($statusCode >= 400) { $this->handleErrorResponse( $statusCode, - $rawBody, + (string) $response->getBody(), $response->getHeaderLine('Retry-After') ); } - $decoded = json_decode($rawBody, true); + return $response; + } - if (!is_array($decoded)) { - throw new InvalidResponseException($statusCode, $rawBody); + private function handleApiErrors(int $statusCode, array $responseBody): void + { + if (!empty($responseBody['error'])) { + throw new ApiException($statusCode, $responseBody); } - - return $decoded; } /** @@ -135,6 +169,7 @@ private function handleErrorResponse( ?\Throwable $previous = null ): never { $responseBody = json_decode($rawBody, true); + $responseBody = is_array($responseBody) ? $responseBody : null; match (true) { $statusCode === 401 => throw new AuthenticationException($responseBody, $previous), @@ -155,10 +190,11 @@ private function handleErrorResponse( * @param array $data Request data * @param bool $isMultipart Whether the request contains multipart data * @param array $additionalHeaders Additional headers to include in the request + * @param bool $repeatFormFields Encode array values as repeated form fields * * @return array The prepared request options */ - private function prepareRequestOptions(array $data, bool $isMultipart, array $additionalHeaders): array + private function prepareRequestOptions(array $data, bool $isMultipart, array $additionalHeaders, bool $repeatFormFields = false): array { $data['AccountKey'] = $this->accountKey; @@ -169,13 +205,18 @@ private function prepareRequestOptions(array $data, bool $isMultipart, array $ad if ($isMultipart) { $options = [ RequestOptions::MULTIPART => $this->prepareMultipartData($data), - 'headers' => array_merge($headers, ['Content-Type' => 'multipart/form-data'], $additionalHeaders), + 'headers' => array_merge($headers, $additionalHeaders), ]; } else { $options = [ - RequestOptions::FORM_PARAMS => $data, 'headers' => array_merge($headers, ['Content-Type' => 'application/x-www-form-urlencoded'], $additionalHeaders), ]; + + if ($repeatFormFields) { + $options[RequestOptions::BODY] = Query::build($data, PHP_QUERY_RFC1738); + } else { + $options[RequestOptions::FORM_PARAMS] = $data; + } } return $options; diff --git a/src/DTO/ProviderEnrollmentDTO.php b/src/DTO/ProviderEnrollmentDTO.php index 52764b3..e99d2d1 100644 --- a/src/DTO/ProviderEnrollmentDTO.php +++ b/src/DTO/ProviderEnrollmentDTO.php @@ -23,7 +23,7 @@ * @param string $enrollType The enrollment type * @param string $provTaxId The provider tax ID * @param string|null $provNpi The provider NPI (optional) - * @param string|null $provNameLast The provider's last name (optional) + * @param string|null $provNameLast The organization name or individual's last name (optional) * @param string|null $provNameFirst The provider's first name (optional) * @param string|null $provNameMiddle The provider's middle name (optional) * @param string|null $contact The contact person (optional) @@ -37,6 +37,7 @@ * @param string|null $provCity The provider's city (optional) * @param string|null $provState The provider's state (optional) * @param string|null $provZip The provider's ZIP code (optional) + * @param bool $isOrganization Whether to validate as an organization; excluded from API fields */ public function __construct( public string $payerId, @@ -56,7 +57,8 @@ public function __construct( public ?string $provAddr2 = null, public ?string $provCity = null, public ?string $provState = null, - public ?string $provZip = null + public ?string $provZip = null, + public bool $isOrganization = false ) { $this->validateRequiredFields(); $this->validateEnrollType(); @@ -111,7 +113,7 @@ private function validateSituationalFields(): void if (empty($this->provNameLast)) { throw new InvalidArgumentException('provNameLast is required when provNpi is not provided.'); } - if (empty($this->provNameFirst)) { + if (!$this->isOrganization && empty($this->provNameFirst)) { throw new InvalidArgumentException('provNameFirst is required when provNpi is not provided and the provider is an individual.'); } } @@ -177,10 +179,11 @@ public function toArray(): array * Creates a ProviderEnrollmentDTO from an array. * * @param array $data The array containing provider enrollment data. + * @param bool $isOrganization Whether to validate as an organization; excluded from API fields. * @return self A new instance of ProviderEnrollmentDTO. * @throws InvalidArgumentException If required fields are missing. */ - public static function fromArray(array $data): self + public static function fromArray(array $data, bool $isOrganization = false): self { return new self( payerId: $data['payerid'] ?? throw new InvalidArgumentException('payerid is required.'), @@ -200,7 +203,8 @@ public static function fromArray(array $data): self provAddr2: $data['prov_addr_2'] ?? null, provCity: $data['prov_city'] ?? null, provState: $data['prov_state'] ?? null, - provZip: $data['prov_zip'] ?? null + provZip: $data['prov_zip'] ?? null, + isOrganization: $isOrganization ); } -} \ No newline at end of file +} diff --git a/src/Exceptions/ApiException.php b/src/Exceptions/ApiException.php index b876c07..71d5856 100644 --- a/src/Exceptions/ApiException.php +++ b/src/Exceptions/ApiException.php @@ -5,12 +5,13 @@ use Throwable; /** - * Thrown when the Claim.MD API returns an HTTP error response. + * Thrown for HTTP failures or top-level Claim.MD API errors. */ class ApiException extends ClaimMDException { private int $statusCode; private ?array $responseBody; + private array $apiErrors; public function __construct( int $statusCode, @@ -20,9 +21,20 @@ public function __construct( ) { $this->statusCode = $statusCode; $this->responseBody = $responseBody; + $errors = $responseBody['error'] ?? []; + $this->apiErrors = is_array($errors) ? (array_is_list($errors) ? $errors : [$errors]) : []; if ($message === '') { - $message = "Claim.MD API request failed with status code {$statusCode}"; + $messages = []; + foreach ($this->apiErrors as $error) { + $errorMessage = $error['error_mesg'] ?? $error['error_message'] ?? null; + if (is_string($errorMessage) && $errorMessage !== '') { + $messages[] = $errorMessage; + } + } + $message = $messages === [] + ? "Claim.MD API request failed with status code {$statusCode}" + : implode('; ', $messages); } parent::__construct($message, $statusCode, $previous); @@ -37,4 +49,22 @@ public function getResponseBody(): ?array { return $this->responseBody; } + + public function getApiErrors(): array + { + return $this->apiErrors; + } + + /** @return list */ + public function getApiErrorCodes(): array + { + $codes = []; + foreach ($this->apiErrors as $error) { + $code = $error['error_code'] ?? null; + if (is_string($code) || is_int($code)) { + $codes[] = (string) $code; + } + } + return $codes; + } } diff --git a/src/Exceptions/InvalidResponseException.php b/src/Exceptions/InvalidResponseException.php index 7c3a7c5..5666392 100644 --- a/src/Exceptions/InvalidResponseException.php +++ b/src/Exceptions/InvalidResponseException.php @@ -5,7 +5,7 @@ use Throwable; /** - * Thrown when the API response cannot be decoded as valid JSON. + * Thrown when the API response does not match the expected format. */ class InvalidResponseException extends ClaimMDException { @@ -15,13 +15,14 @@ class InvalidResponseException extends ClaimMDException public function __construct( int $statusCode, string $rawBody, - ?Throwable $previous = null + ?Throwable $previous = null, + string $expectedFormat = 'JSON' ) { $this->statusCode = $statusCode; $this->rawBody = $rawBody; parent::__construct( - "Claim.MD API returned a non-JSON response (HTTP {$statusCode})", + "Claim.MD API returned a non-{$expectedFormat} response (HTTP {$statusCode})", $statusCode, $previous ); diff --git a/src/Requests/ClaimRequest.php b/src/Requests/ClaimRequest.php index ccef30b..28cf782 100644 --- a/src/Requests/ClaimRequest.php +++ b/src/Requests/ClaimRequest.php @@ -2,9 +2,12 @@ namespace Nextvisit\ClaimMD\Requests; +use Generator; use GuzzleHttp\Exception\GuzzleException; +use InvalidArgumentException; use Nextvisit\ClaimMD\Client; use Nextvisit\ClaimMD\DTO\ClaimAppealDTO; +use Nextvisit\ClaimMD\Exceptions\ApiException; use Nextvisit\ClaimMD\Exceptions\ClaimMDException; /** @@ -17,6 +20,7 @@ class ClaimRequest private const string MODIFY_ENDPOINT = '/services/modify/'; private const string APPEAL_ENDPOINT = '/services/appeal/'; private const string NOTES_ENDPOINT = '/services/notes/'; + private const string CLAIM_DATA_ENDPOINT = '/services/claimdata/'; /** * ClaimRequest constructor. @@ -28,19 +32,86 @@ public function __construct(private readonly Client $client) } /** - * Archives a claim with the given claim ID. + * Archives one or more claims. * - * @param string $claimId The ID of the claim to be archived. + * @param string|list $claimId Claim ID or a non-empty list of claim IDs. * * @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 + public function archive(string|array $claimId): array { + if (is_array($claimId)) { + if ($claimId === [] || !array_is_list($claimId)) { + throw new InvalidArgumentException('Claim IDs must be a non-empty list of strings'); + } + foreach ($claimId as $id) { + if (!is_string($id) || $id === '') { + throw new InvalidArgumentException('Each claim ID must be a non-empty string'); + } + } + + return $this->client->sendRequest('POST', self::ARCHIVE_ENDPOINT, ['claimid' => $claimId], repeatFormFields: true); + } + return $this->client->sendRequest('POST', self::ARCHIVE_ENDPOINT, ['claimid' => $claimId]); } + public function downloadTransmittedClaims( + string $transmitDate, + string $claimForm, + ?string $billNpi = null, + ?string $billTaxId = null, + ?string $payerId = null, + int $page = 0 + ): string { + if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $transmitDate)) { + throw new InvalidArgumentException('Transmit date must be in the format yyyy-mm-dd'); + } + $parts = explode('-', $transmitDate); + if (!checkdate((int) $parts[1], (int) $parts[2], (int) $parts[0])) { + throw new InvalidArgumentException('Transmit date must be a valid calendar date in the format yyyy-mm-dd'); + } + if (!in_array($claimForm, ['1500', 'ub', 'dental'], true)) { + throw new InvalidArgumentException('Claim form must be one of: 1500, ub, dental'); + } + if ($page < 0) { + throw new InvalidArgumentException('Page must be zero or greater'); + } + + return $this->client->sendX12Request('POST', self::CLAIM_DATA_ENDPOINT, array_filter([ + 'transmit_date' => $transmitDate, + 'claim_form' => $claimForm, + 'bill_npi' => $billNpi, + 'bill_taxid' => $billTaxId, + 'payerid' => $payerId, + 'pg' => $page, + ], fn($value) => $value !== null)); + } + + /** @return Generator */ + public function downloadAllTransmittedClaims( + string $transmitDate, + string $claimForm, + ?string $billNpi = null, + ?string $billTaxId = null, + ?string $payerId = null + ): Generator { + for ($page = 0; ; $page++) { + try { + $x12 = $this->downloadTransmittedClaims($transmitDate, $claimForm, $billNpi, $billTaxId, $payerId, $page); + } catch (ApiException $e) { + if ($e->getStatusCode() === 200 && $e->getApiErrorCodes() === ['711']) { + return; + } + throw $e; + } + + yield $page => $x12; + } + } + /** * Retrieves a list of modifications based on provided parameters. * @@ -88,4 +159,4 @@ public function notes(?string $noteId = null, ?string $claimMdId = null): array { return $this->client->sendRequest('POST', self::NOTES_ENDPOINT, array_filter(['ClaimMD_ID' => $claimMdId, 'NoteID' => $noteId], fn($value) => $value !== null)); } -} \ No newline at end of file +} diff --git a/src/Requests/FileRequest.php b/src/Requests/FileRequest.php index f6bfa98..3e2b308 100644 --- a/src/Requests/FileRequest.php +++ b/src/Requests/FileRequest.php @@ -15,8 +15,8 @@ */ class FileRequest { - private const string UPLOAD_ENDPOINT = '/services/upload'; - private const string UPLOAD_LIST_ENDPOINT = '/services/uploadlist'; + private const string UPLOAD_ENDPOINT = '/services/upload/'; + private const string UPLOAD_LIST_ENDPOINT = '/services/uploadlist/'; /** * FileRequest constructor. @@ -102,4 +102,4 @@ private function prepareFile(mixed $file): StreamInterface throw new InvalidArgumentException('Invalid file provided. Must be a resource.'); } -} \ No newline at end of file +} diff --git a/tests/Unit/ClientTest.php b/tests/Unit/ClientTest.php index 0d1f50b..c28155c 100644 --- a/tests/Unit/ClientTest.php +++ b/tests/Unit/ClientTest.php @@ -1,9 +1,11 @@ toContain('data=value'); }); - it('sends a multipart request when isMultipart is true', function () { + it('sends upload boundaries matching the request body', function (string $requestClass, string $method, string $endpoint) { $container = []; $history = Middleware::history($container); @@ -70,14 +74,35 @@ $guzzleClient = new GuzzleClient(['handler' => $handlerStack]); $client = new Client('test-account-key', new Config(), $guzzleClient); - $result = $client->sendRequest('POST', '/upload', ['file' => 'content'], true); + $file = fopen('php://temp', 'w+'); + fwrite($file, 'synthetic X12 upload'); + rewind($file); - expect($result)->toBe(['uploaded' => true]); - - $request = $container[0]['request']; - $contentType = $request->getHeaderLine('Content-Type'); - expect($contentType)->toContain('multipart/form-data'); - }); + try { + $result = (new $requestClass($client))->$method($file); + expect($result)->toBe(['uploaded' => true]); + + $request = $container[0]['request']; + expect($request->getUri()->getPath())->toBe($endpoint); + expect($request->getHeaderLine('Content-Type'))->toContain('multipart/form-data'); + expect(preg_match('/boundary=([^;]+)/', $request->getHeaderLine('Content-Type'), $matches))->toBe(1); + $boundary = trim($matches[1], '"'); + expect((string) $request->getBody()) + ->toStartWith("--{$boundary}\r\n") + ->toContain("\r\n--{$boundary}--\r\n") + ->toContain('name="File"') + ->toContain('synthetic X12 upload') + ->toContain('name="AccountKey"') + ->toContain('test-account-key'); + } finally { + if (is_resource($file)) { + fclose($file); + } + } + })->with([ + 'batch upload' => [FileRequest::class, 'upload', '/services/upload/'], + '270 eligibility' => [EligibilityRequest::class, 'checkEligibility270271', '/services/elig/'], + ]); it('includes additional headers when provided', function () { $container = []; @@ -240,4 +265,101 @@ expect($e->getStatusCode())->toBe(200); } }); + + it('throws for HTTP 200 API errors', function (array $errors, array $codes, string $message, string $method) { + $body = ['error' => $errors, 'request_id' => 'synthetic-request']; + $mock = new MockHandler([new Response(200, ['Content-Type' => 'application/json'], json_encode($body))]); + $client = new Client('test-key', httpClient: new GuzzleClient(['handler' => HandlerStack::create($mock)])); + + try { + $client->$method('POST', '/test'); + $this->fail('Expected an API exception'); + } catch (ApiException $e) { + expect($e::class)->toBe(ApiException::class); + expect($e->getStatusCode())->toBe(200); + expect($e->getCode())->toBe(200); + expect($e->getResponseBody())->toBe($body); + expect($e->getApiErrorCodes())->toBe($codes); + expect($e->getApiErrors())->toBe(array_is_list($errors) ? $errors : [$errors]); + expect($e->getMessage())->toBe($message); + } + })->with([ + 'object with error_mesg' => [['error_code' => '401', 'error_mesg' => 'Invalid claim_form value.'], ['401'], 'Invalid claim_form value.'], + 'list with error_message' => [[['error_code' => 20, 'error_message' => 'Invalid AccountKey']], ['20'], 'Invalid AccountKey'], + 'object with error_message' => [['error_code' => 711, 'error_message' => 'No claims found.'], ['711'], 'No claims found.'], + 'list with mixed message fields' => [[ + ['error_code' => '710', 'error_mesg' => 'Invalid transmit_date value.'], + ['error_code' => 401, 'error_message' => 'Invalid claim_form value.'], + ], ['710', '401'], 'Invalid transmit_date value.; Invalid claim_form value.'], + ])->with(['sendRequest', 'sendX12Request']); + + it('returns claim status data with an empty top-level error', function () { + $body = ['error' => [], 'claim' => [['claimid' => '123', 'error' => ['error_code' => '20']]]]; + $mock = new MockHandler([new Response(200, [], json_encode($body))]); + $client = new Client('test-key', httpClient: new GuzzleClient(['handler' => HandlerStack::create($mock)])); + + expect($client->sendRequest('POST', '/services/response/'))->toBe($body); + }); + + it('returns exact X12 bytes and requests JSON error bodies', function () { + $x12 = "ISA*00* *00* ~\r\nST*837*0001~\r\nSE*2*0001~\r\n"; + $container = []; + $mock = new MockHandler([new Response(200, ['Content-Type' => 'Application/EDI-X12; charset=us-ascii'], $x12)]); + $stack = HandlerStack::create($mock); + $stack->push(Middleware::history($container)); + $client = new Client('test-key', httpClient: new GuzzleClient(['handler' => $stack])); + + expect($client->sendX12Request('POST', '/services/claimdata/', ['AccountKey' => 'caller-key']))->toBe($x12); + expect($container[0]['request']->getHeaderLine('Accept'))->toBe('application/json'); + expect((string) $container[0]['request']->getBody())->toBe('AccountKey=test-key'); + }); + + it('rejects unexpected X12 response formats', function (string $contentType, string $body) { + $mock = new MockHandler([new Response(200, ['Content-Type' => $contentType], $body)]); + $client = new Client('test-key', httpClient: new GuzzleClient(['handler' => HandlerStack::create($mock)])); + + try { + $client->sendX12Request('POST', '/services/claimdata/'); + $this->fail('Expected an invalid response exception'); + } catch (InvalidResponseException $e) { + expect($e->getRawBody())->toBe($body); + expect($e->getStatusCode())->toBe(200); + expect($e->getMessage())->toContain('non-X12 response'); + } + })->with([ + ['text/html', 'Error'], + ['application/json', '{"status":"ok"}'], + ]); + + it('keeps HTTP errors on X12 requests', function (bool $httpErrors) { + $body = ['error' => ['error_code' => '711', 'error_mesg' => 'No claims found.']]; + $mock = new MockHandler([new Response(429, ['Retry-After' => '30'], json_encode($body))]); + $client = new Client('test-key', httpClient: new GuzzleClient([ + 'handler' => HandlerStack::create($mock), + 'http_errors' => $httpErrors, + ])); + + try { + $client->sendX12Request('POST', '/services/claimdata/'); + $this->fail('Expected a rate limit exception'); + } catch (RateLimitException $e) { + expect($e->getStatusCode())->toBe(429); + expect($e->getRetryAfter())->toBe(30); + expect($e->getApiErrorCodes())->toBe(['711']); + expect($e->getResponseBody())->toBe($body); + } + })->with([true, false]); + + it('passes through request failures without a response', function (string $method) { + $exception = RequestException::create(new Request('POST', '/test')); + $mock = new MockHandler([$exception]); + $client = new Client('test-key', httpClient: new GuzzleClient(['handler' => HandlerStack::create($mock)])); + + try { + $client->$method('POST', '/test'); + $this->fail('Expected the request exception'); + } catch (RequestException $e) { + expect($e)->toBe($exception); + } + })->with(['sendRequest', 'sendX12Request']); }); diff --git a/tests/Unit/DTO/ProviderEnrollmentDTOTest.php b/tests/Unit/DTO/ProviderEnrollmentDTOTest.php index 3b36553..e0225f7 100644 --- a/tests/Unit/DTO/ProviderEnrollmentDTOTest.php +++ b/tests/Unit/DTO/ProviderEnrollmentDTOTest.php @@ -31,6 +31,24 @@ expect($dto->provNameFirst)->toBe('John'); }); + it('creates an organization without NPI or first name and excludes the SDK flag from API fields', function () { + $dto = new ProviderEnrollmentDTO( + payerId: 'PAYER123', + enrollType: 'era', + provTaxId: '12-3456789', + provNameLast: 'Example Clinic', + isOrganization: true + ); + + expect($dto->isOrganization)->toBeTrue(); + expect($dto->toArray())->toBe([ + 'payerid' => 'PAYER123', + 'enroll_type' => 'era', + 'prov_taxid' => '12-3456789', + 'prov_name_l' => 'Example Clinic', + ]); + }); + it('creates a DTO with all optional fields', function () { $dto = new ProviderEnrollmentDTO( payerId: 'PAYER123', @@ -121,6 +139,15 @@ ); })->throws(InvalidArgumentException::class, 'provNameLast is required when provNpi is not provided.'); + it('throws exception when NPI not provided and organization name is missing', function () { + new ProviderEnrollmentDTO( + payerId: 'PAYER123', + enrollType: 'era', + provTaxId: '12-3456789', + isOrganization: true + ); + })->throws(InvalidArgumentException::class, 'provNameLast is required when provNpi is not provided.'); + it('throws exception when NPI not provided and provNameFirst is missing for individual', function () { new ProviderEnrollmentDTO( payerId: 'PAYER123', @@ -200,6 +227,20 @@ }); describe('fromArray', function () { + it('creates an organization from API fields and excludes the SDK flag from API fields', function () { + $data = [ + 'payerid' => 'PAYER123', + 'enroll_type' => 'era', + 'prov_taxid' => '12-3456789', + 'prov_name_l' => 'Example Clinic', + ]; + + $dto = ProviderEnrollmentDTO::fromArray($data, isOrganization: true); + + expect($dto->isOrganization)->toBeTrue(); + expect($dto->toArray())->toBe($data); + }); + it('creates a DTO from array', function () { $data = [ 'payerid' => 'PAYER123', diff --git a/tests/Unit/Requests/ClaimRequestTest.php b/tests/Unit/Requests/ClaimRequestTest.php index 0c2c8c0..56612c5 100644 --- a/tests/Unit/Requests/ClaimRequestTest.php +++ b/tests/Unit/Requests/ClaimRequestTest.php @@ -1,7 +1,13 @@ toBe(['status' => 'archived']); }); + + it('encodes claim IDs as repeated form keys', function (string|array $claimIds, string $encoded) { + $container = []; + $mock = new MockHandler([new Response(200, [], '{"status":"archived"}')]); + $stack = HandlerStack::create($mock); + $stack->push(Middleware::history($container)); + $client = new Client('test-key', httpClient: new GuzzleClient(['handler' => $stack])); + + expect((new ClaimRequest($client))->archive($claimIds))->toBe(['status' => 'archived']); + $request = $container[0]['request']; + expect($request->getUri()->getPath())->toBe('/services/archive/'); + expect($request->getHeaderLine('Content-Type'))->toBe('application/x-www-form-urlencoded'); + expect((string) $request->getBody())->toBe($encoded . '&AccountKey=test-key'); + })->with([ + 'single claim' => ['A&1', 'claimid=A%261'], + 'multiple claims' => [['A&1', 'B 2'], 'claimid=A%261&claimid=B+2'], + ]); + + it('rejects invalid claim ID lists', function (array $claimIds) { + $this->claimRequest->archive($claimIds); + })->with([ + 'empty' => [[]], + 'empty ID' => [['']], + 'non-string ID' => [[123]], + 'named keys' => [['first' => '123']], + ])->throws(InvalidArgumentException::class); + }); + + describe('downloadTransmittedClaims', function () { + it('downloads the first page for each claim form', function (string $claimForm) { + $this->mockClient->shouldReceive('sendX12Request')->once() + ->with('POST', '/services/claimdata/', [ + 'transmit_date' => '2024-02-29', + 'claim_form' => $claimForm, + 'pg' => 0, + ]) + ->andReturn('ISA*synthetic-837~'); + + expect($this->claimRequest->downloadTransmittedClaims('2024-02-29', $claimForm))->toBe('ISA*synthetic-837~'); + })->with(['1500', 'ub', 'dental']); + + it('maps all download filters and an explicit page', function () { + $this->mockClient->shouldReceive('sendX12Request')->once() + ->with('POST', '/services/claimdata/', [ + 'transmit_date' => '2026-08-19', + 'claim_form' => 'ub', + 'bill_npi' => '1234567890', + 'bill_taxid' => '123456789', + 'payerid' => 'PAYER123', + 'pg' => 2, + ]) + ->andReturn('ISA*synthetic-837I~'); + + expect($this->claimRequest->downloadTransmittedClaims( + '2026-08-19', 'ub', '1234567890', '123456789', 'PAYER123', 2 + ))->toBe('ISA*synthetic-837I~'); + }); + + it('rejects invalid download parameters', function (string $date, string $form, int $page) { + $this->claimRequest->downloadTransmittedClaims($date, $form, page: $page); + })->with([ + ['08/19/2026', '1500', 0], + ['2026-02-29', '1500', 0], + ['2026-08-19', '500', 0], + ['2026-08-19', '1500', -1], + ])->throws(InvalidArgumentException::class); + + it('throws the no-claims error for a single page', function () { + $error = new ApiException(200, ['error' => ['error_code' => '711', 'error_mesg' => 'No claims found.']]); + $this->mockClient->shouldReceive('sendX12Request')->once()->andThrow($error); + + $this->claimRequest->downloadTransmittedClaims('2026-08-19', '1500'); + })->throws(ApiException::class, 'No claims found.'); + }); + + describe('downloadAllTransmittedClaims', function () { + it('yields each X12 page and stops on code 711', function () { + $pages = ["ISA*page-zero~\r\n", "ISA*page-one~\r\n"]; + $container = []; + $mock = new MockHandler([ + new Response(200, ['Content-Type' => 'application/edi-x12'], $pages[0]), + new Response(200, ['Content-Type' => 'application/edi-x12'], $pages[1]), + new Response(200, ['Content-Type' => 'application/json'], '{"error":{"error_code":"711","error_mesg":"No claims found."}}'), + ]); + $stack = HandlerStack::create($mock); + $stack->push(Middleware::history($container)); + $client = new Client('test-key', httpClient: new GuzzleClient(['handler' => $stack])); + $request = new ClaimRequest($client); + $generator = $request->downloadAllTransmittedClaims('2026-08-19', 'dental', '1234567890', '123456789', 'PAYER123'); + + expect($container)->toBe([]); + expect(iterator_to_array($generator))->toBe($pages); + expect($container)->toHaveCount(3); + foreach ($container as $page => $transaction) { + parse_str((string) $transaction['request']->getBody(), $data); + expect($data)->toBe([ + 'transmit_date' => '2026-08-19', + 'claim_form' => 'dental', + 'bill_npi' => '1234567890', + 'bill_taxid' => '123456789', + 'payerid' => 'PAYER123', + 'pg' => (string) $page, + 'AccountKey' => 'test-key', + ]); + } + }); + + it('yields no pages when the first response has code 711', function (array $errors) { + $mock = new MockHandler([new Response(200, [], json_encode(['error' => $errors]))]); + $client = new Client('test-key', httpClient: new GuzzleClient(['handler' => HandlerStack::create($mock)])); + + expect(iterator_to_array((new ClaimRequest($client))->downloadAllTransmittedClaims('2026-08-19', '1500')))->toBe([]); + })->with([ + 'object' => [['error_code' => '711', 'error_mesg' => 'No claims found.']], + 'list' => [[['error_code' => 711, 'error_message' => 'No claims found.']]], + ]); + + it('throws other API and HTTP errors', function (int $status, array $errors) { + $body = ['error' => $errors]; + $mock = new MockHandler([new Response($status, [], json_encode($body))]); + $client = new Client('test-key', httpClient: new GuzzleClient(['handler' => HandlerStack::create($mock)])); + + try { + iterator_to_array((new ClaimRequest($client))->downloadAllTransmittedClaims('2026-08-19', '1500')); + $this->fail('Expected an API exception'); + } catch (ApiException $e) { + expect($e->getStatusCode())->toBe($status); + expect($e->getResponseBody())->toBe($body); + } + })->with([ + 'invalid date' => [200, ['error_code' => '710', 'error_mesg' => 'Invalid transmit_date value.']], + 'invalid form' => [200, ['error_code' => '401', 'error_mesg' => 'Invalid claim_form value.']], + 'mixed errors' => [200, [['error_code' => '711'], ['error_code' => '20']]], + 'HTTP error with 711' => [500, ['error_code' => '711']], + ]); }); describe('listModifications', function () { diff --git a/tests/Unit/Requests/FileRequestTest.php b/tests/Unit/Requests/FileRequestTest.php index f4ebcbf..fce92ef 100644 --- a/tests/Unit/Requests/FileRequestTest.php +++ b/tests/Unit/Requests/FileRequestTest.php @@ -18,7 +18,7 @@ $this->mockClient ->shouldReceive('sendRequest') ->once() - ->with('POST', '/services/uploadlist', []) + ->with('POST', '/services/uploadlist/', []) ->andReturn(['uploads' => []]); $result = $this->fileRequest->getUploadList(); @@ -30,7 +30,7 @@ $this->mockClient ->shouldReceive('sendRequest') ->once() - ->with('POST', '/services/uploadlist', ['Page' => 2]) + ->with('POST', '/services/uploadlist/', ['Page' => 2]) ->andReturn(['uploads' => [], 'page' => 2]); $result = $this->fileRequest->getUploadList(page: 2); @@ -42,7 +42,7 @@ $this->mockClient ->shouldReceive('sendRequest') ->once() - ->with('POST', '/services/uploadlist', ['UploadDate' => '2024-01-15']) + ->with('POST', '/services/uploadlist/', ['UploadDate' => '2024-01-15']) ->andReturn(['uploads' => []]); $result = $this->fileRequest->getUploadList(uploadDate: '2024-01-15'); @@ -54,7 +54,7 @@ $this->mockClient ->shouldReceive('sendRequest') ->once() - ->with('POST', '/services/uploadlist', ['Page' => 1, 'UploadDate' => '2024-01-15']) + ->with('POST', '/services/uploadlist/', ['Page' => 1, 'UploadDate' => '2024-01-15']) ->andReturn(['uploads' => []]); $result = $this->fileRequest->getUploadList(1, '2024-01-15');