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 DataTable, { type Column } from '../ui/DataTable';
import StatusToggle from './StatusToggle';
import EnvironmentCell from './EnvironmentCell';
import type { BankAccount } from '../../hooks/usePaymentMethods';
/**
@@ -13,21 +14,32 @@ export default function BankAccountTable({
data,
loading,
togglingUuid,
assigningUuid,
environmentName,
onToggle,
onEdit,
onAssign,
onAdd,
}: {
data: BankAccount[];
loading?: boolean;
togglingUuid: string | null;
assigningUuid: string | null;
environmentName: string;
onToggle: (uuid: string) => void;
onEdit: (account: BankAccount) => void;
onAssign: (uuid: string) => void;
onAdd: () => void;
}) {
const columns: Column<BankAccount>[] = [
{ key: 'bank_name', header: 'نام بانک' },
{ key: 'card_number', header: 'شماره کارت', render: (r) => r.card_number || '—' },
{ key: 'account_number', header: 'شماره حساب', render: (r) => r.account_number || '—' },
{
key: 'entity_type',
header: 'محیط',
render: (r) => <EnvironmentCell entityType={r.entity_type} environmentName={environmentName} />,
},
{
key: 'is_active',
header: 'وضعیت',
@@ -53,14 +65,24 @@ export default function BankAccountTable({
emptyMessage="هنوز حساب بانکی ثبت نشده است"
emptyAction={<button className="btn primary sm" onClick={onAdd}>افزودن حساب بانکی</button>}
actions={(r) => (
<button
className="btn sm ghost"
style={{ color: 'var(--accent)' }}
onClick={() => onEdit(r)}
>
<PencilIcon style={{ width: 14 }} />
ویرایش
</button>
r.entity_type === null ? (
<button
className="btn sm primary"
disabled={assigningUuid === r.uuid}
onClick={() => onAssign(r.uuid)}
>
انتساب به {environmentName}
</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 DataTable, { type Column } from '../ui/DataTable';
import StatusToggle from './StatusToggle';
import EnvironmentCell from './EnvironmentCell';
import type { Pos } from '../../hooks/usePaymentMethods';
/**
@@ -12,21 +13,32 @@ export default function PosTable({
data,
loading,
togglingUuid,
assigningUuid,
environmentName,
onToggle,
onEdit,
onAssign,
onAdd,
}: {
data: Pos[];
loading?: boolean;
togglingUuid: string | null;
assigningUuid: string | null;
environmentName: string;
onToggle: (uuid: string) => void;
onEdit: (pos: Pos) => void;
onAssign: (uuid: string) => void;
onAdd: () => void;
}) {
const columns: Column<Pos>[] = [
{ key: 'bank_name', header: 'نام بانک' },
{ key: 'serial_number', header: 'شماره سریال', render: (r) => r.serial_number || '—' },
{ key: 'terminal_number', header: 'شماره ترمینال', render: (r) => r.terminal_number || '—' },
{
key: 'entity_type',
header: 'محیط',
render: (r) => <EnvironmentCell entityType={r.entity_type} environmentName={environmentName} />,
},
{
key: 'is_active',
header: 'وضعیت',
@@ -52,14 +64,24 @@ export default function PosTable({
emptyMessage="هنوز کارت خوانی ثبت نشده است"
emptyAction={<button className="btn primary sm" onClick={onAdd}>افزودن کارت خوان</button>}
actions={(r) => (
<button
className="btn sm ghost"
style={{ color: 'var(--accent)' }}
onClick={() => onEdit(r)}
>
<PencilIcon style={{ width: 14 }} />
ویرایش
</button>
r.entity_type === null ? (
<button
className="btn sm primary"
disabled={assigningUuid === r.uuid}
onClick={() => onAssign(r.uuid)}
>
انتساب به {environmentName}
</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';
/**
* روش‌های پرداختِ کلینیک (حساب بانکی + کارت‌خوان) برای صفحهٔ «مدیریت پرداخت».
* روش‌های پرداختِ **محیط فعال** (حساب بانکی + کارت‌خوان) برای صفحهٔ «مدیریت پرداخت».
* منبع: /api/v1/my/payment-methods/... — این رکوردها بعداً از فاکتور مراجعه‌کننده
* برای ثبت روش پرداختِ یک سرویس ارجاع داده می‌شوند.
*
* از فاز ۶، کارت‌ها به محیط تعلق دارند نه به کاربر: با تعویض محیط، فهرست عوض
* می‌شود. کارت‌های بازمانده‌ای که هنوز محیطی ندارند با entity_type = null در همین
* فهرست می‌آیند و باید پیش از استفاده به محیطی منتسب شوند.
*/
/** null یعنی «محیطش هنوز تعیین نشده». */
export type PaymentMethodEnvironment = 'doctor' | 'clinic' | null;
export interface BankAccount {
uuid: string;
bank_name: string;
@@ -16,6 +23,7 @@ export interface BankAccount {
shaba_number: string | null;
is_active: boolean;
created_at: number;
entity_type: PaymentMethodEnvironment;
}
export interface Pos {
@@ -26,6 +34,7 @@ export interface Pos {
account_number: string | null;
is_active: boolean;
created_at: number;
entity_type: PaymentMethodEnvironment;
}
export interface BankAccountInput {
@@ -78,6 +87,15 @@ export function useToggleBankAccountStatus() {
});
}
/** کارتِ بی‌محیط را به محیط فعال می‌چسباند؛ پیش از آن قابل ویرایش نیست. */
export function useAssignBankAccountEnvironment() {
const qc = useQueryClient();
return useMutation<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 ──────────────────────────────────────────────────────────────
export function usePosDevices() {
@@ -110,3 +128,12 @@ export function useTogglePosStatus() {
onSuccess: () => qc.invalidateQueries({ queryKey: POS_KEY }),
});
}
/** قرینهٔ useAssignBankAccountEnvironment برای کارت‌خوان. */
export function useAssignPosEnvironment() {
const qc = useQueryClient();
return useMutation<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 PosFormModal from '../components/paymentMethods/PosFormModal';
import {
useAssignBankAccountEnvironment,
useAssignPosEnvironment,
useBankAccounts,
usePosDevices,
useToggleBankAccountStatus,
@@ -15,11 +17,15 @@ import {
type BankAccount,
type Pos,
} from '../hooks/usePaymentMethods';
import { useAuthStore } from '../stores/authStore';
/**
* صفحهٔ «مدیریت پرداخت» (/admin/my-financial) — پورت تب PaymentManagement از
* clinic-pro-tauri. روش‌های پرداختِ کلینیک (حساب بانکی + کارت‌خوان) که بعداً از
* فاکتور مراجعه‌کننده برای ثبت روش پرداخت یک سرویس ارجاع می‌شوند.
* clinic-pro-tauri. روش‌های پرداختِ **محیط فعال** (حساب بانکی + کارت‌خوان) که بعداً
* از فاکتور مراجعه‌کننده برای ثبت روش پرداخت یک سرویس ارجاع می‌شوند.
*
* نام محیط در سرتیتر می‌آید چون از فاز ۶ کارت‌ها به محیط تعلق دارند نه به کاربر؛
* بدون آن، کاربرِ چندمحیطی با تعویض محیط فکر می‌کند کارت‌هایش گم شده‌اند.
*/
function MyFinancialPageContent() {
const [activeTab, setActiveTab] = useState<PaymentTab>('bank');
@@ -32,6 +38,11 @@ function MyFinancialPageContent() {
const posQuery = usePosDevices();
const toggleBank = useToggleBankAccountStatus();
const togglePos = useTogglePosStatus();
const assignBank = useAssignBankAccountEnvironment();
const assignPos = useAssignPosEnvironment();
const context = useAuthStore(s => s.context);
const environmentName = context?.name || 'محیط فعال';
const banks = bankQuery.data?.data ?? [];
const posDevices = posQuery.data?.data ?? [];
@@ -42,10 +53,14 @@ function MyFinancialPageContent() {
};
const onError = (e: unknown) => toast.error(e instanceof Error ? e.message : 'خطا در تغییر وضعیت');
const onAssignError = (e: unknown) => toast.error(e instanceof Error ? e.message : 'خطا در تعیین محیط');
return (
<div className="page">
<PageHeader title="مدیریت پرداخت" description="روش‌های پرداخت کلینیک (حساب بانکی و کارت‌خوان)" />
<PageHeader
title="مدیریت پرداخت"
description={`روش‌های پرداخت «${environmentName}» (حساب بانکی و کارت‌خوان)`}
/>
<div className="card card-pad">
<PaymentMethodHead activeTab={activeTab} onTabChange={setActiveTab} onAdd={openAdd} />
@@ -55,8 +70,11 @@ function MyFinancialPageContent() {
data={banks}
loading={bankQuery.isLoading}
togglingUuid={toggleBank.isPending ? toggleBank.variables ?? null : null}
assigningUuid={assignBank.isPending ? assignBank.variables ?? null : null}
environmentName={environmentName}
onToggle={(uuid) => toggleBank.mutate(uuid, { onError })}
onEdit={(account) => { setEditingBank(account); setBankModalOpen(true); }}
onAssign={(uuid) => assignBank.mutate(uuid, { onError: onAssignError })}
onAdd={openAdd}
/>
) : (
@@ -64,8 +82,11 @@ function MyFinancialPageContent() {
data={posDevices}
loading={posQuery.isLoading}
togglingUuid={togglePos.isPending ? togglePos.variables ?? null : null}
assigningUuid={assignPos.isPending ? assignPos.variables ?? null : null}
environmentName={environmentName}
onToggle={(uuid) => togglePos.mutate(uuid, { onError })}
onEdit={(pos) => { setEditingPos(pos); setPosModalOpen(true); }}
onAssign={(uuid) => assignPos.mutate(uuid, { onError: onAssignError })}
onAdd={openAdd}
/>
)}