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
@@ -14,6 +14,7 @@ import AdminSubscriptionPage from './AdminSubscriptionPage';
const get = api.get as ReturnType<typeof vi.fn>;
const patch = api.patch 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 = [
{
@@ -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(<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();
});
});