Three things kept a city-site booking from ever reaching its representative. The domain never resolved. City sites carry their own domain on cities.domain while a representative's coverage is a set of cities, and representations.domain is normally only filled for a global agent. The resolver looked at that column alone, so bookingRepId was always null and the commission guard rejected every booking made through a city site. It now falls back to the active representative covering that city, and stays null when two of them cover it — an ambiguous money assignment has to be resolved in the data, not guessed. Commission waited for confirmation. The money has already arrived when the gateway callback succeeds; confirming the appointment is the doctor's or secretary's job and may happen days later or never. It is now recorded on payment, with the appointment still pending. Recording is idempotent, so the confirmation path stays and creates nothing twice. The dashboard counted every appointment of the representative's doctors, including the ones a secretary typed into the panel. It now counts only bookings that came from the representative's own site. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
209 lines
8.7 KiB
PHP
209 lines
8.7 KiB
PHP
<?php
|
|
|
|
namespace App\Tests\Representation;
|
|
|
|
use App\Config\Repository\SiteConfigRepository;
|
|
use App\Location\Entity\City;
|
|
use App\Payment\Entity\Payment;
|
|
use App\Representation\Entity\Representation;
|
|
use App\Representation\Service\DomainContextResolver;
|
|
use App\Settlement\Repository\FinancialBreakdownRepository;
|
|
use App\Settlement\Service\CommissionService;
|
|
use App\Tests\ApiTestCase;
|
|
|
|
/**
|
|
* قانون کمیسیون دامنهمحور: کمیسیون فقط وقتی ثبت میشود که خرید از دامنهی نماینده
|
|
* انجام شده باشد و پزشک/کلینیک هم متعلق به همان نماینده باشد. DomainContextResolver
|
|
* تنها نقطهی نگاشت دامنه است.
|
|
*/
|
|
class DomainCommissionTest extends ApiTestCase
|
|
{
|
|
private function makeRep(?string $domain = null, bool $global = false, bool $active = true): Representation
|
|
{
|
|
$owner = $this->createUser(['ROLE_USER', 'ROLE_REPRESENTATION']);
|
|
$rep = new Representation($owner, 'نماینده دامنه');
|
|
$rep->setCommissionPercent('10');
|
|
if ($domain !== null) $rep->setDomain($domain);
|
|
$rep->setIsGlobal($global);
|
|
$rep->setActive($active);
|
|
$this->em->persist($rep);
|
|
$this->em->flush();
|
|
|
|
return $rep;
|
|
}
|
|
|
|
private function makePayment(string $frontendAddress): Payment
|
|
{
|
|
$payment = $this->stampTenant(new Payment($this->createUser(), 2_000_000, 'mock', Payment::TYPE_APPOINTMENT, $frontendAddress));
|
|
$this->em->persist($payment);
|
|
$this->em->flush();
|
|
|
|
return $payment;
|
|
}
|
|
|
|
private function resolver(): DomainContextResolver
|
|
{
|
|
return static::getContainer()->get(DomainContextResolver::class);
|
|
}
|
|
|
|
// ── DomainContextResolver ────────────────────────────────────────────────
|
|
|
|
public function testResolverMatchesRepresentationDomainWithNormalization(): void
|
|
{
|
|
$domain = 'rep-' . uniqid() . '.ir';
|
|
$rep = $this->makeRep($domain, global: true);
|
|
|
|
$ctx = $this->resolver()->resolve('https://www.' . $domain . ':443/payment/result');
|
|
|
|
$this->assertSame($rep->getId(), $ctx->representationId());
|
|
$this->assertTrue($ctx->isGlobalRepresentation);
|
|
$this->assertNull($ctx->city);
|
|
}
|
|
|
|
public function testResolverIgnoresInactiveRepresentation(): void
|
|
{
|
|
$domain = 'rep-' . uniqid() . '.ir';
|
|
$this->makeRep($domain, active: false);
|
|
|
|
$ctx = $this->resolver()->resolve($domain);
|
|
|
|
$this->assertNull($ctx->representation);
|
|
$this->assertFalse($ctx->isGlobalRepresentation);
|
|
}
|
|
|
|
public function testResolverUnknownDomainIsEmpty(): void
|
|
{
|
|
$ctx = $this->resolver()->resolve('nowhere-' . uniqid() . '.ir');
|
|
|
|
$this->assertNull($ctx->representation);
|
|
$this->assertNull($ctx->city);
|
|
}
|
|
|
|
public function testResolverMatchesByFirstLabelForLocalDev(): void
|
|
{
|
|
$prefix = 'repdev' . substr(uniqid(), -6);
|
|
$rep = $this->makeRep($prefix . '.ir', global: true);
|
|
|
|
// host لوکال با TLD متفاوت (`.localhost`) باید همان نماینده را بیابد.
|
|
$ctx = $this->resolver()->resolve($prefix . '.localhost');
|
|
|
|
$this->assertSame($rep->getId(), $ctx->representationId());
|
|
$this->assertTrue($ctx->isGlobalRepresentation);
|
|
}
|
|
|
|
/**
|
|
* سایتهای شهری دامنهٔ خودشان را دارند و نماینده ستون `domain` ندارد؛ پوشش
|
|
* نماینده شهری است. بدون این نگاشت، دامنهٔ شهری به هیچ نمایندهای نمیرسید و
|
|
* پورسانتِ همهٔ نوبتهای آن سایتها ساکت از بین میرفت.
|
|
*/
|
|
private function makeCity(string $domain): City
|
|
{
|
|
$city = new City('شهر تست ' . substr(uniqid(), -6));
|
|
$city->setDomain($domain);
|
|
$this->em->persist($city);
|
|
$this->em->flush();
|
|
|
|
return $city;
|
|
}
|
|
|
|
public function testResolverFallsBackToTheCityRepresentationForACityDomain(): void
|
|
{
|
|
$domain = 'city-' . substr(uniqid(), -6) . '-nobat.ir';
|
|
$city = $this->makeCity($domain);
|
|
$rep = $this->makeRep();
|
|
$rep->setCities([$city]);
|
|
$this->em->flush();
|
|
|
|
$ctx = $this->resolver()->resolve('https://' . $domain . '/payment/result');
|
|
|
|
$this->assertSame($rep->getId(), $ctx->representationId());
|
|
$this->assertSame($city->getId(), $ctx->city?->getId());
|
|
$this->assertFalse($ctx->isGlobalRepresentation, 'نمایندهٔ شهری سراسری نیست');
|
|
}
|
|
|
|
public function testRepresentationOwnDomainWinsOverTheCityRepresentation(): void
|
|
{
|
|
$domain = 'city-' . substr(uniqid(), -6) . '-nobat.ir';
|
|
$city = $this->makeCity($domain);
|
|
$cityRep = $this->makeRep();
|
|
$cityRep->setCities([$city]);
|
|
$domainRep = $this->makeRep($domain);
|
|
$this->em->flush();
|
|
|
|
$this->assertSame($domainRep->getId(), $this->resolver()->resolve($domain)->representationId());
|
|
}
|
|
|
|
/** دو نمایندهٔ فعال روی یک شهر یعنی انتساب پول مبهم است؛ حدس زده نمیشود. */
|
|
public function testAmbiguousCityCoverageResolvesToNoRepresentation(): void
|
|
{
|
|
$domain = 'city-' . substr(uniqid(), -6) . '-nobat.ir';
|
|
$city = $this->makeCity($domain);
|
|
$this->makeRep()->setCities([$city]);
|
|
$this->makeRep()->setCities([$city]);
|
|
$this->em->flush();
|
|
|
|
$ctx = $this->resolver()->resolve($domain);
|
|
|
|
$this->assertNull($ctx->representation);
|
|
$this->assertSame($city->getId(), $ctx->city?->getId());
|
|
}
|
|
|
|
public function testInactiveCityRepresentationIsIgnored(): void
|
|
{
|
|
$domain = 'city-' . substr(uniqid(), -6) . '-nobat.ir';
|
|
$city = $this->makeCity($domain);
|
|
$this->makeRep(active: false)->setCities([$city]);
|
|
$this->em->flush();
|
|
|
|
$this->assertNull($this->resolver()->resolve($domain)->representation);
|
|
}
|
|
|
|
// ── CommissionService: گارد دوشرطی ───────────────────────────────────────
|
|
|
|
public function testAppointmentCommissionOnlyWhenDomainOwnerMatchesDoctorOwner(): void
|
|
{
|
|
static::getContainer()->get(SiteConfigRepository::class)->set('appointment_commission_enabled', '1');
|
|
$commission = static::getContainer()->get(CommissionService::class);
|
|
$breakdowns = static::getContainer()->get(FinancialBreakdownRepository::class);
|
|
|
|
$rep = $this->makeRep('rep-' . uniqid() . '.ir');
|
|
$other = $this->makeRep('rep-' . uniqid() . '.ir');
|
|
|
|
// دامنهی نمایندهی دیگر → بدون کمیسیون
|
|
$p1 = $this->makePayment('https://' . $other->getDomain() . '/payment/result');
|
|
$commission->processAppointment($p1, $rep->getId(), $other->getId(), 7);
|
|
$this->assertFalse($breakdowns->existsForPayment($p1));
|
|
|
|
// بدون دامنه (پرداخت بدون frontend_address) → بدون کمیسیون
|
|
$p2 = $this->makePayment('');
|
|
$commission->processAppointment($p2, $rep->getId(), null, 7);
|
|
$this->assertFalse($breakdowns->existsForPayment($p2));
|
|
|
|
// دامنه و مالک یکی → کمیسیون ثبت میشود
|
|
$p3 = $this->makePayment('https://' . $rep->getDomain() . '/payment/result');
|
|
$commission->processAppointment($p3, $rep->getId(), $rep->getId(), 7);
|
|
$this->assertTrue($breakdowns->existsForPayment($p3));
|
|
}
|
|
|
|
public function testSubscriptionCommissionUsesSameDomainGuard(): void
|
|
{
|
|
$config = static::getContainer()->get(SiteConfigRepository::class);
|
|
$config->set('upgrade_commission_enabled', '1');
|
|
$config->set('upgrade_commission_percent', '20');
|
|
$commission = static::getContainer()->get(CommissionService::class);
|
|
$breakdowns = static::getContainer()->get(FinancialBreakdownRepository::class);
|
|
|
|
$rep = $this->makeRep('rep-' . uniqid() . '.ir');
|
|
|
|
// نمایندهی دامنه معلوم نیست → بدون کمیسیون (رفتار قدیمی کمیسیون میداد)
|
|
$p1 = $this->makePayment('');
|
|
$commission->processSubscription($p1, $rep->getId(), null, 5, null);
|
|
$this->assertFalse($breakdowns->existsForPayment($p1));
|
|
|
|
// دامنه و مالک یکی → کمیسیون
|
|
$p2 = $this->makePayment('https://' . $rep->getDomain() . '/panel');
|
|
$commission->processSubscription($p2, $rep->getId(), $rep->getId(), 5, null);
|
|
$this->assertTrue($breakdowns->existsForPayment($p2));
|
|
}
|
|
}
|