feat(subscription): allow admin to delete a granted subscription

Granting stacks a new row and extends the existing expiry, so a mistaken
grant had no way back: the report tab was read-only and no endpoint deleted
a subscription (only DELETE .../subscription/period/{uuid}, a different
resource).

Adds DELETE /api/v1/admin/subscription/{uuid}. Payment-backed subscriptions
are refused with 409 — deleting one would leave the sales report with a
payment that owns nothing; refunds are the correct path there.

The report tab gets a per-row delete with a confirm dialog, plus a button
that jumps to the grant tab so add and remove live in the same place.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-19 21:59:12 +03:30
co-authored by Claude Opus 5
parent 5eb3bd586f
commit a1584c3296
6 changed files with 239 additions and 3 deletions
@@ -0,0 +1,78 @@
<?php
namespace App\Tests\Subscription;
use App\Config\Repository\SiteConfigRepository;
use App\Payment\Entity\Payment;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Exception\AppException;
use App\Subscription\Entity\ClinicSubscription;
use App\Subscription\Repository\ClinicSubscriptionRepository;
use App\Subscription\Repository\SubscriptionPeriodRepository;
use App\Subscription\Repository\SubscriptionPlanRepository;
use App\Subscription\Service\SubscriptionService;
use PHPUnit\Framework\TestCase;
/**
* Revoking undoes an admin grant. A payment-backed subscription is out of scope:
* deleting it would leave the sales report with a payment that owns nothing.
*/
class RevokeSubscriptionTest extends TestCase
{
private ?ClinicSubscription $removed = null;
private function service(?ClinicSubscription $found): SubscriptionService
{
$subscriptionRepo = $this->createStub(ClinicSubscriptionRepository::class);
$subscriptionRepo->method('findByUuid')->willReturn($found);
$subscriptionRepo->method('remove')->willReturnCallback(function (ClinicSubscription $s): void {
$this->removed = $s;
});
return new SubscriptionService(
$subscriptionRepo,
$this->createStub(SubscriptionPlanRepository::class),
$this->createStub(SubscriptionPeriodRepository::class),
$this->createStub(SiteConfigRepository::class),
);
}
public function testGrantedSubscriptionIsRemoved(): void
{
$subscription = $this->createStub(ClinicSubscription::class);
$subscription->method('getPayment')->willReturn(null);
$this->service($subscription)->revoke('sub-uuid');
$this->assertSame($subscription, $this->removed);
}
public function testUnknownUuidIsRejected(): void
{
try {
$this->service(null)->revoke('missing-uuid');
$this->fail('Expected AppException');
} catch (AppException $e) {
$this->assertSame(ErrorCodes::ERR_SUBSCRIPTION_NOT_FOUND, $e->getErrorCode());
$this->assertSame(404, $e->getHttpStatus());
}
$this->assertNull($this->removed);
}
public function testPaidSubscriptionIsNotRemoved(): void
{
$subscription = $this->createStub(ClinicSubscription::class);
$subscription->method('getPayment')->willReturn($this->createStub(Payment::class));
try {
$this->service($subscription)->revoke('sub-uuid');
$this->fail('Expected AppException');
} catch (AppException $e) {
$this->assertSame(ErrorCodes::ERR_CONFLICT_001, $e->getErrorCode());
$this->assertSame(409, $e->getHttpStatus());
}
$this->assertNull($this->removed);
}
}