diff --git a/assets/admin/components/paymentMethods/BankAccountTable.tsx b/assets/admin/components/paymentMethods/BankAccountTable.tsx index 0b905315..f8c80016 100644 --- a/assets/admin/components/paymentMethods/BankAccountTable.tsx +++ b/assets/admin/components/paymentMethods/BankAccountTable.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { PencilIcon } from '@heroicons/react/24/outline'; import DataTable, { type Column } from '../ui/DataTable'; import StatusToggle from './StatusToggle'; +import EnvironmentCell from './EnvironmentCell'; import type { BankAccount } from '../../hooks/usePaymentMethods'; /** @@ -13,21 +14,32 @@ export default function BankAccountTable({ data, loading, togglingUuid, + assigningUuid, + environmentName, onToggle, onEdit, + onAssign, onAdd, }: { data: BankAccount[]; loading?: boolean; togglingUuid: string | null; + assigningUuid: string | null; + environmentName: string; onToggle: (uuid: string) => void; onEdit: (account: BankAccount) => void; + onAssign: (uuid: string) => 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: 'entity_type', + header: 'محیط', + render: (r) => , + }, { key: 'is_active', header: 'وضعیت', @@ -53,14 +65,24 @@ export default function BankAccountTable({ emptyMessage="هنوز حساب بانکی ثبت نشده است" emptyAction={} actions={(r) => ( - + r.entity_type === null ? ( + + ) : ( + + ) )} /> diff --git a/assets/admin/components/paymentMethods/EnvironmentCell.tsx b/assets/admin/components/paymentMethods/EnvironmentCell.tsx new file mode 100644 index 00000000..449287c2 --- /dev/null +++ b/assets/admin/components/paymentMethods/EnvironmentCell.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import type { PaymentMethodEnvironment } from '../../hooks/usePaymentMethods'; + +/** + * محیطِ یک روش پرداخت. کارت‌ها از فاز ۶ به محیط تعلق دارند نه به کاربر، پس کاربری + * که هم مطب شخصی دارد هم کلینیک باید ببیند کارتی که جلوی چشمش است مالِ کجاست. + * + * تهی یعنی بازمانده‌ای از پیش از نشانه‌گذاری که مالکش چند محیط داشت و حدس زدنِ + * محیطش یعنی پول به حساب اشتباه. چنین کارتی تا وقتی منتسب نشده قابل استفاده نیست. + */ +export default function EnvironmentCell({ + entityType, + environmentName, +}: { + entityType: PaymentMethodEnvironment; + environmentName: string; +}) { + if (entityType === null) { + return محیط تعیین‌نشده; + } + + return {environmentName}; +} diff --git a/assets/admin/components/paymentMethods/PosTable.tsx b/assets/admin/components/paymentMethods/PosTable.tsx index 72a954fb..9d219022 100644 --- a/assets/admin/components/paymentMethods/PosTable.tsx +++ b/assets/admin/components/paymentMethods/PosTable.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { PencilIcon } from '@heroicons/react/24/outline'; import DataTable, { type Column } from '../ui/DataTable'; import StatusToggle from './StatusToggle'; +import EnvironmentCell from './EnvironmentCell'; import type { Pos } from '../../hooks/usePaymentMethods'; /** @@ -12,21 +13,32 @@ export default function PosTable({ data, loading, togglingUuid, + assigningUuid, + environmentName, onToggle, onEdit, + onAssign, onAdd, }: { data: Pos[]; loading?: boolean; togglingUuid: string | null; + assigningUuid: string | null; + environmentName: string; onToggle: (uuid: string) => void; onEdit: (pos: Pos) => void; + onAssign: (uuid: string) => 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: 'entity_type', + header: 'محیط', + render: (r) => , + }, { key: 'is_active', header: 'وضعیت', @@ -52,14 +64,24 @@ export default function PosTable({ emptyMessage="هنوز کارت خوانی ثبت نشده است" emptyAction={} actions={(r) => ( - + r.entity_type === null ? ( + + ) : ( + + ) )} /> diff --git a/assets/admin/hooks/usePaymentMethods.ts b/assets/admin/hooks/usePaymentMethods.ts index c167dbbf..2bf5149b 100644 --- a/assets/admin/hooks/usePaymentMethods.ts +++ b/assets/admin/hooks/usePaymentMethods.ts @@ -3,11 +3,18 @@ import { api } from '../lib/api'; import type { ApiResponse } from '../lib/api'; /** - * روش‌های پرداختِ کلینیک (حساب بانکی + کارت‌خوان) برای صفحهٔ «مدیریت پرداخت». + * روش‌های پرداختِ **محیط فعال** (حساب بانکی + کارت‌خوان) برای صفحهٔ «مدیریت پرداخت». * منبع: /api/v1/my/payment-methods/... — این رکوردها بعداً از فاکتور مراجعه‌کننده * برای ثبت روش پرداختِ یک سرویس ارجاع داده می‌شوند. + * + * از فاز ۶، کارت‌ها به محیط تعلق دارند نه به کاربر: با تعویض محیط، فهرست عوض + * می‌شود. کارت‌های بازمانده‌ای که هنوز محیطی ندارند با entity_type = null در همین + * فهرست می‌آیند و باید پیش از استفاده به محیطی منتسب شوند. */ +/** null یعنی «محیطش هنوز تعیین نشده». */ +export type PaymentMethodEnvironment = 'doctor' | 'clinic' | null; + export interface BankAccount { uuid: string; bank_name: string; @@ -16,6 +23,7 @@ export interface BankAccount { shaba_number: string | null; is_active: boolean; created_at: number; + entity_type: PaymentMethodEnvironment; } export interface Pos { @@ -26,6 +34,7 @@ export interface Pos { account_number: string | null; is_active: boolean; created_at: number; + entity_type: PaymentMethodEnvironment; } export interface BankAccountInput { @@ -78,6 +87,15 @@ export function useToggleBankAccountStatus() { }); } +/** کارتِ بی‌محیط را به محیط فعال می‌چسباند؛ پیش از آن قابل ویرایش نیست. */ +export function useAssignBankAccountEnvironment() { + const qc = useQueryClient(); + return useMutation, unknown, string>({ + mutationFn: (uuid) => api.patch(`/api/v1/my/payment-methods/bank-accounts/${uuid}/environment`, {}), + onSuccess: () => qc.invalidateQueries({ queryKey: BANK_KEY }), + }); +} + // ── POS devices ────────────────────────────────────────────────────────────── export function usePosDevices() { @@ -110,3 +128,12 @@ export function useTogglePosStatus() { onSuccess: () => qc.invalidateQueries({ queryKey: POS_KEY }), }); } + +/** قرینهٔ useAssignBankAccountEnvironment برای کارت‌خوان. */ +export function useAssignPosEnvironment() { + const qc = useQueryClient(); + return useMutation, unknown, string>({ + mutationFn: (uuid) => api.patch(`/api/v1/my/payment-methods/pos/${uuid}/environment`, {}), + onSuccess: () => qc.invalidateQueries({ queryKey: POS_KEY }), + }); +} diff --git a/assets/admin/pages/MyFinancialPage.environment.test.tsx b/assets/admin/pages/MyFinancialPage.environment.test.tsx new file mode 100644 index 00000000..3fbe8eb5 --- /dev/null +++ b/assets/admin/pages/MyFinancialPage.environment.test.tsx @@ -0,0 +1,81 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { screen, fireEvent, waitFor } from '@testing-library/react'; +import { renderWithProviders } from '../test/utils'; + +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 MyFinancialPage from './MyFinancialPage'; + +const get = api.get as ReturnType; +const patch = api.patch as ReturnType; + +const assigned = { + uuid: 'bank-assigned', + bank_name: 'ملی', + card_number: '6037', + account_number: '111', + shaba_number: null, + is_active: true, + created_at: 0, + entity_type: 'clinic' as const, +}; + +const unassigned = { ...assigned, uuid: 'bank-orphan', bank_name: 'ملت', entity_type: null }; + +beforeEach(() => { + get.mockReset(); + patch.mockReset(); + useAuthStore.setState({ + primaryRole: 'clinic', + context: { type: 'clinic', db_uuid: 'c1', name: 'کلینیک مرکزی', role: 'clinic' }, + } as any); + get.mockImplementation((url: string) => + Promise.resolve({ success: true, data: url.includes('bank-accounts') ? [assigned, unassigned] : [] }), + ); + patch.mockResolvedValue({ success: true, data: { ...unassigned, entity_type: 'clinic' } }); +}); + +/** + * کارت‌ها از فاز ۶ به محیط تعلق دارند نه به کاربر. کاربرِ چندمحیطی باید ببیند + * کارت‌های جلوی چشمش مالِ کدام محیط‌اند، وگرنه با تعویض محیط فکر می‌کند گمشان کرده. + */ +describe('MyFinancialPage — محیط روش‌های پرداخت', () => { + it('نام محیط فعال را در سرتیتر می‌آورد', async () => { + renderWithProviders(); + + expect(await screen.findByText(/روش‌های پرداخت «کلینیک مرکزی»/)).toBeInTheDocument(); + }); + + it('کارت محیط‌دار را با نام محیط و کارت بی‌محیط را با نشانهٔ «تعیین‌نشده» نشان می‌دهد', async () => { + renderWithProviders(); + + expect(await screen.findByText('محیط تعیین‌نشده')).toBeInTheDocument(); + // نام محیط هم در سرتیتر می‌آید هم در ستون محیطِ کارتِ محیط‌دار + expect(screen.getAllByText(/کلینیک مرکزی/).length).toBeGreaterThan(1); + }); + + it('کارت بی‌محیط به‌جای «ویرایش» دکمهٔ انتساب می‌گیرد و آن را به محیط فعال می‌چسباند', async () => { + renderWithProviders(); + + const assign = await screen.findByRole('button', { name: /انتساب به کلینیک مرکزی/ }); + fireEvent.click(assign); + + await waitFor(() => + expect(patch).toHaveBeenCalledWith( + '/api/v1/my/payment-methods/bank-accounts/bank-orphan/environment', + {}, + ), + ); + }); + + it('کارت محیط‌دار همچنان دکمهٔ ویرایش دارد', async () => { + renderWithProviders(); + + expect(await screen.findAllByRole('button', { name: /ویرایش/ })).toHaveLength(1); + }); +}); diff --git a/assets/admin/pages/MyFinancialPage.tsx b/assets/admin/pages/MyFinancialPage.tsx index 85723283..98f70639 100644 --- a/assets/admin/pages/MyFinancialPage.tsx +++ b/assets/admin/pages/MyFinancialPage.tsx @@ -8,6 +8,8 @@ import PosTable from '../components/paymentMethods/PosTable'; import BankAccountFormModal from '../components/paymentMethods/BankAccountFormModal'; import PosFormModal from '../components/paymentMethods/PosFormModal'; import { + useAssignBankAccountEnvironment, + useAssignPosEnvironment, useBankAccounts, usePosDevices, useToggleBankAccountStatus, @@ -15,11 +17,15 @@ import { type BankAccount, type Pos, } from '../hooks/usePaymentMethods'; +import { useAuthStore } from '../stores/authStore'; /** * صفحهٔ «مدیریت پرداخت» (/admin/my-financial) — پورت تب PaymentManagement از - * clinic-pro-tauri. روش‌های پرداختِ کلینیک (حساب بانکی + کارت‌خوان) که بعداً از - * فاکتور مراجعه‌کننده برای ثبت روش پرداخت یک سرویس ارجاع می‌شوند. + * clinic-pro-tauri. روش‌های پرداختِ **محیط فعال** (حساب بانکی + کارت‌خوان) که بعداً + * از فاکتور مراجعه‌کننده برای ثبت روش پرداخت یک سرویس ارجاع می‌شوند. + * + * نام محیط در سرتیتر می‌آید چون از فاز ۶ کارت‌ها به محیط تعلق دارند نه به کاربر؛ + * بدون آن، کاربرِ چندمحیطی با تعویض محیط فکر می‌کند کارت‌هایش گم شده‌اند. */ function MyFinancialPageContent() { const [activeTab, setActiveTab] = useState('bank'); @@ -32,6 +38,11 @@ function MyFinancialPageContent() { const posQuery = usePosDevices(); const toggleBank = useToggleBankAccountStatus(); const togglePos = useTogglePosStatus(); + const assignBank = useAssignBankAccountEnvironment(); + const assignPos = useAssignPosEnvironment(); + + const context = useAuthStore(s => s.context); + const environmentName = context?.name || 'محیط فعال'; const banks = bankQuery.data?.data ?? []; const posDevices = posQuery.data?.data ?? []; @@ -42,10 +53,14 @@ function MyFinancialPageContent() { }; const onError = (e: unknown) => toast.error(e instanceof Error ? e.message : 'خطا در تغییر وضعیت'); + const onAssignError = (e: unknown) => toast.error(e instanceof Error ? e.message : 'خطا در تعیین محیط'); return (
- +
@@ -55,8 +70,11 @@ function MyFinancialPageContent() { data={banks} loading={bankQuery.isLoading} togglingUuid={toggleBank.isPending ? toggleBank.variables ?? null : null} + assigningUuid={assignBank.isPending ? assignBank.variables ?? null : null} + environmentName={environmentName} onToggle={(uuid) => toggleBank.mutate(uuid, { onError })} onEdit={(account) => { setEditingBank(account); setBankModalOpen(true); }} + onAssign={(uuid) => assignBank.mutate(uuid, { onError: onAssignError })} onAdd={openAdd} /> ) : ( @@ -64,8 +82,11 @@ function MyFinancialPageContent() { data={posDevices} loading={posQuery.isLoading} togglingUuid={togglePos.isPending ? togglePos.variables ?? null : null} + assigningUuid={assignPos.isPending ? assignPos.variables ?? null : null} + environmentName={environmentName} onToggle={(uuid) => togglePos.mutate(uuid, { onError })} onEdit={(pos) => { setEditingPos(pos); setPosModalOpen(true); }} + onAssign={(uuid) => assignPos.mutate(uuid, { onError: onAssignError })} onAdd={openAdd} /> )} diff --git a/docs/api/payment-method.md b/docs/api/payment-method.md index d5609e5b..a63c6025 100644 --- a/docs/api/payment-method.md +++ b/docs/api/payment-method.md @@ -2,14 +2,30 @@ > **Prefix:** `/api/v1/my/payment-methods` -Per-clinic payment methods managed from the settings screen (`/admin/my-financial`, +Per-**environment** payment methods managed from the settings screen (`/admin/my-financial`, tab "مدیریت پرداخت"). Two resources: **bank accounts** and **POS (card reader) devices**. > **دسترسی منشی:** روش‌های پرداخت زیرمجموعهٔ منبع `payments` هستند. برای `ROLE_SECRETARY` (`SecretaryAccessChecker`): GET→`payments.view`, POST→`payments.create`, PUT/PATCH→`payments.update`؛ نبودِ مجوز یا رابطهٔ فعال → `403`. جزئیات: [secretary.md](secretary.md). 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. +All endpoints are scoped to the **active environment** (`EntityContextResolver`), not to +the acting user. A doctor who also owns a clinic sees a different set of cards in each +environment; switching context switches the list. `user_id` is still stored, but only +records who registered the card. + +> **کارت‌های بدون محیط.** ردیف‌هایی که پیش از این نشانه‌گذاری ثبت شده‌اند و مالکشان +> بیش از یک محیط دارد، `entity_type: null` می‌گیرند: هیچ ستونی نمی‌گفت کارت مال کدام +> محیط است و حدس زدنش یعنی پول به حساب اشتباه. +> +> چنین ردیفی در **هیچ** محیطی «متعلق» نیست (`TenantFilter` شرط تساوی می‌گذارد و NULL +> با هیچ مقداری برابر نیست) و تا تعیین محیط **قابل ویرایش نیست** — ولی در فهرست +> خودِ مالک می‌آید تا با `PATCH .../{uuid}/environment` به محیط فعال منتسبش کند. +> این کوئری عمداً بیرون فیلتر و محدود به `user_id` اجرا می‌شود. +> جزئیات: [architecture/tenancy.md](../architecture/tenancy.md). + +**اگر محیط فعالی حل نشود** (مثلاً نقش کلینیک بدون کلینیکِ واقعی، یا منشیِ بدون +`UserActiveContext`) همهٔ این endpointها `403 ERR_FORBIDDEN_001` می‌دهند. **Permission:** authenticated user with one of `ROLE_CLINIC`, `ROLE_DOCTOR`, `ROLE_SECRETARY`, `ROLE_ADMIN` (otherwise `403 ERR_FORBIDDEN_001`). @@ -20,7 +36,8 @@ All endpoints are scoped to the acting user — a clinic never sees another's re ### GET `/api/v1/my/payment-methods/bank-accounts` -List the current clinic's bank accounts (newest first). +List the bank accounts of the active environment (newest first), followed by any of the +caller's own cards that still have no environment. #### Response `200` ```json @@ -28,17 +45,33 @@ List the current clinic's bank accounts (newest first). "success": true, "data": [ { - "uuid": "b1e0...-...", + "uuid": "b73d0c8e-3833-4314-83ba-937b6d4dbc60", "bank_name": "ملی", "card_number": "6037991234567890", "account_number": "0101234567890", "shaba_number": "IR820540102680020817909002", "is_active": true, - "created_at": 1752566400 + "created_at": 1785238315, + "entity_type": "clinic" + }, + { + "uuid": "21225430-0108-4adf-99f1-978e0a864c71", + "bank_name": "ملت", + "card_number": null, + "account_number": "0209876543210", + "shaba_number": null, + "is_active": true, + "created_at": 1785238315, + "entity_type": null } ] } ``` + +| Field | Type | Description | +|-------|------|-------------| +| `entity_type` | `"doctor"` \| `"clinic"` \| `null` | محیطِ مالک؛ `null` یعنی هنوز تعیین نشده و کارت قابل استفاده نیست | + Empty list returns `"data": []`. --- @@ -56,7 +89,7 @@ Create a bank account. | `shaba_number` | string | ❌ | IBAN / SHABA | #### Response `201` -Single created record (same shape as list item). +Single created record (same shape as list item). `entity_type` is the active environment. #### Errors - `422 ERR_VALIDATION_001` — `bank_name` or `account_number` missing (`field` set). @@ -72,7 +105,7 @@ keys change. Empty `bank_name`/`account_number` → `422`. Updated record. #### Errors -- `404 ERR_NOT_FOUND_001` — uuid unknown or owned by another clinic. +- `404 ERR_NOT_FOUND_001` — uuid unknown, owned by another environment, or still unassigned. - `422 ERR_VALIDATION_001` — provided `bank_name`/`account_number` empty. --- @@ -85,7 +118,43 @@ Toggle `is_active` (active ⇄ inactive). No body. Record with flipped `is_active`. #### Errors -- `404 ERR_NOT_FOUND_001` — uuid unknown or not owned. +- `404 ERR_NOT_FOUND_001` — uuid unknown, owned by another environment, or still unassigned. + +--- + +### PATCH `/api/v1/my/payment-methods/bank-accounts/{uuid}/environment` + +کارتِ **بی‌محیطِ خودِ کاربر** را به محیط فعال می‌چسباند. بدون بدنه. + +شرط‌های مالکیت و بی‌محیط بودن داخل خودِ `UPDATE` هستند، پس دو درخواست هم‌زمان +نمی‌توانند یک کارت را به دو محیط بچسبانند و کارتِ محیط‌دار هم ربوده نمی‌شود. + +#### Response `200` +```json +{ + "success": true, + "data": { + "uuid": "21225430-0108-4adf-99f1-978e0a864c71", + "bank_name": "ملت", + "card_number": null, + "account_number": "0209876543210", + "shaba_number": null, + "is_active": true, + "created_at": 1785238315, + "entity_type": "clinic" + } +} +``` + +#### Errors +`404 ERR_NOT_FOUND_001` — uuid ناشناس، مالِ کاربر دیگر، یا از قبل محیط دارد (انتساب دوباره بی‌اثر است): +```json +{ + "success": false, + "data": null, + "errors": [{ "code": "ERR_NOT_FOUND_001", "message": "حساب بانکیِ بدون محیط یافت نشد" }] +} +``` --- @@ -93,7 +162,8 @@ Record with flipped `is_active`. ### GET `/api/v1/my/payment-methods/pos` -List the current clinic's card reader devices (newest first). +List the card reader devices of the active environment (newest first), followed by any of +the caller's own devices that still have no environment. #### Response `200` ```json @@ -101,13 +171,14 @@ List the current clinic's card reader devices (newest first). "success": true, "data": [ { - "uuid": "c2f1...-...", + "uuid": "382eb554-5931-4f07-b1cc-53ade2597438", "bank_name": "ملت", "serial_number": "SN-98765", "terminal_number": "123456", "account_number": null, "is_active": true, - "created_at": 1752566400 + "created_at": 1785238315, + "entity_type": "clinic" } ] } @@ -157,3 +228,16 @@ Record with flipped `is_active`. #### Errors - `404 ERR_NOT_FOUND_001` — uuid unknown or not owned. + +--- + +### PATCH `/api/v1/my/payment-methods/pos/{uuid}/environment` + +قرینهٔ endpoint انتساب حساب بانکی: کارتخوانِ بی‌محیطِ خودِ کاربر را به محیط فعال +می‌چسباند. بدون بدنه. + +#### Response `200` +رکورد با `entity_type` پرشده. + +#### Errors +- `404 ERR_NOT_FOUND_001` — uuid ناشناس، مالِ کاربر دیگر، یا از قبل محیط دارد (پیام: «کارت خوانِ بدون محیط یافت نشد»). diff --git a/docs/api/payment.md b/docs/api/payment.md index fddc8456..18c7ffbc 100644 --- a/docs/api/payment.md +++ b/docs/api/payment.md @@ -5,6 +5,8 @@ > **دسترسی منشی:** `GET /api/v1/my/payments` برای `ROLE_SECRETARY` به مجوز `payments.view` نیاز دارد (`SecretaryAccessChecker`)؛ نبودِ مجوز → `403`. جزئیات: [secretary.md](secretary.md). +> **محیط پرداخت:** هر پرداخت جفت `(entity_type, entity_id)` دارد و به محیط **گیرنده** تعلق می‌گیرد، نه به پرداخت‌کننده — نوبت → محیط همان نوبت، اشتراک → محیطی که خریدار صاحبش است، شارژ پیامک → محیط همان کیف پول. بیمار محیطی انتخاب نکرده، پس `TenantFilter` برایش خاموش است و پرداخت خودش را می‌بیند. جزئیات: [architecture/tenancy.md](../architecture/tenancy.md). + --- ## معماری (Flow & مسئولیت‌ها) @@ -355,8 +357,18 @@ Initiate a subscription / wallet top-up payment (not tied to a specific appointm |------|------|-------------| | `ERR_AUTH_001` | 401 | Missing token | | `ERR_PAYMENT_002` | 422 | Invalid amount | +| `ERR_PAYMENT_004` | 422 | خریدار صاحب هیچ محیطی نیست (نه پزشک، نه کلینیک) | | `ERR_PAYMENT_001` | 503 | Gateway unavailable | +```json +{ + "success": false, + "errors": [{ "code": "ERR_PAYMENT_004", "message": "محیط این پرداخت مشخص نیست" }] +} +``` + +> اشتراک روی محیطی می‌نشیند که خریدار **صاحبش** است (اول مطب شخصی، بعد کلینیک) — نه روی محیط فعالش. همان جفت روی خودِ اشتراک هم ثبت می‌شود، پس پرداخت و اشتراک هرگز روی دو محیط متفاوت نمی‌افتند. + --- ## POST/GET `/api/v1/subscription-payment/callback/{gateway}` diff --git a/docs/architecture/tenancy.md b/docs/architecture/tenancy.md index 2de200e3..af18247a 100644 --- a/docs/architecture/tenancy.md +++ b/docs/architecture/tenancy.md @@ -96,7 +96,7 @@ clinic_uuid صریحِ درخواست > UserActiveContext ذخیره‌شده | جفت tenant دارد | فیلتر پوششش می‌دهد | `appointments`، `patient_records`، `service_sections` | | `ENTITIES` | عمداً سراسری | `cities`، `specialties`، `users`، `blogs` | | `AGGREGATE_CHILDREN` | محیط را از ریشه به ارث می‌برد | `patient_notes` → `patient_records` | -| `DEFERRED` | بدهی ثبت‌شده، هنوز طبقه‌بندی نشده | جدول‌های مالی | +| `DEFERRED` | بدهی ثبت‌شده، هنوز طبقه‌بندی نشده | **خالی** — فاز ۶ آخرین موردش را تعیین تکلیف کرد | ### ⚠️ فرزندان aggregate تور ایمنی ندارند @@ -126,9 +126,32 @@ $this->tenantOwnership->allBelongTo($context, $entities); // یک بی `TenantLookupInventoryTest` تعداد این جست‌وجوها را per-file نگه می‌دارد. افزودن یک `findByUuid` تازه روی موجودیت محیط‌دار تست را قرمز می‌کند تا کسی ثابت کند محیطش بررسی می‌شود و بعد عدد را به‌روز کند. -### بدهی باقی‌مانده +### جدول‌های مالی -جدول‌های مالی (`payments`، `settlements`، `financial_breakdowns`، `wallet_transactions`، `secretary_earnings`، `bank_accounts`، `pos_devices`) در `DEFERRED` ثبت شده‌اند. مالکیتشان دوگانه است — پرداخت‌کننده در برابر دریافت‌کننده — و تصمیم درباره‌شان تحلیل جدا می‌خواهد. `testDeferredDebtDoesNotGrow` جلوی رشد بی‌صدای این فهرست را می‌گیرد. +بدهی صفر است. `testThereIsNoUnclassifiedDebtLeft` خالی‌ماندنش را اجبار می‌کند. + +استدلالِ اولیهٔ «مالکیتشان دوگانه است» درست نبود: پرداخت به محیطِ **گیرنده** تعلق دارد و پرداخت‌کننده مانعی نیست، چون بیمار محیطی انتخاب نکرده و فیلتر برایش خاموش است. + +| جدول | تصمیم | چرا | +|---|---|---| +| `payments` | جفت محیط | نوبت → محیط نوبت · اشتراک → محیطی که خریدار صاحبش است · شارژ پیامک → محیط همان کیف پول | +| `payment_logs` · `financial_breakdowns` | فرزند `Payment` | با FK به پرداخت لنگر می‌خورند | +| `secretary_earnings` | فرزند `FinancialBreakdown` | زنجیره تا `payments` می‌رسد | +| `wallet_transactions` | `ENTITIES` | کیف پولِ **شخص** است: موجودی از مجموع credit−debitِ همان کاربر مشتق می‌شود و `payment_id` تهی‌پذیر است — تفکیک به محیط، خودِ موجودی را بی‌معنا می‌کند | +| `settlements` | `ENTITIES` | برداشت از همان کیف پولِ شخصی (`SettlementController` موجودی را با `getWalletBalance(user)` می‌سنجد) | +| `bank_accounts` · `pos_devices` | جفت محیط، **تهی‌پذیر** | از کاربر به محیط منتقل شدند؛ موارد مبهم تهی ماندند (پایین) | + +نتیجهٔ عملی برای زنجیره: تضمین فقط تا جایی است که کوئری به `payments` لنگر بزند. `SecretaryEarningRepository::reportFor` این کار را با `join('b.payment','p')` می‌کند و فیلتر روی همان می‌نشیند؛ `FinancialChainTenantTest` همین را می‌سنجد. + +### ⚠️ نقطهٔ ضعف: کارتِ بی‌محیط در هیچ محیطی دیده نمی‌شود + +`bank_accounts` و `pos_devices` تنها جدول‌هایی‌اند که جفت محیطشان **تهی‌پذیر** است ({@see `NullableTenantOwnedTrait`}). دلیل: تا فاز ۶ روی `User` ثبت می‌شدند و برای کاربری که چند محیط دارد هیچ ستونی نمی‌گفت کدام کارت مال کدام محیط است. تصمیم گرفته شد **حدس زده نشود**؛ ردیف مبهم تهی می‌ماند تا مالک خودش تعیین کند. + +هزینه‌اش این است: فیلتر شرط تساوی می‌گذارد و `NULL` با هیچ مقداری برابر نیست، پس چنین ردیفی از هر کوئری DQL غایب است — حتی برای کسی که خودش ثبتش کرده. + +راه خروج، تنها استثنای این دامنه است: `findUnassignedByUser()` و `assignEntity()` عمداً با DBAL خام اجرا می‌شوند (فیلتر رویشان اعمال نمی‌شود) و به‌جای فیلتر، محدودیت `user_id` را در خودِ کوئری دارند. انتساب با `PATCH /api/v1/my/payment-methods/{bank-accounts|pos}/{uuid}/environment` انجام می‌شود و شرط‌های مالکیت و بی‌محیط بودن داخل خودِ `UPDATE`اند تا دو درخواست هم‌زمان یک کارت را به دو محیط نچسبانند. + +پنل ادمین این ردیف‌ها را با نشانهٔ «محیط تعیین‌نشده» و دکمهٔ انتساب نشان می‌دهد، وگرنه کاربر چندمحیطی فکر می‌کند کارتش گم شده. --- @@ -145,6 +168,7 @@ $this->tenantOwnership->allBelongTo($context, $entities); // یک بی | `Doctor/Command/Purge*Command` · `Shared/Command/SeedDemoDataCommand` | کنسول | dry-run پیش‌فرض، `--force` لازم، prod از سطح kernel مسدود | | `Shared/Controller/HealthController` | سراسری | `SELECT 1` | | `Shared/Logging/DbLogger` | سراسری | `app_log` در `GlobalTables` | +| `PaymentMethod/Repository/{BankAccount,Pos}Repository` | **عمداً بیرون فیلتر** | تنها راه رسیدن به ردیفِ بی‌محیط؛ هر دو کوئری `WHERE user_id = ?` دارند و `assignEntity` شرط `entity_type IS NULL` را هم داخل `UPDATE` نگه می‌دارد. `PaymentMethodTenantTest` تلاش برای تصاحب کارت شخص دیگر را می‌سنجد | `getReference()` در کل `src/` یک مورد است و روی `User` (سراسری) — بدون اثر tenant. @@ -171,6 +195,10 @@ php bin/console app:tenant:dump --tenant=clinic:12 --output=/tmp/clinic12.sql ۳. اگر فرزند یک aggregate است → در `GlobalTables::AGGREGATE_CHILDREN` با ریشهٔ صریح ۴. تست را اجرا کن: `ddev exec php bin/phpunit tests/Shared/TenantSchemaCoverageTest.php` +`NullableTenantOwnedTrait` برای entity **جدید** نیست. فقط برای جدولی است که از قبل وجود داشته و مالکِ بعضی ردیف‌هایش از داده قابل تشخیص نیست؛ entity جدید از روز اول محیط دارد، پس ستون تهی‌پذیر فقط تور ایمنی را سوراخ می‌کند. + +`DEFERRED` هم راه فرار نیست: خالی است و باید خالی بماند. + ایندکس‌ها: `entity_type, entity_id` باید **ستون‌های اول** هر ایندکس ترکیبیِ لیست باشند، وگرنه MariaDB برای شرط فیلتر از آن استفاده نمی‌کند. --- @@ -189,3 +217,6 @@ php bin/console app:tenant:dump --tenant=clinic:12 --output=/tmp/clinic12.sql | `tests/Shared/TenantLookupInventoryTest.php` | جست‌وجوی uuid تازه‌ای بدون بازبینی اضافه نشده | | `tests/Appointment/ServiceModeSectionDurationTest.php` | سرویسِ محیط دیگر نه اسلات می‌دهد نه به نوبت می‌چسبد | | `tests/Patient/SessionServiceTenantTest.php` | سرویس/پرسنلِ محیط دیگر نه قیمت می‌خورد نه ذخیره می‌شود | +| `tests/Payment/PaymentTenantTest.php` | پرداخت به محیط گیرنده می‌نشیند؛ بیمار پرداخت خودش را می‌بیند، محیط دیگر نمی‌بیند | +| `tests/Settlement/FinancialChainTenantTest.php` | زنجیرهٔ مالی از راه لنگر به `payments` جدا می‌شود؛ کیف پول عمداً سراسری می‌ماند | +| `tests/PaymentMethod/PaymentMethodTenantTest.php` | کارت‌ها per-محیط‌اند؛ ردیف بی‌محیط دیده می‌شود ولی تا انتساب قابل ویرایش نیست | diff --git a/migrations/Version20260728140000.php b/migrations/Version20260728140000.php new file mode 100644 index 00000000..b2ca0996 --- /dev/null +++ b/migrations/Version20260728140000.php @@ -0,0 +1,103 @@ +connection rather than addSql() because the + * NOT NULL guard has to sit between the backfill and the tightening; addSql() + * defers everything to the end of up(). + */ +final class Version20260728140000 extends AbstractMigration +{ + public function getDescription(): string + { + return 'Mark payments with the environment that receives them'; + } + + public function up(Schema $schema): void + { + $this->connection->executeStatement( + 'ALTER TABLE payments ADD entity_type VARCHAR(10) NULL, ADD entity_id INT NULL' + ); + + // Appointment payments: the appointment already carries the pair. + $this->connection->executeStatement( + 'UPDATE payments p JOIN appointments a ON a.id = p.appointment_id + SET p.entity_type = a.entity_type, p.entity_id = a.entity_id + WHERE p.appointment_id IS NOT NULL' + ); + + // SMS wallet charges: the environment was stored in the metadata when the + // charge was started, which is also what PaymentManager reads on callback. + $this->connection->executeStatement( + "UPDATE payments + SET entity_type = JSON_UNQUOTE(JSON_EXTRACT(metadata, '$.entity_type')), + entity_id = JSON_EXTRACT(metadata, '$.entity_id') + WHERE entity_type IS NULL + AND type = 'sms_wallet' + AND JSON_EXTRACT(metadata, '$.entity_id') IS NOT NULL" + ); + + // Subscription payments: the environment the payer owns. + $this->connection->executeStatement( + "UPDATE payments p JOIN doctors d ON d.user_id = p.user_id + SET p.entity_type = 'doctor', p.entity_id = d.id + WHERE p.entity_type IS NULL" + ); + $this->connection->executeStatement( + "UPDATE payments p JOIN clinics c ON c.user_id = p.user_id + SET p.entity_type = 'clinic', p.entity_id = c.id + WHERE p.entity_type IS NULL" + ); + + // A payment left without an environment is a kind this analysis has not + // seen. Guessing one would put real money in the wrong ledger. + $remaining = (int) $this->connection->fetchOne( + 'SELECT COUNT(*) FROM payments WHERE entity_type IS NULL OR entity_id IS NULL' + ); + $this->abortIf( + $remaining > 0, + "Backfill left {$remaining} payments without an environment; classify them by hand before rerunning." + ); + + $this->connection->executeStatement( + 'ALTER TABLE payments MODIFY entity_type VARCHAR(10) NOT NULL, MODIFY entity_id INT NOT NULL' + ); + $this->connection->executeStatement( + 'CREATE INDEX idx_payments_entity_date ON payments (entity_type, entity_id, created_at)' + ); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP INDEX idx_payments_entity_date ON payments'); + $this->addSql('ALTER TABLE payments DROP entity_type, DROP entity_id'); + } + + /** DDL on MariaDB commits implicitly; wrapping up() in a transaction would be a lie. */ + public function isTransactional(): bool + { + return false; + } +} diff --git a/migrations/Version20260728141500.php b/migrations/Version20260728141500.php new file mode 100644 index 00000000..584ba2fb --- /dev/null +++ b/migrations/Version20260728141500.php @@ -0,0 +1,90 @@ +connection->executeStatement( + "ALTER TABLE {$table} ADD entity_type VARCHAR(10) NULL, ADD entity_id INT NULL" + ); + + // Only owners with exactly one environment can be resolved without a guess. + $this->connection->executeStatement( + "UPDATE {$table} t + JOIN (SELECT u.id AS user_id, + MAX(d.id) AS doctor_id, + MAX(c.id) AS clinic_id, + COUNT(DISTINCT d.id) + COUNT(DISTINCT c.id) AS envs + FROM users u + LEFT JOIN doctors d ON d.user_id = u.id + LEFT JOIN clinics c ON c.user_id = u.id + GROUP BY u.id) x ON x.user_id = t.user_id + SET t.entity_type = IF(x.clinic_id IS NOT NULL, 'clinic', 'doctor'), + t.entity_id = IFNULL(x.clinic_id, x.doctor_id) + WHERE x.envs = 1" + ); + + $unassigned = (int) $this->connection->fetchOne( + "SELECT COUNT(*) FROM {$table} WHERE entity_type IS NULL" + ); + $this->write(sprintf( + ' %s: %d row(s) left without an environment — their owner has more than one and must choose.', + $table, + $unassigned, + )); + + $this->connection->executeStatement( + "CREATE INDEX idx_{$table}_entity ON {$table} (entity_type, entity_id)" + ); + } + } + + public function down(Schema $schema): void + { + foreach (self::TABLES as $table) { + $this->addSql("DROP INDEX idx_{$table}_entity ON {$table}"); + $this->addSql("ALTER TABLE {$table} DROP entity_type, DROP entity_id"); + } + } + + /** DDL on MariaDB commits implicitly; wrapping up() in a transaction would be a lie. */ + public function isTransactional(): bool + { + return false; + } +} diff --git a/src/Payment/Controller/PaymentController.php b/src/Payment/Controller/PaymentController.php index 65cc71ab..3836573a 100644 --- a/src/Payment/Controller/PaymentController.php +++ b/src/Payment/Controller/PaymentController.php @@ -12,6 +12,8 @@ use App\Payment\Repository\PaymentRepository; use App\Payment\Service\PaymentManager; use App\Secretary\Security\SecretaryAccessChecker; use App\Shared\Constant\ErrorCodes; +use App\Shared\Context\EntityContext; +use App\Shared\Context\EntityContextResolver; use App\Shared\Controller\BaseController; use OpenApi\Attributes as OA; use Symfony\Component\HttpFoundation\JsonResponse; @@ -38,6 +40,7 @@ class PaymentController extends BaseController private readonly SiteConfigRepository $configRepo, private readonly SecretaryAccessChecker $secretaryAccess, private readonly \App\Clinic\Security\ClinicDoctorAccessChecker $clinicDoctorAccess, + private readonly EntityContextResolver $contextResolver, private readonly string $appBaseUrl, private readonly string $allowedFrontendHosts = '', ) {} @@ -118,6 +121,7 @@ class PaymentController extends BaseController $feeRials = (int) $this->configRepo->get('appointment_fee_rials'); $payment = new Payment($user, $feeRials, $gatewayName, Payment::TYPE_APPOINTMENT, $frontendAddress); $payment->setAppointment($appointment); + $payment->assignTenant(EntityContext::forBooking($appointment->getDoctor(), $appointment->getClinic())); $this->paymentRepo->save($payment); // مرورگر به این endpoint بک‌اند می‌رود؛ آنجا صلاحیت نهایی + ارتباط با بانک @@ -201,6 +205,7 @@ class PaymentController extends BaseController $feeRials = (int) $this->configRepo->get('appointment_fee_rials'); $payment = new Payment($appointment->getUser(), $feeRials, $gatewayName, Payment::TYPE_APPOINTMENT, $return); $payment->setAppointment($appointment); + $payment->assignTenant(EntityContext::forBooking($appointment->getDoctor(), $appointment->getClinic())); $this->paymentRepo->save($payment); return $payment; } @@ -405,8 +410,16 @@ class PaymentController extends BaseController return $this->error(ErrorCodes::ERR_VALIDATION_001, 'درگاه پرداخت نامعتبر یا غیرفعال است', 422, 'gateway'); } + // اشتراک به حساب محیطی می‌نشیند که کاربر صاحبش است؛ همان مرجعی که + // PaymentManager::handleSubscriptionActivation خودِ اشتراک را با آن می‌سازد. + $owner = $this->contextResolver->ownedEntity($user); + if (!$owner->isResolved()) { + 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]); } diff --git a/src/Payment/Entity/Payment.php b/src/Payment/Entity/Payment.php index 35def484..0dcd55a1 100644 --- a/src/Payment/Entity/Payment.php +++ b/src/Payment/Entity/Payment.php @@ -4,16 +4,25 @@ namespace App\Payment\Entity; use App\Appointment\Entity\Appointment; use App\Auth\Entity\User; +use App\Shared\Tenant\TenantOwnedTrait; use Doctrine\ORM\Mapping as ORM; use App\Payment\Repository\PaymentRepository; use Symfony\Component\Uid\Uuid; +/** + * پرداخت همیشه به محیطِ گیرنده تعلق دارد، نه به پرداخت‌کننده: نوبت → محیط همان + * نوبت، اشتراک و شارژ پیامک → محیطی که برایش خریداری شده. بیمار همچنان پرداخت + * خودش را می‌بیند چون TenantFilter برای کاربرِ بی‌محیط خاموش می‌ماند. + */ #[ORM\Entity(repositoryClass: PaymentRepository::class)] #[ORM\Table(name: 'payments')] #[ORM\Index(columns: ['order_id'], name: 'idx_payments_order')] #[ORM\Index(columns: ['user_id'], name: 'idx_payments_user')] +#[ORM\Index(columns: ['entity_type', 'entity_id', 'created_at'], name: 'idx_payments_entity_date')] class Payment { + use TenantOwnedTrait; + public const STATUS_PENDING = 'pending'; public const STATUS_SUCCESS = 'success'; public const STATUS_FAILED = 'failed'; diff --git a/src/Payment/Service/PaymentManager.php b/src/Payment/Service/PaymentManager.php index f941ad7e..35ba6770 100644 --- a/src/Payment/Service/PaymentManager.php +++ b/src/Payment/Service/PaymentManager.php @@ -14,6 +14,7 @@ use App\Payment\Repository\PaymentLogRepository; use App\Payment\Repository\PaymentRepository; use App\Representation\Service\JalaliDateService; use App\Settlement\Service\CommissionService; +use App\Shared\Context\EntityContext; use App\Sms\Entity\SmsLog; use App\Sms\Service\SmsService; use App\Sms\Service\SmsWalletService; @@ -346,20 +347,27 @@ final class PaymentManager return; } - $user = $payment->getUser(); - $doctor = $this->doctorRepo->findByUser($user); + // محیط از خودِ پرداخت خوانده می‌شود، نه دوباره از کاربر: اشتراک باید دقیقاً + // روی همان محیطی بنشیند که هنگام خرید پرداختش ثبت شد، حتی اگر کاربر بین + // خرید و بازگشت از درگاه محیط تازه‌ای پیدا کرده باشد. $bookingRepId = $this->bookingRepresentationIdFor($payment); + $entityId = $payment->getEntityId(); + + if ($payment->getEntityType() === EntityContext::TYPE_DOCTOR) { + $doctor = $this->doctorRepo->find($entityId); + if ($doctor === null) { + return; + } + $this->subscriptionService->createFromPayment($payment, 'doctor', $entityId, $periodUuid); + $this->commissionService->processSubscription($payment, $doctor->getRepresentationId(), $bookingRepId, $entityId, null); - if ($doctor !== null) { - $this->subscriptionService->createFromPayment($payment, 'doctor', $doctor->getId(), $periodUuid); - $this->commissionService->processSubscription($payment, $doctor->getRepresentationId(), $bookingRepId, $doctor->getId(), null); return; } - $clinic = $this->clinicRepo->findByUser($user); + $clinic = $this->clinicRepo->find($entityId); if ($clinic !== null) { - $this->subscriptionService->createFromPayment($payment, 'clinic', $clinic->getId(), $periodUuid); - $this->commissionService->processSubscription($payment, $clinic->getRepresentationId(), $bookingRepId, null, $clinic->getId()); + $this->subscriptionService->createFromPayment($payment, 'clinic', $entityId, $periodUuid); + $this->commissionService->processSubscription($payment, $clinic->getRepresentationId(), $bookingRepId, null, $entityId); } } diff --git a/src/PaymentMethod/Controller/PaymentMethodController.php b/src/PaymentMethod/Controller/PaymentMethodController.php index 4c3d68dc..e1e18d1a 100644 --- a/src/PaymentMethod/Controller/PaymentMethodController.php +++ b/src/PaymentMethod/Controller/PaymentMethodController.php @@ -6,6 +6,8 @@ use App\Auth\Entity\User; use App\PaymentMethod\Service\PaymentMethodService; use App\Secretary\Security\SecretaryAccessChecker; use App\Shared\Constant\ErrorCodes; +use App\Shared\Context\EntityContext; +use App\Shared\Context\EntityContextResolver; use App\Shared\Controller\BaseController; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; @@ -15,8 +17,12 @@ use Symfony\Component\Security\Http\Attribute\IsGranted; use OpenApi\Attributes as OA; /** - * Per-clinic payment methods: bank accounts and POS (card reader) devices. - * Scoped to the acting user; only clinic/doctor/secretary roles may manage them. + * Per-environment payment methods: bank accounts and POS (card reader) devices. + * + * اسکوپ محیط فعال است، نه کاربر: پزشکی که هم مطب شخصی دارد و هم کلینیک، بسته به + * محیط فعالش کارت‌های متفاوتی می‌بیند. کارت‌های بازمانده‌ای که هنوز محیطی ندارند + * با `entity_type: null` در همان فهرست می‌آیند و با endpoint انتساب به محیط فعال + * چسبانده می‌شوند. */ #[OA\Tag(name: 'Payment Methods')] #[Route('/api/v1/my/payment-methods')] @@ -29,14 +35,17 @@ class PaymentMethodController extends BaseController private readonly PaymentMethodService $service, private readonly SecretaryAccessChecker $secretaryAccess, private readonly \App\Clinic\Security\ClinicDoctorAccessChecker $clinicDoctorAccess, + private readonly EntityContextResolver $contextResolver, ) {} /** نقش مجاز + مجوز منشی روی منبع payments (روش‌های پرداخت زیرمجموعهٔ مالی است). */ - private function guard(User $user, string $action): void + private function guard(User $user, string $action): EntityContext { $this->assertRole($user); $this->secretaryAccess->denyUnlessGranted($user, 'payments', $action); $this->clinicDoctorAccess->denyUnlessGranted($user, 'payments', $action); + + return $this->contextResolver->resolve($user); } // ---- Bank accounts ----------------------------------------------------- @@ -44,35 +53,43 @@ class PaymentMethodController extends BaseController #[Route('/bank-accounts', methods: ['GET'])] public function listBankAccounts(#[CurrentUser] User $user): JsonResponse { - $this->guard($user, 'view'); + $context = $this->guard($user, 'view'); - return $this->success($this->service->listBankAccounts($user)); + return $this->success($this->service->listBankAccounts($context, $user)); } #[Route('/bank-accounts', methods: ['POST'])] public function createBankAccount(Request $request, #[CurrentUser] User $user): JsonResponse { - $this->guard($user, 'create'); - $data = json_decode($request->getContent(), true) ?? []; + $context = $this->guard($user, 'create'); + $data = json_decode($request->getContent(), true) ?? []; - return $this->success($this->service->createBankAccount($user, $data), 201); + return $this->success($this->service->createBankAccount($context, $user, $data), 201); } #[Route('/bank-accounts/{uuid}', methods: ['PUT'])] public function updateBankAccount(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse { - $this->guard($user, 'update'); - $data = json_decode($request->getContent(), true) ?? []; + $context = $this->guard($user, 'update'); + $data = json_decode($request->getContent(), true) ?? []; - return $this->success($this->service->updateBankAccount($user, $uuid, $data)); + return $this->success($this->service->updateBankAccount($context, $uuid, $data)); } #[Route('/bank-accounts/{uuid}/status', methods: ['PATCH'])] public function toggleBankAccountStatus(string $uuid, #[CurrentUser] User $user): JsonResponse { - $this->guard($user, 'update'); + $context = $this->guard($user, 'update'); - return $this->success($this->service->toggleBankAccountStatus($user, $uuid)); + return $this->success($this->service->toggleBankAccountStatus($context, $uuid)); + } + + #[Route('/bank-accounts/{uuid}/environment', methods: ['PATCH'])] + public function assignBankAccountEnvironment(string $uuid, #[CurrentUser] User $user): JsonResponse + { + $context = $this->guard($user, 'update'); + + return $this->success($this->service->assignBankAccountEnvironment($context, $user, $uuid)); } // ---- POS devices ------------------------------------------------------- @@ -80,35 +97,43 @@ class PaymentMethodController extends BaseController #[Route('/pos', methods: ['GET'])] public function listPos(#[CurrentUser] User $user): JsonResponse { - $this->guard($user, 'view'); + $context = $this->guard($user, 'view'); - return $this->success($this->service->listPos($user)); + return $this->success($this->service->listPos($context, $user)); } #[Route('/pos', methods: ['POST'])] public function createPos(Request $request, #[CurrentUser] User $user): JsonResponse { - $this->guard($user, 'create'); - $data = json_decode($request->getContent(), true) ?? []; + $context = $this->guard($user, 'create'); + $data = json_decode($request->getContent(), true) ?? []; - return $this->success($this->service->createPos($user, $data), 201); + return $this->success($this->service->createPos($context, $user, $data), 201); } #[Route('/pos/{uuid}', methods: ['PUT'])] public function updatePos(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse { - $this->guard($user, 'update'); - $data = json_decode($request->getContent(), true) ?? []; + $context = $this->guard($user, 'update'); + $data = json_decode($request->getContent(), true) ?? []; - return $this->success($this->service->updatePos($user, $uuid, $data)); + return $this->success($this->service->updatePos($context, $uuid, $data)); } #[Route('/pos/{uuid}/status', methods: ['PATCH'])] public function togglePosStatus(string $uuid, #[CurrentUser] User $user): JsonResponse { - $this->guard($user, 'update'); + $context = $this->guard($user, 'update'); - return $this->success($this->service->togglePosStatus($user, $uuid)); + return $this->success($this->service->togglePosStatus($context, $uuid)); + } + + #[Route('/pos/{uuid}/environment', methods: ['PATCH'])] + public function assignPosEnvironment(string $uuid, #[CurrentUser] User $user): JsonResponse + { + $context = $this->guard($user, 'update'); + + return $this->success($this->service->assignPosEnvironment($context, $user, $uuid)); } private function assertRole(User $user): void diff --git a/src/PaymentMethod/Entity/BankAccount.php b/src/PaymentMethod/Entity/BankAccount.php index ce347c0b..b5371aa2 100644 --- a/src/PaymentMethod/Entity/BankAccount.php +++ b/src/PaymentMethod/Entity/BankAccount.php @@ -4,19 +4,28 @@ namespace App\PaymentMethod\Entity; use App\Auth\Entity\User; use App\PaymentMethod\Repository\BankAccountRepository; +use App\Shared\Tenant\NullableTenantOwnedTrait; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Uid\Uuid; /** - * A clinic's bank account used as a payment method. Referenced from patient - * invoices to record which account a service payment was made to. This entity - * only stores the account info; the payment linkage lives on the invoice side. + * A bank account used as a payment method. Referenced from patient invoices to + * record which account a service payment was made to. This entity only stores + * the account info; the payment linkage lives on the invoice side. + * + * حساب مالِ **محیط** است، نه کاربر: پزشکی که هم مطب شخصی دارد و هم کلینیک، + * حساب‌هایشان جداست. `user_id` می‌ماند تا بدانیم چه کسی ثبتش کرده، ولی اسکوپِ + * خواندن و ویرایش، محیط است. حساب‌هایی که از پیش از فاز ۶ مانده‌اند و مالکشان + * چند محیط دارد، محیطشان تهی است تا خودش تعیین کند. */ #[ORM\Entity(repositoryClass: BankAccountRepository::class)] #[ORM\Table(name: 'bank_accounts')] #[ORM\Index(columns: ['user_id'], name: 'idx_bank_accounts_user')] +#[ORM\Index(columns: ['entity_type', 'entity_id'], name: 'idx_bank_accounts_entity')] class BankAccount { + use NullableTenantOwnedTrait; + #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column(type: 'integer')] @@ -94,6 +103,8 @@ class BankAccount 'shaba_number' => $this->shabaNumber, 'is_active' => $this->isActive, 'created_at' => $this->createdAt, + // تهی یعنی «محیطش هنوز تعیین نشده»؛ پنل با همین نشانه‌گذاری‌اش می‌کند. + 'entity_type' => $this->entityType, ]; } } diff --git a/src/PaymentMethod/Entity/Pos.php b/src/PaymentMethod/Entity/Pos.php index a6073880..168da817 100644 --- a/src/PaymentMethod/Entity/Pos.php +++ b/src/PaymentMethod/Entity/Pos.php @@ -4,18 +4,25 @@ namespace App\PaymentMethod\Entity; use App\Auth\Entity\User; use App\PaymentMethod\Repository\PosRepository; +use App\Shared\Tenant\NullableTenantOwnedTrait; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Uid\Uuid; /** - * A clinic's card reader (POS) device used as a payment method. Referenced from - * patient invoices to record which device a service payment was collected on. + * A card reader (POS) device used as a payment method. Referenced from patient + * invoices to record which device a service payment was collected on. + * + * قرینهٔ {@see BankAccount}: دستگاه مالِ محیط است نه کاربر، و ردیف‌های مبهمِ + * پیش از فاز ۶ محیط تهی دارند تا مالک خودش تعیین کند. */ #[ORM\Entity(repositoryClass: PosRepository::class)] #[ORM\Table(name: 'pos_devices')] #[ORM\Index(columns: ['user_id'], name: 'idx_pos_devices_user')] +#[ORM\Index(columns: ['entity_type', 'entity_id'], name: 'idx_pos_devices_entity')] class Pos { + use NullableTenantOwnedTrait; + #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column(type: 'integer')] @@ -93,6 +100,8 @@ class Pos 'account_number' => $this->accountNumber, 'is_active' => $this->isActive, 'created_at' => $this->createdAt, + // تهی یعنی «محیطش هنوز تعیین نشده»؛ پنل با همین نشانه‌گذاری‌اش می‌کند. + 'entity_type' => $this->entityType, ]; } } diff --git a/src/PaymentMethod/Repository/BankAccountRepository.php b/src/PaymentMethod/Repository/BankAccountRepository.php index 3dd8802b..4ef651b1 100644 --- a/src/PaymentMethod/Repository/BankAccountRepository.php +++ b/src/PaymentMethod/Repository/BankAccountRepository.php @@ -20,15 +20,80 @@ class BankAccountRepository extends ServiceEntityRepository } /** @return BankAccount[] */ - public function findByUser(User $user): array + public function findByEntity(string $entityType, int $entityId): array { return $this->createQueryBuilder('b') - ->where('b.user = :user')->setParameter('user', $user) + ->where('b.entityType = :type')->setParameter('type', $entityType) + ->andWhere('b.entityId = :id')->setParameter('id', $entityId) ->orderBy('b.createdAt', 'DESC') ->getQuery() ->getResult(); } + /** + * حساب‌های بی‌محیطِ همین کاربر — بازمانده‌های پیش از فاز ۶ که مالکشان چند محیط + * داشت و انتسابشان حدس می‌خواست. + * + * عمداً DBAL است نه DQL: TenantFilter شرط تساوی می‌گذارد و NULL با هیچ مقداری + * برابر نیست، پس این ردیف‌ها از DQL هرگز برنمی‌گردند. به‌جای فیلتر، محدودیت + * مالکیت همین‌جا با user_id گذاشته شده. + * + * شکل خروجی باید با {@see BankAccount::toArray()} یکی بماند؛ + * PaymentMethodTenantTest همین را می‌سنجد. + * + * @return array> + */ + public function findUnassignedByUser(User $user): array + { + $rows = $this->getEntityManager()->getConnection()->fetchAllAssociative( + 'SELECT uuid, bank_name, card_number, account_number, shaba_number, is_active, created_at + FROM bank_accounts + WHERE user_id = ? AND (entity_type IS NULL OR entity_id IS NULL) + ORDER BY created_at DESC', + [$user->getId()], + ); + + return array_map(static fn (array $r) => [ + 'uuid' => $r['uuid'], + 'bank_name' => $r['bank_name'], + 'card_number' => $r['card_number'], + 'account_number' => $r['account_number'], + 'shaba_number' => $r['shaba_number'], + 'is_active' => (bool) $r['is_active'], + 'created_at' => (int) $r['created_at'], + 'entity_type' => null, + ], $rows); + } + + /** + * انتساب یک حساب بی‌محیط به یک محیط. شرط‌های مالکیت و بی‌محیط بودن داخل خودِ + * UPDATE‌اند تا دو درخواست هم‌زمان نتوانند یک حساب را به دو محیط بچسبانند. + * + * @return bool چیزی انتساب یافت؟ false یعنی یافت نشد یا از قبل محیط داشت. + */ + public function assignEntity(string $uuid, User $user, string $entityType, int $entityId): bool + { + return $this->getEntityManager()->getConnection()->executeStatement( + 'UPDATE bank_accounts SET entity_type = ?, entity_id = ?, updated_at = ? + WHERE uuid = ? AND user_id = ? AND (entity_type IS NULL OR entity_id IS NULL)', + [$entityType, $entityId, time(), $uuid, $user->getId()], + ) > 0; + } + + /** + * پس از نوشتنِ DBAL، نمونهٔ داخل identity map هنوز مقدار قدیمی را دارد و + * findOneBy همان را برمی‌گرداند بی‌آنکه تازه‌اش کند. این متد صریحاً تازه می‌کند. + */ + public function reloadByUuid(string $uuid): ?BankAccount + { + $account = $this->findByUuid($uuid); + if ($account !== null) { + $this->getEntityManager()->refresh($account); + } + + return $account; + } + public function save(BankAccount $entity, bool $flush = true): void { $this->getEntityManager()->persist($entity); diff --git a/src/PaymentMethod/Repository/PosRepository.php b/src/PaymentMethod/Repository/PosRepository.php index 300cb89f..382e9f41 100644 --- a/src/PaymentMethod/Repository/PosRepository.php +++ b/src/PaymentMethod/Repository/PosRepository.php @@ -20,15 +20,67 @@ class PosRepository extends ServiceEntityRepository } /** @return Pos[] */ - public function findByUser(User $user): array + public function findByEntity(string $entityType, int $entityId): array { return $this->createQueryBuilder('p') - ->where('p.user = :user')->setParameter('user', $user) + ->where('p.entityType = :type')->setParameter('type', $entityType) + ->andWhere('p.entityId = :id')->setParameter('id', $entityId) ->orderBy('p.createdAt', 'DESC') ->getQuery() ->getResult(); } + /** + * قرینهٔ {@see BankAccountRepository::findUnassignedByUser()} — و به همان دلیل + * DBAL است: ردیف بی‌محیط از DQL برنمی‌گردد چون فیلتر شرط تساوی می‌گذارد. + * + * شکل خروجی باید با {@see Pos::toArray()} یکی بماند. + * + * @return array> + */ + public function findUnassignedByUser(User $user): array + { + $rows = $this->getEntityManager()->getConnection()->fetchAllAssociative( + 'SELECT uuid, bank_name, serial_number, terminal_number, account_number, is_active, created_at + FROM pos_devices + WHERE user_id = ? AND (entity_type IS NULL OR entity_id IS NULL) + ORDER BY created_at DESC', + [$user->getId()], + ); + + return array_map(static fn (array $r) => [ + 'uuid' => $r['uuid'], + 'bank_name' => $r['bank_name'], + 'serial_number' => $r['serial_number'], + 'terminal_number' => $r['terminal_number'], + 'account_number' => $r['account_number'], + 'is_active' => (bool) $r['is_active'], + 'created_at' => (int) $r['created_at'], + 'entity_type' => null, + ], $rows); + } + + /** @see BankAccountRepository::assignEntity() */ + public function assignEntity(string $uuid, User $user, string $entityType, int $entityId): bool + { + return $this->getEntityManager()->getConnection()->executeStatement( + 'UPDATE pos_devices SET entity_type = ?, entity_id = ?, updated_at = ? + WHERE uuid = ? AND user_id = ? AND (entity_type IS NULL OR entity_id IS NULL)', + [$entityType, $entityId, time(), $uuid, $user->getId()], + ) > 0; + } + + /** @see BankAccountRepository::reloadByUuid() */ + public function reloadByUuid(string $uuid): ?Pos + { + $pos = $this->findByUuid($uuid); + if ($pos !== null) { + $this->getEntityManager()->refresh($pos); + } + + return $pos; + } + public function save(Pos $entity, bool $flush = true): void { $this->getEntityManager()->persist($entity); diff --git a/src/PaymentMethod/Service/PaymentMethodService.php b/src/PaymentMethod/Service/PaymentMethodService.php index 8fc9324f..72836b31 100644 --- a/src/PaymentMethod/Service/PaymentMethodService.php +++ b/src/PaymentMethod/Service/PaymentMethodService.php @@ -8,32 +8,44 @@ use App\PaymentMethod\Entity\Pos; use App\PaymentMethod\Repository\BankAccountRepository; use App\PaymentMethod\Repository\PosRepository; use App\Shared\Constant\ErrorCodes; +use App\Shared\Context\EntityContext; use App\Shared\Exception\AppException; +use App\Shared\Tenant\TenantOwnershipChecker; /** - * Business logic for a clinic's payment methods (bank accounts + POS devices). - * Every read/write is scoped to the acting user so one clinic can never touch - * another's records. Ported from clinic-pro-tauri PaymentManagement tab. + * Business logic for an environment's payment methods (bank accounts + POS + * devices). Ported from clinic-pro-tauri PaymentManagement tab. + * + * اسکوپ **محیط** است نه کاربر: پزشکی که هم مطب شخصی دارد و هم کلینیک، در هر محیط + * کارت‌های همان محیط را می‌بیند. `user_id` هنگام ساخت از کاربر جاری پر می‌شود، ولی + * فقط می‌گوید چه کسی ثبتش کرده. + * + * ردیف‌های بازمانده از پیش از فاز ۶ محیط تهی دارند و در هیچ محیطی دیده نمی‌شوند؛ + * مالکشان آن‌ها را در فهرست «بی‌محیط» می‌بیند و با assign*Environment به محیط + * فعالش می‌چسباند. */ class PaymentMethodService { public function __construct( - private readonly BankAccountRepository $bankRepo, - private readonly PosRepository $posRepo, + private readonly BankAccountRepository $bankRepo, + private readonly PosRepository $posRepo, + private readonly TenantOwnershipChecker $tenantOwnership, ) {} // ---- Bank accounts ----------------------------------------------------- /** @return array> */ - public function listBankAccounts(User $user): array + public function listBankAccounts(EntityContext $context, User $user): array { - return array_map( - static fn (BankAccount $b) => $b->toArray(), - $this->bankRepo->findByUser($user), + [$type, $id] = $this->pair($context); + + return array_merge( + array_map(static fn (BankAccount $b) => $b->toArray(), $this->bankRepo->findByEntity($type, $id)), + $this->bankRepo->findUnassignedByUser($user), ); } - public function createBankAccount(User $user, array $data): array + public function createBankAccount(EntityContext $context, User $user, array $data): array { $bankName = trim((string) ($data['bank_name'] ?? '')); $accountNumber = trim((string) ($data['account_number'] ?? '')); @@ -47,15 +59,18 @@ class PaymentMethodService throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'شماره حساب الزامی است', 422, 'account_number'); } + $this->pair($context); + $account = new BankAccount($user, $bankName, $accountNumber, $cardNumber, $shabaNumber); + $account->assignTenant($context); $this->bankRepo->save($account); return $account->toArray(); } - public function updateBankAccount(User $user, string $uuid, array $data): array + public function updateBankAccount(EntityContext $context, string $uuid, array $data): array { - $account = $this->ownedBankAccount($user, $uuid); + $account = $this->ownedBankAccount($context, $uuid); if (array_key_exists('bank_name', $data)) { $bankName = trim((string) $data['bank_name']); @@ -83,19 +98,36 @@ class PaymentMethodService return $account->toArray(); } - public function toggleBankAccountStatus(User $user, string $uuid): array + public function toggleBankAccountStatus(EntityContext $context, string $uuid): array { - $account = $this->ownedBankAccount($user, $uuid); + $account = $this->ownedBankAccount($context, $uuid); $account->setActive(!$account->isActive()); $this->bankRepo->save($account); return $account->toArray(); } - private function ownedBankAccount(User $user, string $uuid): BankAccount + /** حسابِ بی‌محیطِ خودِ کاربر را به محیط فعال می‌چسباند. */ + public function assignBankAccountEnvironment(EntityContext $context, User $user, string $uuid): array + { + [$type, $id] = $this->pair($context); + + if (!$this->bankRepo->assignEntity($uuid, $user, $type, $id)) { + throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'حساب بانکیِ بدون محیط یافت نشد', 404); + } + + $account = $this->bankRepo->reloadByUuid($uuid); + if (!$this->tenantOwnership->belongsTo($context, $account)) { + throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'حساب بانکی یافت نشد', 404); + } + + return $account->toArray(); + } + + private function ownedBankAccount(EntityContext $context, string $uuid): BankAccount { $account = $this->bankRepo->findByUuid($uuid); - if ($account === null || $account->getUser()->getId() !== $user->getId()) { + if (!$this->tenantOwnership->belongsTo($context, $account)) { throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'حساب بانکی یافت نشد', 404); } @@ -105,15 +137,17 @@ class PaymentMethodService // ---- POS devices ------------------------------------------------------- /** @return array> */ - public function listPos(User $user): array + public function listPos(EntityContext $context, User $user): array { - return array_map( - static fn (Pos $p) => $p->toArray(), - $this->posRepo->findByUser($user), + [$type, $id] = $this->pair($context); + + return array_merge( + array_map(static fn (Pos $p) => $p->toArray(), $this->posRepo->findByEntity($type, $id)), + $this->posRepo->findUnassignedByUser($user), ); } - public function createPos(User $user, array $data): array + public function createPos(EntityContext $context, User $user, array $data): array { $bankName = trim((string) ($data['bank_name'] ?? '')); $terminalNumber = trim((string) ($data['terminal_number'] ?? '')); @@ -127,15 +161,18 @@ class PaymentMethodService throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'شماره ترمینال الزامی است', 422, 'terminal_number'); } + $this->pair($context); + $pos = new Pos($user, $bankName, $terminalNumber, $serialNumber, $accountNumber); + $pos->assignTenant($context); $this->posRepo->save($pos); return $pos->toArray(); } - public function updatePos(User $user, string $uuid, array $data): array + public function updatePos(EntityContext $context, string $uuid, array $data): array { - $pos = $this->ownedPos($user, $uuid); + $pos = $this->ownedPos($context, $uuid); if (array_key_exists('bank_name', $data)) { $bankName = trim((string) $data['bank_name']); @@ -163,22 +200,56 @@ class PaymentMethodService return $pos->toArray(); } - public function togglePosStatus(User $user, string $uuid): array + public function togglePosStatus(EntityContext $context, string $uuid): array { - $pos = $this->ownedPos($user, $uuid); + $pos = $this->ownedPos($context, $uuid); $pos->setActive(!$pos->isActive()); $this->posRepo->save($pos); return $pos->toArray(); } - private function ownedPos(User $user, string $uuid): Pos + /** کارتخوانِ بی‌محیطِ خودِ کاربر را به محیط فعال می‌چسباند. */ + public function assignPosEnvironment(EntityContext $context, User $user, string $uuid): array + { + [$type, $id] = $this->pair($context); + + if (!$this->posRepo->assignEntity($uuid, $user, $type, $id)) { + throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'کارت خوانِ بدون محیط یافت نشد', 404); + } + + $pos = $this->posRepo->reloadByUuid($uuid); + if (!$this->tenantOwnership->belongsTo($context, $pos)) { + throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'کارت خوان یافت نشد', 404); + } + + return $pos->toArray(); + } + + private function ownedPos(EntityContext $context, string $uuid): Pos { $pos = $this->posRepo->findByUuid($uuid); - if ($pos === null || $pos->getUser()->getId() !== $user->getId()) { + if (!$this->tenantOwnership->belongsTo($context, $pos)) { throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'کارت خوان یافت نشد', 404); } return $pos; } + + /** + * محیطِ حل‌شده الزامی است: منشیِ بدون محیط فعال یا کاربری که هنوز پزشک/کلینیکی + * ندارد، نه کارتی برای دیدن دارد نه جایی برای ساختنش. + * + * @return array{0: string, 1: int} + */ + private function pair(EntityContext $context): array + { + if (!$context->isResolved()) { + throw new AppException(ErrorCodes::ERR_FORBIDDEN_001, 'محیط فعالی برای روش‌های پرداخت انتخاب نشده است', 403); + } + + [$type, $id] = $context->toEntityPair(); + + return [$type, (int) $id]; + } } diff --git a/src/Shared/Command/SeedDemoDataCommand.php b/src/Shared/Command/SeedDemoDataCommand.php index 3a6c5d70..fb952cb1 100644 --- a/src/Shared/Command/SeedDemoDataCommand.php +++ b/src/Shared/Command/SeedDemoDataCommand.php @@ -730,6 +730,7 @@ class SeedDemoDataCommand extends Command $user = $this->em->getReference(\App\Auth\Entity\User::class, $patientIds[$i]); $payment = new Payment($user, $fee, 'mock', Payment::TYPE_APPOINTMENT, 'https://' . $rep['domain'] . '/payment/result'); + $payment->assignTenantPair('doctor', (int) $doc['id']); $payment->setMetadata(['demo' => true, 'scenario' => 'match-service']); $this->em->persist($payment); $this->em->flush(); diff --git a/src/Shared/Constant/ErrorCodes.php b/src/Shared/Constant/ErrorCodes.php index 22f04cff..72393a93 100644 --- a/src/Shared/Constant/ErrorCodes.php +++ b/src/Shared/Constant/ErrorCodes.php @@ -29,6 +29,7 @@ class ErrorCodes public const ERR_PAYMENT_001 = 'ERR_PAYMENT_001'; public const ERR_PAYMENT_002 = 'ERR_PAYMENT_002'; public const ERR_PAYMENT_003 = 'ERR_PAYMENT_003'; + public const ERR_PAYMENT_004 = 'ERR_PAYMENT_004'; // Appointment public const ERR_APPOINTMENT_001 = 'ERR_APPOINTMENT_001'; @@ -127,6 +128,7 @@ class ErrorCodes self::ERR_PAYMENT_001 => 'درگاه پرداخت در دسترس نیست', self::ERR_PAYMENT_002 => 'مبلغ پرداخت نامعتبر است', self::ERR_PAYMENT_003 => 'وضعیت نوبت برای پرداخت مناسب نیست', + self::ERR_PAYMENT_004 => 'محیط این پرداخت مشخص نیست', self::ERR_APPOINTMENT_001 => 'اسلات انتخاب‌شده در دسترس نیست', self::ERR_APPOINTMENT_002 => 'نوبت قابل لغو نیست', self::ERR_FILE_001 => 'فرمت فایل مجاز نیست', diff --git a/src/Shared/Context/EntityContextResolver.php b/src/Shared/Context/EntityContextResolver.php index 133727b5..3b90986c 100644 --- a/src/Shared/Context/EntityContextResolver.php +++ b/src/Shared/Context/EntityContextResolver.php @@ -73,6 +73,25 @@ class EntityContextResolver } } + /** + * محیطی که کاربر **صاحبش** است، مستقل از محیط فعال و از نقش‌هایش. + * + * برای خریدهایی است که به حساب خودِ صاحب می‌نشیند (اشتراک): آنجا «کجا ایستاده‌ام» + * مهم نیست، «چه چیزی دارم» مهم است. تنها مرجعِ این پرسش همین متد است تا پرداختِ + * اشتراک و خودِ اشتراک هرگز روی دو محیط متفاوت ننشینند. + */ + public function ownedEntity(User $user): EntityContext + { + $doctor = $this->doctorRepo->findByUser($user); + if ($doctor !== null) { + return EntityContext::forDoctor($doctor); + } + + $clinic = $this->clinicRepo->findByUser($user); + + return $clinic !== null ? EntityContext::forClinic($clinic) : EntityContext::unknown(); + } + /** مالک کلینیک، ادمین، پزشکِ عضو همان کلینیک، یا منشیِ دارای رابطهٔ فعال در آن. */ public function canActInClinic(User $user, Clinic $clinic): bool { diff --git a/src/Shared/Tenant/GlobalTables.php b/src/Shared/Tenant/GlobalTables.php index 466a495f..8a39372b 100644 --- a/src/Shared/Tenant/GlobalTables.php +++ b/src/Shared/Tenant/GlobalTables.php @@ -66,6 +66,10 @@ final class GlobalTables // استثنای مستندشده در فاز ۲ \App\Appointment\Entity\Holiday::class => 'clinic=NULL یعنی «همهٔ محیط‌ها»، نه «مطب شخصی» — جفت tenant این را نمی‌تواند بیان کند', + + // کیف پولِ شخص — استثنای مستندشده در فاز ۶ + \App\Settlement\Entity\WalletTransaction::class => 'کیف پول خودِ شخص است نه محیط: موجودی از مجموع credit−debitِ همان کاربر مشتق می‌شود و payment_id تهی‌پذیر است، پس تفکیک به محیط، موجودی را بی‌معنا می‌کند', + \App\Settlement\Entity\Settlement::class => 'برداشت از همان کیف پولِ شخصی (SettlementController موجودی را با getWalletBalance(user) می‌سنجد)؛ محیط ندارد چون کیف پول ندارد', ]; /** @@ -107,25 +111,21 @@ final class GlobalTables \App\Insurance\Entity\TenantServiceCoverage::class => \App\Insurance\Entity\TenantInsurance::class, \App\Sms\Entity\SmsWalletTransaction::class => \App\Sms\Entity\SmsWallet::class, + + \App\Payment\Entity\PaymentLog::class => \App\Payment\Entity\Payment::class, + \App\Settlement\Entity\FinancialBreakdown::class => \App\Payment\Entity\Payment::class, + \App\Secretary\Entity\SecretaryEarning::class => \App\Settlement\Entity\FinancialBreakdown::class, ]; /** - * بدهی ثبت‌شده: مالکیتشان دوگانه است (پرداخت‌کننده در برابر دریافت‌کننده) و - * تصمیم درباره‌شان تحلیل جدا می‌خواهد. migration اشتباه روی دادهٔ مالی برگشت‌پذیر - * نیست، پس عمداً در این فاز دست نخوردند. + * بدهیِ طبقه‌بندی: کلاسی که هنوز تصمیمی درباره‌اش گرفته نشده. * - * این فهرست باید کوچک شود، نه بزرگ. + * فاز ۶ آخرین هشت موردش را تعیین تکلیف کرد و اکنون خالی است. خالی بماند: + * هر افزوده‌ای یعنی جدولی بیرون از هر تضمینی مانده. اگر تصمیم واقعاً به تحلیل + * بیشتری نیاز دارد، همین‌جا با دلیل ثبتش کن — ولی TenantSchemaCoverageTest + * خالی‌بودن را اجبار می‌کند تا این کار بی‌صدا نگذرد. * * @var array */ - public const DEFERRED = [ - \App\Payment\Entity\Payment::class => 'پرداخت بین بیمار و محیط؛ هر دو طرف باید ببینندش', - \App\Payment\Entity\PaymentLog::class => 'فرزند Payment؛ با همان تصمیم می‌رود', - \App\Settlement\Entity\Settlement::class => 'تسویهٔ سامانه با صاحب محیط', - \App\Settlement\Entity\FinancialBreakdown::class => 'تفکیک سهم‌ها بین چند طرف یک پرداخت', - \App\Settlement\Entity\WalletTransaction::class => 'کیف پول کاربر، نه محیط', - \App\Secretary\Entity\SecretaryEarning::class => 'سهم منشی از یک پرداخت', - \App\PaymentMethod\Entity\BankAccount::class => 'حساب بانکی روی User ثبت شده، نه روی محیط', - \App\PaymentMethod\Entity\Pos::class => 'دستگاه کارتخوان روی User ثبت شده، نه روی محیط', - ]; + public const DEFERRED = []; } diff --git a/src/Shared/Tenant/NullableTenantOwnedTrait.php b/src/Shared/Tenant/NullableTenantOwnedTrait.php new file mode 100644 index 00000000..34e0b3bc --- /dev/null +++ b/src/Shared/Tenant/NullableTenantOwnedTrait.php @@ -0,0 +1,50 @@ +entityType; } + + public function getEntityId(): ?int { return $this->entityId; } + + public function hasTenant(): bool + { + return $this->entityType !== null && $this->entityId !== null; + } + + /** @throws \InvalidArgumentException اگر محیط حل نشده باشد */ + public function assignTenant(EntityContext $context): void + { + if (!$context->isResolved()) { + throw new \InvalidArgumentException(sprintf( + 'Cannot assign an unresolved tenant context to %s.', + static::class, + )); + } + + [$this->entityType, $this->entityId] = $context->toEntityPair(); + } +} diff --git a/src/Sms/Controller/SmsWalletController.php b/src/Sms/Controller/SmsWalletController.php index 3190f2d7..f7260280 100644 --- a/src/Sms/Controller/SmsWalletController.php +++ b/src/Sms/Controller/SmsWalletController.php @@ -95,6 +95,7 @@ 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]); $this->paymentRepo->save($payment); diff --git a/tests/ApiTestCase.php b/tests/ApiTestCase.php index e7aaee5a..9780093c 100644 --- a/tests/ApiTestCase.php +++ b/tests/ApiTestCase.php @@ -8,6 +8,7 @@ use App\Appointment\Entity\WeeklySchedule; use App\Auth\Entity\User; use App\Clinic\Entity\Clinic; use App\Doctor\Entity\Doctor; +use App\Payment\Entity\Payment; use App\Shared\Context\EntityContext; use App\Subscription\Entity\SubscriptionPlan; use Doctrine\DBAL\Exception\UniqueConstraintViolationException; @@ -25,6 +26,12 @@ use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; */ abstract class ApiTestCase extends WebTestCase { + /** + * محیطِ رکوردهایی که موضوعِ تست، محیطشان نیست. عمداً یک ثابت است تا هیچ تستی + * تصادفاً با محیطِ واقعیِ تستِ دیگری برخورد نکند. + */ + protected const TENANTLESS_TEST_ENTITY_ID = 1; + protected KernelBrowser $client; protected EntityManagerInterface $em; @@ -134,6 +141,25 @@ abstract class ApiTestCase extends WebTestCase return $schedule; } + /** + * محیط پرداخت را می‌گذارد: از نوبتش اگر داشته باشد، وگرنه محیطِ ثابتی که + * موضوع تست نیست. مثل بقیهٔ جدول‌های محیط‌دار، ستون‌ها NOT NULL‌اند و پرداختِ + * بی‌محیط سرِ flush می‌شکند — همان رفتاری که کد واقعی هم دارد. + */ + protected function stampTenant(Payment $payment): Payment + { + $appointment = $payment->getAppointment(); + if ($appointment !== null) { + $payment->assignTenant(EntityContext::forBooking($appointment->getDoctor(), $appointment->getClinic())); + + return $payment; + } + + $payment->assignTenantPair(EntityContext::TYPE_DOCTOR, self::TENANTLESS_TEST_ENTITY_ID); + + return $payment; + } + protected function newDateOverride(Doctor $doctor, int $date, bool $active = false, ?Clinic $clinic = null): DateOverride { $this->flushIfNew($doctor, $clinic); diff --git a/tests/Appointment/AppointmentExpiryServiceTest.php b/tests/Appointment/AppointmentExpiryServiceTest.php index 972293cd..4a1a7f2c 100644 --- a/tests/Appointment/AppointmentExpiryServiceTest.php +++ b/tests/Appointment/AppointmentExpiryServiceTest.php @@ -34,6 +34,7 @@ class AppointmentExpiryServiceTest extends ApiTestCase $payment = new Payment($patient, 100_000, 'mellat', 'appointment'); $payment->setAppointment($appt); + $this->stampTenant($payment); $this->em->persist($payment); $appointments[] = [$appt, $payment]; diff --git a/tests/Database/UniqueConstraintsTest.php b/tests/Database/UniqueConstraintsTest.php index 242c79ad..9dce5a68 100644 --- a/tests/Database/UniqueConstraintsTest.php +++ b/tests/Database/UniqueConstraintsTest.php @@ -29,9 +29,9 @@ class UniqueConstraintsTest extends ApiTestCase public function testDuplicateGatewayTokenRejected(): void { $token = 'tok-' . bin2hex(random_bytes(6)); - $a = new Payment($this->createUser(), 1000, 'mellat', Payment::TYPE_SUBSCRIPTION); + $a = $this->stampTenant(new Payment($this->createUser(), 1000, 'mellat', Payment::TYPE_SUBSCRIPTION)); $a->setGatewayToken($token); - $b = new Payment($this->createUser(), 1000, 'mellat', Payment::TYPE_SUBSCRIPTION); + $b = $this->stampTenant(new Payment($this->createUser(), 1000, 'mellat', Payment::TYPE_SUBSCRIPTION)); $b->setGatewayToken($token); $this->em->persist($a); $this->em->persist($b); @@ -57,7 +57,7 @@ class UniqueConstraintsTest extends ApiTestCase public function testDuplicateBreakdownPaymentSourceRejected(): void { $user = $this->createUser(); - $payment = new Payment($user, 100_000, 'mellat', Payment::TYPE_APPOINTMENT); + $payment = $this->stampTenant(new Payment($user, 100_000, 'mellat', Payment::TYPE_APPOINTMENT)); $this->em->persist($payment); $this->em->flush(); diff --git a/tests/Patient/PatientFinancialsTest.php b/tests/Patient/PatientFinancialsTest.php index 3e6c27a8..b4fbaa22 100644 --- a/tests/Patient/PatientFinancialsTest.php +++ b/tests/Patient/PatientFinancialsTest.php @@ -34,7 +34,7 @@ class PatientFinancialsTest extends ApiTestCase { [$owner, $record, $patient] = $this->recordFor(); - $payment = new Payment($patient, 250000, 'mellat', 'appointment'); + $payment = $this->stampTenant(new Payment($patient, 250000, 'mellat', 'appointment')); $payment->setStatus(Payment::STATUS_SUCCESS); $this->em->persist($payment); $this->em->flush(); diff --git a/tests/Payment/AppointmentPaidConfirmFilesSessionTest.php b/tests/Payment/AppointmentPaidConfirmFilesSessionTest.php index 2e857d0a..37328739 100644 --- a/tests/Payment/AppointmentPaidConfirmFilesSessionTest.php +++ b/tests/Payment/AppointmentPaidConfirmFilesSessionTest.php @@ -47,6 +47,7 @@ class AppointmentPaidConfirmFilesSessionTest extends ApiTestCase $payment = new Payment($appointment->getUser(), 50_000, 'mock', Payment::TYPE_APPOINTMENT); $payment->setAppointment($appointment); + $this->stampTenant($payment); $this->em->persist($payment); $this->em->flush(); diff --git a/tests/Payment/PaymentCallbackAmountTest.php b/tests/Payment/PaymentCallbackAmountTest.php index 56bffe7d..1295ffe3 100644 --- a/tests/Payment/PaymentCallbackAmountTest.php +++ b/tests/Payment/PaymentCallbackAmountTest.php @@ -28,7 +28,7 @@ class PaymentCallbackAmountTest extends ApiTestCase private function makePayment(int $amountRials): Payment { $user = $this->createUser(); - $payment = new Payment($user, $amountRials, 'mock', Payment::TYPE_SMS_WALLET); + $payment = $this->stampTenant(new Payment($user, $amountRials, 'mock', Payment::TYPE_SMS_WALLET)); $this->em->persist($payment); $this->em->flush(); diff --git a/tests/Payment/PaymentTenantTest.php b/tests/Payment/PaymentTenantTest.php new file mode 100644 index 00000000..3229e2ac --- /dev/null +++ b/tests/Payment/PaymentTenantTest.php @@ -0,0 +1,165 @@ +get(EntityManagerInterface::class); + } + + protected function tearDown(): void + { + $filters = $this->em()->getFilters(); + if ($filters->isEnabled(TenantFilter::NAME)) { + $filters->disable(TenantFilter::NAME); + } + + parent::tearDown(); + } + + private function makeDoctor(): Doctor + { + $doctor = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر پرداخت'); + $this->em->persist($doctor); + $this->em->flush(); + + return $doctor; + } + + private function makeClinic(): Clinic + { + $clinic = new Clinic($this->createUser(['ROLE_CLINIC'])); + $clinic->setName('کلینیک پرداخت'); + $this->em->persist($clinic); + $this->em->flush(); + + return $clinic; + } + + /** پرداخت را از دیتابیس می‌خواند، نه از پاسخ — پاسخ محیط را برنمی‌گرداند. */ + private function storedPayment(string $uuid): Payment + { + $this->em->clear(); + $payment = $this->em->getRepository(Payment::class)->findOneBy(['uuid' => $uuid]); + self::assertNotNull($payment, 'پرداخت باید ذخیره شده باشد'); + + return $payment; + } + + private function payForAppointment(?Clinic $clinic): Payment + { + $doctor = $this->makeDoctor(); + $patient = $this->createUser(['ROLE_USER']); + $start = strtotime('+10 days') + random_int(0, 500_000) * 7; + + $appointment = $this->newAppointment($doctor, $patient, $start, $start + 900, $clinic); + $this->em->persist($appointment); + $this->em->flush(); + + $res = $this->authJson('POST', '/api/v1/payment/appointment', $patient, [ + 'appointment_uuid' => $appointment->getUuid(), + 'gateway' => 'mellat', + ]); + self::assertSame(200, $this->responseCode(), 'شروع پرداخت نوبت باید موفق باشد'); + + return $this->storedPayment($res['data']['payment_uuid']); + } + + /** ✅ پرداخت نوبتِ یک کلینیک به همان کلینیک می‌نشیند. */ + public function testAppointmentPaymentBelongsToTheClinicOfTheAppointment(): void + { + $clinic = $this->makeClinic(); + $payment = $this->payForAppointment($clinic); + + self::assertSame('clinic', $payment->getEntityType()); + self::assertSame($clinic->getId(), $payment->getEntityId()); + } + + /** ✅ نوبتِ مطب شخصی به خودِ پزشک. */ + public function testAppointmentPaymentOfAPersonalPracticeBelongsToTheDoctor(): void + { + $payment = $this->payForAppointment(null); + + self::assertSame('doctor', $payment->getEntityType()); + } + + /** ✅ اشتراک به محیطی که خریدار صاحبش است. */ + public function testSubscriptionPaymentBelongsToTheEnvironmentTheBuyerOwns(): void + { + $doctor = $this->makeDoctor(); + + $res = $this->authJson('POST', '/api/v1/subscription-payment', $doctor->getUser(), [ + 'gateway' => 'mellat', + 'amount_rials' => 1_000_000, + ]); + self::assertSame(200, $this->responseCode()); + + $payment = $this->storedPayment($res['data']['payment_uuid']); + self::assertSame('doctor', $payment->getEntityType()); + self::assertSame($doctor->getId(), $payment->getEntityId()); + } + + /** + * ❌ کاربری که نه پزشک است نه کلینیک، اشتراک برای هیچ محیطی نمی‌خرد. بدون این + * گارد، ردیفی با محیطِ نامعتبر ساخته می‌شد یا flush بی‌پیام می‌شکست. + */ + public function testSubscriptionPaymentWithoutAnOwnedEnvironmentIsRejected(): void + { + $res = $this->authJson('POST', '/api/v1/subscription-payment', $this->createUser(['ROLE_USER']), [ + 'gateway' => 'mellat', + 'amount_rials' => 1_000_000, + ]); + + self::assertSame(422, $this->responseCode()); + self::assertSame(ErrorCodes::ERR_PAYMENT_004, $res['errors'][0]['code']); + } + + /** ⚠️ مرزی: بیمار محیطی ندارد، پس فیلتر خاموش است و پرداخت خودش را می‌بیند. */ + public function testThePayingPatientStillSeesTheirOwnPayment(): void + { + $clinic = $this->makeClinic(); + $payment = $this->payForAppointment($clinic); + $patient = $payment->getUser(); + + $res = $this->authJson('GET', '/api/v1/payment/' . $payment->getUuid(), $patient); + + self::assertSame(200, $this->responseCode(), 'بیمار باید پرداخت خودش را ببیند'); + self::assertSame($payment->getUuid(), $res['data']['uuid']); + } + + /** ⚠️ محیط دیگر همان پرداخت را اصلاً نمی‌بیند — تور ایمنیِ TenantFilter. */ + public function testAnotherEnvironmentDoesNotSeeThePaymentAtAll(): void + { + $clinic = $this->makeClinic(); + $payment = $this->payForAppointment($clinic); + $uuid = $payment->getUuid(); + + $this->em->clear(); + $this->em()->getFilters() + ->enable(TenantFilter::NAME) + ->setParameter(TenantFilter::PARAM_TYPE, 'clinic', 'string') + ->setParameter(TenantFilter::PARAM_ID, $clinic->getId() + 1000, 'integer'); + + self::assertNull( + $this->em->getRepository(Payment::class)->findOneBy(['uuid' => $uuid]), + 'پرداخت محیط دیگر نباید دیده شود', + ); + } +} diff --git a/tests/PaymentMethod/PaymentMethodTenantTest.php b/tests/PaymentMethod/PaymentMethodTenantTest.php new file mode 100644 index 00000000..60c7df10 --- /dev/null +++ b/tests/PaymentMethod/PaymentMethodTenantTest.php @@ -0,0 +1,200 @@ +createUser(['ROLE_DOCTOR', 'ROLE_CLINIC']); + + $doctor = new Doctor($user, 'دکتر دو محیطه'); + $this->em->persist($doctor); + + $clinic = new Clinic($user); + $clinic->setName('کلینیک همان شخص'); + $this->em->persist($clinic); + $this->em->flush(); + + return [$user, $doctor, $clinic]; + } + + private function switchTo(User $user, string $type, string $uuid): void + { + // هر درخواستِ API ممکن است EntityManager را پاک کند، پس کاربر دوباره از + // همین EM گرفته می‌شود تا UserActiveContext به نمونهٔ جداشده وصل نشود. + $managed = $this->em->find(User::class, $user->getId()); + $existing = $this->em->getRepository(UserActiveContext::class)->findOneBy(['user' => $managed]); + if ($existing !== null) { + $this->em->remove($existing); + $this->em->flush(); + } + + $this->em->persist(new UserActiveContext($managed, $uuid, $type)); + $this->em->flush(); + } + + private function createBankAccount(User $user, string $bankName): array + { + $res = $this->authJson('POST', '/api/v1/my/payment-methods/bank-accounts', $user, [ + 'bank_name' => $bankName, + 'account_number' => (string) random_int(1_000_000, 9_999_999), + ]); + self::assertSame(201, $this->responseCode()); + + return $res['data']; + } + + private function listBankAccounts(User $user): array + { + $res = $this->authJson('GET', '/api/v1/my/payment-methods/bank-accounts', $user); + self::assertSame(200, $this->responseCode()); + + return $res['data']; + } + + /** ✅ همان شخص، دو محیط، دو دستهٔ جدا از کارت‌ها. */ + public function testTheSamePersonSeesDifferentCardsInEachEnvironment(): void + { + [$user, $doctor, $clinic] = $this->multiEnvironmentUser(); + + $this->switchTo($user, EntityContext::TYPE_DOCTOR, $doctor->getUuid()); + $personal = $this->createBankAccount($user, 'کارت مطب'); + + $this->switchTo($user, EntityContext::TYPE_CLINIC, $clinic->getUuid()); + $clinical = $this->createBankAccount($user, 'کارت کلینیک'); + + self::assertSame(['کارت کلینیک'], array_column($this->listBankAccounts($user), 'bank_name')); + + $this->switchTo($user, EntityContext::TYPE_DOCTOR, $doctor->getUuid()); + self::assertSame(['کارت مطب'], array_column($this->listBankAccounts($user), 'bank_name')); + + self::assertNotSame($personal['uuid'], $clinical['uuid']); + } + + /** ❌ کارتِ محیط دیگر حتی برای همان شخص قابل ویرایش نیست. */ + public function testACardOfTheOtherEnvironmentCannotBeEditedEvenByItsOwner(): void + { + [$user, $doctor, $clinic] = $this->multiEnvironmentUser(); + + $this->switchTo($user, EntityContext::TYPE_DOCTOR, $doctor->getUuid()); + $personal = $this->createBankAccount($user, 'کارت مطب'); + + $this->switchTo($user, EntityContext::TYPE_CLINIC, $clinic->getUuid()); + $this->authJson('PATCH', '/api/v1/my/payment-methods/bank-accounts/' . $personal['uuid'] . '/status', $user); + + self::assertSame(404, $this->responseCode()); + } + + /** + * ⚠️ مرزی: کارتِ بی‌محیط (بازماندهٔ پیش از فاز ۶) در فهرست می‌آید با نشانهٔ + * `entity_type: null`، ولی تا وقتی محیطش تعیین نشده قابل ویرایش نیست. + */ + public function testAnUnassignedCardIsListedButNotEditable(): void + { + [$user, $doctor] = $this->multiEnvironmentUser(); + $this->switchTo($user, EntityContext::TYPE_DOCTOR, $doctor->getUuid()); + + $orphan = new BankAccount($user, 'کارت بی‌محیط', '555000', '', ''); + $this->em->persist($orphan); + $this->em->flush(); + + $listed = $this->listBankAccounts($user); + self::assertSame(['کارت بی‌محیط'], array_column($listed, 'bank_name')); + self::assertNull($listed[0]['entity_type'], 'باید با نشانهٔ «محیط تعیین‌نشده» بیاید'); + + $this->authJson('PATCH', '/api/v1/my/payment-methods/bank-accounts/' . $orphan->getUuid() . '/status', $user); + self::assertSame(404, $this->responseCode(), 'تا تعیین محیط، ویرایش‌پذیر نیست'); + } + + /** ✅ انتساب، کارتِ بی‌محیط را به محیط فعال می‌چسباند و ویرایش‌پذیرش می‌کند. */ + public function testAssigningAnEnvironmentMakesTheCardUsable(): void + { + [$user, $doctor] = $this->multiEnvironmentUser(); + $this->switchTo($user, EntityContext::TYPE_DOCTOR, $doctor->getUuid()); + + $orphan = new BankAccount($user, 'کارت بی‌محیط', '555000', '', ''); + $this->em->persist($orphan); + $this->em->flush(); + $uuid = $orphan->getUuid(); + + $res = $this->authJson('PATCH', "/api/v1/my/payment-methods/bank-accounts/$uuid/environment", $user); + self::assertSame(200, $this->responseCode()); + self::assertSame('doctor', $res['data']['entity_type']); + + $this->authJson('PATCH', "/api/v1/my/payment-methods/bank-accounts/$uuid/status", $user); + self::assertSame(200, $this->responseCode(), 'بعد از انتساب باید ویرایش‌پذیر باشد'); + } + + /** ❌ انتساب دوباره روی کارتی که محیط دارد، بی‌اثر است — نه ربودن کارت محیط دیگر. */ + public function testAssigningAnAlreadyAssignedCardIsRejected(): void + { + [$user, $doctor, $clinic] = $this->multiEnvironmentUser(); + + $this->switchTo($user, EntityContext::TYPE_DOCTOR, $doctor->getUuid()); + $personal = $this->createBankAccount($user, 'کارت مطب'); + + $this->switchTo($user, EntityContext::TYPE_CLINIC, $clinic->getUuid()); + $this->authJson( + 'PATCH', + '/api/v1/my/payment-methods/bank-accounts/' . $personal['uuid'] . '/environment', + $user, + ); + + self::assertSame(404, $this->responseCode()); + } + + /** ❌ کارتِ بی‌محیطِ شخص دیگر با انتساب هم به دست نمی‌آید. */ + public function testAnotherPersonsUnassignedCardCannotBeClaimed(): void + { + [$user, $doctor] = $this->multiEnvironmentUser(); + $this->switchTo($user, EntityContext::TYPE_DOCTOR, $doctor->getUuid()); + + [$stranger] = $this->multiEnvironmentUser(); + $orphan = new Pos($stranger, 'کارتخوان بیگانه', '900900'); + $this->em->persist($orphan); + $this->em->flush(); + + $this->authJson('PATCH', '/api/v1/my/payment-methods/pos/' . $orphan->getUuid() . '/environment', $user); + + self::assertSame(404, $this->responseCode()); + } + + /** شکل ردیفِ بی‌محیط باید دقیقاً همان شکل ردیفِ محیط‌دار باشد، وگرنه پنل می‌شکند. */ + public function testUnassignedRowsHaveTheSameShapeAsAssignedOnes(): void + { + [$user, $doctor] = $this->multiEnvironmentUser(); + $this->switchTo($user, EntityContext::TYPE_DOCTOR, $doctor->getUuid()); + + $assigned = $this->createBankAccount($user, 'کارت محیط‌دار'); + + $orphan = new BankAccount($user, 'کارت بی‌محیط', '555000', '', ''); + $this->em->persist($orphan); + $this->em->flush(); + + $rows = $this->listBankAccounts($user); + self::assertCount(2, $rows); + + $keys = array_map(static fn (array $row) => array_keys($row), $rows); + self::assertSame($keys[0], $keys[1], 'کلیدهای ردیف بی‌محیط با ردیف محیط‌دار یکی نیست'); + self::assertSame(array_keys($assigned), $keys[0]); + } +} diff --git a/tests/PaymentMethod/PaymentMethodTest.php b/tests/PaymentMethod/PaymentMethodTest.php index 08f56cac..44c17f8a 100644 --- a/tests/PaymentMethod/PaymentMethodTest.php +++ b/tests/PaymentMethod/PaymentMethodTest.php @@ -2,21 +2,49 @@ namespace App\Tests\PaymentMethod; +use App\Auth\Entity\User; +use App\Auth\Entity\UserActiveContext; +use App\Clinic\Entity\Clinic; +use App\Doctor\Entity\Doctor; +use App\Secretary\Entity\DoctorSecretary; +use App\Shared\Context\EntityContext; use App\Tests\ApiTestCase; /** - * Functional coverage for the per-clinic payment methods API + * Functional coverage for the per-environment payment methods API * (bank accounts + POS devices). Success, error and boundary cases. + * + * اسکوپ از فاز ۶ به بعد **محیط** است نه کاربر، پس هر تست به یک محیط واقعی + * (پزشک یا کلینیک) نیاز دارد؛ کاربری با نقش کلینیک ولی بدون کلینیک، محیطی ندارد. */ class PaymentMethodTest extends ApiTestCase { + /** کاربری با نقش کلینیک و یک کلینیک واقعی — یعنی محیط دارد. */ + private function clinicOwner(): User + { + $owner = $this->createUser(['ROLE_CLINIC']); + $clinic = new Clinic($owner); + $clinic->setName('کلینیک روش پرداخت'); + $this->em->persist($clinic); + $this->em->flush(); + + return $owner; + } + + private function doctorOwner(): User + { + $user = $this->createUser(['ROLE_DOCTOR']); + $this->em->persist(new Doctor($user, 'دکتر روش پرداخت')); + $this->em->flush(); + + return $user; + } + // ---- Bank accounts ----------------------------------------------------- public function testEmptyBankAccountListForNewClinic(): void { - $user = $this->createUser(['ROLE_CLINIC']); - - $res = $this->authJson('GET', '/api/v1/my/payment-methods/bank-accounts', $user); + $res = $this->authJson('GET', '/api/v1/my/payment-methods/bank-accounts', $this->clinicOwner()); $this->assertSame(200, $this->responseCode()); $this->assertTrue($res['success']); @@ -25,7 +53,7 @@ class PaymentMethodTest extends ApiTestCase public function testCreateAndListBankAccount(): void { - $user = $this->createUser(['ROLE_CLINIC']); + $user = $this->clinicOwner(); $created = $this->authJson('POST', '/api/v1/my/payment-methods/bank-accounts', $user, [ 'bank_name' => 'ملی', @@ -38,6 +66,7 @@ class PaymentMethodTest extends ApiTestCase $this->assertSame('ملی', $created['data']['bank_name']); $this->assertTrue($created['data']['is_active']); $this->assertNotEmpty($created['data']['uuid']); + $this->assertSame('clinic', $created['data']['entity_type'], 'حساب باید به محیط فعال بچسبد'); $list = $this->authJson('GET', '/api/v1/my/payment-methods/bank-accounts', $user); $this->assertCount(1, $list['data']); @@ -46,9 +75,7 @@ class PaymentMethodTest extends ApiTestCase public function testCreateBankAccountValidationErrorWhenBankNameMissing(): void { - $user = $this->createUser(['ROLE_CLINIC']); - - $res = $this->authJson('POST', '/api/v1/my/payment-methods/bank-accounts', $user, [ + $res = $this->authJson('POST', '/api/v1/my/payment-methods/bank-accounts', $this->clinicOwner(), [ 'account_number' => '0101234567890', ]); @@ -59,7 +86,7 @@ class PaymentMethodTest extends ApiTestCase public function testUpdateBankAccount(): void { - $user = $this->createUser(['ROLE_CLINIC']); + $user = $this->clinicOwner(); $created = $this->authJson('POST', '/api/v1/my/payment-methods/bank-accounts', $user, [ 'bank_name' => 'ملی', 'account_number' => '0101234567890', @@ -78,7 +105,7 @@ class PaymentMethodTest extends ApiTestCase public function testToggleBankAccountStatus(): void { - $user = $this->createUser(['ROLE_CLINIC']); + $user = $this->clinicOwner(); $created = $this->authJson('POST', '/api/v1/my/payment-methods/bank-accounts', $user, [ 'bank_name' => 'ملی', 'account_number' => '0101234567890', @@ -94,9 +121,11 @@ class PaymentMethodTest extends ApiTestCase 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); + $res = $this->authJson( + 'PATCH', + '/api/v1/my/payment-methods/bank-accounts/does-not-exist/status', + $this->clinicOwner(), + ); $this->assertSame(404, $this->responseCode()); $this->assertFalse($res['success']); @@ -104,8 +133,8 @@ class PaymentMethodTest extends ApiTestCase public function testCannotTouchAnotherClinicsBankAccount(): void { - $owner = $this->createUser(['ROLE_CLINIC']); - $other = $this->createUser(['ROLE_CLINIC']); + $owner = $this->clinicOwner(); + $other = $this->clinicOwner(); $created = $this->authJson('POST', '/api/v1/my/payment-methods/bank-accounts', $owner, [ 'bank_name' => 'ملی', 'account_number' => '0101234567890', @@ -120,9 +149,15 @@ class PaymentMethodTest extends ApiTestCase public function testBankAccountForbiddenForPlainUser(): void { - $user = $this->createUser(['ROLE_USER']); + $this->authJson('GET', '/api/v1/my/payment-methods/bank-accounts', $this->createUser(['ROLE_USER'])); - $this->authJson('GET', '/api/v1/my/payment-methods/bank-accounts', $user); + $this->assertSame(403, $this->responseCode()); + } + + /** ❌ نقشِ کلینیک بدون کلینیکِ واقعی محیطی ندارد، پس کارتی هم ندارد. */ + public function testRoleWithoutAnActualEnvironmentIsRejected(): void + { + $this->authJson('GET', '/api/v1/my/payment-methods/bank-accounts', $this->createUser(['ROLE_CLINIC'])); $this->assertSame(403, $this->responseCode()); } @@ -131,7 +166,7 @@ class PaymentMethodTest extends ApiTestCase public function testCreateAndListPos(): void { - $user = $this->createUser(['ROLE_DOCTOR']); + $user = $this->doctorOwner(); $created = $this->authJson('POST', '/api/v1/my/payment-methods/pos', $user, [ 'bank_name' => 'ملت', @@ -143,6 +178,7 @@ class PaymentMethodTest extends ApiTestCase $this->assertSame('ملت', $created['data']['bank_name']); $this->assertSame('123456', $created['data']['terminal_number']); $this->assertTrue($created['data']['is_active']); + $this->assertSame('doctor', $created['data']['entity_type']); $list = $this->authJson('GET', '/api/v1/my/payment-methods/pos', $user); $this->assertCount(1, $list['data']); @@ -150,9 +186,7 @@ class PaymentMethodTest extends ApiTestCase public function testCreatePosValidationErrorWhenTerminalMissing(): void { - $user = $this->createUser(['ROLE_DOCTOR']); - - $res = $this->authJson('POST', '/api/v1/my/payment-methods/pos', $user, [ + $res = $this->authJson('POST', '/api/v1/my/payment-methods/pos', $this->doctorOwner(), [ 'bank_name' => 'ملت', ]); @@ -163,7 +197,7 @@ class PaymentMethodTest extends ApiTestCase public function testTogglePosStatus(): void { // منشی به روش‌های پرداخت فقط با مجوز payments از طریق رابطهٔ فعال دسترسی دارد. - $user = $this->createSecretaryWithPayments(['view' => true, 'create' => true, 'update' => true]); + $user = $this->createSecretaryWithPayments(['view' => true, 'create' => true, 'update' => true]); $created = $this->authJson('POST', '/api/v1/my/payment-methods/pos', $user, [ 'bank_name' => 'تجارت', 'terminal_number' => '345678', @@ -189,34 +223,30 @@ class PaymentMethodTest extends ApiTestCase } /** منشی با رابطهٔ فعالِ کلینیک + context + مجوز payments مشخص. */ - private function createSecretaryWithPayments(array $payments): \App\Auth\Entity\User + private function createSecretaryWithPayments(array $payments): User { $owner = $this->createUser(['ROLE_CLINIC']); - $clinic = new \App\Clinic\Entity\Clinic($owner); + $clinic = new Clinic($owner); $this->em->persist($clinic); - $doctor = new \App\Doctor\Entity\Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر تست'); + $doctor = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر تست'); $this->em->persist($doctor); $clinic->getDoctors()->add($doctor); $secretary = $this->createUser(['ROLE_SECRETARY']); - $rel = new \App\Secretary\Entity\DoctorSecretary($doctor, $secretary, $clinic); + $rel = new DoctorSecretary($doctor, $secretary, $clinic); $rel->mergePermissions(['resources' => ['payments' => $payments]]); $this->em->persist($rel); - $this->em->persist(new \App\Auth\Entity\UserActiveContext( - $secretary, - $clinic->getUuid(), - \App\Shared\Context\EntityContext::TYPE_CLINIC, - )); + $this->em->persist(new UserActiveContext($secretary, $clinic->getUuid(), EntityContext::TYPE_CLINIC)); $this->em->flush(); return $secretary; } - public function testPosListIsolatedPerUser(): void + public function testPosListIsolatedPerEnvironment(): void { - $a = $this->createUser(['ROLE_CLINIC']); - $b = $this->createUser(['ROLE_CLINIC']); + $a = $this->clinicOwner(); + $b = $this->clinicOwner(); $this->authJson('POST', '/api/v1/my/payment-methods/pos', $a, [ 'bank_name' => 'صادرات', 'terminal_number' => '901234', diff --git a/tests/Representation/DomainCommissionTest.php b/tests/Representation/DomainCommissionTest.php index 08d18678..b50a6c22 100644 --- a/tests/Representation/DomainCommissionTest.php +++ b/tests/Representation/DomainCommissionTest.php @@ -33,7 +33,7 @@ class DomainCommissionTest extends ApiTestCase private function makePayment(string $frontendAddress): Payment { - $payment = new Payment($this->createUser(), 2_000_000, 'mock', Payment::TYPE_APPOINTMENT, $frontendAddress); + $payment = $this->stampTenant(new Payment($this->createUser(), 2_000_000, 'mock', Payment::TYPE_APPOINTMENT, $frontendAddress)); $this->em->persist($payment); $this->em->flush(); diff --git a/tests/Secretary/SecretaryOnlineShareTest.php b/tests/Secretary/SecretaryOnlineShareTest.php index 5086ac62..4e2aead2 100644 --- a/tests/Secretary/SecretaryOnlineShareTest.php +++ b/tests/Secretary/SecretaryOnlineShareTest.php @@ -62,6 +62,7 @@ class SecretaryOnlineShareTest extends ApiTestCase $payment = new Payment($this->createUser(), self::GROSS, 'mock', Payment::TYPE_APPOINTMENT, ''); $payment->setAppointment($appointment); + $this->stampTenant($payment); $this->em->persist($payment); $this->em->flush(); diff --git a/tests/Settlement/FinancialBreakdownIntegrityTest.php b/tests/Settlement/FinancialBreakdownIntegrityTest.php index aecbaa38..aa02fa5a 100644 --- a/tests/Settlement/FinancialBreakdownIntegrityTest.php +++ b/tests/Settlement/FinancialBreakdownIntegrityTest.php @@ -17,7 +17,7 @@ class FinancialBreakdownIntegrityTest extends ApiTestCase public function testDeletingPaymentWithBreakdownIsRestricted(): void { $user = $this->createUser(); - $payment = new Payment($user, 100_000, 'mellat', Payment::TYPE_APPOINTMENT); + $payment = $this->stampTenant(new Payment($user, 100_000, 'mellat', Payment::TYPE_APPOINTMENT)); $this->em->persist($payment); $breakdown = new FinancialBreakdown( diff --git a/tests/Settlement/FinancialChainTenantTest.php b/tests/Settlement/FinancialChainTenantTest.php new file mode 100644 index 00000000..f3d7e24b --- /dev/null +++ b/tests/Settlement/FinancialChainTenantTest.php @@ -0,0 +1,143 @@ +get(EntityManagerInterface::class); + } + + protected function tearDown(): void + { + $filters = $this->em()->getFilters(); + if ($filters->isEnabled(TenantFilter::NAME)) { + $filters->disable(TenantFilter::NAME); + } + + parent::tearDown(); + } + + private function enableFilterFor(string $type, int $id): void + { + $this->em()->getFilters() + ->enable(TenantFilter::NAME) + ->setParameter(TenantFilter::PARAM_TYPE, $type, 'string') + ->setParameter(TenantFilter::PARAM_ID, $id, 'integer'); + } + + private function makeDoctor(): Doctor + { + $doctor = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر زنجیره'); + $this->em->persist($doctor); + $this->em->flush(); + + return $doctor; + } + + /** یک سهم منشی از پرداختِ محیطِ داده‌شده، با کل زنجیره‌اش. */ + private function earningFor(Doctor $doctor, User $secretary): SecretaryEarning + { + $payment = new Payment($this->createUser(), 1_000_000, 'mock', Payment::TYPE_APPOINTMENT); + $payment->assignTenantPair('doctor', $doctor->getId()); + $this->em->persist($payment); + + $breakdown = new FinancialBreakdown( + $payment, FinancialBreakdown::SOURCE_APPOINTMENT, $secretary, + 1_000_000, 0, '0.00', 0, 1_000_000, '10.00', 100_000, 900_000, null, null, null, + ); + $this->em->persist($breakdown); + + $earning = new SecretaryEarning($breakdown, $secretary, 'rel-' . bin2hex(random_bytes(4)), 5.0, 50_000); + $this->em->persist($earning); + $this->em->flush(); + + return $earning; + } + + /** @return string[] */ + private function reportedUuids(User $secretary): array + { + $repo = static::getContainer()->get(SecretaryEarningRepository::class); + $report = $repo->reportFor($secretary, 1, 50); + + return array_column($report['items'] ?? [], 'uuid'); + } + + /** ✅ سهمِ محیط خودی از راه لنگر به پرداخت دیده می‌شود. */ + public function testAnEarningIsVisibleInsideItsOwnEnvironment(): void + { + $doctor = $this->makeDoctor(); + $secretary = $this->createUser(['ROLE_SECRETARY']); + $earning = $this->earningFor($doctor, $secretary); + + $this->em->clear(); + $this->enableFilterFor('doctor', $doctor->getId()); + + self::assertContains($earning->getUuid(), $this->reportedUuids($secretary)); + } + + /** + * ❌ همان سهم در محیط دیگر ناپدید می‌شود — نه به‌خاطر شرط دستی، بلکه چون + * کوئری به `payments` لنگر زده و فیلتر روی همان join نشسته است. + */ + public function testTheSameEarningDisappearsInAnotherEnvironment(): void + { + $doctor = $this->makeDoctor(); + $secretary = $this->createUser(['ROLE_SECRETARY']); + $earning = $this->earningFor($doctor, $secretary); + + $this->em->clear(); + $this->enableFilterFor('doctor', $doctor->getId() + 1000); + + self::assertNotContains($earning->getUuid(), $this->reportedUuids($secretary)); + } + + /** + * ⚠️ مرزی: کیف پول شخص محیط ندارد و در هر محیطی دیده می‌شود. این نشتی نیست، + * تصمیم است — موجودی از مجموع credit−debitِ همان کاربر مشتق می‌شود و تفکیکش + * به محیط، خودِ موجودی را بی‌معنا می‌کند. + */ + public function testTheUserWalletStaysGlobalAcrossEnvironments(): void + { + $patient = $this->createUser(['ROLE_USER']); + $txn = new WalletTransaction($patient, 500_000, WalletTransaction::TYPE_CREDIT, 500_000); + $this->em->persist($txn); + $this->em->flush(); + $uuid = $txn->getUuid(); + + $this->em->clear(); + $this->enableFilterFor('clinic', 987_654); + + $repo = static::getContainer()->get(WalletTransactionRepository::class); + self::assertNotNull( + $repo->findOneBy(['uuid' => $uuid]), + 'کیف پول شخص نباید به محیط قفل شود', + ); + } +} diff --git a/tests/Shared/TenantSchemaCoverageTest.php b/tests/Shared/TenantSchemaCoverageTest.php index f1ef01eb..60b24ffc 100644 --- a/tests/Shared/TenantSchemaCoverageTest.php +++ b/tests/Shared/TenantSchemaCoverageTest.php @@ -126,15 +126,15 @@ class TenantSchemaCoverageTest extends ApiTestCase } /** - * بدهی باید کوچک شود نه بزرگ. اگر کلاسی به DEFERRED اضافه شد، این عدد هم باید - * عمداً بالا برود — یعنی تصمیم دیده می‌شود، نه اینکه بی‌صدا بگذرد. + * فاز ۶ بدهی را صفر کرد. از «رشد نکن» به «صفر بمان»: افزودن دوباره یعنی جدولی + * بیرون از هر تضمینی مانده، و باید تصمیم آگاهانه باشد نه یک ردشدنِ بی‌صدا. */ - public function testDeferredDebtDoesNotGrow(): void + public function testThereIsNoUnclassifiedDebtLeft(): void { - self::assertLessThanOrEqual( - 8, - count(GlobalTables::DEFERRED), - 'جدول‌های مالی طبقه‌بندی‌نشده بیشتر شدند؛ فهرست DEFERRED باید کوچک شود', + self::assertSame( + [], + GlobalTables::DEFERRED, + 'بدهی طبقه‌بندی باید صفر بماند؛ هر کلاس یا جفت محیط می‌گیرد یا با دلیل سراسری/فرزند aggregate می‌شود', ); } }