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:
@@ -14,6 +14,7 @@ import AdminSubscriptionPage from './AdminSubscriptionPage';
|
|||||||
const get = api.get as ReturnType<typeof vi.fn>;
|
const get = api.get as ReturnType<typeof vi.fn>;
|
||||||
const patch = api.patch as ReturnType<typeof vi.fn>;
|
const patch = api.patch as ReturnType<typeof vi.fn>;
|
||||||
const post = api.post as ReturnType<typeof vi.fn>;
|
const post = api.post as ReturnType<typeof vi.fn>;
|
||||||
|
const del = api.delete as ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
const PLANS = [
|
const PLANS = [
|
||||||
{
|
{
|
||||||
@@ -213,7 +214,27 @@ describe('AdminSubscriptionPage — اعطای اشتراک', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
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 () => {
|
it('اشتراک اعطایی را «اعطایی» نشان میدهد، نه «پولی»', async () => {
|
||||||
get.mockImplementation((url: string) => {
|
get.mockImplementation((url: string) => {
|
||||||
@@ -241,4 +262,27 @@ describe('AdminSubscriptionPage — گزارش', () => {
|
|||||||
expect(screen.getByText('ادمین')).toBeInTheDocument();
|
expect(screen.getByText('ادمین')).toBeInTheDocument();
|
||||||
expect(screen.queryByText('پولی')).not.toBeInTheDocument();
|
expect(screen.queryByText('پولی')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('حذف اشتراک پس از تأیید، DELETE میفرستد', async () => {
|
||||||
|
mockReportRow();
|
||||||
|
del.mockResolvedValue({ success: true, data: null });
|
||||||
|
|
||||||
|
renderWithProviders(<AdminSubscriptionPage />, { 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(<AdminSubscriptionPage />, { route: '/admin/admin-subscription' });
|
||||||
|
fireEvent.click(await screen.findByText('گزارش فروش'));
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: /اعطای اشتراک جدید/ }));
|
||||||
|
|
||||||
|
expect(await screen.findByText(/پلن و دوره/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -626,8 +626,10 @@ function GrantTab() {
|
|||||||
|
|
||||||
// ── Report tab ────────────────────────────────────────────────────────────
|
// ── Report tab ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function ReportTab() {
|
function ReportTab({ onAdd }: { onAdd: () => void }) {
|
||||||
|
const qc = useQueryClient();
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
|
const [deleteRow, setDeleteRow] = useState<ReportRow | null>(null);
|
||||||
const limit = 20;
|
const limit = 20;
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
@@ -635,11 +637,27 @@ function ReportTab() {
|
|||||||
queryFn: () => api.get<PaginatedResponse<ReportRow>>(`/api/v1/admin/subscription/report?page=${page}&limit=${limit}`),
|
queryFn: () => api.get<PaginatedResponse<ReportRow>>(`/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 rows: ReportRow[] = data?.data ?? [];
|
||||||
const total = data?.meta?.totalRecords ?? 0;
|
const total = data?.meta?.totalRecords ?? 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
|
<div className="card-pad" style={{ display: 'flex', justifyContent: 'flex-end', borderBottom: '1px solid var(--border)' }}>
|
||||||
|
<button type="button" className="btn primary" onClick={onAdd}>
|
||||||
|
<PlusIcon style={{ width: 15 }} /> اعطای اشتراک جدید
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="card-pad" style={{ color: 'var(--text-3)' }}>در حال بارگذاری...</div>
|
<div className="card-pad" style={{ color: 'var(--text-3)' }}>در حال بارگذاری...</div>
|
||||||
) : rows.length === 0 ? (
|
) : rows.length === 0 ? (
|
||||||
@@ -655,6 +673,7 @@ function ReportTab() {
|
|||||||
<th style={{ textAlign: 'right', padding: '10px 16px', color: 'var(--text-3)', fontWeight: 500 }}>شروع</th>
|
<th style={{ textAlign: 'right', padding: '10px 16px', color: 'var(--text-3)', fontWeight: 500 }}>شروع</th>
|
||||||
<th style={{ textAlign: 'right', padding: '10px 16px', color: 'var(--text-3)', fontWeight: 500 }}>انقضا</th>
|
<th style={{ textAlign: 'right', padding: '10px 16px', color: 'var(--text-3)', fontWeight: 500 }}>انقضا</th>
|
||||||
<th style={{ textAlign: 'right', padding: '10px 16px', color: 'var(--text-3)', fontWeight: 500 }}>تاریخ ثبت</th>
|
<th style={{ textAlign: 'right', padding: '10px 16px', color: 'var(--text-3)', fontWeight: 500 }}>تاریخ ثبت</th>
|
||||||
|
<th style={{ textAlign: 'left', padding: '10px 16px', color: 'var(--text-3)', fontWeight: 500 }}>عملیات</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -689,6 +708,17 @@ function ReportTab() {
|
|||||||
{row.expiresAt ? formatDate(row.expiresAt) : <span className="muted">بینهایت</span>}
|
{row.expiresAt ? formatDate(row.expiresAt) : <span className="muted">بینهایت</span>}
|
||||||
</td>
|
</td>
|
||||||
<td style={{ padding: '10px 16px', color: 'var(--text-3)' }}>{formatDate(row.createdAt)}</td>
|
<td style={{ padding: '10px 16px', color: 'var(--text-3)' }}>{formatDate(row.createdAt)}</td>
|
||||||
|
<td style={{ padding: '10px 16px', textAlign: 'left' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn sm"
|
||||||
|
title="حذف اشتراک"
|
||||||
|
aria-label={`حذف اشتراک ${row.entityName ?? row.entityId}`}
|
||||||
|
onClick={() => setDeleteRow(row)}
|
||||||
|
>
|
||||||
|
<TrashIcon style={{ width: 13 }} />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -698,6 +728,17 @@ function ReportTab() {
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={!!deleteRow}
|
||||||
|
title="حذف اشتراک"
|
||||||
|
message={`آیا مطمئن هستید که میخواهید اشتراک «${deleteRow?.entityName ?? ''}» را حذف کنید؟ دسترسی این محیط به قابلیتهای پلن قطع میشود.`}
|
||||||
|
confirmLabel="حذف"
|
||||||
|
danger
|
||||||
|
loading={deleteMut.isPending}
|
||||||
|
onConfirm={() => deleteRow && deleteMut.mutate(deleteRow.uuid)}
|
||||||
|
onCancel={() => setDeleteRow(null)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -722,7 +763,7 @@ export default function AdminSubscriptionPage() {
|
|||||||
|
|
||||||
{tab === 'plans' && <PlansTab />}
|
{tab === 'plans' && <PlansTab />}
|
||||||
{tab === 'grant' && <GrantTab />}
|
{tab === 'grant' && <GrantTab />}
|
||||||
{tab === 'report' && <ReportTab />}
|
{tab === 'report' && <ReportTab onAdd={() => setTab('grant')} />}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -340,6 +340,35 @@ callback مشترک همهٔ درگاهها و همهٔ نوعهای پر
|
|||||||
> پلن. پس اعطای پلنی پایینتر از پلن فعال، عملاً پلن مؤثر مقصد را کاهش میدهد. پنل
|
> پلن. پس اعطای پلنی پایینتر از پلن فعال، عملاً پلن مؤثر مقصد را کاهش میدهد. پنل
|
||||||
> ادمین قبل از ثبت این حالت تأیید میگیرد؛ خودِ endpoint جلوی آن را نمیگیرد.
|
> ادمین قبل از ثبت این حالت تأیید میگیرد؛ خودِ 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}
|
### GET /api/v1/admin/subscription/active/{entityType}/{entityUuid}
|
||||||
**Permission:** `ROLE_ADMIN` — اشتراک فعالِ یک مقصد، برای نمایش پیش از اعطا
|
**Permission:** `ROLE_ADMIN` — اشتراک فعالِ یک مقصد، برای نمایش پیش از اعطا
|
||||||
|
|
||||||
|
|||||||
@@ -330,6 +330,25 @@ class SubscriptionController extends BaseController
|
|||||||
return $this->success($subscription->toArray(), 201);
|
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 را ناخواسته انجام ندهد.
|
* اشتراک فعالِ یک مقصد — پیش از اعطا، تا ادمین downgrade را ناخواسته انجام ندهد.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -170,6 +170,31 @@ class SubscriptionService
|
|||||||
return $subscription;
|
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
|
public function deleteByPayment(\App\Payment\Entity\Payment $payment): void
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user