From b459d082a45abe8fe624eb7dde900954d4cffe4b Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Wed, 15 Jul 2026 11:39:32 +0330 Subject: [PATCH] feat: port payment management tab from tauri to admin dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add per-clinic payment methods (bank accounts + POS/card-reader devices) under the "مدیریت پرداخت" settings tab at /admin/my-financial, ported from clinic-pro-tauri's mock-only PaymentManagement tab into a real persisted feature. These records are referenceable (by uuid) from patient invoices to record which method a service payment was made with. Backend (new src/PaymentMethod domain): - BankAccount + Pos entities, repositories, PaymentMethodService (validation, ownership scoping, create/update/toggle logic). - Thin PaymentMethodController exposing /api/v1/my/payment-methods/{bank-accounts,pos} (GET/POST/PUT + PATCH .../status), guarded to clinic/doctor/secretary/admin. - Migration for bank_accounts + pos_devices tables. - Functional tests (success + validation/404/403 + empty boundaries). - docs/api/payment-method.md. Frontend: - Replace MyFinancialPage content with the payment-management UI (two tabs, tables, add/edit modals, status toggle) using the admin design system. - usePaymentMethods hook (TanStack Query) + presentational components. - Update page test to cover tabs, data, empty state and the add modal. Co-Authored-By: Claude Opus 4.8 --- .../paymentMethods/BankAccountFormModal.tsx | 104 ++++++++++ .../paymentMethods/BankAccountTable.tsx | 68 +++++++ .../paymentMethods/PaymentMethodHead.tsx | 52 +++++ .../paymentMethods/PosFormModal.tsx | 97 +++++++++ .../components/paymentMethods/PosTable.tsx | 67 ++++++ .../paymentMethods/StatusToggle.tsx | 32 +++ .../admin/components/paymentMethods/banks.ts | 16 ++ assets/admin/hooks/usePaymentMethods.ts | 112 ++++++++++ assets/admin/pages/MyFinancialPage.test.tsx | 72 ++++++- assets/admin/pages/MyFinancialPage.tsx | 144 ++++++------- docs/api/payment-method.md | 157 ++++++++++++++ migrations/Version20260715075904.php | 37 ++++ .../Controller/PaymentMethodController.php | 109 ++++++++++ src/PaymentMethod/Entity/BankAccount.php | 99 +++++++++ src/PaymentMethod/Entity/Pos.php | 98 +++++++++ .../Repository/BankAccountRepository.php | 37 ++++ .../Repository/PosRepository.php | 37 ++++ .../Service/PaymentMethodService.php | 184 +++++++++++++++++ tests/PaymentMethod/PaymentMethodTest.php | 191 ++++++++++++++++++ 19 files changed, 1631 insertions(+), 82 deletions(-) create mode 100644 assets/admin/components/paymentMethods/BankAccountFormModal.tsx create mode 100644 assets/admin/components/paymentMethods/BankAccountTable.tsx create mode 100644 assets/admin/components/paymentMethods/PaymentMethodHead.tsx create mode 100644 assets/admin/components/paymentMethods/PosFormModal.tsx create mode 100644 assets/admin/components/paymentMethods/PosTable.tsx create mode 100644 assets/admin/components/paymentMethods/StatusToggle.tsx create mode 100644 assets/admin/components/paymentMethods/banks.ts create mode 100644 assets/admin/hooks/usePaymentMethods.ts create mode 100644 docs/api/payment-method.md create mode 100644 migrations/Version20260715075904.php create mode 100644 src/PaymentMethod/Controller/PaymentMethodController.php create mode 100644 src/PaymentMethod/Entity/BankAccount.php create mode 100644 src/PaymentMethod/Entity/Pos.php create mode 100644 src/PaymentMethod/Repository/BankAccountRepository.php create mode 100644 src/PaymentMethod/Repository/PosRepository.php create mode 100644 src/PaymentMethod/Service/PaymentMethodService.php create mode 100644 tests/PaymentMethod/PaymentMethodTest.php diff --git a/assets/admin/components/paymentMethods/BankAccountFormModal.tsx b/assets/admin/components/paymentMethods/BankAccountFormModal.tsx new file mode 100644 index 00000000..13991170 --- /dev/null +++ b/assets/admin/components/paymentMethods/BankAccountFormModal.tsx @@ -0,0 +1,104 @@ +import React, { useEffect, useState } from 'react'; +import { toast } from 'sonner'; +import Modal from '../ui/Modal'; +import SearchableSelect from '../ui/SearchableSelect'; +import { BANK_OPTIONS } from './banks'; +import { + useCreateBankAccount, + useUpdateBankAccount, + type BankAccount, +} from '../../hooks/usePaymentMethods'; + +/** + * فرم افزودن/ویرایش حساب بانکی — پورت مبدأ ModalAddBankAccount.jsx. + * اگر `account` داده شود حالت ویرایش، وگرنه افزودن. + */ +export default function BankAccountFormModal({ + open, + account, + onClose, +}: { + open: boolean; + account: BankAccount | null; + onClose: () => void; +}) { + const isEdit = !!account; + const [bankName, setBankName] = useState(''); + const [cardNumber, setCardNumber] = useState(''); + const [accountNumber, setAccountNumber] = useState(''); + const [shabaNumber, setShabaNumber] = useState(''); + + const createMut = useCreateBankAccount(); + const updateMut = useUpdateBankAccount(); + const pending = createMut.isPending || updateMut.isPending; + + useEffect(() => { + if (!open) return; + setBankName(account?.bank_name ?? ''); + setCardNumber(account?.card_number ?? ''); + setAccountNumber(account?.account_number ?? ''); + setShabaNumber(account?.shaba_number ?? ''); + }, [open, account]); + + const submit = () => { + if (!bankName.trim()) { toast.error('نام بانک الزامی است'); return; } + if (!accountNumber.trim()) { toast.error('شماره حساب الزامی است'); return; } + + const body = { + bank_name: bankName.trim(), + account_number: accountNumber.trim(), + card_number: cardNumber.trim(), + shaba_number: shabaNumber.trim(), + }; + + const onSuccess = () => { + toast.success(isEdit ? 'حساب بانکی ویرایش شد' : 'حساب بانکی اضافه شد'); + onClose(); + }; + const onError = (e: unknown) => toast.error(e instanceof Error ? e.message : 'خطا در ذخیره'); + + if (isEdit && account) { + updateMut.mutate({ uuid: account.uuid, body }, { onSuccess, onError }); + } else { + createMut.mutate(body, { onSuccess, onError }); + } + }; + + return ( + + {pending ? '...' : isEdit ? 'ذخیره تغییرات' : 'اضافه کردن حساب بانکی'} + + } + > +
+
+ + setBankName(v != null ? String(v) : '')} + placeholder="انتخاب بانک" + /> +
+
+ + setCardNumber(e.target.value)} placeholder="شماره کارت" /> +
+
+ + setAccountNumber(e.target.value)} placeholder="شماره حساب" /> +
+
+ + setShabaNumber(e.target.value)} placeholder="شبا" /> +
+
+
+ ); +} diff --git a/assets/admin/components/paymentMethods/BankAccountTable.tsx b/assets/admin/components/paymentMethods/BankAccountTable.tsx new file mode 100644 index 00000000..0b905315 --- /dev/null +++ b/assets/admin/components/paymentMethods/BankAccountTable.tsx @@ -0,0 +1,68 @@ +import React from 'react'; +import { PencilIcon } from '@heroicons/react/24/outline'; +import DataTable, { type Column } from '../ui/DataTable'; +import StatusToggle from './StatusToggle'; +import type { BankAccount } from '../../hooks/usePaymentMethods'; + +/** + * جدول حساب‌های بانکی — پورت مبدأ BankAccountList.jsx. + * ستون «شماره ترمینال» مبدأ به «شماره کارت» تغییر کرد (حساب بانکی ترمینال ندارد؛ + * فرم افزودن شماره کارت می‌گیرد). + */ +export default function BankAccountTable({ + data, + loading, + togglingUuid, + onToggle, + onEdit, + onAdd, +}: { + data: BankAccount[]; + loading?: boolean; + togglingUuid: string | null; + onToggle: (uuid: string) => void; + onEdit: (account: BankAccount) => void; + onAdd: () => void; +}) { + const columns: Column[] = [ + { key: 'bank_name', header: 'نام بانک' }, + { key: 'card_number', header: 'شماره کارت', render: (r) => r.card_number || '—' }, + { key: 'account_number', header: 'شماره حساب', render: (r) => r.account_number || '—' }, + { + key: 'is_active', + header: 'وضعیت', + render: (r) => ( + onToggle(r.uuid)} + /> + ), + }, + ]; + + return ( + <> +

+ مدیریت و اضافه کردن حساب های بانکی کلینیک +

+ + columns={columns} + data={data} + loading={loading} + emptyMessage="هنوز حساب بانکی ثبت نشده است" + emptyAction={} + actions={(r) => ( + + )} + /> + + ); +} diff --git a/assets/admin/components/paymentMethods/PaymentMethodHead.tsx b/assets/admin/components/paymentMethods/PaymentMethodHead.tsx new file mode 100644 index 00000000..9d446a1c --- /dev/null +++ b/assets/admin/components/paymentMethods/PaymentMethodHead.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import { PlusIcon } from '@heroicons/react/24/outline'; + +export type PaymentTab = 'bank' | 'pos'; + +/** + * سربرگ صفحهٔ مدیریت پرداخت — پورت مبدأ PaymentManagement/Head.jsx. + * تیتر + سوییچ تب (حساب بانکی / کارت خوان) + دکمهٔ افزودن. + */ +export default function PaymentMethodHead({ + activeTab, + onTabChange, + onAdd, +}: { + activeTab: PaymentTab; + onTabChange: (tab: PaymentTab) => void; + onAdd: () => void; +}) { + return ( +
+

+ مدیریت پرداخت‌ها +

+
+
+ + +
+ +
+
+ ); +} diff --git a/assets/admin/components/paymentMethods/PosFormModal.tsx b/assets/admin/components/paymentMethods/PosFormModal.tsx new file mode 100644 index 00000000..1f955c0e --- /dev/null +++ b/assets/admin/components/paymentMethods/PosFormModal.tsx @@ -0,0 +1,97 @@ +import React, { useEffect, useState } from 'react'; +import { toast } from 'sonner'; +import Modal from '../ui/Modal'; +import SearchableSelect from '../ui/SearchableSelect'; +import { BANK_OPTIONS } from './banks'; +import { + useCreatePos, + useUpdatePos, + type Pos, +} from '../../hooks/usePaymentMethods'; + +/** + * فرم افزودن/ویرایش کارت‌خوان — پورت مبدأ ModalAddPose.jsx. + * اگر `pos` داده شود حالت ویرایش، وگرنه افزودن. + */ +export default function PosFormModal({ + open, + pos, + onClose, +}: { + open: boolean; + pos: Pos | null; + onClose: () => void; +}) { + const isEdit = !!pos; + const [bankName, setBankName] = useState(''); + const [terminalNumber, setTerminalNumber] = useState(''); + const [accountNumber, setAccountNumber] = useState(''); + + const createMut = useCreatePos(); + const updateMut = useUpdatePos(); + const pending = createMut.isPending || updateMut.isPending; + + useEffect(() => { + if (!open) return; + setBankName(pos?.bank_name ?? ''); + setTerminalNumber(pos?.terminal_number ?? ''); + setAccountNumber(pos?.account_number ?? ''); + }, [open, pos]); + + const submit = () => { + if (!bankName.trim()) { toast.error('نام بانک الزامی است'); return; } + if (!terminalNumber.trim()) { toast.error('شماره ترمینال الزامی است'); return; } + + const body = { + bank_name: bankName.trim(), + terminal_number: terminalNumber.trim(), + account_number: accountNumber.trim(), + }; + + const onSuccess = () => { + toast.success(isEdit ? 'کارت خوان ویرایش شد' : 'کارت خوان اضافه شد'); + onClose(); + }; + const onError = (e: unknown) => toast.error(e instanceof Error ? e.message : 'خطا در ذخیره'); + + if (isEdit && pos) { + updateMut.mutate({ uuid: pos.uuid, body }, { onSuccess, onError }); + } else { + createMut.mutate(body, { onSuccess, onError }); + } + }; + + return ( + + {pending ? '...' : isEdit ? 'ذخیره تغییرات' : 'اضافه کردن کارت خوان'} + + } + > +
+
+ + setBankName(v != null ? String(v) : '')} + placeholder="انتخاب بانک" + /> +
+
+ + setTerminalNumber(e.target.value)} placeholder="شماره ترمینال" /> +
+
+ + setAccountNumber(e.target.value)} placeholder="شماره حساب" /> +
+
+
+ ); +} diff --git a/assets/admin/components/paymentMethods/PosTable.tsx b/assets/admin/components/paymentMethods/PosTable.tsx new file mode 100644 index 00000000..72a954fb --- /dev/null +++ b/assets/admin/components/paymentMethods/PosTable.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +import { PencilIcon } from '@heroicons/react/24/outline'; +import DataTable, { type Column } from '../ui/DataTable'; +import StatusToggle from './StatusToggle'; +import type { Pos } from '../../hooks/usePaymentMethods'; + +/** + * جدول کارت‌خوان‌ها — پورت مبدأ PoseList.jsx. + * ستون‌ها: نام بانک، شماره سریال، شماره ترمینال، وضعیت، عملیات. + */ +export default function PosTable({ + data, + loading, + togglingUuid, + onToggle, + onEdit, + onAdd, +}: { + data: Pos[]; + loading?: boolean; + togglingUuid: string | null; + onToggle: (uuid: string) => void; + onEdit: (pos: Pos) => void; + onAdd: () => void; +}) { + const columns: Column[] = [ + { key: 'bank_name', header: 'نام بانک' }, + { key: 'serial_number', header: 'شماره سریال', render: (r) => r.serial_number || '—' }, + { key: 'terminal_number', header: 'شماره ترمینال', render: (r) => r.terminal_number || '—' }, + { + key: 'is_active', + header: 'وضعیت', + render: (r) => ( + onToggle(r.uuid)} + /> + ), + }, + ]; + + return ( + <> +

+ مدیریت و اضافه کردن دستگاه های کارت خوان موجود +

+ + columns={columns} + data={data} + loading={loading} + emptyMessage="هنوز کارت خوانی ثبت نشده است" + emptyAction={} + actions={(r) => ( + + )} + /> + + ); +} diff --git a/assets/admin/components/paymentMethods/StatusToggle.tsx b/assets/admin/components/paymentMethods/StatusToggle.tsx new file mode 100644 index 00000000..585a2e59 --- /dev/null +++ b/assets/admin/components/paymentMethods/StatusToggle.tsx @@ -0,0 +1,32 @@ +import React from 'react'; + +/** + * سوییچ وضعیت فعال/غیرفعال — معادل MUI Switch مبدأ با دیزاین‌سیستم مقصد. + * برچسب فعال/غیرفعال کنار سوییچ نمایش داده می‌شود (مثل مبدأ). + */ +export default function StatusToggle({ + active, + onToggle, + disabled, +}: { + active: boolean; + onToggle: () => void; + disabled?: boolean; +}) { + const label = active ? 'فعال' : 'غیرفعال'; + return ( +
+ + {label} +
+ ); +} diff --git a/assets/admin/components/paymentMethods/banks.ts b/assets/admin/components/paymentMethods/banks.ts new file mode 100644 index 00000000..5a870b64 --- /dev/null +++ b/assets/admin/components/paymentMethods/banks.ts @@ -0,0 +1,16 @@ +import type { SelectOption } from '../ui/SearchableSelect'; + +/** + * فهرست بانک‌های قابل انتخاب — عیناً از مبدأ clinic-pro-tauri + * (PaymentManagement/list/ModalAddBankAccount.jsx). value = label تا نام بانک + * مستقیم ذخیره شود (بک‌اند نام را نگه می‌دارد، نه شناسه). + */ +export const BANK_OPTIONS: SelectOption[] = [ + { value: 'ملی', label: 'ملی' }, + { value: 'ملت', label: 'ملت' }, + { value: 'تجارت', label: 'تجارت' }, + { value: 'صادرات', label: 'صادرات' }, + { value: 'سپه', label: 'سپه' }, + { value: 'پاسارگاد', label: 'پاسارگاد' }, + { value: 'رفاه', label: 'رفاه' }, +]; diff --git a/assets/admin/hooks/usePaymentMethods.ts b/assets/admin/hooks/usePaymentMethods.ts new file mode 100644 index 00000000..c167dbbf --- /dev/null +++ b/assets/admin/hooks/usePaymentMethods.ts @@ -0,0 +1,112 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { api } from '../lib/api'; +import type { ApiResponse } from '../lib/api'; + +/** + * روش‌های پرداختِ کلینیک (حساب بانکی + کارت‌خوان) برای صفحهٔ «مدیریت پرداخت». + * منبع: /api/v1/my/payment-methods/... — این رکوردها بعداً از فاکتور مراجعه‌کننده + * برای ثبت روش پرداختِ یک سرویس ارجاع داده می‌شوند. + */ + +export interface BankAccount { + uuid: string; + bank_name: string; + card_number: string | null; + account_number: string; + shaba_number: string | null; + is_active: boolean; + created_at: number; +} + +export interface Pos { + uuid: string; + bank_name: string; + serial_number: string | null; + terminal_number: string; + account_number: string | null; + is_active: boolean; + created_at: number; +} + +export interface BankAccountInput { + bank_name: string; + account_number: string; + card_number?: string; + shaba_number?: string; +} + +export interface PosInput { + bank_name: string; + terminal_number: string; + serial_number?: string; + account_number?: string; +} + +const BANK_KEY = ['payment-methods', 'bank-accounts'] as const; +const POS_KEY = ['payment-methods', 'pos'] as const; + +// ── Bank accounts ──────────────────────────────────────────────────────────── + +export function useBankAccounts() { + return useQuery>({ + queryKey: BANK_KEY, + queryFn: () => api.get('/api/v1/my/payment-methods/bank-accounts'), + }); +} + +export function useCreateBankAccount() { + const qc = useQueryClient(); + return useMutation, unknown, BankAccountInput>({ + mutationFn: (body) => api.post('/api/v1/my/payment-methods/bank-accounts', body), + onSuccess: () => qc.invalidateQueries({ queryKey: BANK_KEY }), + }); +} + +export function useUpdateBankAccount() { + const qc = useQueryClient(); + return useMutation, unknown, { uuid: string; body: BankAccountInput }>({ + mutationFn: ({ uuid, body }) => api.put(`/api/v1/my/payment-methods/bank-accounts/${uuid}`, body), + onSuccess: () => qc.invalidateQueries({ queryKey: BANK_KEY }), + }); +} + +export function useToggleBankAccountStatus() { + const qc = useQueryClient(); + return useMutation, unknown, string>({ + mutationFn: (uuid) => api.patch(`/api/v1/my/payment-methods/bank-accounts/${uuid}/status`, {}), + onSuccess: () => qc.invalidateQueries({ queryKey: BANK_KEY }), + }); +} + +// ── POS devices ────────────────────────────────────────────────────────────── + +export function usePosDevices() { + return useQuery>({ + queryKey: POS_KEY, + queryFn: () => api.get('/api/v1/my/payment-methods/pos'), + }); +} + +export function useCreatePos() { + const qc = useQueryClient(); + return useMutation, unknown, PosInput>({ + mutationFn: (body) => api.post('/api/v1/my/payment-methods/pos', body), + onSuccess: () => qc.invalidateQueries({ queryKey: POS_KEY }), + }); +} + +export function useUpdatePos() { + const qc = useQueryClient(); + return useMutation, unknown, { uuid: string; body: PosInput }>({ + mutationFn: ({ uuid, body }) => api.put(`/api/v1/my/payment-methods/pos/${uuid}`, body), + onSuccess: () => qc.invalidateQueries({ queryKey: POS_KEY }), + }); +} + +export function useTogglePosStatus() { + const qc = useQueryClient(); + return useMutation, unknown, string>({ + mutationFn: (uuid) => api.patch(`/api/v1/my/payment-methods/pos/${uuid}/status`, {}), + onSuccess: () => qc.invalidateQueries({ queryKey: POS_KEY }), + }); +} diff --git a/assets/admin/pages/MyFinancialPage.test.tsx b/assets/admin/pages/MyFinancialPage.test.tsx index c8f0c710..41b6b6d0 100644 --- a/assets/admin/pages/MyFinancialPage.test.tsx +++ b/assets/admin/pages/MyFinancialPage.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { screen } from '@testing-library/react'; +import { screen, fireEvent, within } from '@testing-library/react'; import { renderWithProviders } from '../test/utils'; vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })); @@ -13,20 +13,70 @@ import MyFinancialPage from './MyFinancialPage'; const get = api.get as ReturnType; +const bankRow = { + uuid: 'bank-1', bank_name: 'ملی', card_number: '6037991234567890', + account_number: '0101234567890', shaba_number: null, is_active: true, created_at: 1, +}; +const posRow = { + uuid: 'pos-1', bank_name: 'ملت', serial_number: 'SN-98765', + terminal_number: '123456', account_number: null, is_active: false, created_at: 1, +}; + +function mockData({ banks = [bankRow], pos = [posRow] } = {}) { + get.mockImplementation((url: string) => { + if (url.includes('/bank-accounts')) return Promise.resolve({ success: true, data: banks }); + if (url.includes('/pos')) return Promise.resolve({ success: true, data: pos }); + return Promise.resolve({ success: true, data: [] }); + }); +} + beforeEach(() => { get.mockReset(); - get.mockResolvedValue({ success: true, data: { - total_paid: 0, total_pending: 0, monthly_chart: [], - } }); + mockData(); }); -describe('MyFinancialPage inside the settings shell', () => { - it('renders the settings sub-nav around the page content', async () => { +describe('MyFinancialPage — payment methods', () => { + it('renders the settings shell and the payment management header', async () => { renderWithProviders(, { route: '/admin/my-financial' }); - // settings shell menu - expect(await screen.findByText('خرید اشتراک')).toBeInTheDocument(); - expect(screen.getByText('خدمات')).toBeInTheDocument(); - // page's own content - expect(screen.getByText('گزارش مالی')).toBeInTheDocument(); + expect(await screen.findByText('خرید اشتراک')).toBeInTheDocument(); // settings sub-nav + expect(screen.getByText('مدیریت پرداخت‌ها')).toBeInTheDocument(); // page header + expect(screen.getByRole('tab', { name: 'حساب بانکی' })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: 'کارت خوان' })).toBeInTheDocument(); + }); + + it('shows bank accounts by default', async () => { + renderWithProviders(, { route: '/admin/my-financial' }); + expect(await screen.findByText('0101234567890')).toBeInTheDocument(); + expect(screen.getByText('6037991234567890')).toBeInTheDocument(); + expect(screen.getByText('مدیریت و اضافه کردن حساب های بانکی کلینیک')).toBeInTheDocument(); + }); + + it('switches to the POS tab and lists devices', async () => { + renderWithProviders(, { route: '/admin/my-financial' }); + await screen.findByText('0101234567890'); + + fireEvent.click(screen.getByRole('tab', { name: 'کارت خوان' })); + + expect(await screen.findByText('SN-98765')).toBeInTheDocument(); + expect(screen.getByText('123456')).toBeInTheDocument(); + expect(screen.getByText('مدیریت و اضافه کردن دستگاه های کارت خوان موجود')).toBeInTheDocument(); + }); + + it('renders an empty state when there are no bank accounts', async () => { + mockData({ banks: [] }); + renderWithProviders(, { route: '/admin/my-financial' }); + expect(await screen.findByText('هنوز حساب بانکی ثبت نشده است')).toBeInTheDocument(); + }); + + it('opens the add bank account modal from the header button', async () => { + renderWithProviders(, { route: '/admin/my-financial' }); + await screen.findByText('0101234567890'); + + fireEvent.click(screen.getByRole('button', { name: /افزودن حساب بانکی/ })); + + const dialog = await screen.findByText('افزودن حساب بانکی', { selector: 'h2' }); + expect(dialog).toBeInTheDocument(); + const modal = dialog.closest('.modal') as HTMLElement; + expect(within(modal).getByText('شبا')).toBeInTheDocument(); }); }); diff --git a/assets/admin/pages/MyFinancialPage.tsx b/assets/admin/pages/MyFinancialPage.tsx index e94febc6..66f56038 100644 --- a/assets/admin/pages/MyFinancialPage.tsx +++ b/assets/admin/pages/MyFinancialPage.tsx @@ -1,84 +1,86 @@ -import React from 'react'; +import React, { useState } from 'react'; +import { toast } from 'sonner'; import SettingsLayout from '../components/layout/SettingsLayout'; -import { useQuery } from '@tanstack/react-query'; -import { api } from '../lib/api'; -import type { ApiResponse } from '../lib/api'; -import { formatRial } from '../lib/utils'; import PageHeader from '../components/ui/PageHeader'; +import PaymentMethodHead, { type PaymentTab } from '../components/paymentMethods/PaymentMethodHead'; +import BankAccountTable from '../components/paymentMethods/BankAccountTable'; +import PosTable from '../components/paymentMethods/PosTable'; +import BankAccountFormModal from '../components/paymentMethods/BankAccountFormModal'; +import PosFormModal from '../components/paymentMethods/PosFormModal'; +import { + useBankAccounts, + usePosDevices, + useToggleBankAccountStatus, + useTogglePosStatus, + type BankAccount, + type Pos, +} from '../hooks/usePaymentMethods'; -interface MonthlyEntry { - month: string; - paid: number; - count: number; -} - -interface FinancialSummary { - total_paid: number; - total_pending: number; - total_refunded: number; - count_paid: number; - monthly_chart: MonthlyEntry[]; -} - -function KpiCard({ label, value, color }: { label: string; value: string; color: string }) { - return ( -
-
{label}
-
{value}
-
- ); -} - -const BAR_MAX_HEIGHT = 120; - +/** + * صفحهٔ «مدیریت پرداخت» (/admin/my-financial) — پورت تب PaymentManagement از + * clinic-pro-tauri. روش‌های پرداختِ کلینیک (حساب بانکی + کارت‌خوان) که بعداً از + * فاکتور مراجعه‌کننده برای ثبت روش پرداخت یک سرویس ارجاع می‌شوند. + */ function MyFinancialPageContent() { - const { data, isLoading } = useQuery>({ - queryKey: ['my-financial-summary'], - queryFn: () => api.get('/api/v1/my/financial-summary'), - }); + const [activeTab, setActiveTab] = useState('bank'); + const [bankModalOpen, setBankModalOpen] = useState(false); + const [posModalOpen, setPosModalOpen] = useState(false); + const [editingBank, setEditingBank] = useState(null); + const [editingPos, setEditingPos] = useState(null); - const summary = data?.data; - const maxPaid = summary?.monthly_chart?.reduce((m, e) => Math.max(m, e.paid), 1) ?? 1; + const bankQuery = useBankAccounts(); + const posQuery = usePosDevices(); + const toggleBank = useToggleBankAccountStatus(); + const togglePos = useTogglePosStatus(); + + const banks = bankQuery.data?.data ?? []; + const posDevices = posQuery.data?.data ?? []; + + const openAdd = () => { + if (activeTab === 'bank') { setEditingBank(null); setBankModalOpen(true); } + else { setEditingPos(null); setPosModalOpen(true); } + }; + + const onError = (e: unknown) => toast.error(e instanceof Error ? e.message : 'خطا در تغییر وضعیت'); return (
- + - {isLoading ? ( -
در حال بارگذاری...
- ) : ( - <> -
- - - - -
+
+ -
-
نمودار ۶ ماه اخیر
- {summary?.monthly_chart?.length ? ( -
- {summary.monthly_chart.map((entry) => { - const barH = Math.max(4, Math.round((entry.paid / maxPaid) * BAR_MAX_HEIGHT)); - return ( -
-
{formatRial(entry.paid)}
-
-
{entry.month}
-
- ); - })} -
- ) : ( -
داده‌ای برای نمایش وجود ندارد
- )} -
- - )} + {activeTab === 'bank' ? ( + toggleBank.mutate(uuid, { onError })} + onEdit={(account) => { setEditingBank(account); setBankModalOpen(true); }} + onAdd={openAdd} + /> + ) : ( + togglePos.mutate(uuid, { onError })} + onEdit={(pos) => { setEditingPos(pos); setPosModalOpen(true); }} + onAdd={openAdd} + /> + )} +
+ + { setBankModalOpen(false); setEditingBank(null); }} + /> + { setPosModalOpen(false); setEditingPos(null); }} + />
); } diff --git a/docs/api/payment-method.md b/docs/api/payment-method.md new file mode 100644 index 00000000..60bafc59 --- /dev/null +++ b/docs/api/payment-method.md @@ -0,0 +1,157 @@ +# Payment Methods API + +> **Prefix:** `/api/v1/my/payment-methods` + +Per-clinic payment methods managed from the settings screen (`/admin/my-financial`, +tab "مدیریت پرداخت"). Two resources: **bank accounts** and **POS (card reader) devices**. +Records are stored so a patient invoice can later reference which account/device a +service payment was made to. + +All endpoints are scoped to the acting user — a clinic never sees another's records. + +**Permission:** authenticated user with one of `ROLE_CLINIC`, `ROLE_DOCTOR`, +`ROLE_SECRETARY`, `ROLE_ADMIN` (otherwise `403 ERR_FORBIDDEN_001`). + +--- + +## Bank accounts + +### GET `/api/v1/my/payment-methods/bank-accounts` + +List the current clinic's bank accounts (newest first). + +#### Response `200` +```json +{ + "success": true, + "data": [ + { + "uuid": "b1e0...-...", + "bank_name": "ملی", + "card_number": "6037991234567890", + "account_number": "0101234567890", + "shaba_number": "IR820540102680020817909002", + "is_active": true, + "created_at": 1752566400 + } + ] +} +``` +Empty list returns `"data": []`. + +--- + +### POST `/api/v1/my/payment-methods/bank-accounts` + +Create a bank account. + +#### Body +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `bank_name` | string | ✅ | Bank name | +| `account_number` | string | ✅ | Account number | +| `card_number` | string | ❌ | Card number | +| `shaba_number` | string | ❌ | IBAN / SHABA | + +#### Response `201` +Single created record (same shape as list item). + +#### Errors +- `422 ERR_VALIDATION_001` — `bank_name` or `account_number` missing (`field` set). + +--- + +### PUT `/api/v1/my/payment-methods/bank-accounts/{uuid}` + +Update a bank account. Any subset of the create fields may be sent; only provided +keys change. Empty `bank_name`/`account_number` → `422`. + +#### Response `200` +Updated record. + +#### Errors +- `404 ERR_NOT_FOUND_001` — uuid unknown or owned by another clinic. +- `422 ERR_VALIDATION_001` — provided `bank_name`/`account_number` empty. + +--- + +### PATCH `/api/v1/my/payment-methods/bank-accounts/{uuid}/status` + +Toggle `is_active` (active ⇄ inactive). No body. + +#### Response `200` +Record with flipped `is_active`. + +#### Errors +- `404 ERR_NOT_FOUND_001` — uuid unknown or not owned. + +--- + +## POS devices + +### GET `/api/v1/my/payment-methods/pos` + +List the current clinic's card reader devices (newest first). + +#### Response `200` +```json +{ + "success": true, + "data": [ + { + "uuid": "c2f1...-...", + "bank_name": "ملت", + "serial_number": "SN-98765", + "terminal_number": "123456", + "account_number": null, + "is_active": true, + "created_at": 1752566400 + } + ] +} +``` + +--- + +### POST `/api/v1/my/payment-methods/pos` + +Create a POS device. + +#### Body +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `bank_name` | string | ✅ | Bank name | +| `terminal_number` | string | ✅ | Terminal number | +| `serial_number` | string | ❌ | Device serial number | +| `account_number` | string | ❌ | Linked account number | + +#### Response `201` +Single created record. + +#### Errors +- `422 ERR_VALIDATION_001` — `bank_name` or `terminal_number` missing (`field` set). + +--- + +### PUT `/api/v1/my/payment-methods/pos/{uuid}` + +Update a POS device. Partial update; empty `bank_name`/`terminal_number` → `422`. + +#### Response `200` +Updated record. + +#### Errors +- `404 ERR_NOT_FOUND_001` — uuid unknown or not owned. +- `422 ERR_VALIDATION_001` — provided `bank_name`/`terminal_number` empty. + +--- + +### PATCH `/api/v1/my/payment-methods/pos/{uuid}/status` + +Toggle `is_active`. No body. + +#### Response `200` +Record with flipped `is_active`. + +#### Errors +- `404 ERR_NOT_FOUND_001` — uuid unknown or not owned. diff --git a/migrations/Version20260715075904.php b/migrations/Version20260715075904.php new file mode 100644 index 00000000..cfebc244 --- /dev/null +++ b/migrations/Version20260715075904.php @@ -0,0 +1,37 @@ +addSql('CREATE TABLE bank_accounts (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, bank_name VARCHAR(100) NOT NULL, card_number VARCHAR(32) DEFAULT NULL, account_number VARCHAR(64) NOT NULL, shaba_number VARCHAR(34) DEFAULT NULL, is_active TINYINT NOT NULL, created_at INT NOT NULL, updated_at INT NOT NULL, user_id INT NOT NULL, UNIQUE INDEX UNIQ_FB88842BD17F50A6 (uuid), INDEX idx_bank_accounts_user (user_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('CREATE TABLE pos_devices (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, bank_name VARCHAR(100) NOT NULL, serial_number VARCHAR(64) DEFAULT NULL, terminal_number VARCHAR(64) NOT NULL, account_number VARCHAR(64) DEFAULT NULL, is_active TINYINT NOT NULL, created_at INT NOT NULL, updated_at INT NOT NULL, user_id INT NOT NULL, UNIQUE INDEX UNIQ_7C0A337CD17F50A6 (uuid), INDEX idx_pos_devices_user (user_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('ALTER TABLE bank_accounts ADD CONSTRAINT FK_FB88842BA76ED395 FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE RESTRICT'); + $this->addSql('ALTER TABLE pos_devices ADD CONSTRAINT FK_7C0A337CA76ED395 FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE RESTRICT'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE bank_accounts DROP FOREIGN KEY FK_FB88842BA76ED395'); + $this->addSql('ALTER TABLE pos_devices DROP FOREIGN KEY FK_7C0A337CA76ED395'); + $this->addSql('DROP TABLE bank_accounts'); + $this->addSql('DROP TABLE pos_devices'); + } +} diff --git a/src/PaymentMethod/Controller/PaymentMethodController.php b/src/PaymentMethod/Controller/PaymentMethodController.php new file mode 100644 index 00000000..eec98d06 --- /dev/null +++ b/src/PaymentMethod/Controller/PaymentMethodController.php @@ -0,0 +1,109 @@ +assertRole($user); + + return $this->success($this->service->listBankAccounts($user)); + } + + #[Route('/bank-accounts', methods: ['POST'])] + public function createBankAccount(Request $request, #[CurrentUser] User $user): JsonResponse + { + $this->assertRole($user); + $data = json_decode($request->getContent(), true) ?? []; + + return $this->success($this->service->createBankAccount($user, $data), 201); + } + + #[Route('/bank-accounts/{uuid}', methods: ['PUT'])] + public function updateBankAccount(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse + { + $this->assertRole($user); + $data = json_decode($request->getContent(), true) ?? []; + + return $this->success($this->service->updateBankAccount($user, $uuid, $data)); + } + + #[Route('/bank-accounts/{uuid}/status', methods: ['PATCH'])] + public function toggleBankAccountStatus(string $uuid, #[CurrentUser] User $user): JsonResponse + { + $this->assertRole($user); + + return $this->success($this->service->toggleBankAccountStatus($user, $uuid)); + } + + // ---- POS devices ------------------------------------------------------- + + #[Route('/pos', methods: ['GET'])] + public function listPos(#[CurrentUser] User $user): JsonResponse + { + $this->assertRole($user); + + return $this->success($this->service->listPos($user)); + } + + #[Route('/pos', methods: ['POST'])] + public function createPos(Request $request, #[CurrentUser] User $user): JsonResponse + { + $this->assertRole($user); + $data = json_decode($request->getContent(), true) ?? []; + + return $this->success($this->service->createPos($user, $data), 201); + } + + #[Route('/pos/{uuid}', methods: ['PUT'])] + public function updatePos(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse + { + $this->assertRole($user); + $data = json_decode($request->getContent(), true) ?? []; + + return $this->success($this->service->updatePos($user, $uuid, $data)); + } + + #[Route('/pos/{uuid}/status', methods: ['PATCH'])] + public function togglePosStatus(string $uuid, #[CurrentUser] User $user): JsonResponse + { + $this->assertRole($user); + + return $this->success($this->service->togglePosStatus($user, $uuid)); + } + + private function assertRole(User $user): void + { + if (!array_intersect(self::ALLOWED_ROLES, $user->getRoles())) { + throw new \App\Shared\Exception\AppException(ErrorCodes::ERR_FORBIDDEN_001, 'دسترسی ندارید', 403); + } + } +} diff --git a/src/PaymentMethod/Entity/BankAccount.php b/src/PaymentMethod/Entity/BankAccount.php new file mode 100644 index 00000000..ce347c0b --- /dev/null +++ b/src/PaymentMethod/Entity/BankAccount.php @@ -0,0 +1,99 @@ +uuid = Uuid::v4()->toRfc4122(); + $this->user = $user; + $this->bankName = $bankName; + $this->accountNumber = $accountNumber; + $this->cardNumber = $cardNumber ?: null; + $this->shabaNumber = $shabaNumber ?: null; + $this->createdAt = time(); + $this->updatedAt = time(); + } + + public function getId(): ?int { return $this->id; } + public function getUuid(): string { return $this->uuid; } + public function getUser(): User { return $this->user; } + public function getBankName(): string { return $this->bankName; } + public function getCardNumber(): ?string { return $this->cardNumber; } + public function getAccountNumber(): string { return $this->accountNumber; } + public function getShabaNumber(): ?string { return $this->shabaNumber; } + public function isActive(): bool { return $this->isActive; } + + public function setBankName(string $v): self { $this->bankName = $v; $this->touch(); return $this; } + public function setCardNumber(?string $v): self { $this->cardNumber = $v ?: null; $this->touch(); return $this; } + public function setAccountNumber(string $v): self { $this->accountNumber = $v; $this->touch(); return $this; } + public function setShabaNumber(?string $v): self { $this->shabaNumber = $v ?: null; $this->touch(); return $this; } + public function setActive(bool $v): self { $this->isActive = $v; $this->touch(); return $this; } + + private function touch(): void { $this->updatedAt = time(); } + + public function toArray(): array + { + return [ + 'uuid' => $this->uuid, + 'bank_name' => $this->bankName, + 'card_number' => $this->cardNumber, + 'account_number' => $this->accountNumber, + 'shaba_number' => $this->shabaNumber, + 'is_active' => $this->isActive, + 'created_at' => $this->createdAt, + ]; + } +} diff --git a/src/PaymentMethod/Entity/Pos.php b/src/PaymentMethod/Entity/Pos.php new file mode 100644 index 00000000..a6073880 --- /dev/null +++ b/src/PaymentMethod/Entity/Pos.php @@ -0,0 +1,98 @@ +uuid = Uuid::v4()->toRfc4122(); + $this->user = $user; + $this->bankName = $bankName; + $this->terminalNumber = $terminalNumber; + $this->serialNumber = $serialNumber ?: null; + $this->accountNumber = $accountNumber ?: null; + $this->createdAt = time(); + $this->updatedAt = time(); + } + + public function getId(): ?int { return $this->id; } + public function getUuid(): string { return $this->uuid; } + public function getUser(): User { return $this->user; } + public function getBankName(): string { return $this->bankName; } + public function getSerialNumber(): ?string { return $this->serialNumber; } + public function getTerminalNumber(): string { return $this->terminalNumber; } + public function getAccountNumber(): ?string { return $this->accountNumber; } + public function isActive(): bool { return $this->isActive; } + + public function setBankName(string $v): self { $this->bankName = $v; $this->touch(); return $this; } + public function setSerialNumber(?string $v): self { $this->serialNumber = $v ?: null; $this->touch(); return $this; } + public function setTerminalNumber(string $v): self { $this->terminalNumber = $v; $this->touch(); return $this; } + public function setAccountNumber(?string $v): self { $this->accountNumber = $v ?: null; $this->touch(); return $this; } + public function setActive(bool $v): self { $this->isActive = $v; $this->touch(); return $this; } + + private function touch(): void { $this->updatedAt = time(); } + + public function toArray(): array + { + return [ + 'uuid' => $this->uuid, + 'bank_name' => $this->bankName, + 'serial_number' => $this->serialNumber, + 'terminal_number' => $this->terminalNumber, + 'account_number' => $this->accountNumber, + 'is_active' => $this->isActive, + 'created_at' => $this->createdAt, + ]; + } +} diff --git a/src/PaymentMethod/Repository/BankAccountRepository.php b/src/PaymentMethod/Repository/BankAccountRepository.php new file mode 100644 index 00000000..3dd8802b --- /dev/null +++ b/src/PaymentMethod/Repository/BankAccountRepository.php @@ -0,0 +1,37 @@ +findOneBy(['uuid' => $uuid]); + } + + /** @return BankAccount[] */ + public function findByUser(User $user): array + { + return $this->createQueryBuilder('b') + ->where('b.user = :user')->setParameter('user', $user) + ->orderBy('b.createdAt', 'DESC') + ->getQuery() + ->getResult(); + } + + public function save(BankAccount $entity, bool $flush = true): void + { + $this->getEntityManager()->persist($entity); + if ($flush) $this->getEntityManager()->flush(); + } +} diff --git a/src/PaymentMethod/Repository/PosRepository.php b/src/PaymentMethod/Repository/PosRepository.php new file mode 100644 index 00000000..300cb89f --- /dev/null +++ b/src/PaymentMethod/Repository/PosRepository.php @@ -0,0 +1,37 @@ +findOneBy(['uuid' => $uuid]); + } + + /** @return Pos[] */ + public function findByUser(User $user): array + { + return $this->createQueryBuilder('p') + ->where('p.user = :user')->setParameter('user', $user) + ->orderBy('p.createdAt', 'DESC') + ->getQuery() + ->getResult(); + } + + public function save(Pos $entity, bool $flush = true): void + { + $this->getEntityManager()->persist($entity); + if ($flush) $this->getEntityManager()->flush(); + } +} diff --git a/src/PaymentMethod/Service/PaymentMethodService.php b/src/PaymentMethod/Service/PaymentMethodService.php new file mode 100644 index 00000000..8fc9324f --- /dev/null +++ b/src/PaymentMethod/Service/PaymentMethodService.php @@ -0,0 +1,184 @@ +> */ + public function listBankAccounts(User $user): array + { + return array_map( + static fn (BankAccount $b) => $b->toArray(), + $this->bankRepo->findByUser($user), + ); + } + + public function createBankAccount(User $user, array $data): array + { + $bankName = trim((string) ($data['bank_name'] ?? '')); + $accountNumber = trim((string) ($data['account_number'] ?? '')); + $cardNumber = trim((string) ($data['card_number'] ?? '')); + $shabaNumber = trim((string) ($data['shaba_number'] ?? '')); + + if ($bankName === '') { + throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'نام بانک الزامی است', 422, 'bank_name'); + } + if ($accountNumber === '') { + throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'شماره حساب الزامی است', 422, 'account_number'); + } + + $account = new BankAccount($user, $bankName, $accountNumber, $cardNumber, $shabaNumber); + $this->bankRepo->save($account); + + return $account->toArray(); + } + + public function updateBankAccount(User $user, string $uuid, array $data): array + { + $account = $this->ownedBankAccount($user, $uuid); + + if (array_key_exists('bank_name', $data)) { + $bankName = trim((string) $data['bank_name']); + if ($bankName === '') { + throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'نام بانک الزامی است', 422, 'bank_name'); + } + $account->setBankName($bankName); + } + if (array_key_exists('account_number', $data)) { + $accountNumber = trim((string) $data['account_number']); + if ($accountNumber === '') { + throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'شماره حساب الزامی است', 422, 'account_number'); + } + $account->setAccountNumber($accountNumber); + } + if (array_key_exists('card_number', $data)) { + $account->setCardNumber(trim((string) $data['card_number'])); + } + if (array_key_exists('shaba_number', $data)) { + $account->setShabaNumber(trim((string) $data['shaba_number'])); + } + + $this->bankRepo->save($account); + + return $account->toArray(); + } + + public function toggleBankAccountStatus(User $user, string $uuid): array + { + $account = $this->ownedBankAccount($user, $uuid); + $account->setActive(!$account->isActive()); + $this->bankRepo->save($account); + + return $account->toArray(); + } + + private function ownedBankAccount(User $user, string $uuid): BankAccount + { + $account = $this->bankRepo->findByUuid($uuid); + if ($account === null || $account->getUser()->getId() !== $user->getId()) { + throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'حساب بانکی یافت نشد', 404); + } + + return $account; + } + + // ---- POS devices ------------------------------------------------------- + + /** @return array> */ + public function listPos(User $user): array + { + return array_map( + static fn (Pos $p) => $p->toArray(), + $this->posRepo->findByUser($user), + ); + } + + public function createPos(User $user, array $data): array + { + $bankName = trim((string) ($data['bank_name'] ?? '')); + $terminalNumber = trim((string) ($data['terminal_number'] ?? '')); + $serialNumber = trim((string) ($data['serial_number'] ?? '')); + $accountNumber = trim((string) ($data['account_number'] ?? '')); + + if ($bankName === '') { + throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'نام بانک الزامی است', 422, 'bank_name'); + } + if ($terminalNumber === '') { + throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'شماره ترمینال الزامی است', 422, 'terminal_number'); + } + + $pos = new Pos($user, $bankName, $terminalNumber, $serialNumber, $accountNumber); + $this->posRepo->save($pos); + + return $pos->toArray(); + } + + public function updatePos(User $user, string $uuid, array $data): array + { + $pos = $this->ownedPos($user, $uuid); + + if (array_key_exists('bank_name', $data)) { + $bankName = trim((string) $data['bank_name']); + if ($bankName === '') { + throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'نام بانک الزامی است', 422, 'bank_name'); + } + $pos->setBankName($bankName); + } + if (array_key_exists('terminal_number', $data)) { + $terminalNumber = trim((string) $data['terminal_number']); + if ($terminalNumber === '') { + throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'شماره ترمینال الزامی است', 422, 'terminal_number'); + } + $pos->setTerminalNumber($terminalNumber); + } + if (array_key_exists('serial_number', $data)) { + $pos->setSerialNumber(trim((string) $data['serial_number'])); + } + if (array_key_exists('account_number', $data)) { + $pos->setAccountNumber(trim((string) $data['account_number'])); + } + + $this->posRepo->save($pos); + + return $pos->toArray(); + } + + public function togglePosStatus(User $user, string $uuid): array + { + $pos = $this->ownedPos($user, $uuid); + $pos->setActive(!$pos->isActive()); + $this->posRepo->save($pos); + + return $pos->toArray(); + } + + private function ownedPos(User $user, string $uuid): Pos + { + $pos = $this->posRepo->findByUuid($uuid); + if ($pos === null || $pos->getUser()->getId() !== $user->getId()) { + throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'کارت خوان یافت نشد', 404); + } + + return $pos; + } +} diff --git a/tests/PaymentMethod/PaymentMethodTest.php b/tests/PaymentMethod/PaymentMethodTest.php new file mode 100644 index 00000000..20390a4f --- /dev/null +++ b/tests/PaymentMethod/PaymentMethodTest.php @@ -0,0 +1,191 @@ +createUser(['ROLE_CLINIC']); + + $res = $this->authJson('GET', '/api/v1/my/payment-methods/bank-accounts', $user); + + $this->assertSame(200, $this->responseCode()); + $this->assertTrue($res['success']); + $this->assertSame([], $res['data']); + } + + public function testCreateAndListBankAccount(): void + { + $user = $this->createUser(['ROLE_CLINIC']); + + $created = $this->authJson('POST', '/api/v1/my/payment-methods/bank-accounts', $user, [ + 'bank_name' => 'ملی', + 'card_number' => '6037991234567890', + 'account_number' => '0101234567890', + 'shaba_number' => 'IR820540102680020817909002', + ]); + + $this->assertSame(201, $this->responseCode()); + $this->assertSame('ملی', $created['data']['bank_name']); + $this->assertTrue($created['data']['is_active']); + $this->assertNotEmpty($created['data']['uuid']); + + $list = $this->authJson('GET', '/api/v1/my/payment-methods/bank-accounts', $user); + $this->assertCount(1, $list['data']); + $this->assertSame('0101234567890', $list['data'][0]['account_number']); + } + + public function testCreateBankAccountValidationErrorWhenBankNameMissing(): void + { + $user = $this->createUser(['ROLE_CLINIC']); + + $res = $this->authJson('POST', '/api/v1/my/payment-methods/bank-accounts', $user, [ + 'account_number' => '0101234567890', + ]); + + $this->assertSame(422, $this->responseCode()); + $this->assertFalse($res['success']); + $this->assertSame('bank_name', $res['errors'][0]['field']); + } + + public function testUpdateBankAccount(): void + { + $user = $this->createUser(['ROLE_CLINIC']); + $created = $this->authJson('POST', '/api/v1/my/payment-methods/bank-accounts', $user, [ + 'bank_name' => 'ملی', + 'account_number' => '0101234567890', + ]); + $uuid = $created['data']['uuid']; + + $updated = $this->authJson('PUT', "/api/v1/my/payment-methods/bank-accounts/$uuid", $user, [ + 'bank_name' => 'ملت', + 'account_number' => '0209876543210', + ]); + + $this->assertSame(200, $this->responseCode()); + $this->assertSame('ملت', $updated['data']['bank_name']); + $this->assertSame('0209876543210', $updated['data']['account_number']); + } + + public function testToggleBankAccountStatus(): void + { + $user = $this->createUser(['ROLE_CLINIC']); + $created = $this->authJson('POST', '/api/v1/my/payment-methods/bank-accounts', $user, [ + 'bank_name' => 'ملی', + 'account_number' => '0101234567890', + ]); + $uuid = $created['data']['uuid']; + $this->assertTrue($created['data']['is_active']); + + $toggled = $this->authJson('PATCH', "/api/v1/my/payment-methods/bank-accounts/$uuid/status", $user); + + $this->assertSame(200, $this->responseCode()); + $this->assertFalse($toggled['data']['is_active']); + } + + public function testToggleUnknownBankAccountReturns404(): void + { + $user = $this->createUser(['ROLE_CLINIC']); + + $res = $this->authJson('PATCH', '/api/v1/my/payment-methods/bank-accounts/does-not-exist/status', $user); + + $this->assertSame(404, $this->responseCode()); + $this->assertFalse($res['success']); + } + + public function testCannotTouchAnotherClinicsBankAccount(): void + { + $owner = $this->createUser(['ROLE_CLINIC']); + $other = $this->createUser(['ROLE_CLINIC']); + $created = $this->authJson('POST', '/api/v1/my/payment-methods/bank-accounts', $owner, [ + 'bank_name' => 'ملی', + 'account_number' => '0101234567890', + ]); + $uuid = $created['data']['uuid']; + + $res = $this->authJson('PATCH', "/api/v1/my/payment-methods/bank-accounts/$uuid/status", $other); + + $this->assertSame(404, $this->responseCode()); + $this->assertFalse($res['success']); + } + + public function testBankAccountForbiddenForPlainUser(): void + { + $user = $this->createUser(['ROLE_USER']); + + $this->authJson('GET', '/api/v1/my/payment-methods/bank-accounts', $user); + + $this->assertSame(403, $this->responseCode()); + } + + // ---- POS devices ------------------------------------------------------- + + public function testCreateAndListPos(): void + { + $user = $this->createUser(['ROLE_DOCTOR']); + + $created = $this->authJson('POST', '/api/v1/my/payment-methods/pos', $user, [ + 'bank_name' => 'ملت', + 'serial_number' => 'SN-98765', + 'terminal_number' => '123456', + ]); + + $this->assertSame(201, $this->responseCode()); + $this->assertSame('ملت', $created['data']['bank_name']); + $this->assertSame('123456', $created['data']['terminal_number']); + $this->assertTrue($created['data']['is_active']); + + $list = $this->authJson('GET', '/api/v1/my/payment-methods/pos', $user); + $this->assertCount(1, $list['data']); + } + + public function testCreatePosValidationErrorWhenTerminalMissing(): void + { + $user = $this->createUser(['ROLE_DOCTOR']); + + $res = $this->authJson('POST', '/api/v1/my/payment-methods/pos', $user, [ + 'bank_name' => 'ملت', + ]); + + $this->assertSame(422, $this->responseCode()); + $this->assertSame('terminal_number', $res['errors'][0]['field']); + } + + public function testTogglePosStatus(): void + { + $user = $this->createUser(['ROLE_SECRETARY']); + $created = $this->authJson('POST', '/api/v1/my/payment-methods/pos', $user, [ + 'bank_name' => 'تجارت', + 'terminal_number' => '345678', + ]); + $uuid = $created['data']['uuid']; + + $toggled = $this->authJson('PATCH', "/api/v1/my/payment-methods/pos/$uuid/status", $user); + + $this->assertSame(200, $this->responseCode()); + $this->assertFalse($toggled['data']['is_active']); + } + + public function testPosListIsolatedPerUser(): void + { + $a = $this->createUser(['ROLE_CLINIC']); + $b = $this->createUser(['ROLE_CLINIC']); + $this->authJson('POST', '/api/v1/my/payment-methods/pos', $a, [ + 'bank_name' => 'صادرات', + 'terminal_number' => '901234', + ]); + + $listB = $this->authJson('GET', '/api/v1/my/payment-methods/pos', $b); + + $this->assertSame([], $listB['data']); + } +}