Phase 5, the last of the tenant-marking series. The Doctrine filter added in phase 4 does not see raw DBAL, so every place that writes SQL by hand was read and classified rather than assumed safe. The audit found no code to fix. ClaimRepository was the only tenant-owning table reached by raw SQL, and all three of its queries already close on c.entity_type/:entity_id. That protection had no test, so it now has one: the claims dashboard is the only tenant surface whose isolation depends entirely on a hand-written WHERE, and nothing would have reported its removal. Everything else falls outside the question. AdminApiController is cross-tenant on purpose behind a class-level ROLE_ADMIN. RepresentationActionController only counts doctors, scoped by representation_id. CategoryImporter interpolates a table name, but it comes from a hardcoded const map behind isValidBundle() and ROLE_ADMIN, so it cannot be steered by input. The purge and seed commands are console-only, dry-run by default, and blocked from prod at the kernel. The health check is SELECT 1 and the logger writes to a global table. getReference() appears once in src, on User, which is global. app:tenant:dump gives one environment's rows as SQL — the practical benefit of database-per-tenant without its cost. It reads the table list from metadata using the same test the filter applies, so a table that gains a tenant pair later is included automatically instead of being silently missed. The --tenant value ends up inside a --where clause and an argv entry, so it is validated by a closed regex rather than escaped; seven malformed inputs are covered, including SQL and shell injection attempts. Verified by running it against the dev database: a real clinic produced 20 tables with only that clinic's rows and no doctor-owned row, an unknown id exited non-zero with a Persian message, "clinic:1 OR 1=1" was refused, and a tenant with no data still produced a valid file. Not verified: browser-level checks of the admin panel and the public site. The OTP login is behind an Altcha proof-of-work, so no interactive token was obtained. What was checked instead: the admin SPA type-checks clean, the public doctor and specialty endpoints answer 200 with cross-tenant results, and neither nobat724_front nor clinic-pro-tauri references owner_type, owner_id, clinic_key or db_type anywhere. The functional suite already exercises the same HTTP path with real JWTs and the subscriber active. Tests: 856 passing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
207 lines
8.4 KiB
PHP
207 lines
8.4 KiB
PHP
<?php
|
|
|
|
namespace App\Tests\Billing;
|
|
|
|
use App\Auth\Entity\User;
|
|
use App\Billing\Entity\Claim;
|
|
use App\Billing\Entity\ClaimItem;
|
|
use App\Billing\Entity\Invoice;
|
|
use App\Billing\Entity\InvoiceItem;
|
|
use App\Billing\ValueObject\ShareBreakdown;
|
|
use App\Doctor\Entity\Doctor;
|
|
use App\Patient\Entity\PatientRecord;
|
|
use App\Tests\ApiTestCase;
|
|
|
|
/**
|
|
* GET /api/v1/billing/claims/by-patient — the claims dashboard's first level.
|
|
*
|
|
* A claim reaches its patient only through claim_item → invoice_item → invoice →
|
|
* patient_record, and one invoice can carry both a base and a supplementary claim.
|
|
* These tests pin the aggregation against double-counting the service amount.
|
|
*/
|
|
class ClaimsByPatientTest extends ApiTestCase
|
|
{
|
|
private User $owner;
|
|
private Doctor $doctor;
|
|
private PatientRecord $record;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
|
|
$this->owner = $this->createUser(['ROLE_DOCTOR']);
|
|
$this->doctor = new Doctor($this->owner, 'دکتر تست');
|
|
$this->em->persist($this->doctor);
|
|
$this->em->flush();
|
|
|
|
$patient = $this->createUser(['ROLE_USER']);
|
|
$this->record = new PatientRecord('doctor', $this->doctor->getId(), $patient, 'doctor', $this->doctor->getId());
|
|
$this->em->persist($this->record);
|
|
$this->em->flush();
|
|
}
|
|
|
|
/** Invoice of $total split into insurance/patient shares, with its claim(s). */
|
|
private function invoiceWithClaims(int $total, int $baseShare, int $suppShare, array $statuses = ['pending']): Invoice
|
|
{
|
|
$patient = $total - $baseShare - $suppShare;
|
|
|
|
$invoice = new Invoice('doctor', $this->doctor->getId());
|
|
$invoice->setPatientRecordId((int) $this->record->getId());
|
|
$item = new InvoiceItem($invoice, 'جراحی', $total, 1, new ShareBreakdown($total, $baseShare, $suppShare, $patient));
|
|
$invoice->addItem($item);
|
|
$invoice->recalculateTotals();
|
|
$this->em->persist($invoice);
|
|
$this->em->persist($item);
|
|
$this->em->flush();
|
|
|
|
foreach ($statuses as $i => $status) {
|
|
$kind = $i === 0 ? Claim::KIND_BASE : Claim::KIND_SUPPLEMENTARY;
|
|
$share = $i === 0 ? $baseShare : $suppShare;
|
|
|
|
$claim = new Claim('doctor', $this->doctor->getId(), 1 + $i, $kind);
|
|
$claimItem = new ClaimItem($claim, (int) $item->getId(), $share);
|
|
$claim->addItem($claimItem);
|
|
if ($status !== Claim::STATUS_PENDING) {
|
|
$claim->submit();
|
|
}
|
|
if ($status === Claim::STATUS_PAID) {
|
|
$claim->approve($share);
|
|
$claim->pay($share);
|
|
}
|
|
$this->em->persist($claim);
|
|
$this->em->persist($claimItem);
|
|
}
|
|
$this->em->flush();
|
|
|
|
return $invoice;
|
|
}
|
|
|
|
public function testAggregatesOneRowPerPatientWithConsistentShares(): void
|
|
{
|
|
$this->invoiceWithClaims(10_000_000, 7_000_000, 0);
|
|
|
|
$res = $this->authJson('GET', '/api/v1/billing/claims/by-patient', $this->owner);
|
|
self::assertSame(200, $this->responseCode());
|
|
self::assertCount(1, $res['data']);
|
|
|
|
$row = $res['data'][0];
|
|
self::assertSame(1, $row['claims_count']);
|
|
self::assertSame(10_000_000, $row['total_services_rials']);
|
|
self::assertSame(7_000_000, $row['total_insurance_rials']);
|
|
self::assertSame(3_000_000, $row['total_patient_rials']);
|
|
self::assertSame('pending', $row['overall_status']);
|
|
}
|
|
|
|
public function testServiceTotalIsNotDoubleCountedWhenAnInvoiceHasTwoClaims(): void
|
|
{
|
|
// پایه و مکمل روی یک صورتحساب: مبلغ خدمات باید یکبار شمرده شود.
|
|
$this->invoiceWithClaims(10_000_000, 6_000_000, 2_000_000, ['pending', 'pending']);
|
|
|
|
$res = $this->authJson('GET', '/api/v1/billing/claims/by-patient', $this->owner);
|
|
$row = $res['data'][0];
|
|
|
|
self::assertSame(2, $row['claims_count']);
|
|
self::assertSame(10_000_000, $row['total_services_rials']);
|
|
self::assertSame(8_000_000, $row['total_insurance_rials']);
|
|
self::assertSame(
|
|
$row['total_services_rials'],
|
|
$row['total_insurance_rials'] + $row['total_patient_rials'],
|
|
);
|
|
}
|
|
|
|
public function testOverallStatusIsMixedWhenClaimsDisagree(): void
|
|
{
|
|
$this->invoiceWithClaims(10_000_000, 6_000_000, 2_000_000, [Claim::STATUS_PAID, Claim::STATUS_PENDING]);
|
|
|
|
$res = $this->authJson('GET', '/api/v1/billing/claims/by-patient', $this->owner);
|
|
|
|
self::assertSame('mixed', $res['data'][0]['overall_status']);
|
|
}
|
|
|
|
public function testStatusFilterNarrowsTheAggregation(): void
|
|
{
|
|
$this->invoiceWithClaims(10_000_000, 7_000_000, 0);
|
|
|
|
$res = $this->authJson('GET', '/api/v1/billing/claims/by-patient?status=paid', $this->owner);
|
|
|
|
self::assertSame(0, $res['meta']['totalRecords']);
|
|
}
|
|
|
|
public function testDetailListsClaimsWithCoverageAndTimeline(): void
|
|
{
|
|
$this->invoiceWithClaims(10_000_000, 7_000_000, 0);
|
|
|
|
$res = $this->authJson('GET', '/api/v1/billing/claims/by-patient/' . $this->record->getUuid(), $this->owner);
|
|
self::assertSame(200, $this->responseCode());
|
|
|
|
$claim = $res['data']['claims'][0];
|
|
// JSON یک float گِرد را به int تبدیل میکند؛ مقایسهی نوعمحور اینجا معنا ندارد.
|
|
self::assertEquals(70, $claim['coverage_percent']);
|
|
self::assertSame(7_000_000, $claim['insurance_share_rials']);
|
|
self::assertSame(3_000_000, $claim['patient_share_rials']);
|
|
self::assertSame(['submitted'], $claim['allowed_transitions']);
|
|
}
|
|
|
|
public function testDetailRefusesARecordOfAnotherTenant(): void
|
|
{
|
|
$otherOwner = $this->createUser(['ROLE_DOCTOR']);
|
|
$otherDoctor = new Doctor($otherOwner, 'دکتر دیگر');
|
|
$this->em->persist($otherDoctor);
|
|
$this->em->flush();
|
|
|
|
$this->authJson('GET', '/api/v1/billing/claims/by-patient/' . $this->record->getUuid(), $otherOwner);
|
|
|
|
self::assertSame(404, $this->responseCode());
|
|
}
|
|
|
|
public function testSubmitStoresTheTrackingNumberAndLogsTheTransition(): void
|
|
{
|
|
$this->invoiceWithClaims(10_000_000, 7_000_000, 0);
|
|
$detail = $this->authJson('GET', '/api/v1/billing/claims/by-patient/' . $this->record->getUuid(), $this->owner);
|
|
$uuid = $detail['data']['claims'][0]['uuid'];
|
|
|
|
$this->authJson('POST', '/api/v1/billing/claims/' . $uuid . '/submit', $this->owner, [
|
|
'tracking_number' => 'TM-1',
|
|
]);
|
|
self::assertSame(200, $this->responseCode());
|
|
|
|
$after = $this->authJson('GET', '/api/v1/billing/claims/by-patient/' . $this->record->getUuid(), $this->owner);
|
|
$claim = $after['data']['claims'][0];
|
|
|
|
self::assertSame('TM-1', $claim['tracking_number']);
|
|
self::assertSame('submitted', $claim['status']);
|
|
self::assertSame('submitted', end($claim['logs'])['to_status']);
|
|
}
|
|
|
|
public function testRejectWithoutAReasonIsRefused(): void
|
|
{
|
|
$this->invoiceWithClaims(10_000_000, 7_000_000, 0, [Claim::STATUS_SUBMITTED]);
|
|
$detail = $this->authJson('GET', '/api/v1/billing/claims/by-patient/' . $this->record->getUuid(), $this->owner);
|
|
$uuid = $detail['data']['claims'][0]['uuid'];
|
|
|
|
$this->authJson('POST', '/api/v1/billing/claims/' . $uuid . '/reject', $this->owner, []);
|
|
|
|
self::assertSame(422, $this->responseCode());
|
|
}
|
|
|
|
/**
|
|
* این داشبورد با SQL خام ساخته میشود، پس TenantFilter آن را نمیبیند و تنها
|
|
* محافظش شرط دستیِ `c.entity_type/:entity_id` در ClaimRepository است. اگر آن
|
|
* WHERE روزی برداشته شود، هیچ چیز جز این تست خبر نمیدهد.
|
|
*/
|
|
public function testAnotherTenantsClaimsNeverAppearInTheDashboard(): void
|
|
{
|
|
$this->invoiceWithClaims(10_000_000, 7_000_000, 0);
|
|
|
|
$strangerUser = $this->createUser(['ROLE_DOCTOR']);
|
|
$strangerDoctor = new Doctor($strangerUser, 'دکتر بیگانه');
|
|
$this->em->persist($strangerDoctor);
|
|
$this->em->flush();
|
|
|
|
$res = $this->authJson('GET', '/api/v1/billing/claims/by-patient', $strangerUser);
|
|
|
|
self::assertSame(200, $this->responseCode());
|
|
self::assertSame([], $res['data'], 'مطالبات پزشک دیگر نباید دیده شود');
|
|
}
|
|
}
|