Files
clinicpro/assets/admin/pages/SubscriptionPage.test.tsx
T
hamed 7716b40f6a feat: implement tax calculations for subscription and SMS wallet payments
- Updated SubscriptionPeriod interface to include tax-related fields: tax_percent, tax_rials, and payable_rials.
- Modified payment API documentation to reflect changes in tax handling for subscriptions and SMS wallet charges.
- Adjusted PaymentController to calculate payment amounts based on subscription period details instead of client input.
- Enhanced PaymentManager to handle net amounts for SMS wallet charges, ensuring tax is not credited to the wallet.
- Created PaymentTaxCalculator and SubscriptionTaxCalculator services to manage tax calculations consistently across payment types.
- Added tests for tax calculations in both subscription and SMS wallet contexts, ensuring correct behavior with and without tax enabled.
- Updated frontend components to display tax information appropriately during payment processes.
2026-08-09 16:51:22 +03:30

185 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { screen, fireEvent, within } from '@testing-library/react';
import { renderWithProviders } from '../test/utils';
import { formatRial, formatDate } from '../lib/utils';
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
vi.mock('../lib/api', () => ({
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
ApiError: class extends Error {},
}));
import { api } from '../lib/api';
import SubscriptionPage from './SubscriptionPage';
const get = api.get as ReturnType<typeof vi.fn>;
const PLANS = [
{ uuid: 'p-free', name: 'free', level: 0, max_secretaries: 1, max_resources: 1,
features: { patient_records: false, services: false, sms_panel: false }, active: true, periods: [] },
{ uuid: 'p-basic', name: 'basic', level: 1, max_secretaries: 3, max_resources: 3,
features: { patient_records: true, services: true, sms_panel: false }, active: true,
periods: [
{ uuid: 'per-1m', label: 'یک ماهه', duration_months: 1, price_rials: 1000000, is_trial: false },
{ uuid: 'per-12m', label: 'یک ساله', duration_months: 12, price_rials: 9000000, is_trial: false },
] },
{ uuid: 'p-pro', name: 'professional', level: 2, max_secretaries: 10, max_resources: -1,
features: { patient_records: true, services: true, sms_panel: true }, active: true, periods: [] },
];
const DEFAULT_MY = {
subscription: { plan: { name: 'basic', level: 1, max_secretaries: 3, max_resources: 3, features: {} }, is_trial: false, days_remaining: 25 },
used_trial: true, effective_plan: null,
};
function mockApi({ my = DEFAULT_MY, plans = PLANS }: { my?: any; plans?: any[] } = {}) {
get.mockImplementation((url: string) => {
if (url.includes('/subscription/plans')) return Promise.resolve({ success: true, data: plans });
if (url.includes('/subscription/my')) return Promise.resolve({ success: true, data: my });
if (url.includes('/payment/config')) return Promise.resolve({ success: true, data: { test_mode: true, appointment_fee_rials: 0, gateways: [] } });
return Promise.resolve({ success: true, data: null });
});
}
beforeEach(() => { get.mockReset(); mockApi(); });
describe('SubscriptionPage', () => {
it('renders the three plans with the most-popular badge on the professional plan', async () => {
renderWithProviders(<SubscriptionPage />, { route: '/admin/subscription' });
expect(await screen.findByText('پلن پایه')).toBeInTheDocument();
expect(screen.getByText('پلن رایگان')).toBeInTheDocument();
expect(screen.getByText('پلن حرفه‌ای')).toBeInTheDocument();
// most-popular badge sits on the professional (top) card, matching tauri
const proCard = within(screen.getByTestId('plan-card-professional'));
expect(proCard.getByText('محبوب‌ترین')).toBeInTheDocument();
});
/** سقف منابع کنار سقف منشی روی هر کارت پلن می‌نشیند؛ `-1` یعنی «نامحدود». */
it('shows the resource quota on every plan card', async () => {
renderWithProviders(<SubscriptionPage />, { route: '/admin/subscription' });
await screen.findByText('پلن پایه');
expect(within(screen.getByTestId('plan-card-free')).getByText('۱ منبع')).toBeInTheDocument();
expect(within(screen.getByTestId('plan-card-basic')).getByText('۳ منبع')).toBeInTheDocument();
expect(within(screen.getByTestId('plan-card-professional')).getByText('نامحدود منبع')).toBeInTheDocument();
});
it('shows the current-plan card without an expiry warning when the plan is healthy', async () => {
renderWithProviders(<SubscriptionPage />, { route: '/admin/subscription' });
expect(await screen.findByText('پلن فعلی شما:')).toBeInTheDocument();
// 25 days remaining → no expiry warning (source shows it only when < 3 days)
expect(screen.queryByText(/روز تا پایان اشتراک/)).not.toBeInTheDocument();
});
it('warns when the subscription is expiring within 3 days', async () => {
mockApi({ my: { ...DEFAULT_MY, subscription: { ...DEFAULT_MY.subscription, days_remaining: 2 } } });
renderWithProviders(<SubscriptionPage />, { route: '/admin/subscription' });
expect(await screen.findByText(/روز تا پایان اشتراک/)).toBeInTheDocument();
});
it('shows the jalali expiry date on the current-plan card', async () => {
// 1767225600 = 2026-01-01T00:00:00Z → ۱۴۰۴/۱۰/۱۱ in Tehran
mockApi({ my: { ...DEFAULT_MY, subscription: { ...DEFAULT_MY.subscription, expires_at: 1767225600 } } });
renderWithProviders(<SubscriptionPage />, { route: '/admin/subscription' });
expect(await screen.findByText('زمان پایان اشتراک:')).toBeInTheDocument();
expect(screen.getByText(formatDate(1767225600))).toBeInTheDocument();
expect(screen.getByText(/روز مانده/)).toBeInTheDocument();
});
it('falls back to «بدون تاریخ انقضا» when the subscription has no expiry', async () => {
renderWithProviders(<SubscriptionPage />, { route: '/admin/subscription' });
expect(await screen.findByText('بدون تاریخ انقضا')).toBeInTheDocument();
});
it('renders the current-plan card right-to-left', async () => {
renderWithProviders(<SubscriptionPage />, { route: '/admin/subscription' });
const label = await screen.findByText('پلن فعلی شما:');
expect(label.closest('[dir]')).toHaveAttribute('dir', 'rtl');
});
it('shows the expired message when no days remain', async () => {
mockApi({ my: { ...DEFAULT_MY, subscription: { ...DEFAULT_MY.subscription, days_remaining: 0 } } });
renderWithProviders(<SubscriptionPage />, { route: '/admin/subscription' });
expect(await screen.findByText('اشتراک شما به پایان رسیده است')).toBeInTheDocument();
});
it('shows "no subscription" when the user has none', async () => {
mockApi({ my: { subscription: null, used_trial: false, effective_plan: null } });
renderWithProviders(<SubscriptionPage />, { route: '/admin/subscription' });
expect(await screen.findByText('اشتراک ندارید')).toBeInTheDocument();
});
it('the period toggle switches the displayed price', async () => {
renderWithProviders(<SubscriptionPage />, { route: '/admin/subscription' });
await screen.findByText('پلن پایه');
const card = within(screen.getByTestId('plan-card-basic'));
// default = longest period (یک ساله)
expect(card.getByText(formatRial(9000000))).toBeInTheDocument();
// switch to monthly
fireEvent.click(card.getByRole('tab', { name: 'یک ماهه' }));
expect(card.getByText(formatRial(1000000))).toBeInTheDocument();
});
it('clicking the renew button opens the payment modal with the selected amount', async () => {
renderWithProviders(<SubscriptionPage />, { route: '/admin/subscription' });
// basic is the current plan → its card button reads "تمدید اشتراک"
const card = within(await screen.findByTestId('plan-card-basic'));
fireEvent.click(card.getByText('تمدید اشتراک'));
expect(await screen.findByText('پرداخت اشتراک')).toBeInTheDocument();
expect(screen.getByText('درگاه آزمایشی فعال است')).toBeInTheDocument();
});
it('renders an empty state when there are no plans', async () => {
mockApi({ plans: [] });
renderWithProviders(<SubscriptionPage />, { route: '/admin/subscription' });
expect(await screen.findByText('در حال حاضر پلنی برای نمایش وجود ندارد.')).toBeInTheDocument();
});
});
// ── Tax ────────────────────────────────────────────────────────────────────
// قیمت دوره خالص است و مالیات رویش می‌نشیند؛ کارت و مودال باید جمع کل را نشان
// دهند نه قیمت خالص را، وگرنه کاربر سرِ درگاه عدد دیگری می‌بیند.
const TAXED_PLANS = [
{ uuid: 'p-basic', name: 'basic', level: 1, max_secretaries: 3, max_resources: 3,
features: { patient_records: true, services: true, sms_panel: false }, active: true,
periods: [
{ uuid: 'per-1m', label: 'یک ماهه', duration_months: 1, price_rials: 1000000,
tax_percent: 10, tax_rials: 100000, payable_rials: 1100000, is_trial: false },
] },
];
describe('SubscriptionPage — tax', () => {
beforeEach(() => { get.mockReset(); mockApi({ plans: TAXED_PLANS }); });
it('shows the payable amount on the plan card, not the net price', async () => {
renderWithProviders(<SubscriptionPage />, { route: '/admin/subscription' });
const card = within(await screen.findByTestId('plan-card-basic'));
expect(card.getByText(formatRial(1100000))).toBeInTheDocument();
expect(card.queryByText(formatRial(1000000))).not.toBeInTheDocument();
expect(card.getByText(/۱۰٪ مالیات بر ارزش افزوده/)).toBeInTheDocument();
});
it('breaks the price down inside the payment modal', async () => {
renderWithProviders(<SubscriptionPage />, { route: '/admin/subscription' });
const card = within(await screen.findByTestId('plan-card-basic'));
fireEvent.click(card.getByText('تمدید اشتراک'));
await screen.findByText('پرداخت اشتراک');
expect(screen.getByText('قیمت دوره')).toBeInTheDocument();
expect(screen.getByText('مالیات بر ارزش افزوده ۱۰٪')).toBeInTheDocument();
expect(screen.getByText('جمع کل')).toBeInTheDocument();
expect(screen.getAllByText(formatRial(1100000)).length).toBeGreaterThan(0);
});
it('falls back to the net price when the backend sends no tax fields', async () => {
mockApi({ plans: PLANS });
renderWithProviders(<SubscriptionPage />, { route: '/admin/subscription' });
const card = within(await screen.findByTestId('plan-card-basic'));
expect(card.getByText(formatRial(9000000))).toBeInTheDocument();
});
});