feat(payment): add payment detail endpoint and update payment model with order_id and patient_name
feat(appointment): enhance appointment detail page with time formatting and additional info fix(payment): update payment query to fetch from the correct endpoint and adjust response structure docs(api): add search parameter to payments API documentation and detail response structure test(payment): add unit test for MellatGateway to verify null credentials handling
This commit is contained in:
@@ -6,7 +6,7 @@ import { toast } from 'sonner';
|
|||||||
import { api } from '../lib/api';
|
import { api } from '../lib/api';
|
||||||
import type { ApiResponse } from '../lib/api';
|
import type { ApiResponse } from '../lib/api';
|
||||||
import type { Appointment, AppointmentStatus } from '../types';
|
import type { Appointment, AppointmentStatus } from '../types';
|
||||||
import { formatDate, formatDateTime } from '../lib/utils';
|
import { formatDate, formatDateTime, toDate } from '../lib/utils';
|
||||||
import PageHeader from '../components/ui/PageHeader';
|
import PageHeader from '../components/ui/PageHeader';
|
||||||
import StatusBadge from '../components/ui/StatusBadge';
|
import StatusBadge from '../components/ui/StatusBadge';
|
||||||
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
||||||
@@ -22,6 +22,13 @@ const ALL_STATUSES: { value: AppointmentStatus; label: string }[] = [
|
|||||||
{ value: 'expired', label: 'منقضی' },
|
{ value: 'expired', label: 'منقضی' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const timeOf = (ts?: number | null) => {
|
||||||
|
const d = toDate(ts ?? null);
|
||||||
|
return d
|
||||||
|
? new Intl.DateTimeFormat('fa-IR-u-nu-latn', { hour: '2-digit', minute: '2-digit', hour12: false }).format(d)
|
||||||
|
: '—';
|
||||||
|
};
|
||||||
|
|
||||||
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
|
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="cp-info-row">
|
<div className="cp-info-row">
|
||||||
@@ -64,7 +71,8 @@ export default function AppointmentDetailPage() {
|
|||||||
onError: (err: Error) => toast.error(err.message),
|
onError: (err: Error) => toast.error(err.message),
|
||||||
});
|
});
|
||||||
|
|
||||||
const appt = data?.data;
|
// پاسخ single تودرتو است: { data: { data: {...} } }
|
||||||
|
const appt: any = (data?.data as any)?.data ?? data?.data;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -96,10 +104,12 @@ export default function AppointmentDetailPage() {
|
|||||||
<h3 className="font-semibold text-gray-800 mb-4">اطلاعات بیمار</h3>
|
<h3 className="font-semibold text-gray-800 mb-4">اطلاعات بیمار</h3>
|
||||||
<InfoRow label="نام بیمار" value={appt.patient_name} />
|
<InfoRow label="نام بیمار" value={appt.patient_name} />
|
||||||
<InfoRow label="موبایل" value={<span dir="ltr">{appt.patient_mobile}</span>} />
|
<InfoRow label="موبایل" value={<span dir="ltr">{appt.patient_mobile}</span>} />
|
||||||
<InfoRow label="پزشک" value={`دکتر ${appt.doctor_name}`} />
|
<InfoRow label="پزشک" value={appt.doctor?.name ? `دکتر ${appt.doctor.name}` : null} />
|
||||||
<InfoRow label="تاریخ نوبت" value={formatDate(appt.appointment_date)} />
|
<InfoRow label="تاریخ نوبت" value={formatDate(appt.slot_start)} />
|
||||||
<InfoRow label="ساعت شروع" value={appt.appointment_time} />
|
<InfoRow label="ساعت شروع" value={timeOf(appt.slot_start)} />
|
||||||
<InfoRow label="ساعت پایان" value={appt.end_time} />
|
<InfoRow label="ساعت پایان" value={timeOf(appt.slot_end)} />
|
||||||
|
{appt.patient_reason && <InfoRow label="علت مراجعه" value={appt.patient_reason} />}
|
||||||
|
{appt.note && <InfoRow label="توضیحات" value={appt.note} />}
|
||||||
<InfoRow label="تاریخ ثبت" value={formatDateTime(appt.created_at)} />
|
<InfoRow label="تاریخ ثبت" value={formatDateTime(appt.created_at)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,12 @@ import { formatDate, formatDateTime, formatRial } from '../lib/utils';
|
|||||||
import PageHeader from '../components/ui/PageHeader';
|
import PageHeader from '../components/ui/PageHeader';
|
||||||
import StatusBadge from '../components/ui/StatusBadge';
|
import StatusBadge from '../components/ui/StatusBadge';
|
||||||
|
|
||||||
|
const PAYMENT_TYPE_LABELS: Record<string, string> = {
|
||||||
|
appointment: 'نوبت',
|
||||||
|
subscription: 'اشتراک',
|
||||||
|
sms_wallet: 'کیف پول پیامک',
|
||||||
|
};
|
||||||
|
|
||||||
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
|
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="cp-info-row">
|
<div className="cp-info-row">
|
||||||
@@ -24,7 +30,7 @@ export default function PaymentDetailPage() {
|
|||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ['payment', uuid],
|
queryKey: ['payment', uuid],
|
||||||
queryFn: () => api.get<ApiResponse<Payment>>(`/api/v1/payment/${uuid}`),
|
queryFn: () => api.get<ApiResponse<Payment>>(`/api/v1/admin/payments/${uuid}`),
|
||||||
enabled: !!uuid,
|
enabled: !!uuid,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -58,7 +64,10 @@ export default function PaymentDetailPage() {
|
|||||||
<div className="max-w-lg">
|
<div className="max-w-lg">
|
||||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6">
|
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6">
|
||||||
<InfoRow label="شناسه" value={<span dir="ltr" className="font-mono text-xs">{payment.uuid}</span>} />
|
<InfoRow label="شناسه" value={<span dir="ltr" className="font-mono text-xs">{payment.uuid}</span>} />
|
||||||
|
<InfoRow label="شماره سفارش" value={payment.order_id ? <span dir="ltr" className="font-mono text-xs">{payment.order_id}</span> : null} />
|
||||||
|
<InfoRow label="بیمار" value={payment.patient_name} />
|
||||||
<InfoRow label="موبایل" value={<span dir="ltr">{payment.patient_mobile}</span>} />
|
<InfoRow label="موبایل" value={<span dir="ltr">{payment.patient_mobile}</span>} />
|
||||||
|
<InfoRow label="نوع" value={PAYMENT_TYPE_LABELS[payment.type ?? ''] ?? payment.type} />
|
||||||
<InfoRow label="مبلغ" value={formatRial(payment.amount)} />
|
<InfoRow label="مبلغ" value={formatRial(payment.amount)} />
|
||||||
<InfoRow label="وضعیت" value={<StatusBadge type="payment" value={payment.status} />} />
|
<InfoRow label="وضعیت" value={<StatusBadge type="payment" value={payment.status} />} />
|
||||||
<InfoRow label="درگاه" value={<span className="uppercase">{payment.gateway}</span>} />
|
<InfoRow label="درگاه" value={<span className="uppercase">{payment.gateway}</span>} />
|
||||||
|
|||||||
@@ -122,6 +122,9 @@ export interface Payment {
|
|||||||
appointment_uuid: string | null;
|
appointment_uuid: string | null;
|
||||||
paid_at: string | null;
|
paid_at: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
order_id?: string;
|
||||||
|
patient_name?: string | null;
|
||||||
|
type?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SettlementStatus = "pending" | "approved" | "rejected";
|
export type SettlementStatus = "pending" | "approved" | "rejected";
|
||||||
|
|||||||
+47
-4
@@ -600,6 +600,11 @@ List all payments.
|
|||||||
| `limit` | integer | ❌ | Default: 20 |
|
| `limit` | integer | ❌ | Default: 20 |
|
||||||
| `status` | string | ❌ | `"pending"`, `"paid"`, `"failed"`, `"cancelled"` |
|
| `status` | string | ❌ | `"pending"`, `"paid"`, `"failed"`, `"cancelled"` |
|
||||||
|
|
||||||
|
### Query Parameters (تکمیل)
|
||||||
|
| Param | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `search` | string | ❌ | جستجو در موبایل کاربر، `reference_id` یا `order_id` |
|
||||||
|
|
||||||
### Response `200`
|
### Response `200`
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -607,17 +612,55 @@ List all payments.
|
|||||||
"data": [
|
"data": [
|
||||||
{
|
{
|
||||||
"uuid": "...",
|
"uuid": "...",
|
||||||
"order_id": "CLINICPRO-...",
|
"amount": 500000,
|
||||||
"amount_rials": 500000,
|
"status": "success",
|
||||||
"status": "paid",
|
|
||||||
"gateway": "mellat",
|
"gateway": "mellat",
|
||||||
"created_at": 1717000000
|
"ref_id": "1234567",
|
||||||
|
"patient_mobile": "0912...",
|
||||||
|
"paid_at": "2026-07-02T09:00:00+03:30",
|
||||||
|
"created_at": "2026-07-02T08:55:00+03:30"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"meta": { "totalRecords": 7800, "totalPages": 390, "currentPage": 1 }
|
"meta": { "totalRecords": 7800, "totalPages": 390, "currentPage": 1 }
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> `amount` بر حسب ریال، `ref_id` همان `reference_id` درگاه، `paid_at` فقط برای پرداخت `success` (بر اساس `updated_at`) و در غیر اینصورت `null`. تاریخها ISO-8601.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### GET `/api/v1/admin/payments/{uuid}`
|
||||||
|
|
||||||
|
جزئیات یک پرداخت. **پاسخ تخت است** (`data` مستقیم آبجکت پرداخت، نه nested).
|
||||||
|
|
||||||
|
**Permission:** `ROLE_ADMIN`
|
||||||
|
|
||||||
|
### Response `200`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"uuid": "...",
|
||||||
|
"order_id": "ORD-XXXX",
|
||||||
|
"amount": 500000,
|
||||||
|
"status": "success",
|
||||||
|
"gateway": "mellat",
|
||||||
|
"type": "appointment",
|
||||||
|
"ref_id": "1234567",
|
||||||
|
"patient_mobile": "0912...",
|
||||||
|
"patient_name": "علی احمدی",
|
||||||
|
"appointment_uuid": "...",
|
||||||
|
"paid_at": "2026-07-02T09:00:00+03:30",
|
||||||
|
"created_at": "2026-07-02T08:55:00+03:30"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Errors
|
||||||
|
| Code | HTTP | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `ERR_NOT_FOUND_001` | 404 | پرداخت یافت نشد |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Settlement Management
|
## Settlement Management
|
||||||
|
|||||||
@@ -874,6 +874,54 @@ class AdminApiController extends BaseController
|
|||||||
return $this->paginated($items, (int) $total, $page, $limit);
|
return $this->paginated($items, (int) $total, $page, $limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[OA\Get(
|
||||||
|
path: '/api/v1/admin/payments/{uuid}',
|
||||||
|
summary: 'Payment detail by UUID',
|
||||||
|
security: [['bearerAuth' => []]],
|
||||||
|
parameters: [
|
||||||
|
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
|
||||||
|
],
|
||||||
|
responses: [
|
||||||
|
new OA\Response(response: 200, description: 'Payment detail'),
|
||||||
|
new OA\Response(response: 404, description: 'Payment not found'),
|
||||||
|
]
|
||||||
|
)]
|
||||||
|
#[Route('/api/v1/admin/payments/{uuid}', methods: ['GET'])]
|
||||||
|
public function paymentDetail(string $uuid): JsonResponse
|
||||||
|
{
|
||||||
|
$rows = $this->em->createQueryBuilder()
|
||||||
|
->select(
|
||||||
|
'p.uuid, p.orderId, p.amountRials, p.status, p.gateway, p.type, p.referenceId, p.createdAt, p.updatedAt',
|
||||||
|
'u.mobileNumber as patient_mobile, u.realName as patient_name',
|
||||||
|
'a.uuid as appointment_uuid',
|
||||||
|
)
|
||||||
|
->from(Payment::class, 'p')
|
||||||
|
->join('p.user', 'u')
|
||||||
|
->leftJoin('p.appointment', 'a')
|
||||||
|
->where('p.uuid = :uuid')->setParameter('uuid', $uuid)
|
||||||
|
->getQuery()->getArrayResult();
|
||||||
|
|
||||||
|
if (empty($rows)) {
|
||||||
|
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'پرداخت یافت نشد', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$p = $rows[0];
|
||||||
|
return $this->success([
|
||||||
|
'uuid' => $p['uuid'],
|
||||||
|
'order_id' => $p['orderId'],
|
||||||
|
'amount' => (int) $p['amountRials'],
|
||||||
|
'status' => $p['status'],
|
||||||
|
'gateway' => $p['gateway'],
|
||||||
|
'type' => $p['type'],
|
||||||
|
'ref_id' => $p['referenceId'],
|
||||||
|
'patient_mobile' => $p['patient_mobile'],
|
||||||
|
'patient_name' => $p['patient_name'],
|
||||||
|
'appointment_uuid' => $p['appointment_uuid'],
|
||||||
|
'paid_at' => $p['status'] === 'success' ? date('c', (int) $p['updatedAt']) : null,
|
||||||
|
'created_at' => date('c', (int) $p['createdAt']),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Representations ───────────────────────────────────────────────────────
|
// ── Representations ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[OA\Get(
|
#[OA\Get(
|
||||||
|
|||||||
@@ -14,12 +14,15 @@ class MellatGateway implements PaymentGatewayInterface
|
|||||||
private readonly HttpClientInterface $httpClient,
|
private readonly HttpClientInterface $httpClient,
|
||||||
private readonly SiteConfigRepository $configRepo,
|
private readonly SiteConfigRepository $configRepo,
|
||||||
private readonly LoggerInterface $logger,
|
private readonly LoggerInterface $logger,
|
||||||
private readonly string $terminalId = '',
|
private readonly ?string $terminalId = '',
|
||||||
private readonly string $username = '',
|
private readonly ?string $username = '',
|
||||||
private readonly string $password = '',
|
private readonly ?string $password = '',
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function getName(): string { return 'mellat'; }
|
public function getName(): string
|
||||||
|
{
|
||||||
|
return 'mellat';
|
||||||
|
}
|
||||||
|
|
||||||
public function isConfigured(): bool
|
public function isConfigured(): bool
|
||||||
{
|
{
|
||||||
@@ -28,16 +31,18 @@ class MellatGateway implements PaymentGatewayInterface
|
|||||||
&& $this->cfg('mellat_password', $this->password) !== '';
|
&& $this->cfg('mellat_password', $this->password) !== '';
|
||||||
}
|
}
|
||||||
|
|
||||||
private function cfg(string $key, string $envFallback): string
|
private function cfg(string $key, ?string $envFallback): string
|
||||||
{
|
{
|
||||||
return $this->configRepo->get($key) ?: $envFallback;
|
return (string) ($this->configRepo->get($key) ?: $envFallback ?? '');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function initiate(int $amountRials, string $orderId, string $callbackUrl): PaymentInitResult
|
public function initiate(int $amountRials, string $orderId, string $callbackUrl): PaymentInitResult
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$response = $this->httpClient->request('POST',
|
$response = $this->httpClient->request(
|
||||||
'https://bpm.shaparak.ir/pgwchannel/services/pgw?wsdl', [
|
'POST',
|
||||||
|
'https://bpm.shaparak.ir/pgwchannel/services/pgw?wsdl',
|
||||||
|
[
|
||||||
'body' => $this->buildRequestPayload($amountRials, $orderId, $callbackUrl),
|
'body' => $this->buildRequestPayload($amountRials, $orderId, $callbackUrl),
|
||||||
'headers' => ['Content-Type' => 'text/xml; charset=utf-8'],
|
'headers' => ['Content-Type' => 'text/xml; charset=utf-8'],
|
||||||
'timeout' => 10,
|
'timeout' => 10,
|
||||||
@@ -82,8 +87,10 @@ class MellatGateway implements PaymentGatewayInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$response = $this->httpClient->request('POST',
|
$response = $this->httpClient->request(
|
||||||
'https://bpm.shaparak.ir/pgwchannel/services/pgw?wsdl', [
|
'POST',
|
||||||
|
'https://bpm.shaparak.ir/pgwchannel/services/pgw?wsdl',
|
||||||
|
[
|
||||||
'body' => $this->buildVerifyPayload($refId),
|
'body' => $this->buildVerifyPayload($refId),
|
||||||
'headers' => ['Content-Type' => 'text/xml; charset=utf-8'],
|
'headers' => ['Content-Type' => 'text/xml; charset=utf-8'],
|
||||||
'timeout' => 10,
|
'timeout' => 10,
|
||||||
@@ -164,6 +171,12 @@ XML;
|
|||||||
return trim($parts[1] ?? '');
|
return trim($parts[1] ?? '');
|
||||||
}
|
}
|
||||||
|
|
||||||
private function date(): string { return date('Ymd'); }
|
private function date(): string
|
||||||
private function time(): string { return date('His'); }
|
{
|
||||||
|
return date('Ymd');
|
||||||
|
}
|
||||||
|
private function time(): string
|
||||||
|
{
|
||||||
|
return date('His');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,19 +15,22 @@ class SepGateway implements PaymentGatewayInterface
|
|||||||
private readonly HttpClientInterface $httpClient,
|
private readonly HttpClientInterface $httpClient,
|
||||||
private readonly SiteConfigRepository $configRepo,
|
private readonly SiteConfigRepository $configRepo,
|
||||||
private readonly LoggerInterface $logger,
|
private readonly LoggerInterface $logger,
|
||||||
private readonly string $terminalId = '',
|
private readonly ?string $terminalId = '',
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function getName(): string { return 'sep'; }
|
public function getName(): string
|
||||||
|
{
|
||||||
|
return 'sep';
|
||||||
|
}
|
||||||
|
|
||||||
public function isConfigured(): bool
|
public function isConfigured(): bool
|
||||||
{
|
{
|
||||||
return $this->cfg('sep_terminal_id', $this->terminalId) !== '';
|
return $this->cfg('sep_terminal_id', $this->terminalId) !== '';
|
||||||
}
|
}
|
||||||
|
|
||||||
private function cfg(string $key, string $envFallback): string
|
private function cfg(string $key, ?string $envFallback): string
|
||||||
{
|
{
|
||||||
return $this->configRepo->get($key) ?: $envFallback;
|
return (string) ($this->configRepo->get($key) ?: $envFallback ?? '');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function initiate(int $amountRials, string $orderId, string $callbackUrl): PaymentInitResult
|
public function initiate(int $amountRials, string $orderId, string $callbackUrl): PaymentInitResult
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Tests\Payment;
|
||||||
|
|
||||||
|
use App\Config\Repository\SiteConfigRepository;
|
||||||
|
use App\Payment\Gateway\MellatGateway;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||||
|
|
||||||
|
class MellatGatewayTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testConstructorAcceptsNullCredentials(): void
|
||||||
|
{
|
||||||
|
$httpClient = $this->createMock(HttpClientInterface::class);
|
||||||
|
$configRepo = $this->createMock(SiteConfigRepository::class);
|
||||||
|
$logger = $this->createMock(LoggerInterface::class);
|
||||||
|
|
||||||
|
$configRepo->method('get')->willReturn(null);
|
||||||
|
|
||||||
|
$gateway = new MellatGateway($httpClient, $configRepo, $logger, null, null, null);
|
||||||
|
|
||||||
|
$this->assertFalse($gateway->isConfigured());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user