From a1584c329641cce4452ffc16c9ef277dd0ed1f90 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Wed, 19 Aug 2026 21:59:12 +0330 Subject: [PATCH] feat(subscription): allow admin to delete a granted subscription MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../pages/AdminSubscriptionPage.test.tsx | 46 ++++++++++- assets/admin/pages/AdminSubscriptionPage.tsx | 45 ++++++++++- docs/api/subscription.md | 29 +++++++ .../Controller/SubscriptionController.php | 19 +++++ .../Service/SubscriptionService.php | 25 ++++++ tests/Subscription/RevokeSubscriptionTest.php | 78 +++++++++++++++++++ 6 files changed, 239 insertions(+), 3 deletions(-) create mode 100644 tests/Subscription/RevokeSubscriptionTest.php diff --git a/assets/admin/pages/AdminSubscriptionPage.test.tsx b/assets/admin/pages/AdminSubscriptionPage.test.tsx index 07425219..d45b00fa 100644 --- a/assets/admin/pages/AdminSubscriptionPage.test.tsx +++ b/assets/admin/pages/AdminSubscriptionPage.test.tsx @@ -14,6 +14,7 @@ import AdminSubscriptionPage from './AdminSubscriptionPage'; const get = api.get as ReturnType; const patch = api.patch as ReturnType; const post = api.post as ReturnType; +const del = api.delete as ReturnType; const PLANS = [ { @@ -213,7 +214,27 @@ describe('AdminSubscriptionPage — اعطای اشتراک', () => { }); describe('AdminSubscriptionPage — گزارش', () => { - beforeEach(() => { get.mockReset(); post.mockReset(); }); + beforeEach(() => { get.mockReset(); post.mockReset(); del.mockReset(); }); + + /** یک ردیف گزارشِ اعطایی — تست‌های حذف و ناوبری از همین استفاده می‌کنند. */ + function mockReportRow() { + get.mockImplementation((url: string) => { + if (url.includes('/admin/subscription/plans')) return Promise.resolve({ success: true, data: PLANS }); + if (url.includes('/admin/subscription/report')) { + return Promise.resolve({ + success: true, + meta: { totalRecords: 1 }, + data: [{ + uuid: 's-1', entityType: 'clinic', entityId: 11, entityName: 'کلینیک نمونه', + isTrial: false, isGranted: true, grantedBy: 'ادمین', + startsAt: 1700000000, expiresAt: 1800000000, createdAt: 1700000000, + plan_name: 'professional', plan_level: 2, + }], + }); + } + return Promise.resolve({ success: true, data: [], meta: { totalRecords: 0 } }); + }); + } it('اشتراک اعطایی را «اعطایی» نشان می‌دهد، نه «پولی»', async () => { get.mockImplementation((url: string) => { @@ -241,4 +262,27 @@ describe('AdminSubscriptionPage — گزارش', () => { expect(screen.getByText('ادمین')).toBeInTheDocument(); expect(screen.queryByText('پولی')).not.toBeInTheDocument(); }); + + it('حذف اشتراک پس از تأیید، DELETE می‌فرستد', async () => { + mockReportRow(); + del.mockResolvedValue({ success: true, data: null }); + + renderWithProviders(, { route: '/admin/admin-subscription' }); + fireEvent.click(await screen.findByText('گزارش فروش')); + + fireEvent.click(await screen.findByLabelText('حذف اشتراک کلینیک نمونه')); + fireEvent.click(await screen.findByRole('button', { name: 'حذف' })); + + await waitFor(() => expect(del).toHaveBeenCalledWith('/api/v1/admin/subscription/s-1')); + }); + + it('دکمهٔ اعطای اشتراک از گزارش به تب اعطا می‌برد', async () => { + mockReportRow(); + + renderWithProviders(, { route: '/admin/admin-subscription' }); + fireEvent.click(await screen.findByText('گزارش فروش')); + fireEvent.click(await screen.findByRole('button', { name: /اعطای اشتراک جدید/ })); + + expect(await screen.findByText(/پلن و دوره/)).toBeInTheDocument(); + }); }); diff --git a/assets/admin/pages/AdminSubscriptionPage.tsx b/assets/admin/pages/AdminSubscriptionPage.tsx index c4b00b6c..882d9711 100644 --- a/assets/admin/pages/AdminSubscriptionPage.tsx +++ b/assets/admin/pages/AdminSubscriptionPage.tsx @@ -626,8 +626,10 @@ function GrantTab() { // ── Report tab ──────────────────────────────────────────────────────────── -function ReportTab() { +function ReportTab({ onAdd }: { onAdd: () => void }) { + const qc = useQueryClient(); const [page, setPage] = useState(1); + const [deleteRow, setDeleteRow] = useState(null); const limit = 20; const { data, isLoading } = useQuery({ @@ -635,11 +637,27 @@ function ReportTab() { queryFn: () => api.get>(`/api/v1/admin/subscription/report?page=${page}&limit=${limit}`), }); + const deleteMut = useMutation({ + mutationFn: (uuid: string) => api.delete(`/api/v1/admin/subscription/${uuid}`), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['admin-subscription-report'] }); + qc.invalidateQueries({ queryKey: ['admin-grant-active'] }); + setDeleteRow(null); + toast.success('اشتراک حذف شد'); + }, + onError: (e: any) => { setDeleteRow(null); toast.error(e.message); }, + }); + const rows: ReportRow[] = data?.data ?? []; const total = data?.meta?.totalRecords ?? 0; return (
+
+ +
{isLoading ? (
در حال بارگذاری...
) : rows.length === 0 ? ( @@ -655,6 +673,7 @@ function ReportTab() { شروع انقضا تاریخ ثبت + عملیات @@ -689,6 +708,17 @@ function ReportTab() { {row.expiresAt ? formatDate(row.expiresAt) : بی‌نهایت} {formatDate(row.createdAt)} + + + ))} @@ -698,6 +728,17 @@ function ReportTab() {
)} + + deleteRow && deleteMut.mutate(deleteRow.uuid)} + onCancel={() => setDeleteRow(null)} + /> ); } @@ -722,7 +763,7 @@ export default function AdminSubscriptionPage() { {tab === 'plans' && } {tab === 'grant' && } - {tab === 'report' && } + {tab === 'report' && setTab('grant')} />} ); } diff --git a/docs/api/subscription.md b/docs/api/subscription.md index b79fbbd3..26f916f8 100644 --- a/docs/api/subscription.md +++ b/docs/api/subscription.md @@ -340,6 +340,35 @@ callback مشترک همهٔ درگاه‌ها و همهٔ نوع‌های پر > پلن. پس اعطای پلنی پایین‌تر از پلن فعال، عملاً پلن مؤثر مقصد را کاهش می‌دهد. پنل > ادمین قبل از ثبت این حالت تأیید می‌گیرد؛ خودِ endpoint جلوی آن را نمی‌گیرد. +### DELETE /api/v1/admin/subscription/{uuid} +**Permission:** `ROLE_ADMIN` — حذف اشتراک، برای برگرداندن اعطای اشتباه + +`uuid` همان `uuid` ردیف گزارش است. حذف سخت است، نه soft delete: رکورد از +`clinic_subscriptions` پاک می‌شود و پلن مؤثر مقصد به اشتراک فعال بعدی یا به `free` +برمی‌گردد. + +اشتراکِ متصل به پرداخت حذف نمی‌شود. سند مالی‌اش باید بماند و مسیر درست آن استرداد +وجه است. + +**Response 200** + +```json +{ + "success": true, + "data": null +} +``` + +| وضعیت | کد | حالت | +|-------|----|------| +| 404 | ERR_SUBSCRIPTION_NOT_FOUND | uuid یافت نشد | +| 409 | ERR_CONFLICT_001 | اشتراک به پرداخت متصل است | +| 401 | ERR_AUTH_001 | بدون توکن | +| 403 | — | توکن معتبر ولی بدون `ROLE_ADMIN` | + +> مسیر `DELETE /api/v1/admin/subscription/period/{uuid}` جداست و دورهٔ پلن را +> غیرفعال می‌کند، نه اشتراکِ یک مقصد را. + ### GET /api/v1/admin/subscription/active/{entityType}/{entityUuid} **Permission:** `ROLE_ADMIN` — اشتراک فعالِ یک مقصد، برای نمایش پیش از اعطا diff --git a/src/Subscription/Controller/SubscriptionController.php b/src/Subscription/Controller/SubscriptionController.php index 528a5ad9..e63874e5 100644 --- a/src/Subscription/Controller/SubscriptionController.php +++ b/src/Subscription/Controller/SubscriptionController.php @@ -330,6 +330,25 @@ class SubscriptionController extends BaseController return $this->success($subscription->toArray(), 201); } + /** + * حذف اشتراک — برگرداندنِ اعطای اشتباه. + * + * `grant` هر بار ردیف تازه می‌سازد و روی انقضای قبلی سوار می‌شود، پس بدون این + * مسیر، یک کلیک اضافی در تب اعطا راه برگشت نداشت. + */ + #[Route('/api/v1/admin/subscription/{uuid}', methods: ['DELETE'])] + #[IsGranted('ROLE_ADMIN')] + public function adminRevoke(string $uuid): JsonResponse + { + try { + $this->subscriptionService->revoke($uuid); + } catch (AppException $e) { + return $this->error($e->getErrorCode(), $e->getMessage(), $e->getHttpStatus()); + } + + return $this->success(null); + } + /** * اشتراک فعالِ یک مقصد — پیش از اعطا، تا ادمین downgrade را ناخواسته انجام ندهد. */ diff --git a/src/Subscription/Service/SubscriptionService.php b/src/Subscription/Service/SubscriptionService.php index cdd3288b..8038513c 100644 --- a/src/Subscription/Service/SubscriptionService.php +++ b/src/Subscription/Service/SubscriptionService.php @@ -170,6 +170,31 @@ class SubscriptionService return $subscription; } + /** + * حذف اشتراک توسط ادمین. + * + * فقط اشتراکِ اعطایی یا تریال حذف می‌شود: اشتراکِ متصل به پرداخت سند مالی دارد و + * پاک شدنش گزارش فروش را با ردیف پرداختِ بی‌اشتراک ناسازگار می‌کند. مسیر درستِ + * آن استرداد است (`deleteByPayment`). + */ + public function revoke(string $uuid): void + { + $subscription = $this->subscriptionRepo->findByUuid($uuid); + if ($subscription === null) { + throw new AppException(ErrorCodes::ERR_SUBSCRIPTION_NOT_FOUND, null, 404); + } + + if ($subscription->getPayment() !== null) { + throw new AppException( + ErrorCodes::ERR_CONFLICT_001, + 'اشتراک پرداخت‌شده حذف نمی‌شود؛ از مسیر استرداد وجه اقدام کنید', + 409, + ); + } + + $this->subscriptionRepo->remove($subscription); + } + /** حذف اشتراکِ ساخته‌شده از یک پرداخت (هنگام استرداد/برگشت وجه). */ public function deleteByPayment(\App\Payment\Entity\Payment $payment): void { diff --git a/tests/Subscription/RevokeSubscriptionTest.php b/tests/Subscription/RevokeSubscriptionTest.php new file mode 100644 index 00000000..fec0d39c --- /dev/null +++ b/tests/Subscription/RevokeSubscriptionTest.php @@ -0,0 +1,78 @@ +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); + } +}