feat(tenant): mark the financial tables with their owning environment

Phase 6 of the tenant series. GlobalTables::DEFERRED is now empty and the
coverage test asserts it stays that way.

payments carries the (entity_type, entity_id) pair and belongs to the
receiving side, never the payer: an appointment payment takes the
appointment's environment, a subscription takes the environment its buyer
owns, and an SMS wallet top-up takes the wallet's. The patient never chose
an environment, so TenantFilter stays off for them and they still see their
own payment.

Three corrections to the analysis the phase was planned on, each backed by
the code or the data rather than the plan:

- A third payment type exists. Payment::TYPE_SMS_WALLET is created in
  SmsWalletController and already carries its environment in the metadata;
  without assigning it the write would fail at flush.
- clinic_subscriptions has no user_id, and its trial rows carry no payment,
  so it cannot drive the subscription backfill. The environment is derived
  the way handleSubscriptionActivation derives it — and that method now
  reads the pair off the payment instead of re-deriving it, so a payment and
  the subscription it buys can no longer land on different environments.
- WalletTransaction is not a child of Payment. payment_id is nullable and
  none of the four creation sites set it; the wallet is a person's, with a
  running balance per user. It and Settlement, which withdraws from that same
  wallet, are global with a recorded reason instead.

bank_accounts and pos_devices move from the registering user to the
environment. Their pair is deliberately nullable: nothing in the existing
data says which of a multi-environment owner's cards belongs where, and
guessing would point real money at the wrong account. Ambiguous rows stay
unassigned and the migration reports how many. The cost is that such a row
is invisible in every environment, so the owner reaches it through a
user-scoped lookup that runs outside the filter, and assigns it with
PATCH .../{uuid}/environment. The admin panel marks those rows and offers
the assignment.

