diff --git a/assets/admin/hooks/usePaymentConfig.ts b/assets/admin/hooks/usePaymentConfig.ts index b39c718f..1bb4a57a 100644 --- a/assets/admin/hooks/usePaymentConfig.ts +++ b/assets/admin/hooks/usePaymentConfig.ts @@ -11,6 +11,8 @@ interface PaymentConfig { test_mode: boolean; appointment_fee_rials: number; gateways: PaymentGatewayInfo[]; + /** نرخ مالیات اشتراک و شارژ کیف پول؛ صفر یعنی خاموش. */ + tax_percent: number; } export function usePaymentConfig() { @@ -22,5 +24,6 @@ export function usePaymentConfig() { return { isTestMode: data?.data?.test_mode ?? false, gateways: data?.data?.gateways ?? [], + taxPercent: data?.data?.tax_percent ?? 0, }; } diff --git a/assets/admin/pages/SmsWalletPage.tax.test.tsx b/assets/admin/pages/SmsWalletPage.tax.test.tsx new file mode 100644 index 00000000..ab963444 --- /dev/null +++ b/assets/admin/pages/SmsWalletPage.tax.test.tsx @@ -0,0 +1,83 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { screen, fireEvent } from '@testing-library/react'; +import { renderWithProviders } from '../test/utils'; +import { formatRial, tomanToRial } 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 { useAuthStore } from '../stores/authStore'; +import SmsWalletPage from './SmsWalletPage'; + +const get = api.get as ReturnType; + +/** + * مبلغِ واردشده اعتبارِ کیف پول است — خالص. مالیات رویش اضافه می‌شود، پس عددِ + * دکمهٔ پرداخت باید بزرگ‌تر از مبلغ واردشده باشد وگرنه کاربر سرِ درگاه غافلگیر + * می‌شود. + */ +function mockApi(taxPercent: number) { + get.mockImplementation((url: string) => { + if (url.includes('/sms/wallet/balance')) { + return Promise.resolve({ success: true, data: { balance_rials: 0, sms_price_rials: 5000 } }); + } + if (url.includes('/sms/wallet/logs')) { + return Promise.resolve({ success: true, data: [], meta: { totalRecords: 0, totalPages: 0, currentPage: 1 } }); + } + if (url.includes('/sms/settings')) return Promise.resolve({ success: true, data: null }); + // FeatureGate صفحه را پشت قابلیت پنل پیامک نگه می‌دارد. + if (url.includes('/subscription/my')) { + return Promise.resolve({ + success: true, + data: { subscription: null, used_trial: false, effective_plan: { features: { sms_panel: true } } }, + }); + } + if (url.includes('/payment/config')) { + return Promise.resolve({ + success: true, + data: { test_mode: false, appointment_fee_rials: 0, tax_percent: taxPercent, gateways: [{ name: 'mellat', label: 'بانک ملت' }] }, + }); + } + return Promise.resolve({ success: true, data: null }); + }); +} + +async function openChargeModalWith(amountToman: string) { + renderWithProviders(, { route: '/admin/sms-wallet' }); + fireEvent.click(await screen.findByText('شارژ کیف پول')); + + const input = await screen.findByLabelText('مبلغ شارژ (تومان)'); + fireEvent.change(input, { target: { value: amountToman } }); +} + +describe('SmsWalletPage — tax', () => { + beforeEach(() => { + get.mockReset(); + // useSubscription فقط برای این نقش‌ها کوئری می‌زند؛ بدونش FeatureGate صفحه را می‌بندد. + useAuthStore.setState({ primaryRole: 'doctor', context: null } as any); + }); + + it('adds tax on top of the credit and shows the split', async () => { + mockApi(10); + await openChargeModalWith('100000'); + + const net = tomanToRial(100000); + expect(await screen.findByText('اعتباری که به کیف پول اضافه می‌شود')).toBeInTheDocument(); + expect(screen.getByText('مالیات بر ارزش افزوده ۱۰٪')).toBeInTheDocument(); + expect(screen.getByText(formatRial(net * 0.1))).toBeInTheDocument(); + expect(screen.getAllByText(formatRial(net * 1.1)).length).toBeGreaterThan(0); + }); + + it('shows no tax row and charges the plain amount when tax is off', async () => { + mockApi(0); + await openChargeModalWith('100000'); + + expect(await screen.findByText(/پرداخت .* از طریق بانک ملت/)).toBeInTheDocument(); + expect(screen.queryByText('اعتباری که به کیف پول اضافه می‌شود')).not.toBeInTheDocument(); + expect(screen.getByText(new RegExp(formatRial(tomanToRial(100000))))).toBeInTheDocument(); + }); +}); diff --git a/assets/admin/pages/SmsWalletPage.tsx b/assets/admin/pages/SmsWalletPage.tsx index d63f5fc5..c8731ad9 100644 --- a/assets/admin/pages/SmsWalletPage.tsx +++ b/assets/admin/pages/SmsWalletPage.tsx @@ -66,7 +66,7 @@ function SmsWalletPageInner() { queryFn: () => api.get('/api/v1/sms/settings'), }); - const { isTestMode } = usePaymentConfig(); + const { isTestMode, taxPercent } = usePaymentConfig(); const balance = balanceData?.data; const logs = logsData?.data ?? EMPTY_LOGS; @@ -76,6 +76,12 @@ function SmsWalletPageInner() { const chargeForm = useForm({ resolver: zodResolver(chargeSchema) }); const watchAmount = chargeForm.watch('amount_rials'); + // مبلغِ واردشده خالص است — همان چیزی که به کیف پول می‌نشیند. مالیات رویش اضافه + // می‌شود، دقیقاً با همان فرمولِ بک‌اند، تا عددِ مودال با صفحهٔ بانک یکی باشد. + const chargeNet = tomanToRial(Number(watchAmount) || 0); + const chargeTax = Math.round(chargeNet * taxPercent / 100); + const chargePayable = chargeNet + chargeTax; + const chargeMutation = useMutation({ mutationFn: ({ amount_rials }: ChargeForm) => api.post<{ data: { payment_url: string } }>('/api/v1/sms/wallet/charge', { @@ -548,6 +554,31 @@ function SmsWalletPageInner() { )} + {watchAmount && Number(watchAmount) >= 1000 && taxPercent > 0 && ( +
+
+ اعتباری که به کیف پول اضافه می‌شود + {formatRial(chargeNet)} +
+
+ مالیات بر ارزش افزوده {formatNumber(taxPercent)}٪ + {formatRial(chargeTax)} +
+
+ مبلغ قابل پرداخت + {formatRial(chargePayable)} +
+
+ )} + {watchAmount && Number(watchAmount) >= 1000 && (
{isTestMode - ? `پرداخت آزمایشی ${formatRial(tomanToRial(Number(watchAmount)))}` - : `پرداخت ${formatRial(tomanToRial(Number(watchAmount)))} از طریق ${gateway === 'mellat' ? 'بانک ملت' : 'سپ'}` + ? `پرداخت آزمایشی ${formatRial(chargePayable)}` + : `پرداخت ${formatRial(chargePayable)} از طریق ${gateway === 'mellat' ? 'بانک ملت' : 'سپ'}` }
)} diff --git a/assets/admin/pages/SubscriptionPage.test.tsx b/assets/admin/pages/SubscriptionPage.test.tsx index fb787e60..2c65a511 100644 --- a/assets/admin/pages/SubscriptionPage.test.tsx +++ b/assets/admin/pages/SubscriptionPage.test.tsx @@ -136,3 +136,49 @@ describe('SubscriptionPage', () => { 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(, { 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(, { 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(, { route: '/admin/subscription' }); + const card = within(await screen.findByTestId('plan-card-basic')); + + expect(card.getByText(formatRial(9000000))).toBeInTheDocument(); + }); +}); diff --git a/assets/admin/pages/SubscriptionPage.tsx b/assets/admin/pages/SubscriptionPage.tsx index de2915c7..5207b367 100644 --- a/assets/admin/pages/SubscriptionPage.tsx +++ b/assets/admin/pages/SubscriptionPage.tsx @@ -14,6 +14,15 @@ import { SparkleIcon, PlanCardPerson, CloseCircleFilled, GiftIcon, InfoCircleRedFilled, } from './subscriptionIcons'; +// ── Tax helpers ─────────────────────────────────────────────────────────── +// `price_rials` خالص است و مالیات رویش می‌نشیند. فیلدهای مالیاتی را بک‌اند حساب +// می‌کند؛ fallback فقط برای پاسخِ کش‌شدهٔ نسخهٔ قبلی است. + +export const taxOf = (p: Pick): number => p.tax_rials ?? 0; + +export const payableOf = (p: Pick): number => + p.payable_rials ?? p.price_rials; + // ── Constants ───────────────────────────────────────────────────────────── /** Shared feature labels (also consumed by PaymentSuccessPage). */ @@ -92,9 +101,10 @@ export default function SubscriptionPage() { }); const purchaseMutation = useMutation({ - mutationFn: ({ period_uuid, gateway, amount_rials }: { period_uuid: string; gateway: string; amount_rials: number }) => + // مبلغ فرستاده نمی‌شود: بک‌اند خودش قیمت دوره + مالیات را حساب می‌کند. + mutationFn: ({ period_uuid, gateway }: { period_uuid: string; gateway: string }) => api.post<{ data: { pay_url?: string; redirect_url?: string; payment_url?: string } }>('/api/v1/subscription-payment', { - period_uuid, gateway, amount_rials, + period_uuid, gateway, // Gateway returns here (with ?payment_uuid&status); the success page reads them. frontend_address: `${window.location.origin}/admin/subscription/success`, }), @@ -191,11 +201,36 @@ export default function SubscriptionPage() {
مبلغ قابل پرداخت
- {formatRial(purchaseTarget.period.price_rials)} + {formatRial(payableOf(purchaseTarget.period))}
+ {taxOf(purchaseTarget.period) > 0 && ( +
+
+ قیمت دوره + {formatRial(purchaseTarget.period.price_rials)} +
+
+ مالیات بر ارزش افزوده {formatNumber(purchaseTarget.period.tax_percent)}٪ + {formatRial(taxOf(purchaseTarget.period))} +
+
+ جمع کل + {formatRial(payableOf(purchaseTarget.period))} +
+
+ )} + {isTestMode ? (
- purchaseMutation.mutate({ period_uuid: purchaseTarget.period.uuid, gateway: selectedGateway, amount_rials: purchaseTarget.period.price_rials }) + purchaseMutation.mutate({ period_uuid: purchaseTarget.period.uuid, gateway: selectedGateway }) } > {purchaseMutation.isPending ? 'در حال انتقال...' - : `پرداخت ${formatRial(purchaseTarget.period.price_rials)}`} + : `پرداخت ${formatRial(payableOf(purchaseTarget.period))}`}
)} + {!isFree && selectedPeriod && taxOf(selectedPeriod) > 0 && ( +
+ شامل {formatNumber(selectedPeriod.tax_percent)}٪ مالیات بر ارزش افزوده +
+ )} diff --git a/assets/admin/types/index.ts b/assets/admin/types/index.ts index dfb560a3..ad180f5e 100644 --- a/assets/admin/types/index.ts +++ b/assets/admin/types/index.ts @@ -574,7 +574,13 @@ export interface SubscriptionPeriod { uuid: string; label: string; duration_months: number; + /** قیمت خالص، بدون مالیات. */ price_rials: number; + /** درصد مالیات؛ با مالیاتِ خاموش برابر صفر. */ + tax_percent: number; + tax_rials: number; + /** قیمت خالص + مالیات — مبلغی که واقعاً پرداخت می‌شود. */ + payable_rials: number; is_trial: boolean; } diff --git a/docs/api/payment.md b/docs/api/payment.md index 668bff1e..a195f7a6 100644 --- a/docs/api/payment.md +++ b/docs/api/payment.md @@ -106,7 +106,8 @@ "appointment_fee_rials": 150000, "gateways": [ { "name": "mellat", "label": "بانک ملت" } - ] + ], + "tax_percent": 10 } } ``` @@ -116,6 +117,7 @@ | `test_mode` | boolean | `true` = درگاه آزمایشی فعال است — backend از MockGateway استفاده می‌کند و پول واقعی کسر نمی‌شود | | `appointment_fee_rials` | integer | مبلغ هر نوبت به ریال (از تنظیمات سایت، کلید `appointment_fee_rials`). frontend برای نمایش «مبلغ قابل پرداخت» از این می‌خواند؛ مبلغِ واقعیِ تراکنش هم در backend از همین کلید خوانده می‌شود (نه از client) | | `gateways` | array | فقط درگاه‌های **فعال** (اعتبارنامه‌شان در تنظیمات سایت یا env ست شده). هر عضو: `{ name, label }`. frontend فقط همین‌ها را برای انتخاب نمایش می‌دهد. در `test_mode` تنها `[{ "name": "mellat", "label": "بانک ملت (آزمایشی)" }]` برمی‌گردد. اگر هیچ درگاهی فعال نباشد آرایه خالی است و frontend باید پرداخت را غیرفعال کند. | +| `tax_percent` | number | نرخ مالیات بر ارزش افزوده برای **اشتراک** و **شارژ کیف پول پیامک**. صفر یعنی مالیات خاموش است (`tax_enabled=0`). frontend با این عدد جمع کل را پیش از ارسال درخواست نشان می‌دهد؛ مبلغ نهایی همیشه در backend دوباره حساب می‌شود. نوبت این نرخ را به این شکل به‌کار نمی‌برد — آنجا مبلغ شامل مالیات است. | فعال‌بودن هر درگاه با `PaymentGatewayInterface::isConfigured()` **و** کلید فعال‌سازی در تنظیمات سایت تعیین می‌شود: `mellat` نیازمند `mellat_terminal_id` + `mellat_username` + `mellat_password`؛ `sep` نیازمند `sep_terminal_id`. علاوه بر این، اگر ادمین درگاه را در تنظیمات غیرفعال کند (`mellat_enabled` / `sep_enabled` = `"0"`)، آن درگاه از این لیست حذف می‌شود و در `initiate` نیز رد می‌شود (خطای ۴۲۲: «درگاه پرداخت نامعتبر یا غیرفعال است»). کلید تنظیم‌نشده = فعال (پیش‌فرض). @@ -341,7 +343,7 @@ Initiate a subscription / wallet top-up payment (not tied to a specific appointm ```json { "gateway": "mellat", - "amount_rials": 1000000, + "period_uuid": "uuid-of-subscription-period", "frontend_address": "https://myapp.com/wallet/result" } ``` @@ -349,9 +351,14 @@ Initiate a subscription / wallet top-up payment (not tied to a specific appointm | Field | Type | Required | Description | |-------|------|----------|-------------| | `gateway` | string | ✅ | `"mellat"` or `"sep"` | -| `amount_rials` | integer | ✅ | Amount in Rials (min: 10,000) | +| `period_uuid` | string | ✅ | دورهٔ اشتراک؛ مبلغ از آن محاسبه می‌شود | | `frontend_address` | string | ❌ | Redirect URL after payment | +> `amount_rials` دیگر خوانده نمی‌شود. مبلغ = `price_rials + tax_rials` همان دوره، محاسبه‌شده در +> `SubscriptionTaxCalculator`. قیمت دوره خالص است و مالیات رویش اضافه می‌شود — جزئیات در +> [subscription.md](subscription.md#مالیات-دورهها). پاسخ هم هر چهار عدد را برمی‌گرداند: +> `price_rials`، `tax_percent`، `tax_rials`، `payable_rials`. + ### Response `200` ```json { diff --git a/docs/api/sms.md b/docs/api/sms.md index 397d45a9..67c15a48 100644 --- a/docs/api/sms.md +++ b/docs/api/sms.md @@ -349,13 +349,37 @@ Updated template with `status: "rejected"`. "data": { "payment_uuid": "...", "pay_url": "{APP_BASE_URL}/api/v1/payment/pay/ORD-...", - "order_id": "ORD-..." + "order_id": "ORD-...", + "net_rials": 50000, + "tax_percent": 10, + "tax_rials": 5000, + "payable_rials": 55000 } } ``` > این endpoint فقط `Payment` (type=`sms_wallet`) می‌سازد و `pay_url` می‌دهد؛ **ارتباط با بانک اینجا انجام نمی‌شود** و از flow واحد پرداخت (`GET /payment/pay/{orderId}` → callback → `PaymentManager`) عبور می‌کند. کلاینت باید مرورگر را به `pay_url` هدایت کند. پس از پرداخت موفق، `PaymentManager` موجودی کیف را خودکار شارژ می‌کند. +#### مالیات + +`amount_rials` ورودی **خالص** است — همان اعتباری که به کیف پول می‌نشیند. مالیات رویش +**اضافه** می‌شود و مبلغی که به بانک می‌رود `payable_rials` است. + +| فیلد | معنی | +|------|------| +| `net_rials` | اعتباری که بعد از پرداخت موفق به کیف پول اضافه می‌شود | +| `tax_percent` | درصد مؤثر؛ با `tax_enabled=0` برابر `0` | +| `tax_rials` | `round(net_rials × tax_percent / 100)` | +| `payable_rials` | `net_rials + tax_rials` — مبلغ رکورد `Payment` و مبلغ درگاه | + +نرخ از همان کلیدهای سراسری `tax_enabled` / `tax_percent` می‌آید؛ محاسبه در +`App\Payment\Service\PaymentTaxCalculator`. + +**اعتبار کیف پول هرگز شامل مالیات نیست.** مقدار خالص در `metadata.net_rials` رکورد پرداخت +ذخیره می‌شود و `PaymentManager::handleSmsWalletCharge` همان را شارژ می‌کند — نه +`amount_rials` را. استرداد هم قرینهٔ همین است. پرداخت‌های قدیمی که `net_rials` ندارند به +مبلغ کلشان fallback می‌کنند. + ### GET /api/v1/sms/wallet/logs تراکنش‌های کیف پیامک (paginated). diff --git a/docs/api/subscription.md b/docs/api/subscription.md index a1a7f2f7..b79fbbd3 100644 --- a/docs/api/subscription.md +++ b/docs/api/subscription.md @@ -12,6 +12,25 @@ > `max_resources` سقف منابع محیط است. مقدار `-1` یعنی نامحدود. مقادیر شیپ‌شده: `free` = ۱، `basic` = ۳، `professional` = `-1`. اجرای این سقف در `POST /api/v1/resource` است — [resource.md](resource.md). +### مالیات دوره‌ها + +`price_rials` هر دوره **خالص** است و مالیات رویش **اضافه** می‌شود. این برعکسِ نوبت است؛ آنجا +مبلغ شامل مالیات است و `CommissionService` مالیات را از دلش استخراج می‌کند. + +نرخ از همان کلیدهای سراسری `tax_enabled` و `tax_percent` در SiteConfig می‌آید — کلید جداگانه‌ای +برای اشتراک وجود ندارد. محاسبه در `App\Subscription\Service\SubscriptionTaxCalculator`. + +هر دوره سه فیلد محاسبه‌شدهٔ اضافه دارد. `price_rials` دست‌نخورده می‌ماند تا کلاینت قدیمی نشکند: + +| فیلد | معنی | +|------|------| +| `price_rials` | قیمت خالص، بدون مالیات — همان چیزی که ادمین وارد می‌کند | +| `tax_percent` | درصد مؤثر؛ با `tax_enabled=0` برابر `0` | +| `tax_rials` | `round(price_rials × tax_percent / 100)` | +| `payable_rials` | `price_rials + tax_rials` — مبلغی که واقعاً پرداخت می‌شود | + +دورهٔ رایگان یا تریال (`price_rials = 0`) مالیات نمی‌گیرد. + **Response 200:** ```json { @@ -42,6 +61,9 @@ "label": "یک ماهه", "duration_months": 1, "price_rials": 290000, + "tax_percent": 10, + "tax_rials": 29000, + "payable_rials": 319000, "is_trial": false, "active": true, "sort_order": 1 @@ -141,7 +163,6 @@ ```json { "gateway": "mellat", - "amount_rials": 290000, "period_uuid": "uuid-of-subscription-period", "frontend_address": "https://example.com/payment-result" } @@ -150,18 +171,25 @@ | فیلد | نوع | الزامی | |------|-----|--------| | gateway | string (mellat\|sep) | ✅ | -| amount_rials | integer | ✅ | | period_uuid | string (UUID) | ✅ | | frontend_address | string (URL) | ❌ | +> **`amount_rials` دیگر پذیرفته نمی‌شود.** مبلغ سمت سرور از دورهٔ اشتراک محاسبه می‌شود: +> `price_rials + tax_rials`. اگر کلاینت آن را بفرستد نادیده گرفته می‌شود. دلیلش بستنِ راهِ +> دستکاری قیمت است. دورهٔ ناموجود یا غیرفعال → `422 ERR_VALIDATION_001` روی فیلد `period_uuid`. + **Response 200:** ```json { "success": true, "data": { "payment_uuid": "...", - "redirect_url": "https://gateway.shaparak.ir/...", - "order_id": "ORD-XXXXXXXXXXXXXXXX" + "pay_url": "https://clinic-pro.ir/api/v1/payment/pay/ORD-XXXXXXXXXXXXXXXX", + "order_id": "ORD-XXXXXXXXXXXXXXXX", + "price_rials": 290000, + "tax_percent": 10, + "tax_rials": 29000, + "payable_rials": 319000 } } ``` diff --git a/src/Payment/Controller/PaymentController.php b/src/Payment/Controller/PaymentController.php index 452f7302..39e68a05 100644 --- a/src/Payment/Controller/PaymentController.php +++ b/src/Payment/Controller/PaymentController.php @@ -41,6 +41,9 @@ class PaymentController extends BaseController private readonly SecretaryAccessChecker $secretaryAccess, private readonly \App\Clinic\Security\ClinicDoctorAccessChecker $clinicDoctorAccess, private readonly EntityContextResolver $contextResolver, + private readonly \App\Subscription\Repository\SubscriptionPeriodRepository $subscriptionPeriodRepo, + private readonly \App\Subscription\Service\SubscriptionTaxCalculator $subscriptionTax, + private readonly \App\Payment\Service\PaymentTaxCalculator $paymentTax, private readonly string $appBaseUrl, private readonly string $allowedFrontendHosts = '', ) {} @@ -413,11 +416,7 @@ class PaymentController extends BaseController $data = json_decode($request->getContent(), true) ?? []; $gatewayName = trim($data['gateway'] ?? 'mellat'); $frontendAddress = trim($data['frontend_address'] ?? ''); - $amountRials = (int) ($data['amount_rials'] ?? 0); - - if ($amountRials <= 0) { - return $this->error(ErrorCodes::ERR_PAYMENT_002, ErrorCodes::message(ErrorCodes::ERR_PAYMENT_002), 422); - } + $periodUuid = trim($data['period_uuid'] ?? ''); if (!empty($frontendAddress) && !$this->isAllowedFrontend($frontendAddress)) { return $this->error(ErrorCodes::ERR_VALIDATION_001, 'آدرس بازگشت مجاز نیست', 422, 'frontend_address'); @@ -434,19 +433,32 @@ class PaymentController extends BaseController return $this->error(ErrorCodes::ERR_PAYMENT_004, ErrorCodes::message(ErrorCodes::ERR_PAYMENT_004), 422); } - $periodUuid = trim($data['period_uuid'] ?? ''); - $payment = new Payment($user, $amountRials, $gatewayName, Payment::TYPE_SUBSCRIPTION, $frontendAddress); - $payment->assignTenant($owner); - if ($periodUuid !== '') { - $payment->setMetadata(['period_uuid' => $periodUuid]); + // مبلغ از دورهٔ اشتراک محاسبه می‌شود، نه از بدنهٔ درخواست: قیمت دوره خالص + // است و مالیات رویش می‌نشیند، و کلاینت نباید بتواند مبلغ را تعیین کند. + $period = $periodUuid !== '' ? $this->subscriptionPeriodRepo->findByUuid($periodUuid) : null; + if ($period === null || !$period->isActive()) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'دورهٔ اشتراک نامعتبر است', 422, 'period_uuid'); } + + $amountRials = $this->subscriptionTax->payableForPeriod($period); + if ($amountRials <= 0) { + return $this->error(ErrorCodes::ERR_PAYMENT_002, ErrorCodes::message(ErrorCodes::ERR_PAYMENT_002), 422); + } + + $payment = new Payment($user, $amountRials, $gatewayName, Payment::TYPE_SUBSCRIPTION, $frontendAddress); + $payment->assignTenant($owner); + $payment->setMetadata(['period_uuid' => $periodUuid]); $this->paymentRepo->save($payment); // مثل appointment: ارتباط با بانک اینجا نیست؛ در GET /payment/pay انجام می‌شود. return $this->success([ - 'payment_uuid' => $payment->getUuid(), - 'pay_url' => $this->appBaseUrl . '/api/v1/payment/pay/' . $payment->getOrderId(), - 'order_id' => $payment->getOrderId(), + 'payment_uuid' => $payment->getUuid(), + 'pay_url' => $this->appBaseUrl . '/api/v1/payment/pay/' . $payment->getOrderId(), + 'order_id' => $payment->getOrderId(), + 'price_rials' => $period->getPriceRials(), + 'tax_percent' => $this->subscriptionTax->percent(), + 'tax_rials' => $this->subscriptionTax->taxOf($period->getPriceRials()), + 'payable_rials' => $amountRials, ]); } @@ -502,6 +514,9 @@ class PaymentController extends BaseController 'test_mode' => $this->gateways->isTestMode(), 'appointment_fee_rials' => (int) ($this->configRepo->get('appointment_fee_rials') ?: 0), 'gateways' => $this->gateways->activeGateways(), + // نرخ مالیاتِ اشتراک و شارژ کیف پول، تا کلاینت بتواند جمع کل را پیش از + // ارسال درخواست نشان دهد. صفر یعنی مالیات خاموش است. + 'tax_percent' => $this->paymentTax->percent(), ]); } diff --git a/src/Payment/Service/PaymentManager.php b/src/Payment/Service/PaymentManager.php index 7df49068..6b240846 100644 --- a/src/Payment/Service/PaymentManager.php +++ b/src/Payment/Service/PaymentManager.php @@ -305,8 +305,10 @@ final class PaymentManager if ($entityType === null || $entityId === null) { return; } + // قرینهٔ handleSmsWalletCharge: همان مبلغی که اعتبار شده بود پس گرفته می‌شود. $wallet = $this->smsWalletService->getOrCreate($entityType, $entityId); - $this->smsWalletService->deduct($wallet, $payment->getAmountRials(), 'استرداد شارژ کیف پیامک'); + $credit = (int) ($meta['net_rials'] ?? $payment->getAmountRials()); + $this->smsWalletService->deduct($wallet, $credit, 'استرداد شارژ کیف پیامک'); } /** @@ -386,8 +388,11 @@ final class PaymentManager return; } + // اعتبار = مبلغ خالص، نه مبلغ پرداختی: مالیات سهم دولت است نه شارژ کاربر. + // پرداخت‌های قدیمی `net_rials` ندارند و همان مبلغ کلشان اعتبار می‌شود. $wallet = $this->smsWalletService->getOrCreate($entityType, $entityId); - $this->smsWalletService->charge($wallet, $payment->getAmountRials(), $payment); + $credit = (int) ($meta['net_rials'] ?? $payment->getAmountRials()); + $this->smsWalletService->charge($wallet, $credit, $payment); } // ── Helpers ─────────────────────────────────────────────────────────────── diff --git a/src/Payment/Service/PaymentTaxCalculator.php b/src/Payment/Service/PaymentTaxCalculator.php new file mode 100644 index 00000000..edeeebb6 --- /dev/null +++ b/src/Payment/Service/PaymentTaxCalculator.php @@ -0,0 +1,61 @@ +configRepo->get('tax_enabled') !== '1') { + return 0.0; + } + + return max(0.0, (float) $this->configRepo->get('tax_percent')); + } + + public function taxOf(int $netRials): int + { + $percent = $this->percent(); + if ($percent <= 0.0 || $netRials <= 0) { + return 0; + } + + return (int) round($netRials * $percent / 100); + } + + /** مبلغی که کاربر واقعاً می‌پردازد: مبلغ خالص + مالیات. */ + public function payableOf(int $netRials): int + { + return $netRials + $this->taxOf($netRials); + } + + /** + * تفکیک کامل، برای برگرداندن در پاسخ API. + * + * @return array{net_rials: int, tax_percent: float, tax_rials: int, payable_rials: int} + */ + public function breakdown(int $netRials): array + { + return [ + 'net_rials' => $netRials, + 'tax_percent' => $this->percent(), + 'tax_rials' => $this->taxOf($netRials), + 'payable_rials' => $this->payableOf($netRials), + ]; + } +} diff --git a/src/Sms/Controller/SmsWalletController.php b/src/Sms/Controller/SmsWalletController.php index f7260280..574a3cb3 100644 --- a/src/Sms/Controller/SmsWalletController.php +++ b/src/Sms/Controller/SmsWalletController.php @@ -40,6 +40,7 @@ class SmsWalletController extends BaseController private readonly \App\Config\Repository\SiteConfigRepository $configRepo, private readonly \App\Secretary\Security\SecretaryAccessChecker $secretaryAccess, private readonly \App\Clinic\Security\ClinicDoctorAccessChecker $clinicDoctorAccess, + private readonly \App\Payment\Service\PaymentTaxCalculator $tax, private readonly string $appBaseUrl, ) {} @@ -82,12 +83,18 @@ class SmsWalletController extends BaseController $data = json_decode($request->getContent(), true) ?? []; $gatewayName = trim($data['gateway'] ?? 'mellat'); - $amountRials = (int) ($data['amount_rials'] ?? 0); + // `amount_rials` مبلغی است که به کیف پول می‌نشیند — خالص، بدون مالیات. + $netRials = (int) ($data['amount_rials'] ?? 0); - if ($amountRials <= 0) { + if ($netRials <= 0) { return $this->error(ErrorCodes::ERR_PAYMENT_002, 'مبلغ شارژ نامعتبر است', 422); } + // مالیات روی مبلغ شارژ اضافه می‌شود؛ اعتبارِ کیف پول همان مبلغ خالص می‌ماند، + // وگرنه کاربر مالیات را هم به‌صورت اعتبار پیامک پس می‌گرفت. + $breakdown = $this->tax->breakdown($netRials); + $amountRials = $breakdown['payable_rials']; + // فقط اعتبارسنجی درگاه؛ ارتباط با بانک در flow واحد (GET /payment/pay) انجام می‌شود. if ($this->gateways->resolve($gatewayName) === null) { return $this->error(ErrorCodes::ERR_VALIDATION_001, 'درگاه پرداخت نامعتبر است', 422); @@ -96,14 +103,19 @@ class SmsWalletController extends BaseController $frontendAddress = trim($data['frontend_address'] ?? ''); $payment = new Payment($user, $amountRials, $gatewayName, Payment::TYPE_SMS_WALLET, $frontendAddress); $payment->assignTenantPair($entityType, $entityId); - $payment->setMetadata(['entity_type' => $entityType, 'entity_id' => $entityId]); + $payment->setMetadata([ + 'entity_type' => $entityType, + 'entity_id' => $entityId, + // اعتباری که بعد از پرداخت موفق به کیف پول می‌نشیند — بدون مالیات. + 'net_rials' => $netRials, + ]); $this->paymentRepo->save($payment); return $this->success([ 'payment_uuid' => $payment->getUuid(), 'pay_url' => $this->appBaseUrl . '/api/v1/payment/pay/' . $payment->getOrderId(), 'order_id' => $payment->getOrderId(), - ]); + ] + $breakdown); } #[Route('/api/v1/sms/wallet/logs', methods: ['GET'])] diff --git a/src/Subscription/Controller/SubscriptionController.php b/src/Subscription/Controller/SubscriptionController.php index c6a3a893..528a5ad9 100644 --- a/src/Subscription/Controller/SubscriptionController.php +++ b/src/Subscription/Controller/SubscriptionController.php @@ -15,6 +15,7 @@ use App\Subscription\Repository\SubscriptionPlanRepository; use App\Subscription\Repository\SubscriptionPeriodRepository; use App\Subscription\Repository\ClinicSubscriptionRepository; use App\Subscription\Service\SubscriptionService; +use App\Subscription\Service\SubscriptionTaxCalculator; use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; @@ -36,6 +37,7 @@ class SubscriptionController extends BaseController private readonly UserActiveContextRepository $contextRepo, private readonly EntityManagerInterface $em, private readonly \App\Secretary\Security\SecretaryAccessChecker $secretaryAccess, + private readonly SubscriptionTaxCalculator $tax, ) {} // ── Public ────────────────────────────────────────────────────────────── @@ -45,10 +47,10 @@ class SubscriptionController extends BaseController { $plans = $this->planRepo->findAllActive(); - return $this->success(array_map( + return $this->success($this->tax->decoratePlans(array_map( fn(SubscriptionPlan $p) => $p->toArray(withPeriods: true), $plans - )); + ))); } // ── Authenticated ──────────────────────────────────────────────────────── @@ -123,7 +125,7 @@ class SubscriptionController extends BaseController $plans = $this->planRepo->findAllForAdmin(); return $this->paginated( - array_map(fn(SubscriptionPlan $p) => $p->toArray(withPeriods: true), $plans), + $this->tax->decoratePlans(array_map(fn(SubscriptionPlan $p) => $p->toArray(withPeriods: true), $plans)), count($plans), 1, 100 @@ -247,7 +249,7 @@ class SubscriptionController extends BaseController $this->periodRepo->save($period); - return $this->success($period->toArray(), 201); + return $this->success($this->tax->decoratePeriod($period->toArray()), 201); } #[Route('/api/v1/admin/subscription/period/{uuid}', methods: ['PATCH'])] @@ -269,7 +271,7 @@ class SubscriptionController extends BaseController $this->periodRepo->save($period); - return $this->success($period->toArray()); + return $this->success($this->tax->decoratePeriod($period->toArray())); } #[Route('/api/v1/admin/subscription/period/{uuid}', methods: ['DELETE'])] diff --git a/src/Subscription/Service/SubscriptionTaxCalculator.php b/src/Subscription/Service/SubscriptionTaxCalculator.php new file mode 100644 index 00000000..79d22434 --- /dev/null +++ b/src/Subscription/Service/SubscriptionTaxCalculator.php @@ -0,0 +1,73 @@ +tax->percent(); + } + + public function taxOf(int $netRials): int + { + return $this->tax->taxOf($netRials); + } + + public function payableOf(int $netRials): int + { + return $this->tax->payableOf($netRials); + } + + public function payableForPeriod(SubscriptionPeriod $period): int + { + return $this->tax->payableOf($period->getPriceRials()); + } + + /** + * سه فیلد مالیاتی را کنار `price_rials` می‌گذارد. کلیدِ خودِ قیمت دست نمی‌خورد + * تا کلاینت‌های قدیمی نشکنند. + * + * @param array $period خروجی `SubscriptionPeriod::toArray()` + * @return array + */ + public function decoratePeriod(array $period): array + { + $net = (int) ($period['price_rials'] ?? 0); + + return $period + [ + 'tax_percent' => $this->tax->percent(), + 'tax_rials' => $this->tax->taxOf($net), + 'payable_rials' => $this->tax->payableOf($net), + ]; + } + + /** + * همان کار را روی `periods` هر پلن انجام می‌دهد. + * + * @param array> $plans خروجی `SubscriptionPlan::toArray(withPeriods: true)` + * @return array> + */ + public function decoratePlans(array $plans): array + { + return array_map(function (array $plan): array { + if (isset($plan['periods']) && is_array($plan['periods'])) { + $plan['periods'] = array_map($this->decoratePeriod(...), $plan['periods']); + } + + return $plan; + }, $plans); + } +} diff --git a/tests/Payment/PaymentTenantTest.php b/tests/Payment/PaymentTenantTest.php index 3229e2ac..2898bf9e 100644 --- a/tests/Payment/PaymentTenantTest.php +++ b/tests/Payment/PaymentTenantTest.php @@ -34,6 +34,18 @@ class PaymentTenantTest extends ApiTestCase parent::tearDown(); } + private function makePricedPeriod(int $priceRials): \App\Subscription\Entity\SubscriptionPeriod + { + $plan = new \App\Subscription\Entity\SubscriptionPlan('plan-' . bin2hex(random_bytes(4)), 5, 1, []); + $this->em->persist($plan); + + $period = new \App\Subscription\Entity\SubscriptionPeriod($plan, 'یک ماهه', 1, $priceRials); + $this->em->persist($period); + $this->em->flush(); + + return $period; + } + private function makeDoctor(): Doctor { $doctor = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر پرداخت'); @@ -105,9 +117,10 @@ class PaymentTenantTest extends ApiTestCase { $doctor = $this->makeDoctor(); + // مبلغ از خودِ دوره می‌آید (قیمت خالص + مالیات)، نه از بدنهٔ درخواست. $res = $this->authJson('POST', '/api/v1/subscription-payment', $doctor->getUser(), [ - 'gateway' => 'mellat', - 'amount_rials' => 1_000_000, + 'gateway' => 'mellat', + 'period_uuid' => $this->makePricedPeriod(1_000_000)->getUuid(), ]); self::assertSame(200, $this->responseCode()); @@ -123,8 +136,8 @@ class PaymentTenantTest extends ApiTestCase public function testSubscriptionPaymentWithoutAnOwnedEnvironmentIsRejected(): void { $res = $this->authJson('POST', '/api/v1/subscription-payment', $this->createUser(['ROLE_USER']), [ - 'gateway' => 'mellat', - 'amount_rials' => 1_000_000, + 'gateway' => 'mellat', + 'period_uuid' => $this->makePricedPeriod(1_000_000)->getUuid(), ]); self::assertSame(422, $this->responseCode()); diff --git a/tests/Sms/SmsWalletTaxTest.php b/tests/Sms/SmsWalletTaxTest.php new file mode 100644 index 00000000..09db6bb1 --- /dev/null +++ b/tests/Sms/SmsWalletTaxTest.php @@ -0,0 +1,116 @@ +em->getRepository(SiteConfig::class)->findOneBy(['configKey' => $key]); + if ($cfg === null) { + $this->em->persist(new SiteConfig($key, $value)); + } else { + $cfg->setValue($value); + } + $this->em->flush(); + } + + private function enableTax(string $percent = '10'): void + { + $this->setConfig('tax_enabled', '1'); + $this->setConfig('tax_percent', $percent); + } + + private function makeDoctor(): Doctor + { + $doctor = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر کیف پول'); + $this->em->persist($doctor); + $this->em->flush(); + + return $doctor; + } + + /** @return array */ + private function charge(Doctor $doctor, int $netRials): array + { + $res = $this->authJson('POST', '/api/v1/sms/wallet/charge', $doctor->getUser(), [ + 'gateway' => 'mellat', + 'amount_rials' => $netRials, + ]); + self::assertSame(200, $this->responseCode()); + + return $res['data']; + } + + public function testChargeResponseSplitsNetAndTax(): void + { + $this->enableTax('10'); + + $data = $this->charge($this->makeDoctor(), 1_000_000); + + self::assertSame(1_000_000, $data['net_rials']); + // JSON عدد اعشاریِ ۱۰٫۰ را ۱۰ سریالایز می‌کند، پس مقایسه با نوعِ شل. + self::assertEquals(10, $data['tax_percent']); + self::assertSame(100_000, $data['tax_rials']); + self::assertSame(1_100_000, $data['payable_rials']); + } + + public function testThePaymentRowCarriesTheTaxedAmount(): void + { + $this->enableTax('10'); + + $data = $this->charge($this->makeDoctor(), 1_000_000); + $payment = $this->em->getRepository(Payment::class)->findOneBy(['uuid' => $data['payment_uuid']]); + + self::assertSame(1_100_000, $payment->getAmountRials(), 'مبلغ بانک باید شامل مالیات باشد'); + self::assertSame(1_000_000, $payment->getMetadata()['net_rials']); + } + + public function testWalletIsCreditedWithTheNetAmountNotTheTaxedOne(): void + { + $this->enableTax('10'); + $doctor = $this->makeDoctor(); + $wallets = static::getContainer()->get(SmsWalletService::class); + $before = $wallets->getBalance('doctor', $doctor->getId()); + + $data = $this->charge($doctor, 1_000_000); + $payment = $this->em->getRepository(Payment::class)->findOneBy(['uuid' => $data['payment_uuid']]); + + $this->client->request('POST', \App\Payment\Service\PaymentManager::CALLBACK_PATH . '?' . http_build_query([ + 'gateway' => 'mellat', + 'order_id' => $payment->getOrderId(), + 'mock' => '1', + 'ResCode' => '0', + 'mock_amount' => '1100000', + ])); + + $this->em->clear(); + self::assertSame( + $before + 1_000_000, + $wallets->getBalance('doctor', $doctor->getId()), + 'مالیات نباید به اعتبار پیامک تبدیل شود', + ); + } + + public function testDisabledTaxKeepsChargeAmountUnchanged(): void + { + $this->setConfig('tax_enabled', '0'); + $this->setConfig('tax_percent', '10'); + + $data = $this->charge($this->makeDoctor(), 1_000_000); + + self::assertSame(0, $data['tax_rials']); + self::assertSame(1_000_000, $data['payable_rials']); + } +} diff --git a/tests/Subscription/SubscriptionTaxTest.php b/tests/Subscription/SubscriptionTaxTest.php new file mode 100644 index 00000000..29caf853 --- /dev/null +++ b/tests/Subscription/SubscriptionTaxTest.php @@ -0,0 +1,150 @@ +em->getRepository(SiteConfig::class)->findOneBy(['configKey' => $key]); + if ($cfg === null) { + $this->em->persist(new SiteConfig($key, $value)); + } else { + $cfg->setValue($value); + } + $this->em->flush(); + } + + private function enableTax(string $percent = '10'): void + { + $this->setConfig('tax_enabled', '1'); + $this->setConfig('tax_percent', $percent); + } + + private function makePeriod(int $priceRials): SubscriptionPeriod + { + $plan = new SubscriptionPlan('plan-' . bin2hex(random_bytes(4)), 5, 1, []); + $this->em->persist($plan); + + $period = new SubscriptionPeriod($plan, 'یک ماهه', 1, $priceRials); + $this->em->persist($period); + $this->em->flush(); + + return $period; + } + + private function calculator(): SubscriptionTaxCalculator + { + return static::getContainer()->get(SubscriptionTaxCalculator::class); + } + + public function testTaxIsAddedOnTopOfTheNetPrice(): void + { + $this->enableTax('10'); + $calc = $this->calculator(); + + self::assertSame(29_000, $calc->taxOf(290_000)); + self::assertSame(319_000, $calc->payableOf(290_000), 'مالیات باید اضافه شود، نه استخراج'); + } + + public function testDisabledTaxLeavesThePriceUntouched(): void + { + $this->setConfig('tax_enabled', '0'); + $this->setConfig('tax_percent', '10'); + $calc = $this->calculator(); + + self::assertSame(0.0, $calc->percent()); + self::assertSame(0, $calc->taxOf(290_000)); + self::assertSame(290_000, $calc->payableOf(290_000)); + } + + public function testFreePeriodStaysFree(): void + { + $this->enableTax('10'); + + self::assertSame(0, $this->calculator()->payableOf(0)); + } + + public function testPublicPlanListExposesTaxPerPeriod(): void + { + $this->enableTax('10'); + $this->makePeriod(290_000); + + // این روت پشت firewall نشسته (بر خلاف چیزی که docs می‌گوید)، پس با توکن زده می‌شود. + $this->authJson('GET', '/api/v1/subscription/plans', $this->createUser()); + self::assertSame(200, $this->responseCode()); + + $periods = []; + foreach (json_decode((string) $this->client->getResponse()->getContent(), true)['data'] as $plan) { + foreach ($plan['periods'] ?? [] as $period) { + $periods[] = $period; + } + } + + self::assertNotEmpty($periods, 'حداقل یک دوره باید برگردد'); + foreach ($periods as $period) { + self::assertArrayHasKey('tax_percent', $period); + self::assertArrayHasKey('tax_rials', $period); + self::assertArrayHasKey('payable_rials', $period); + self::assertSame( + $period['price_rials'] + $period['tax_rials'], + $period['payable_rials'], + ); + } + } + + public function testPaymentChargesPricePlusTaxAndIgnoresTheClientAmount(): void + { + $this->enableTax('10'); + $period = $this->makePeriod(290_000); + $user = $this->createUser(['ROLE_DOCTOR']); + $doctor = new \App\Doctor\Entity\Doctor($user, 'دکتر مالیات'); + $this->em->persist($doctor); + $this->em->flush(); + + $this->authJson('POST', '/api/v1/subscription-payment', $user, [ + 'gateway' => 'mellat', + 'period_uuid' => $period->getUuid(), + // مبلغِ دستکاری‌شده باید نادیده گرفته شود. + 'amount_rials' => 1_000, + ]); + self::assertSame(200, $this->responseCode()); + + $body = json_decode((string) $this->client->getResponse()->getContent(), true)['data']; + self::assertSame(290_000, $body['price_rials']); + self::assertSame(29_000, $body['tax_rials']); + self::assertSame(319_000, $body['payable_rials']); + + $payment = $this->em->getRepository(Payment::class)->findOneBy(['uuid' => $body['payment_uuid']]); + self::assertSame(319_000, $payment->getAmountRials(), 'مبلغ ذخیره‌شده باید شامل مالیات باشد'); + } + + public function testPaymentRejectsAnUnknownPeriod(): void + { + $this->enableTax('10'); + $user = $this->createUser(['ROLE_DOCTOR']); + $doctor = new \App\Doctor\Entity\Doctor($user, 'دکتر بدون دوره'); + $this->em->persist($doctor); + $this->em->flush(); + + $this->authJson('POST', '/api/v1/subscription-payment', $user, [ + 'gateway' => 'mellat', + 'period_uuid' => 'does-not-exist', + 'amount_rials' => 290_000, + ]); + + self::assertSame(422, $this->responseCode()); + } +}