Tests: 896 backend (+11), 570 frontend (+4). PHPStan unchanged at its 17
pre-existing errors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-28 15:06:28 +03:30
co-authored by Claude Opus 5
parent d2f4b5c428
commit c9d4348c46
40 changed files with 1582 additions and 163 deletions
@@ -2,6 +2,7 @@ import React from 'react';
import { PencilIcon } from '@heroicons/react/24/outline'; import { PencilIcon } from '@heroicons/react/24/outline';
import DataTable, { type Column } from '../ui/DataTable'; import DataTable, { type Column } from '../ui/DataTable';
import StatusToggle from './StatusToggle'; import StatusToggle from './StatusToggle';
import EnvironmentCell from './EnvironmentCell';
import type { BankAccount } from '../../hooks/usePaymentMethods'; import type { BankAccount } from '../../hooks/usePaymentMethods';
/** /**
@@ -13,21 +14,32 @@ export default function BankAccountTable({
data, data,
loading, loading,
togglingUuid, togglingUuid,
assigningUuid,
environmentName,
onToggle, onToggle,
onEdit, onEdit,
onAssign,
onAdd, onAdd,
}: { }: {
data: BankAccount[]; data: BankAccount[];
loading?: boolean; loading?: boolean;
togglingUuid: string | null; togglingUuid: string | null;
assigningUuid: string | null;
environmentName: string;
onToggle: (uuid: string) => void; onToggle: (uuid: string) => void;
onEdit: (account: BankAccount) => void; onEdit: (account: BankAccount) => void;
onAssign: (uuid: string) => void;
onAdd: () => void; onAdd: () => void;
}) { }) {
const columns: Column<BankAccount>[] = [ const columns: Column<BankAccount>[] = [
{ key: 'bank_name', header: 'نام بانک' }, { key: 'bank_name', header: 'نام بانک' },
{ key: 'card_number', header: 'شماره کارت', render: (r) => r.card_number || '—' }, { key: 'card_number', header: 'شماره کارت', render: (r) => r.card_number || '—' },
{ key: 'account_number', header: 'شماره حساب', render: (r) => r.account_number || '—' }, { key: 'account_number', header: 'شماره حساب', render: (r) => r.account_number || '—' },
{
key: 'entity_type',
header: 'محیط',
render: (r) => <EnvironmentCell entityType={r.entity_type} environmentName={environmentName} />,
},
{ {
key: 'is_active', key: 'is_active',
header: 'وضعیت', header: 'وضعیت',
@@ -53,14 +65,24 @@ export default function BankAccountTable({
emptyMessage="هنوز حساب بانکی ثبت نشده است" emptyMessage="هنوز حساب بانکی ثبت نشده است"
emptyAction={<button className="btn primary sm" onClick={onAdd}>افزودن حساب بانکی</button>} emptyAction={<button className="btn primary sm" onClick={onAdd}>افزودن حساب بانکی</button>}
actions={(r) => ( actions={(r) => (
<button r.entity_type === null ? (
className="btn sm ghost" <button
style={{ color: 'var(--accent)' }} className="btn sm primary"
onClick={() => onEdit(r)} disabled={assigningUuid === r.uuid}
> onClick={() => onAssign(r.uuid)}
<PencilIcon style={{ width: 14 }} /> >
ویرایش انتساب به {environmentName}
</button> </button>
) : (
<button
className="btn sm ghost"
style={{ color: 'var(--accent)' }}
onClick={() => onEdit(r)}
>
<PencilIcon style={{ width: 14 }} />
ویرایش
</button>
)
)} )}
/> />
</> </>
@@ -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 <span className="badge amber">محیط تعییننشده</span>;
}
return <span className="badge green">{environmentName}</span>;
}
@@ -2,6 +2,7 @@ import React from 'react';
import { PencilIcon } from '@heroicons/react/24/outline'; import { PencilIcon } from '@heroicons/react/24/outline';
import DataTable, { type Column } from '../ui/DataTable'; import DataTable, { type Column } from '../ui/DataTable';
import StatusToggle from './StatusToggle'; import StatusToggle from './StatusToggle';
import EnvironmentCell from './EnvironmentCell';
import type { Pos } from '../../hooks/usePaymentMethods'; import type { Pos } from '../../hooks/usePaymentMethods';
/** /**
@@ -12,21 +13,32 @@ export default function PosTable({
data, data,
loading, loading,
togglingUuid, togglingUuid,
assigningUuid,
environmentName,
onToggle, onToggle,
onEdit, onEdit,
onAssign,
onAdd, onAdd,
}: { }: {
data: Pos[]; data: Pos[];
loading?: boolean; loading?: boolean;
togglingUuid: string | null; togglingUuid: string | null;
assigningUuid: string | null;
environmentName: string;
onToggle: (uuid: string) => void; onToggle: (uuid: string) => void;
onEdit: (pos: Pos) => void; onEdit: (pos: Pos) => void;
onAssign: (uuid: string) => void;
onAdd: () => void; onAdd: () => void;
}) { }) {
const columns: Column<Pos>[] = [ const columns: Column<Pos>[] = [
{ key: 'bank_name', header: 'نام بانک' }, { key: 'bank_name', header: 'نام بانک' },
{ key: 'serial_number', header: 'شماره سریال', render: (r) => r.serial_number || '—' }, { key: 'serial_number', header: 'شماره سریال', render: (r) => r.serial_number || '—' },
{ key: 'terminal_number', header: 'شماره ترمینال', render: (r) => r.terminal_number || '—' }, { key: 'terminal_number', header: 'شماره ترمینال', render: (r) => r.terminal_number || '—' },
{
key: 'entity_type',
header: 'محیط',
render: (r) => <EnvironmentCell entityType={r.entity_type} environmentName={environmentName} />,
},
{ {
key: 'is_active', key: 'is_active',
header: 'وضعیت', header: 'وضعیت',
@@ -52,14 +64,24 @@ export default function PosTable({
emptyMessage="هنوز کارت خوانی ثبت نشده است" emptyMessage="هنوز کارت خوانی ثبت نشده است"
emptyAction={<button className="btn primary sm" onClick={onAdd}>افزودن کارت خوان</button>} emptyAction={<button className="btn primary sm" onClick={onAdd}>افزودن کارت خوان</button>}
actions={(r) => ( actions={(r) => (
<button r.entity_type === null ? (
className="btn sm ghost" <button
style={{ color: 'var(--accent)' }} className="btn sm primary"
onClick={() => onEdit(r)} disabled={assigningUuid === r.uuid}
> onClick={() => onAssign(r.uuid)}
<PencilIcon style={{ width: 14 }} /> >
ویرایش انتساب به {environmentName}
</button> </button>
) : (
<button
className="btn sm ghost"
style={{ color: 'var(--accent)' }}
onClick={() => onEdit(r)}
>
<PencilIcon style={{ width: 14 }} />
ویرایش
</button>
)
)} )}
/> />
</> </>
+28 -1
View File
@@ -3,11 +3,18 @@ import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api'; import type { ApiResponse } from '../lib/api';
/** /**
* روش‌های پرداختِ کلینیک (حساب بانکی + کارت‌خوان) برای صفحهٔ «مدیریت پرداخت». * روش‌های پرداختِ **محیط فعال** (حساب بانکی + کارت‌خوان) برای صفحهٔ «مدیریت پرداخت».
* منبع: /api/v1/my/payment-methods/... — این رکوردها بعداً از فاکتور مراجعه‌کننده * منبع: /api/v1/my/payment-methods/... — این رکوردها بعداً از فاکتور مراجعه‌کننده
* برای ثبت روش پرداختِ یک سرویس ارجاع داده می‌شوند. * برای ثبت روش پرداختِ یک سرویس ارجاع داده می‌شوند.
*
* از فاز ۶، کارت‌ها به محیط تعلق دارند نه به کاربر: با تعویض محیط، فهرست عوض
* می‌شود. کارت‌های بازمانده‌ای که هنوز محیطی ندارند با entity_type = null در همین
* فهرست می‌آیند و باید پیش از استفاده به محیطی منتسب شوند.
*/ */
/** null یعنی «محیطش هنوز تعیین نشده». */
export type PaymentMethodEnvironment = 'doctor' | 'clinic' | null;
export interface BankAccount { export interface BankAccount {
uuid: string; uuid: string;
bank_name: string; bank_name: string;
@@ -16,6 +23,7 @@ export interface BankAccount {
shaba_number: string | null; shaba_number: string | null;
is_active: boolean; is_active: boolean;
created_at: number; created_at: number;
entity_type: PaymentMethodEnvironment;
} }
export interface Pos { export interface Pos {
@@ -26,6 +34,7 @@ export interface Pos {
account_number: string | null; account_number: string | null;
is_active: boolean; is_active: boolean;
created_at: number; created_at: number;
entity_type: PaymentMethodEnvironment;
} }
export interface BankAccountInput { export interface BankAccountInput {
@@ -78,6 +87,15 @@ export function useToggleBankAccountStatus() {
}); });
} }
/** کارتِ بی‌محیط را به محیط فعال می‌چسباند؛ پیش از آن قابل ویرایش نیست. */
export function useAssignBankAccountEnvironment() {
const qc = useQueryClient();
return useMutation<ApiResponse<BankAccount>, unknown, string>({
mutationFn: (uuid) => api.patch(`/api/v1/my/payment-methods/bank-accounts/${uuid}/environment`, {}),
onSuccess: () => qc.invalidateQueries({ queryKey: BANK_KEY }),
});
}
// ── POS devices ────────────────────────────────────────────────────────────── // ── POS devices ──────────────────────────────────────────────────────────────
export function usePosDevices() { export function usePosDevices() {
@@ -110,3 +128,12 @@ export function useTogglePosStatus() {
onSuccess: () => qc.invalidateQueries({ queryKey: POS_KEY }), onSuccess: () => qc.invalidateQueries({ queryKey: POS_KEY }),
}); });
} }
/** قرینهٔ useAssignBankAccountEnvironment برای کارت‌خوان. */
export function useAssignPosEnvironment() {
const qc = useQueryClient();
return useMutation<ApiResponse<Pos>, unknown, string>({
mutationFn: (uuid) => api.patch(`/api/v1/my/payment-methods/pos/${uuid}/environment`, {}),
onSuccess: () => qc.invalidateQueries({ queryKey: POS_KEY }),
});
}
@@ -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<typeof vi.fn>;
const patch = api.patch as ReturnType<typeof vi.fn>;
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(<MyFinancialPage />);
expect(await screen.findByText(/روش‌های پرداخت «کلینیک مرکزی»/)).toBeInTheDocument();
});
it('کارت محیط‌دار را با نام محیط و کارت بی‌محیط را با نشانهٔ «تعیین‌نشده» نشان می‌دهد', async () => {
renderWithProviders(<MyFinancialPage />);
expect(await screen.findByText('محیط تعیین‌نشده')).toBeInTheDocument();
// نام محیط هم در سرتیتر می‌آید هم در ستون محیطِ کارتِ محیط‌دار
expect(screen.getAllByText(/کلینیک مرکزی/).length).toBeGreaterThan(1);
});
it('کارت بی‌محیط به‌جای «ویرایش» دکمهٔ انتساب می‌گیرد و آن را به محیط فعال می‌چسباند', async () => {
renderWithProviders(<MyFinancialPage />);
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(<MyFinancialPage />);
expect(await screen.findAllByRole('button', { name: /ویرایش/ })).toHaveLength(1);
});
});
+24 -3
View File
@@ -8,6 +8,8 @@ import PosTable from '../components/paymentMethods/PosTable';
import BankAccountFormModal from '../components/paymentMethods/BankAccountFormModal'; import BankAccountFormModal from '../components/paymentMethods/BankAccountFormModal';
import PosFormModal from '../components/paymentMethods/PosFormModal'; import PosFormModal from '../components/paymentMethods/PosFormModal';
import { import {
useAssignBankAccountEnvironment,
useAssignPosEnvironment,
useBankAccounts, useBankAccounts,
usePosDevices, usePosDevices,
useToggleBankAccountStatus, useToggleBankAccountStatus,
@@ -15,11 +17,15 @@ import {
type BankAccount, type BankAccount,
type Pos, type Pos,
} from '../hooks/usePaymentMethods'; } from '../hooks/usePaymentMethods';
import { useAuthStore } from '../stores/authStore';
/** /**
* صفحهٔ «مدیریت پرداخت» (/admin/my-financial) — پورت تب PaymentManagement از * صفحهٔ «مدیریت پرداخت» (/admin/my-financial) — پورت تب PaymentManagement از
* clinic-pro-tauri. روش‌های پرداختِ کلینیک (حساب بانکی + کارت‌خوان) که بعداً از * clinic-pro-tauri. روش‌های پرداختِ **محیط فعال** (حساب بانکی + کارت‌خوان) که بعداً
* فاکتور مراجعه‌کننده برای ثبت روش پرداخت یک سرویس ارجاع می‌شوند. * از فاکتور مراجعه‌کننده برای ثبت روش پرداخت یک سرویس ارجاع می‌شوند.
*
* نام محیط در سرتیتر می‌آید چون از فاز ۶ کارت‌ها به محیط تعلق دارند نه به کاربر؛
* بدون آن، کاربرِ چندمحیطی با تعویض محیط فکر می‌کند کارت‌هایش گم شده‌اند.
*/ */
function MyFinancialPageContent() { function MyFinancialPageContent() {
const [activeTab, setActiveTab] = useState<PaymentTab>('bank'); const [activeTab, setActiveTab] = useState<PaymentTab>('bank');
@@ -32,6 +38,11 @@ function MyFinancialPageContent() {
const posQuery = usePosDevices(); const posQuery = usePosDevices();
const toggleBank = useToggleBankAccountStatus(); const toggleBank = useToggleBankAccountStatus();
const togglePos = useTogglePosStatus(); 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 banks = bankQuery.data?.data ?? [];
const posDevices = posQuery.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 onError = (e: unknown) => toast.error(e instanceof Error ? e.message : 'خطا در تغییر وضعیت');
const onAssignError = (e: unknown) => toast.error(e instanceof Error ? e.message : 'خطا در تعیین محیط');
return ( return (
<div className="page"> <div className="page">
<PageHeader title="مدیریت پرداخت" description="روش‌های پرداخت کلینیک (حساب بانکی و کارت‌خوان)" /> <PageHeader
title="مدیریت پرداخت"
description={`روش‌های پرداخت «${environmentName}» (حساب بانکی و کارت‌خوان)`}
/>
<div className="card card-pad"> <div className="card card-pad">
<PaymentMethodHead activeTab={activeTab} onTabChange={setActiveTab} onAdd={openAdd} /> <PaymentMethodHead activeTab={activeTab} onTabChange={setActiveTab} onAdd={openAdd} />
@@ -55,8 +70,11 @@ function MyFinancialPageContent() {
data={banks} data={banks}
loading={bankQuery.isLoading} loading={bankQuery.isLoading}
togglingUuid={toggleBank.isPending ? toggleBank.variables ?? null : null} togglingUuid={toggleBank.isPending ? toggleBank.variables ?? null : null}
assigningUuid={assignBank.isPending ? assignBank.variables ?? null : null}
environmentName={environmentName}
onToggle={(uuid) => toggleBank.mutate(uuid, { onError })} onToggle={(uuid) => toggleBank.mutate(uuid, { onError })}
onEdit={(account) => { setEditingBank(account); setBankModalOpen(true); }} onEdit={(account) => { setEditingBank(account); setBankModalOpen(true); }}
onAssign={(uuid) => assignBank.mutate(uuid, { onError: onAssignError })}
onAdd={openAdd} onAdd={openAdd}
/> />
) : ( ) : (
@@ -64,8 +82,11 @@ function MyFinancialPageContent() {
data={posDevices} data={posDevices}
loading={posQuery.isLoading} loading={posQuery.isLoading}
togglingUuid={togglePos.isPending ? togglePos.variables ?? null : null} togglingUuid={togglePos.isPending ? togglePos.variables ?? null : null}
assigningUuid={assignPos.isPending ? assignPos.variables ?? null : null}
environmentName={environmentName}
onToggle={(uuid) => togglePos.mutate(uuid, { onError })} onToggle={(uuid) => togglePos.mutate(uuid, { onError })}
onEdit={(pos) => { setEditingPos(pos); setPosModalOpen(true); }} onEdit={(pos) => { setEditingPos(pos); setPosModalOpen(true); }}
onAssign={(uuid) => assignPos.mutate(uuid, { onError: onAssignError })}
onAdd={openAdd} onAdd={openAdd}
/> />
)} )}
+95 -11
View File
@@ -2,14 +2,30 @@
> **Prefix:** `/api/v1/my/payment-methods` > **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**. 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). > **دسترسی منشی:** روش‌های پرداخت زیرمجموعهٔ منبع `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 Records are stored so a patient invoice can later reference which account/device a
service payment was made to. 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`, **Permission:** authenticated user with one of `ROLE_CLINIC`, `ROLE_DOCTOR`,
`ROLE_SECRETARY`, `ROLE_ADMIN` (otherwise `403 ERR_FORBIDDEN_001`). `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` ### 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` #### Response `200`
```json ```json
@@ -28,17 +45,33 @@ List the current clinic's bank accounts (newest first).
"success": true, "success": true,
"data": [ "data": [
{ {
"uuid": "b1e0...-...", "uuid": "b73d0c8e-3833-4314-83ba-937b6d4dbc60",
"bank_name": "ملی", "bank_name": "ملی",
"card_number": "6037991234567890", "card_number": "6037991234567890",
"account_number": "0101234567890", "account_number": "0101234567890",
"shaba_number": "IR820540102680020817909002", "shaba_number": "IR820540102680020817909002",
"is_active": true, "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": []`. Empty list returns `"data": []`.
--- ---
@@ -56,7 +89,7 @@ Create a bank account.
| `shaba_number` | string | ❌ | IBAN / SHABA | | `shaba_number` | string | ❌ | IBAN / SHABA |
#### Response `201` #### 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 #### Errors
- `422 ERR_VALIDATION_001``bank_name` or `account_number` missing (`field` set). - `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. Updated record.
#### Errors #### 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. - `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`. Record with flipped `is_active`.
#### Errors #### 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` ### 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` #### Response `200`
```json ```json
@@ -101,13 +171,14 @@ List the current clinic's card reader devices (newest first).
"success": true, "success": true,
"data": [ "data": [
{ {
"uuid": "c2f1...-...", "uuid": "382eb554-5931-4f07-b1cc-53ade2597438",
"bank_name": "ملت", "bank_name": "ملت",
"serial_number": "SN-98765", "serial_number": "SN-98765",
"terminal_number": "123456", "terminal_number": "123456",
"account_number": null, "account_number": null,
"is_active": true, "is_active": true,
"created_at": 1752566400 "created_at": 1785238315,
"entity_type": "clinic"
} }
] ]
} }
@@ -157,3 +228,16 @@ Record with flipped `is_active`.
#### Errors #### Errors
- `404 ERR_NOT_FOUND_001` — uuid unknown or not owned. - `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 ناشناس، مالِ کاربر دیگر، یا از قبل محیط دارد (پیام: «کارت خوانِ بدون محیط یافت نشد»).
+12
View File
@@ -5,6 +5,8 @@
> **دسترسی منشی:** `GET /api/v1/my/payments` برای `ROLE_SECRETARY` به مجوز `payments.view` نیاز دارد (`SecretaryAccessChecker`)؛ نبودِ مجوز → `403`. جزئیات: [secretary.md](secretary.md). > **دسترسی منشی:** `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 & مسئولیت‌ها) ## معماری (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_AUTH_001` | 401 | Missing token |
| `ERR_PAYMENT_002` | 422 | Invalid amount | | `ERR_PAYMENT_002` | 422 | Invalid amount |
| `ERR_PAYMENT_004` | 422 | خریدار صاحب هیچ محیطی نیست (نه پزشک، نه کلینیک) |
| `ERR_PAYMENT_001` | 503 | Gateway unavailable | | `ERR_PAYMENT_001` | 503 | Gateway unavailable |
```json
{
"success": false,
"errors": [{ "code": "ERR_PAYMENT_004", "message": "محیط این پرداخت مشخص نیست" }]
}
```
> اشتراک روی محیطی می‌نشیند که خریدار **صاحبش** است (اول مطب شخصی، بعد کلینیک) — نه روی محیط فعالش. همان جفت روی خودِ اشتراک هم ثبت می‌شود، پس پرداخت و اشتراک هرگز روی دو محیط متفاوت نمی‌افتند.
--- ---
## POST/GET `/api/v1/subscription-payment/callback/{gateway}` ## POST/GET `/api/v1/subscription-payment/callback/{gateway}`
+34 -3
View File
@@ -96,7 +96,7 @@ clinic_uuid صریحِ درخواست > UserActiveContext ذخیره‌شده
| جفت tenant دارد | فیلتر پوششش می‌دهد | `appointments`، `patient_records`، `service_sections` | | جفت tenant دارد | فیلتر پوششش می‌دهد | `appointments`، `patient_records`، `service_sections` |
| `ENTITIES` | عمداً سراسری | `cities`، `specialties`، `users`، `blogs` | | `ENTITIES` | عمداً سراسری | `cities`، `specialties`، `users`، `blogs` |
| `AGGREGATE_CHILDREN` | محیط را از ریشه به ارث می‌برد | `patient_notes``patient_records` | | `AGGREGATE_CHILDREN` | محیط را از ریشه به ارث می‌برد | `patient_notes``patient_records` |
| `DEFERRED` | بدهی ثبت‌شده، هنوز طبقه‌بندی نشده | جدول‌های مالی | | `DEFERRED` | بدهی ثبت‌شده، هنوز طبقه‌بندی نشده | **خالی** — فاز ۶ آخرین موردش را تعیین تکلیف کرد |
### ⚠️ فرزندان aggregate تور ایمنی ندارند ### ⚠️ فرزندان aggregate تور ایمنی ندارند
@@ -126,9 +126,32 @@ $this->tenantOwnership->allBelongTo($context, $entities); // یک بی
`TenantLookupInventoryTest` تعداد این جست‌وجوها را per-file نگه می‌دارد. افزودن یک `findByUuid` تازه روی موجودیت محیط‌دار تست را قرمز می‌کند تا کسی ثابت کند محیطش بررسی می‌شود و بعد عدد را به‌روز کند. `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 مسدود | | `Doctor/Command/Purge*Command` · `Shared/Command/SeedDemoDataCommand` | کنسول | dry-run پیش‌فرض، `--force` لازم، prod از سطح kernel مسدود |
| `Shared/Controller/HealthController` | سراسری | `SELECT 1` | | `Shared/Controller/HealthController` | سراسری | `SELECT 1` |
| `Shared/Logging/DbLogger` | سراسری | `app_log` در `GlobalTables` | | `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. `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` با ریشهٔ صریح ۳. اگر فرزند یک aggregate است → در `GlobalTables::AGGREGATE_CHILDREN` با ریشهٔ صریح
۴. تست را اجرا کن: `ddev exec php bin/phpunit tests/Shared/TenantSchemaCoverageTest.php` ۴. تست را اجرا کن: `ddev exec php bin/phpunit tests/Shared/TenantSchemaCoverageTest.php`
`NullableTenantOwnedTrait` برای entity **جدید** نیست. فقط برای جدولی است که از قبل وجود داشته و مالکِ بعضی ردیف‌هایش از داده قابل تشخیص نیست؛ entity جدید از روز اول محیط دارد، پس ستون تهی‌پذیر فقط تور ایمنی را سوراخ می‌کند.
`DEFERRED` هم راه فرار نیست: خالی است و باید خالی بماند.
ایندکس‌ها: `entity_type, entity_id` باید **ستون‌های اول** هر ایندکس ترکیبیِ لیست باشند، وگرنه MariaDB برای شرط فیلتر از آن استفاده نمی‌کند. ایندکس‌ها: `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/Shared/TenantLookupInventoryTest.php` | جست‌وجوی uuid تازه‌ای بدون بازبینی اضافه نشده |
| `tests/Appointment/ServiceModeSectionDurationTest.php` | سرویسِ محیط دیگر نه اسلات می‌دهد نه به نوبت می‌چسبد | | `tests/Appointment/ServiceModeSectionDurationTest.php` | سرویسِ محیط دیگر نه اسلات می‌دهد نه به نوبت می‌چسبد |
| `tests/Patient/SessionServiceTenantTest.php` | سرویس/پرسنلِ محیط دیگر نه قیمت می‌خورد نه ذخیره می‌شود | | `tests/Patient/SessionServiceTenantTest.php` | سرویس/پرسنلِ محیط دیگر نه قیمت می‌خورد نه ذخیره می‌شود |
| `tests/Payment/PaymentTenantTest.php` | پرداخت به محیط گیرنده می‌نشیند؛ بیمار پرداخت خودش را می‌بیند، محیط دیگر نمی‌بیند |
| `tests/Settlement/FinancialChainTenantTest.php` | زنجیرهٔ مالی از راه لنگر به `payments` جدا می‌شود؛ کیف پول عمداً سراسری می‌ماند |
| `tests/PaymentMethod/PaymentMethodTenantTest.php` | کارت‌ها per-محیط‌اند؛ ردیف بی‌محیط دیده می‌شود ولی تا انتساب قابل ویرایش نیست |
+103
View File
@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Phase 6 of the tenant-marking series: payments, the root of the financial
* chain. payment_logs, financial_breakdowns and secretary_earnings inherit the
* environment through their foreign keys and get no column of their own.
*
* The environment of a payment is the receiving side, never the payer:
*
* appointment → the environment of the appointment (marked in phase 2)
* subscription → the environment the buyer owns (doctor first, then clinic —
* the same order PaymentManager::handleSubscriptionActivation
* uses to create the subscription itself)
* sms_wallet → the environment already recorded in the payment metadata
*
* clinic_subscriptions cannot drive the subscription backfill: it links to a
* payment (payment_id) rather than to a user, and trial rows carry no payment at
* all, so a payment whose subscription was never activated has no row to join.
*
* Statements run through $this->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;
}
}
+90
View File
@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Phase 6 of the tenant-marking series: bank accounts and POS devices move from
* the user to the environment. A doctor who runs both a private practice and a
* clinic keeps separate card readers for each, so the owning environment — not
* the person who registered the device — is what a payment method belongs to.
*
* user_id stays: it still records who registered the device.
*
* Unlike every other tenant table these two columns stay NULLABLE on purpose.
* Nothing in the existing data says which of a multi-environment owner's cards
* belongs to which environment, and guessing would point real money at the wrong
* account. Those rows are left unassigned for the owner to resolve, and the
* migration reports how many there are so the number is visible in the deploy
* output rather than discovered later.
*
* Consequence, deliberate and documented in docs/architecture/tenancy.md: an
* unassigned row is invisible in every environment, because TenantFilter compares
* for equality and NULL equals nothing. The owner reaches it through the
* "unassigned" list, which is read outside the filter and scoped by user_id.
*/
final class Version20260728141500 extends AbstractMigration
{
private const TABLES = ['bank_accounts', 'pos_devices'];
public function getDescription(): string
{
return 'Move bank accounts and POS devices from their registering user to an environment';
}
public function up(Schema $schema): void
{
foreach (self::TABLES as $table) {
$this->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;
}
}
@@ -12,6 +12,8 @@ use App\Payment\Repository\PaymentRepository;
use App\Payment\Service\PaymentManager; use App\Payment\Service\PaymentManager;
use App\Secretary\Security\SecretaryAccessChecker; use App\Secretary\Security\SecretaryAccessChecker;
use App\Shared\Constant\ErrorCodes; use App\Shared\Constant\ErrorCodes;
use App\Shared\Context\EntityContext;
use App\Shared\Context\EntityContextResolver;
use App\Shared\Controller\BaseController; use App\Shared\Controller\BaseController;
use OpenApi\Attributes as OA; use OpenApi\Attributes as OA;
use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\JsonResponse;
@@ -38,6 +40,7 @@ class PaymentController extends BaseController
private readonly SiteConfigRepository $configRepo, private readonly SiteConfigRepository $configRepo,
private readonly SecretaryAccessChecker $secretaryAccess, private readonly SecretaryAccessChecker $secretaryAccess,
private readonly \App\Clinic\Security\ClinicDoctorAccessChecker $clinicDoctorAccess, private readonly \App\Clinic\Security\ClinicDoctorAccessChecker $clinicDoctorAccess,
private readonly EntityContextResolver $contextResolver,
private readonly string $appBaseUrl, private readonly string $appBaseUrl,
private readonly string $allowedFrontendHosts = '', private readonly string $allowedFrontendHosts = '',
) {} ) {}
@@ -118,6 +121,7 @@ class PaymentController extends BaseController
$feeRials = (int) $this->configRepo->get('appointment_fee_rials'); $feeRials = (int) $this->configRepo->get('appointment_fee_rials');
$payment = new Payment($user, $feeRials, $gatewayName, Payment::TYPE_APPOINTMENT, $frontendAddress); $payment = new Payment($user, $feeRials, $gatewayName, Payment::TYPE_APPOINTMENT, $frontendAddress);
$payment->setAppointment($appointment); $payment->setAppointment($appointment);
$payment->assignTenant(EntityContext::forBooking($appointment->getDoctor(), $appointment->getClinic()));
$this->paymentRepo->save($payment); $this->paymentRepo->save($payment);
// مرورگر به این endpoint بک‌اند می‌رود؛ آنجا صلاحیت نهایی + ارتباط با بانک // مرورگر به این endpoint بک‌اند می‌رود؛ آنجا صلاحیت نهایی + ارتباط با بانک
@@ -201,6 +205,7 @@ class PaymentController extends BaseController
$feeRials = (int) $this->configRepo->get('appointment_fee_rials'); $feeRials = (int) $this->configRepo->get('appointment_fee_rials');
$payment = new Payment($appointment->getUser(), $feeRials, $gatewayName, Payment::TYPE_APPOINTMENT, $return); $payment = new Payment($appointment->getUser(), $feeRials, $gatewayName, Payment::TYPE_APPOINTMENT, $return);
$payment->setAppointment($appointment); $payment->setAppointment($appointment);
$payment->assignTenant(EntityContext::forBooking($appointment->getDoctor(), $appointment->getClinic()));
$this->paymentRepo->save($payment); $this->paymentRepo->save($payment);
return $payment; return $payment;
} }
@@ -405,8 +410,16 @@ class PaymentController extends BaseController
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'درگاه پرداخت نامعتبر یا غیرفعال است', 422, 'gateway'); 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'] ?? ''); $periodUuid = trim($data['period_uuid'] ?? '');
$payment = new Payment($user, $amountRials, $gatewayName, Payment::TYPE_SUBSCRIPTION, $frontendAddress); $payment = new Payment($user, $amountRials, $gatewayName, Payment::TYPE_SUBSCRIPTION, $frontendAddress);
$payment->assignTenant($owner);
if ($periodUuid !== '') { if ($periodUuid !== '') {
$payment->setMetadata(['period_uuid' => $periodUuid]); $payment->setMetadata(['period_uuid' => $periodUuid]);
} }
+9
View File
@@ -4,16 +4,25 @@ namespace App\Payment\Entity;
use App\Appointment\Entity\Appointment; use App\Appointment\Entity\Appointment;
use App\Auth\Entity\User; use App\Auth\Entity\User;
use App\Shared\Tenant\TenantOwnedTrait;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use App\Payment\Repository\PaymentRepository; use App\Payment\Repository\PaymentRepository;
use Symfony\Component\Uid\Uuid; use Symfony\Component\Uid\Uuid;
/**
* پرداخت همیشه به محیطِ گیرنده تعلق دارد، نه به پرداخت‌کننده: نوبت → محیط همان
* نوبت، اشتراک و شارژ پیامک → محیطی که برایش خریداری شده. بیمار همچنان پرداخت
* خودش را می‌بیند چون TenantFilter برای کاربرِ بی‌محیط خاموش می‌ماند.
*/
#[ORM\Entity(repositoryClass: PaymentRepository::class)] #[ORM\Entity(repositoryClass: PaymentRepository::class)]
#[ORM\Table(name: 'payments')] #[ORM\Table(name: 'payments')]
#[ORM\Index(columns: ['order_id'], name: 'idx_payments_order')] #[ORM\Index(columns: ['order_id'], name: 'idx_payments_order')]
#[ORM\Index(columns: ['user_id'], name: 'idx_payments_user')] #[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 class Payment
{ {
use TenantOwnedTrait;
public const STATUS_PENDING = 'pending'; public const STATUS_PENDING = 'pending';
public const STATUS_SUCCESS = 'success'; public const STATUS_SUCCESS = 'success';
public const STATUS_FAILED = 'failed'; public const STATUS_FAILED = 'failed';
+16 -8
View File
@@ -14,6 +14,7 @@ use App\Payment\Repository\PaymentLogRepository;
use App\Payment\Repository\PaymentRepository; use App\Payment\Repository\PaymentRepository;
use App\Representation\Service\JalaliDateService; use App\Representation\Service\JalaliDateService;
use App\Settlement\Service\CommissionService; use App\Settlement\Service\CommissionService;
use App\Shared\Context\EntityContext;
use App\Sms\Entity\SmsLog; use App\Sms\Entity\SmsLog;
use App\Sms\Service\SmsService; use App\Sms\Service\SmsService;
use App\Sms\Service\SmsWalletService; use App\Sms\Service\SmsWalletService;
@@ -346,20 +347,27 @@ final class PaymentManager
return; return;
} }
$user = $payment->getUser(); // محیط از خودِ پرداخت خوانده می‌شود، نه دوباره از کاربر: اشتراک باید دقیقاً
$doctor = $this->doctorRepo->findByUser($user); // روی همان محیطی بنشیند که هنگام خرید پرداختش ثبت شد، حتی اگر کاربر بین
// خرید و بازگشت از درگاه محیط تازه‌ای پیدا کرده باشد.
$bookingRepId = $this->bookingRepresentationIdFor($payment); $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; return;
} }
$clinic = $this->clinicRepo->findByUser($user); $clinic = $this->clinicRepo->find($entityId);
if ($clinic !== null) { if ($clinic !== null) {
$this->subscriptionService->createFromPayment($payment, 'clinic', $clinic->getId(), $periodUuid); $this->subscriptionService->createFromPayment($payment, 'clinic', $entityId, $periodUuid);
$this->commissionService->processSubscription($payment, $clinic->getRepresentationId(), $bookingRepId, null, $clinic->getId()); $this->commissionService->processSubscription($payment, $clinic->getRepresentationId(), $bookingRepId, null, $entityId);
} }
} }
@@ -6,6 +6,8 @@ use App\Auth\Entity\User;
use App\PaymentMethod\Service\PaymentMethodService; use App\PaymentMethod\Service\PaymentMethodService;
use App\Secretary\Security\SecretaryAccessChecker; use App\Secretary\Security\SecretaryAccessChecker;
use App\Shared\Constant\ErrorCodes; use App\Shared\Constant\ErrorCodes;
use App\Shared\Context\EntityContext;
use App\Shared\Context\EntityContextResolver;
use App\Shared\Controller\BaseController; use App\Shared\Controller\BaseController;
use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
@@ -15,8 +17,12 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
use OpenApi\Attributes as OA; use OpenApi\Attributes as OA;
/** /**
* Per-clinic payment methods: bank accounts and POS (card reader) devices. * Per-environment payment methods: bank accounts and POS (card reader) devices.
* Scoped to the acting user; only clinic/doctor/secretary roles may manage them. *
* اسکوپ محیط فعال است، نه کاربر: پزشکی که هم مطب شخصی دارد و هم کلینیک، بسته به
* محیط فعالش کارت‌های متفاوتی می‌بیند. کارت‌های بازمانده‌ای که هنوز محیطی ندارند
* با `entity_type: null` در همان فهرست می‌آیند و با endpoint انتساب به محیط فعال
* چسبانده می‌شوند.
*/ */
#[OA\Tag(name: 'Payment Methods')] #[OA\Tag(name: 'Payment Methods')]
#[Route('/api/v1/my/payment-methods')] #[Route('/api/v1/my/payment-methods')]
@@ -29,14 +35,17 @@ class PaymentMethodController extends BaseController
private readonly PaymentMethodService $service, private readonly PaymentMethodService $service,
private readonly SecretaryAccessChecker $secretaryAccess, private readonly SecretaryAccessChecker $secretaryAccess,
private readonly \App\Clinic\Security\ClinicDoctorAccessChecker $clinicDoctorAccess, private readonly \App\Clinic\Security\ClinicDoctorAccessChecker $clinicDoctorAccess,
private readonly EntityContextResolver $contextResolver,
) {} ) {}
/** نقش مجاز + مجوز منشی روی منبع payments (روش‌های پرداخت زیرمجموعهٔ مالی است). */ /** نقش مجاز + مجوز منشی روی منبع payments (روش‌های پرداخت زیرمجموعهٔ مالی است). */
private function guard(User $user, string $action): void private function guard(User $user, string $action): EntityContext
{ {
$this->assertRole($user); $this->assertRole($user);
$this->secretaryAccess->denyUnlessGranted($user, 'payments', $action); $this->secretaryAccess->denyUnlessGranted($user, 'payments', $action);
$this->clinicDoctorAccess->denyUnlessGranted($user, 'payments', $action); $this->clinicDoctorAccess->denyUnlessGranted($user, 'payments', $action);
return $this->contextResolver->resolve($user);
} }
// ---- Bank accounts ----------------------------------------------------- // ---- Bank accounts -----------------------------------------------------
@@ -44,35 +53,43 @@ class PaymentMethodController extends BaseController
#[Route('/bank-accounts', methods: ['GET'])] #[Route('/bank-accounts', methods: ['GET'])]
public function listBankAccounts(#[CurrentUser] User $user): JsonResponse 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'])] #[Route('/bank-accounts', methods: ['POST'])]
public function createBankAccount(Request $request, #[CurrentUser] User $user): JsonResponse public function createBankAccount(Request $request, #[CurrentUser] User $user): JsonResponse
{ {
$this->guard($user, 'create'); $context = $this->guard($user, 'create');
$data = json_decode($request->getContent(), true) ?? []; $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'])] #[Route('/bank-accounts/{uuid}', methods: ['PUT'])]
public function updateBankAccount(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse public function updateBankAccount(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{ {
$this->guard($user, 'update'); $context = $this->guard($user, 'update');
$data = json_decode($request->getContent(), true) ?? []; $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'])] #[Route('/bank-accounts/{uuid}/status', methods: ['PATCH'])]
public function toggleBankAccountStatus(string $uuid, #[CurrentUser] User $user): JsonResponse 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 ------------------------------------------------------- // ---- POS devices -------------------------------------------------------
@@ -80,35 +97,43 @@ class PaymentMethodController extends BaseController
#[Route('/pos', methods: ['GET'])] #[Route('/pos', methods: ['GET'])]
public function listPos(#[CurrentUser] User $user): JsonResponse 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'])] #[Route('/pos', methods: ['POST'])]
public function createPos(Request $request, #[CurrentUser] User $user): JsonResponse public function createPos(Request $request, #[CurrentUser] User $user): JsonResponse
{ {
$this->guard($user, 'create'); $context = $this->guard($user, 'create');
$data = json_decode($request->getContent(), true) ?? []; $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'])] #[Route('/pos/{uuid}', methods: ['PUT'])]
public function updatePos(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse public function updatePos(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{ {
$this->guard($user, 'update'); $context = $this->guard($user, 'update');
$data = json_decode($request->getContent(), true) ?? []; $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'])] #[Route('/pos/{uuid}/status', methods: ['PATCH'])]
public function togglePosStatus(string $uuid, #[CurrentUser] User $user): JsonResponse 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 private function assertRole(User $user): void
+14 -3
View File
@@ -4,19 +4,28 @@ namespace App\PaymentMethod\Entity;
use App\Auth\Entity\User; use App\Auth\Entity\User;
use App\PaymentMethod\Repository\BankAccountRepository; use App\PaymentMethod\Repository\BankAccountRepository;
use App\Shared\Tenant\NullableTenantOwnedTrait;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid; use Symfony\Component\Uid\Uuid;
/** /**
* A clinic's bank account used as a payment method. Referenced from patient * A bank account used as a payment method. Referenced from patient invoices to
* invoices to record which account a service payment was made to. This entity * record which account a service payment was made to. This entity only stores
* only stores the account info; the payment linkage lives on the invoice side. * the account info; the payment linkage lives on the invoice side.
*
* حساب مالِ **محیط** است، نه کاربر: پزشکی که هم مطب شخصی دارد و هم کلینیک،
* حساب‌هایشان جداست. `user_id` می‌ماند تا بدانیم چه کسی ثبتش کرده، ولی اسکوپِ
* خواندن و ویرایش، محیط است. حساب‌هایی که از پیش از فاز ۶ مانده‌اند و مالکشان
* چند محیط دارد، محیطشان تهی است تا خودش تعیین کند.
*/ */
#[ORM\Entity(repositoryClass: BankAccountRepository::class)] #[ORM\Entity(repositoryClass: BankAccountRepository::class)]
#[ORM\Table(name: 'bank_accounts')] #[ORM\Table(name: 'bank_accounts')]
#[ORM\Index(columns: ['user_id'], name: 'idx_bank_accounts_user')] #[ORM\Index(columns: ['user_id'], name: 'idx_bank_accounts_user')]
#[ORM\Index(columns: ['entity_type', 'entity_id'], name: 'idx_bank_accounts_entity')]
class BankAccount class BankAccount
{ {
use NullableTenantOwnedTrait;
#[ORM\Id] #[ORM\Id]
#[ORM\GeneratedValue] #[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')] #[ORM\Column(type: 'integer')]
@@ -94,6 +103,8 @@ class BankAccount
'shaba_number' => $this->shabaNumber, 'shaba_number' => $this->shabaNumber,
'is_active' => $this->isActive, 'is_active' => $this->isActive,
'created_at' => $this->createdAt, 'created_at' => $this->createdAt,
// تهی یعنی «محیطش هنوز تعیین نشده»؛ پنل با همین نشانه‌گذاری‌اش می‌کند.
'entity_type' => $this->entityType,
]; ];
} }
} }
+11 -2
View File
@@ -4,18 +4,25 @@ namespace App\PaymentMethod\Entity;
use App\Auth\Entity\User; use App\Auth\Entity\User;
use App\PaymentMethod\Repository\PosRepository; use App\PaymentMethod\Repository\PosRepository;
use App\Shared\Tenant\NullableTenantOwnedTrait;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid; use Symfony\Component\Uid\Uuid;
/** /**
* A clinic's card reader (POS) device used as a payment method. Referenced from * A card reader (POS) device used as a payment method. Referenced from patient
* patient invoices to record which device a service payment was collected on. * invoices to record which device a service payment was collected on.
*
* قرینهٔ {@see BankAccount}: دستگاه مالِ محیط است نه کاربر، و ردیف‌های مبهمِ
* پیش از فاز ۶ محیط تهی دارند تا مالک خودش تعیین کند.
*/ */
#[ORM\Entity(repositoryClass: PosRepository::class)] #[ORM\Entity(repositoryClass: PosRepository::class)]
#[ORM\Table(name: 'pos_devices')] #[ORM\Table(name: 'pos_devices')]
#[ORM\Index(columns: ['user_id'], name: 'idx_pos_devices_user')] #[ORM\Index(columns: ['user_id'], name: 'idx_pos_devices_user')]
#[ORM\Index(columns: ['entity_type', 'entity_id'], name: 'idx_pos_devices_entity')]
class Pos class Pos
{ {
use NullableTenantOwnedTrait;
#[ORM\Id] #[ORM\Id]
#[ORM\GeneratedValue] #[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')] #[ORM\Column(type: 'integer')]
@@ -93,6 +100,8 @@ class Pos
'account_number' => $this->accountNumber, 'account_number' => $this->accountNumber,
'is_active' => $this->isActive, 'is_active' => $this->isActive,
'created_at' => $this->createdAt, 'created_at' => $this->createdAt,
// تهی یعنی «محیطش هنوز تعیین نشده»؛ پنل با همین نشانه‌گذاری‌اش می‌کند.
'entity_type' => $this->entityType,
]; ];
} }
} }
@@ -20,15 +20,80 @@ class BankAccountRepository extends ServiceEntityRepository
} }
/** @return BankAccount[] */ /** @return BankAccount[] */
public function findByUser(User $user): array public function findByEntity(string $entityType, int $entityId): array
{ {
return $this->createQueryBuilder('b') 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') ->orderBy('b.createdAt', 'DESC')
->getQuery() ->getQuery()
->getResult(); ->getResult();
} }
/**
* حساب‌های بی‌محیطِ همین کاربر — بازمانده‌های پیش از فاز ۶ که مالکشان چند محیط
* داشت و انتسابشان حدس می‌خواست.
*
* عمداً DBAL است نه DQL: TenantFilter شرط تساوی می‌گذارد و NULL با هیچ مقداری
* برابر نیست، پس این ردیف‌ها از DQL هرگز برنمی‌گردند. به‌جای فیلتر، محدودیت
* مالکیت همین‌جا با user_id گذاشته شده.
*
* شکل خروجی باید با {@see BankAccount::toArray()} یکی بماند؛
* PaymentMethodTenantTest همین را می‌سنجد.
*
* @return array<int, array<string, mixed>>
*/
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 public function save(BankAccount $entity, bool $flush = true): void
{ {
$this->getEntityManager()->persist($entity); $this->getEntityManager()->persist($entity);
+54 -2
View File
@@ -20,15 +20,67 @@ class PosRepository extends ServiceEntityRepository
} }
/** @return Pos[] */ /** @return Pos[] */
public function findByUser(User $user): array public function findByEntity(string $entityType, int $entityId): array
{ {
return $this->createQueryBuilder('p') 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') ->orderBy('p.createdAt', 'DESC')
->getQuery() ->getQuery()
->getResult(); ->getResult();
} }
/**
* قرینهٔ {@see BankAccountRepository::findUnassignedByUser()} — و به همان دلیل
* DBAL است: ردیف بی‌محیط از DQL برنمی‌گردد چون فیلتر شرط تساوی می‌گذارد.
*
* شکل خروجی باید با {@see Pos::toArray()} یکی بماند.
*
* @return array<int, array<string, mixed>>
*/
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 public function save(Pos $entity, bool $flush = true): void
{ {
$this->getEntityManager()->persist($entity); $this->getEntityManager()->persist($entity);
@@ -8,32 +8,44 @@ use App\PaymentMethod\Entity\Pos;
use App\PaymentMethod\Repository\BankAccountRepository; use App\PaymentMethod\Repository\BankAccountRepository;
use App\PaymentMethod\Repository\PosRepository; use App\PaymentMethod\Repository\PosRepository;
use App\Shared\Constant\ErrorCodes; use App\Shared\Constant\ErrorCodes;
use App\Shared\Context\EntityContext;
use App\Shared\Exception\AppException; use App\Shared\Exception\AppException;
use App\Shared\Tenant\TenantOwnershipChecker;
/** /**
* Business logic for a clinic's payment methods (bank accounts + POS devices). * Business logic for an environment's payment methods (bank accounts + POS
* Every read/write is scoped to the acting user so one clinic can never touch * devices). Ported from clinic-pro-tauri PaymentManagement tab.
* another's records. Ported from clinic-pro-tauri PaymentManagement tab. *
* اسکوپ **محیط** است نه کاربر: پزشکی که هم مطب شخصی دارد و هم کلینیک، در هر محیط
* کارت‌های همان محیط را می‌بیند. `user_id` هنگام ساخت از کاربر جاری پر می‌شود، ولی
* فقط می‌گوید چه کسی ثبتش کرده.
*
* ردیف‌های بازمانده از پیش از فاز ۶ محیط تهی دارند و در هیچ محیطی دیده نمی‌شوند؛
* مالکشان آن‌ها را در فهرست «بی‌محیط» می‌بیند و با assign*Environment به محیط
* فعالش می‌چسباند.
*/ */
class PaymentMethodService class PaymentMethodService
{ {
public function __construct( public function __construct(
private readonly BankAccountRepository $bankRepo, private readonly BankAccountRepository $bankRepo,
private readonly PosRepository $posRepo, private readonly PosRepository $posRepo,
private readonly TenantOwnershipChecker $tenantOwnership,
) {} ) {}
// ---- Bank accounts ----------------------------------------------------- // ---- Bank accounts -----------------------------------------------------
/** @return array<int, array<string, mixed>> */ /** @return array<int, array<string, mixed>> */
public function listBankAccounts(User $user): array public function listBankAccounts(EntityContext $context, User $user): array
{ {
return array_map( [$type, $id] = $this->pair($context);
static fn (BankAccount $b) => $b->toArray(),
$this->bankRepo->findByUser($user), 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'] ?? '')); $bankName = trim((string) ($data['bank_name'] ?? ''));
$accountNumber = trim((string) ($data['account_number'] ?? '')); $accountNumber = trim((string) ($data['account_number'] ?? ''));
@@ -47,15 +59,18 @@ class PaymentMethodService
throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'شماره حساب الزامی است', 422, 'account_number'); throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'شماره حساب الزامی است', 422, 'account_number');
} }
$this->pair($context);
$account = new BankAccount($user, $bankName, $accountNumber, $cardNumber, $shabaNumber); $account = new BankAccount($user, $bankName, $accountNumber, $cardNumber, $shabaNumber);
$account->assignTenant($context);
$this->bankRepo->save($account); $this->bankRepo->save($account);
return $account->toArray(); 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)) { if (array_key_exists('bank_name', $data)) {
$bankName = trim((string) $data['bank_name']); $bankName = trim((string) $data['bank_name']);
@@ -83,19 +98,36 @@ class PaymentMethodService
return $account->toArray(); 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()); $account->setActive(!$account->isActive());
$this->bankRepo->save($account); $this->bankRepo->save($account);
return $account->toArray(); 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); $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); throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'حساب بانکی یافت نشد', 404);
} }
@@ -105,15 +137,17 @@ class PaymentMethodService
// ---- POS devices ------------------------------------------------------- // ---- POS devices -------------------------------------------------------
/** @return array<int, array<string, mixed>> */ /** @return array<int, array<string, mixed>> */
public function listPos(User $user): array public function listPos(EntityContext $context, User $user): array
{ {
return array_map( [$type, $id] = $this->pair($context);
static fn (Pos $p) => $p->toArray(),
$this->posRepo->findByUser($user), 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'] ?? '')); $bankName = trim((string) ($data['bank_name'] ?? ''));
$terminalNumber = trim((string) ($data['terminal_number'] ?? '')); $terminalNumber = trim((string) ($data['terminal_number'] ?? ''));
@@ -127,15 +161,18 @@ class PaymentMethodService
throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'شماره ترمینال الزامی است', 422, 'terminal_number'); throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'شماره ترمینال الزامی است', 422, 'terminal_number');
} }
$this->pair($context);
$pos = new Pos($user, $bankName, $terminalNumber, $serialNumber, $accountNumber); $pos = new Pos($user, $bankName, $terminalNumber, $serialNumber, $accountNumber);
$pos->assignTenant($context);
$this->posRepo->save($pos); $this->posRepo->save($pos);
return $pos->toArray(); 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)) { if (array_key_exists('bank_name', $data)) {
$bankName = trim((string) $data['bank_name']); $bankName = trim((string) $data['bank_name']);
@@ -163,22 +200,56 @@ class PaymentMethodService
return $pos->toArray(); 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()); $pos->setActive(!$pos->isActive());
$this->posRepo->save($pos); $this->posRepo->save($pos);
return $pos->toArray(); 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); $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); throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'کارت خوان یافت نشد', 404);
} }
return $pos; 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];
}
} }
@@ -730,6 +730,7 @@ class SeedDemoDataCommand extends Command
$user = $this->em->getReference(\App\Auth\Entity\User::class, $patientIds[$i]); $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 = 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']); $payment->setMetadata(['demo' => true, 'scenario' => 'match-service']);
$this->em->persist($payment); $this->em->persist($payment);
$this->em->flush(); $this->em->flush();
+2
View File
@@ -29,6 +29,7 @@ class ErrorCodes
public const ERR_PAYMENT_001 = 'ERR_PAYMENT_001'; public const ERR_PAYMENT_001 = 'ERR_PAYMENT_001';
public const ERR_PAYMENT_002 = 'ERR_PAYMENT_002'; public const ERR_PAYMENT_002 = 'ERR_PAYMENT_002';
public const ERR_PAYMENT_003 = 'ERR_PAYMENT_003'; public const ERR_PAYMENT_003 = 'ERR_PAYMENT_003';
public const ERR_PAYMENT_004 = 'ERR_PAYMENT_004';
// Appointment // Appointment
public const ERR_APPOINTMENT_001 = 'ERR_APPOINTMENT_001'; public const ERR_APPOINTMENT_001 = 'ERR_APPOINTMENT_001';
@@ -127,6 +128,7 @@ class ErrorCodes
self::ERR_PAYMENT_001 => 'درگاه پرداخت در دسترس نیست', self::ERR_PAYMENT_001 => 'درگاه پرداخت در دسترس نیست',
self::ERR_PAYMENT_002 => 'مبلغ پرداخت نامعتبر است', self::ERR_PAYMENT_002 => 'مبلغ پرداخت نامعتبر است',
self::ERR_PAYMENT_003 => 'وضعیت نوبت برای پرداخت مناسب نیست', self::ERR_PAYMENT_003 => 'وضعیت نوبت برای پرداخت مناسب نیست',
self::ERR_PAYMENT_004 => 'محیط این پرداخت مشخص نیست',
self::ERR_APPOINTMENT_001 => 'اسلات انتخاب‌شده در دسترس نیست', self::ERR_APPOINTMENT_001 => 'اسلات انتخاب‌شده در دسترس نیست',
self::ERR_APPOINTMENT_002 => 'نوبت قابل لغو نیست', self::ERR_APPOINTMENT_002 => 'نوبت قابل لغو نیست',
self::ERR_FILE_001 => 'فرمت فایل مجاز نیست', self::ERR_FILE_001 => 'فرمت فایل مجاز نیست',
@@ -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 public function canActInClinic(User $user, Clinic $clinic): bool
{ {
+14 -14
View File
@@ -66,6 +66,10 @@ final class GlobalTables
// استثنای مستندشده در فاز ۲ // استثنای مستندشده در فاز ۲
\App\Appointment\Entity\Holiday::class => 'clinic=NULL یعنی «همهٔ محیط‌ها»، نه «مطب شخصی» — جفت tenant این را نمی‌تواند بیان کند', \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\Insurance\Entity\TenantServiceCoverage::class => \App\Insurance\Entity\TenantInsurance::class,
\App\Sms\Entity\SmsWalletTransaction::class => \App\Sms\Entity\SmsWallet::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<class-string, string> * @var array<class-string, string>
*/ */
public const DEFERRED = [ 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 ثبت شده، نه روی محیط',
];
} }
@@ -0,0 +1,50 @@
<?php
namespace App\Shared\Tenant;
use App\Shared\Context\EntityContext;
use Doctrine\ORM\Mapping as ORM;
/**
* همان جفت محیطِ {@see TenantOwnedTrait}، ولی تهی‌پذیر — برای جدول‌هایی که پیش از
* نشانه‌گذاری وجود داشتند و مالکِ بعضی ردیف‌هایشان از روی داده قابل تشخیص نیست.
*
* تنها مصرفش حساب بانکی و کارتخوان است: تا فاز ۶ روی `User` ثبت می‌شدند و کاربری
* که چند محیط دارد، هیچ ستونی نمی‌گوید کدام کارتش مال کدام محیط است. حدس زدنش
* یعنی پول به حساب اشتباه؛ پس تهی می‌مانند تا مالک خودش تعیین کند.
*
* ⚠️ ردیفِ تهی در **هیچ** محیطی دیده نمی‌شود، چون TenantFilter شرط تساوی می‌گذارد
* و NULL با هیچ مقداری برابر نیست. این عمدی است ولی نقطهٔ ضعف است و در
* docs/architecture/tenancy.md ثبت شده: تا وقتی مالک محیط را تعیین نکند، کارتش
* از فهرست‌ها غایب است.
*/
trait NullableTenantOwnedTrait
{
#[ORM\Column(name: 'entity_type', type: 'string', length: 10, nullable: true)]
private ?string $entityType = null;
#[ORM\Column(name: 'entity_id', type: 'integer', nullable: true)]
private ?int $entityId = null;
public function getEntityType(): ?string { return $this->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();
}
}
@@ -95,6 +95,7 @@ class SmsWalletController extends BaseController
$frontendAddress = trim($data['frontend_address'] ?? ''); $frontendAddress = trim($data['frontend_address'] ?? '');
$payment = new Payment($user, $amountRials, $gatewayName, Payment::TYPE_SMS_WALLET, $frontendAddress); $payment = new Payment($user, $amountRials, $gatewayName, Payment::TYPE_SMS_WALLET, $frontendAddress);
$payment->assignTenantPair($entityType, $entityId);
$payment->setMetadata(['entity_type' => $entityType, 'entity_id' => $entityId]); $payment->setMetadata(['entity_type' => $entityType, 'entity_id' => $entityId]);
$this->paymentRepo->save($payment); $this->paymentRepo->save($payment);
+26
View File
@@ -8,6 +8,7 @@ use App\Appointment\Entity\WeeklySchedule;
use App\Auth\Entity\User; use App\Auth\Entity\User;
use App\Clinic\Entity\Clinic; use App\Clinic\Entity\Clinic;
use App\Doctor\Entity\Doctor; use App\Doctor\Entity\Doctor;
use App\Payment\Entity\Payment;
use App\Shared\Context\EntityContext; use App\Shared\Context\EntityContext;
use App\Subscription\Entity\SubscriptionPlan; use App\Subscription\Entity\SubscriptionPlan;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException; use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
@@ -25,6 +26,12 @@ use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
*/ */
abstract class ApiTestCase extends WebTestCase abstract class ApiTestCase extends WebTestCase
{ {
/**
* محیطِ رکوردهایی که موضوعِ تست، محیطشان نیست. عمداً یک ثابت است تا هیچ تستی
* تصادفاً با محیطِ واقعیِ تستِ دیگری برخورد نکند.
*/
protected const TENANTLESS_TEST_ENTITY_ID = 1;
protected KernelBrowser $client; protected KernelBrowser $client;
protected EntityManagerInterface $em; protected EntityManagerInterface $em;
@@ -134,6 +141,25 @@ abstract class ApiTestCase extends WebTestCase
return $schedule; 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 protected function newDateOverride(Doctor $doctor, int $date, bool $active = false, ?Clinic $clinic = null): DateOverride
{ {
$this->flushIfNew($doctor, $clinic); $this->flushIfNew($doctor, $clinic);
@@ -34,6 +34,7 @@ class AppointmentExpiryServiceTest extends ApiTestCase
$payment = new Payment($patient, 100_000, 'mellat', 'appointment'); $payment = new Payment($patient, 100_000, 'mellat', 'appointment');
$payment->setAppointment($appt); $payment->setAppointment($appt);
$this->stampTenant($payment);
$this->em->persist($payment); $this->em->persist($payment);
$appointments[] = [$appt, $payment]; $appointments[] = [$appt, $payment];
+3 -3
View File
@@ -29,9 +29,9 @@ class UniqueConstraintsTest extends ApiTestCase
public function testDuplicateGatewayTokenRejected(): void public function testDuplicateGatewayTokenRejected(): void
{ {
$token = 'tok-' . bin2hex(random_bytes(6)); $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); $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); $b->setGatewayToken($token);
$this->em->persist($a); $this->em->persist($a);
$this->em->persist($b); $this->em->persist($b);
@@ -57,7 +57,7 @@ class UniqueConstraintsTest extends ApiTestCase
public function testDuplicateBreakdownPaymentSourceRejected(): void public function testDuplicateBreakdownPaymentSourceRejected(): void
{ {
$user = $this->createUser(); $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->persist($payment);
$this->em->flush(); $this->em->flush();
+1 -1
View File
@@ -34,7 +34,7 @@ class PatientFinancialsTest extends ApiTestCase
{ {
[$owner, $record, $patient] = $this->recordFor(); [$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); $payment->setStatus(Payment::STATUS_SUCCESS);
$this->em->persist($payment); $this->em->persist($payment);
$this->em->flush(); $this->em->flush();
@@ -47,6 +47,7 @@ class AppointmentPaidConfirmFilesSessionTest extends ApiTestCase
$payment = new Payment($appointment->getUser(), 50_000, 'mock', Payment::TYPE_APPOINTMENT); $payment = new Payment($appointment->getUser(), 50_000, 'mock', Payment::TYPE_APPOINTMENT);
$payment->setAppointment($appointment); $payment->setAppointment($appointment);
$this->stampTenant($payment);
$this->em->persist($payment); $this->em->persist($payment);
$this->em->flush(); $this->em->flush();
+1 -1
View File
@@ -28,7 +28,7 @@ class PaymentCallbackAmountTest extends ApiTestCase
private function makePayment(int $amountRials): Payment private function makePayment(int $amountRials): Payment
{ {
$user = $this->createUser(); $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->persist($payment);
$this->em->flush(); $this->em->flush();
+165
View File
@@ -0,0 +1,165 @@
<?php
namespace App\Tests\Payment;
use App\Clinic\Entity\Clinic;
use App\Doctor\Entity\Doctor;
use App\Payment\Entity\Payment;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Tenant\TenantFilter;
use App\Tests\ApiTestCase;
use Doctrine\ORM\EntityManagerInterface;
/**
* پرداخت به محیطِ **گیرنده** تعلق دارد، نه به پرداخت‌کننده: نوبت → محیط همان نوبت،
* اشتراک → محیطی که خریدار صاحبش است، شارژ پیامک → محیطِ همان کیف پول.
*
* بیمار در هیچ محیطی نیست، پس TenantFilter برایش خاموش می‌ماند و پرداخت خودش را
* می‌بیند — همان دلیلی که فاز ۴ فیلتر را فقط روی «محیط انتخاب‌شده» روشن کرد.
*/
class PaymentTenantTest extends ApiTestCase
{
private function em(): EntityManagerInterface
{
return static::getContainer()->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]),
'پرداخت محیط دیگر نباید دیده شود',
);
}
}
@@ -0,0 +1,200 @@
<?php
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\PaymentMethod\Entity\BankAccount;
use App\PaymentMethod\Entity\Pos;
use App\Shared\Context\EntityContext;
use App\Tests\ApiTestCase;
/**
* کارت و کارتخوان مالِ **محیط**اند نه کاربر. پزشکی که هم مطب شخصی دارد و هم
* کلینیک، در هر محیط فقط کارت‌های همان محیط را می‌بیند.
*
* ردیف‌های بازمانده از پیش از فاز ۶ محیط تهی دارند — عمداً، چون هیچ ستونی نمی‌گفت
* کارتِ کاربرِ چندمحیطی مال کدام محیط است و حدس زدنش یعنی پول به حساب اشتباه.
* چنین ردیفی در هیچ محیطی «متعلق» نیست، ولی مالکش باید ببیندش و بتواند تعیینش کند.
*/
class PaymentMethodTenantTest extends ApiTestCase
{
/** پزشکی که کلینیک هم دارد، با محیط فعالِ مشخص. */
private function multiEnvironmentUser(): array
{
$user = $this->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]);
}
}
+64 -34
View File
@@ -2,21 +2,49 @@
namespace App\Tests\PaymentMethod; 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; 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. * (bank accounts + POS devices). Success, error and boundary cases.
*
* اسکوپ از فاز ۶ به بعد **محیط** است نه کاربر، پس هر تست به یک محیط واقعی
* (پزشک یا کلینیک) نیاز دارد؛ کاربری با نقش کلینیک ولی بدون کلینیک، محیطی ندارد.
*/ */
class PaymentMethodTest extends ApiTestCase 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 ----------------------------------------------------- // ---- Bank accounts -----------------------------------------------------
public function testEmptyBankAccountListForNewClinic(): void public function testEmptyBankAccountListForNewClinic(): void
{ {
$user = $this->createUser(['ROLE_CLINIC']); $res = $this->authJson('GET', '/api/v1/my/payment-methods/bank-accounts', $this->clinicOwner());
$res = $this->authJson('GET', '/api/v1/my/payment-methods/bank-accounts', $user);
$this->assertSame(200, $this->responseCode()); $this->assertSame(200, $this->responseCode());
$this->assertTrue($res['success']); $this->assertTrue($res['success']);
@@ -25,7 +53,7 @@ class PaymentMethodTest extends ApiTestCase
public function testCreateAndListBankAccount(): void public function testCreateAndListBankAccount(): void
{ {
$user = $this->createUser(['ROLE_CLINIC']); $user = $this->clinicOwner();
$created = $this->authJson('POST', '/api/v1/my/payment-methods/bank-accounts', $user, [ $created = $this->authJson('POST', '/api/v1/my/payment-methods/bank-accounts', $user, [
'bank_name' => 'ملی', 'bank_name' => 'ملی',
@@ -38,6 +66,7 @@ class PaymentMethodTest extends ApiTestCase
$this->assertSame('ملی', $created['data']['bank_name']); $this->assertSame('ملی', $created['data']['bank_name']);
$this->assertTrue($created['data']['is_active']); $this->assertTrue($created['data']['is_active']);
$this->assertNotEmpty($created['data']['uuid']); $this->assertNotEmpty($created['data']['uuid']);
$this->assertSame('clinic', $created['data']['entity_type'], 'حساب باید به محیط فعال بچسبد');
$list = $this->authJson('GET', '/api/v1/my/payment-methods/bank-accounts', $user); $list = $this->authJson('GET', '/api/v1/my/payment-methods/bank-accounts', $user);
$this->assertCount(1, $list['data']); $this->assertCount(1, $list['data']);
@@ -46,9 +75,7 @@ class PaymentMethodTest extends ApiTestCase
public function testCreateBankAccountValidationErrorWhenBankNameMissing(): void public function testCreateBankAccountValidationErrorWhenBankNameMissing(): void
{ {
$user = $this->createUser(['ROLE_CLINIC']); $res = $this->authJson('POST', '/api/v1/my/payment-methods/bank-accounts', $this->clinicOwner(), [
$res = $this->authJson('POST', '/api/v1/my/payment-methods/bank-accounts', $user, [
'account_number' => '0101234567890', 'account_number' => '0101234567890',
]); ]);
@@ -59,7 +86,7 @@ class PaymentMethodTest extends ApiTestCase
public function testUpdateBankAccount(): void public function testUpdateBankAccount(): void
{ {
$user = $this->createUser(['ROLE_CLINIC']); $user = $this->clinicOwner();
$created = $this->authJson('POST', '/api/v1/my/payment-methods/bank-accounts', $user, [ $created = $this->authJson('POST', '/api/v1/my/payment-methods/bank-accounts', $user, [
'bank_name' => 'ملی', 'bank_name' => 'ملی',
'account_number' => '0101234567890', 'account_number' => '0101234567890',
@@ -78,7 +105,7 @@ class PaymentMethodTest extends ApiTestCase
public function testToggleBankAccountStatus(): void public function testToggleBankAccountStatus(): void
{ {
$user = $this->createUser(['ROLE_CLINIC']); $user = $this->clinicOwner();
$created = $this->authJson('POST', '/api/v1/my/payment-methods/bank-accounts', $user, [ $created = $this->authJson('POST', '/api/v1/my/payment-methods/bank-accounts', $user, [
'bank_name' => 'ملی', 'bank_name' => 'ملی',
'account_number' => '0101234567890', 'account_number' => '0101234567890',
@@ -94,9 +121,11 @@ class PaymentMethodTest extends ApiTestCase
public function testToggleUnknownBankAccountReturns404(): void public function testToggleUnknownBankAccountReturns404(): void
{ {
$user = $this->createUser(['ROLE_CLINIC']); $res = $this->authJson(
'PATCH',
$res = $this->authJson('PATCH', '/api/v1/my/payment-methods/bank-accounts/does-not-exist/status', $user); '/api/v1/my/payment-methods/bank-accounts/does-not-exist/status',
$this->clinicOwner(),
);
$this->assertSame(404, $this->responseCode()); $this->assertSame(404, $this->responseCode());
$this->assertFalse($res['success']); $this->assertFalse($res['success']);
@@ -104,8 +133,8 @@ class PaymentMethodTest extends ApiTestCase
public function testCannotTouchAnotherClinicsBankAccount(): void public function testCannotTouchAnotherClinicsBankAccount(): void
{ {
$owner = $this->createUser(['ROLE_CLINIC']); $owner = $this->clinicOwner();
$other = $this->createUser(['ROLE_CLINIC']); $other = $this->clinicOwner();
$created = $this->authJson('POST', '/api/v1/my/payment-methods/bank-accounts', $owner, [ $created = $this->authJson('POST', '/api/v1/my/payment-methods/bank-accounts', $owner, [
'bank_name' => 'ملی', 'bank_name' => 'ملی',
'account_number' => '0101234567890', 'account_number' => '0101234567890',
@@ -120,9 +149,15 @@ class PaymentMethodTest extends ApiTestCase
public function testBankAccountForbiddenForPlainUser(): void 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()); $this->assertSame(403, $this->responseCode());
} }
@@ -131,7 +166,7 @@ class PaymentMethodTest extends ApiTestCase
public function testCreateAndListPos(): void public function testCreateAndListPos(): void
{ {
$user = $this->createUser(['ROLE_DOCTOR']); $user = $this->doctorOwner();
$created = $this->authJson('POST', '/api/v1/my/payment-methods/pos', $user, [ $created = $this->authJson('POST', '/api/v1/my/payment-methods/pos', $user, [
'bank_name' => 'ملت', 'bank_name' => 'ملت',
@@ -143,6 +178,7 @@ class PaymentMethodTest extends ApiTestCase
$this->assertSame('ملت', $created['data']['bank_name']); $this->assertSame('ملت', $created['data']['bank_name']);
$this->assertSame('123456', $created['data']['terminal_number']); $this->assertSame('123456', $created['data']['terminal_number']);
$this->assertTrue($created['data']['is_active']); $this->assertTrue($created['data']['is_active']);
$this->assertSame('doctor', $created['data']['entity_type']);
$list = $this->authJson('GET', '/api/v1/my/payment-methods/pos', $user); $list = $this->authJson('GET', '/api/v1/my/payment-methods/pos', $user);
$this->assertCount(1, $list['data']); $this->assertCount(1, $list['data']);
@@ -150,9 +186,7 @@ class PaymentMethodTest extends ApiTestCase
public function testCreatePosValidationErrorWhenTerminalMissing(): void public function testCreatePosValidationErrorWhenTerminalMissing(): void
{ {
$user = $this->createUser(['ROLE_DOCTOR']); $res = $this->authJson('POST', '/api/v1/my/payment-methods/pos', $this->doctorOwner(), [
$res = $this->authJson('POST', '/api/v1/my/payment-methods/pos', $user, [
'bank_name' => 'ملت', 'bank_name' => 'ملت',
]); ]);
@@ -163,7 +197,7 @@ class PaymentMethodTest extends ApiTestCase
public function testTogglePosStatus(): void public function testTogglePosStatus(): void
{ {
// منشی به روش‌های پرداخت فقط با مجوز payments از طریق رابطهٔ فعال دسترسی دارد. // منشی به روش‌های پرداخت فقط با مجوز 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, [ $created = $this->authJson('POST', '/api/v1/my/payment-methods/pos', $user, [
'bank_name' => 'تجارت', 'bank_name' => 'تجارت',
'terminal_number' => '345678', 'terminal_number' => '345678',
@@ -189,34 +223,30 @@ class PaymentMethodTest extends ApiTestCase
} }
/** منشی با رابطهٔ فعالِ کلینیک + context + مجوز payments مشخص. */ /** منشی با رابطهٔ فعالِ کلینیک + context + مجوز payments مشخص. */
private function createSecretaryWithPayments(array $payments): \App\Auth\Entity\User private function createSecretaryWithPayments(array $payments): User
{ {
$owner = $this->createUser(['ROLE_CLINIC']); $owner = $this->createUser(['ROLE_CLINIC']);
$clinic = new \App\Clinic\Entity\Clinic($owner); $clinic = new Clinic($owner);
$this->em->persist($clinic); $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); $this->em->persist($doctor);
$clinic->getDoctors()->add($doctor); $clinic->getDoctors()->add($doctor);
$secretary = $this->createUser(['ROLE_SECRETARY']); $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]]); $rel->mergePermissions(['resources' => ['payments' => $payments]]);
$this->em->persist($rel); $this->em->persist($rel);
$this->em->persist(new \App\Auth\Entity\UserActiveContext( $this->em->persist(new UserActiveContext($secretary, $clinic->getUuid(), EntityContext::TYPE_CLINIC));
$secretary,
$clinic->getUuid(),
\App\Shared\Context\EntityContext::TYPE_CLINIC,
));
$this->em->flush(); $this->em->flush();
return $secretary; return $secretary;
} }
public function testPosListIsolatedPerUser(): void public function testPosListIsolatedPerEnvironment(): void
{ {
$a = $this->createUser(['ROLE_CLINIC']); $a = $this->clinicOwner();
$b = $this->createUser(['ROLE_CLINIC']); $b = $this->clinicOwner();
$this->authJson('POST', '/api/v1/my/payment-methods/pos', $a, [ $this->authJson('POST', '/api/v1/my/payment-methods/pos', $a, [
'bank_name' => 'صادرات', 'bank_name' => 'صادرات',
'terminal_number' => '901234', 'terminal_number' => '901234',
@@ -33,7 +33,7 @@ class DomainCommissionTest extends ApiTestCase
private function makePayment(string $frontendAddress): Payment 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->persist($payment);
$this->em->flush(); $this->em->flush();
@@ -62,6 +62,7 @@ class SecretaryOnlineShareTest extends ApiTestCase
$payment = new Payment($this->createUser(), self::GROSS, 'mock', Payment::TYPE_APPOINTMENT, ''); $payment = new Payment($this->createUser(), self::GROSS, 'mock', Payment::TYPE_APPOINTMENT, '');
$payment->setAppointment($appointment); $payment->setAppointment($appointment);
$this->stampTenant($payment);
$this->em->persist($payment); $this->em->persist($payment);
$this->em->flush(); $this->em->flush();
@@ -17,7 +17,7 @@ class FinancialBreakdownIntegrityTest extends ApiTestCase
public function testDeletingPaymentWithBreakdownIsRestricted(): void public function testDeletingPaymentWithBreakdownIsRestricted(): void
{ {
$user = $this->createUser(); $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->persist($payment);
$breakdown = new FinancialBreakdown( $breakdown = new FinancialBreakdown(
@@ -0,0 +1,143 @@
<?php
namespace App\Tests\Settlement;
use App\Auth\Entity\User;
use App\Doctor\Entity\Doctor;
use App\Payment\Entity\Payment;
use App\Secretary\Entity\SecretaryEarning;
use App\Secretary\Repository\SecretaryEarningRepository;
use App\Settlement\Entity\FinancialBreakdown;
use App\Settlement\Entity\WalletTransaction;
use App\Settlement\Repository\WalletTransactionRepository;
use App\Shared\Tenant\TenantFilter;
use App\Tests\ApiTestCase;
use Doctrine\ORM\EntityManagerInterface;
/**
* زنجیرهٔ مالی ستون محیط ندارد و آن را از `payments` به ارث می‌برد:
*
* Payment ─┬─ PaymentLog (payment_id اسکالر)
* └─ FinancialBreakdown ── SecretaryEarning
*
* تضمین فقط تا جایی است که کوئری به ریشه لنگر بزند؛ `reportFor` این کار را با
* join('b.payment','p') می‌کند و همان join است که TenantFilter رویش می‌نشیند.
*
* کیف پول عمداً بیرون این زنجیره است: مالِ شخص است نه محیط، و همین‌جا پین می‌شود
* تا اگر روزی کسی ستون محیط رویش گذاشت، این تصمیم دوباره دیده شود.
*/
class FinancialChainTenantTest extends ApiTestCase
{
private function em(): EntityManagerInterface
{
return static::getContainer()->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]),
'کیف پول شخص نباید به محیط قفل شود',
);
}
}
+7 -7
View File
@@ -126,15 +126,15 @@ class TenantSchemaCoverageTest extends ApiTestCase
} }
/** /**
* بدهی باید کوچک شود نه بزرگ. اگر کلاسی به DEFERRED اضافه شد، این عدد هم باید * فاز ۶ بدهی را صفر کرد. از «رشد نکن» به «صفر بمان»: افزودن دوباره یعنی جدولی
* عمداً بالا برود — یعنی تصمیم دیده می‌شود، نه اینکه بی‌صدا بگذرد. * بیرون از هر تضمینی مانده، و باید تصمیم آگاهانه باشد نه یک ردشدنِ بی‌صدا.
*/ */
public function testDeferredDebtDoesNotGrow(): void public function testThereIsNoUnclassifiedDebtLeft(): void
{ {
self::assertLessThanOrEqual( self::assertSame(
8, [],
count(GlobalTables::DEFERRED), GlobalTables::DEFERRED,
'جدول‌های مالی طبقه‌بندی‌نشده بیشتر شدند؛ فهرست DEFERRED باید کوچک شود', 'بدهی طبقه‌بندی باید صفر بماند؛ هر کلاس یا جفت محیط می‌گیرد یا با دلیل سراسری/فرزند aggregate می‌شود',
); );
} }
} }