ui phase 6

This commit is contained in:
hamid
2026-07-19 09:49:25 +03:30
parent 4c70d8e424
commit a438edeeaa
54 changed files with 1766 additions and 475 deletions
@@ -8,6 +8,7 @@ import type {
InitiatePaymentResult,
InvoiceDto,
PaymentApi,
PaymentHistoryItem,
PaymentOutcomeDto,
} from '../types';
@@ -70,10 +71,19 @@ export const paymentClientApi: PaymentApi = {
// REQ-017: the b8 DTO gives no way to reach the converted booking — the confirmation falls back
// to the bookings list until the field lands.
bookingId: null,
// REQ-046: no client-readable transaction read exists yet — the receipt hides these rows rather
// than render a fabricated tracking code or paid-at timestamp.
trackingCode: null,
paidAt: null,
};
return outcome;
},
getInvoice: async (bookingId: number) =>
unwrap(await clientFetch<ApiEnvelope<InvoiceDto>>(`${INVOICES}/${bookingId}`)),
getPaymentHistory: async () =>
// REQ-047 proposed slug — no customer payment-transactions list exists yet; 404s until delivered
// (the wallet «پرداخت‌ها» tab renders its empty state until then).
unwrap(await clientFetch<ApiEnvelope<PaymentHistoryItem[]>>(`${BOOKINGS}/payment_history`)),
};
+32 -8
View File
@@ -1,6 +1,5 @@
import { multiplyIrr, parseIrr, sleep } from '@/utils';
import { ApiError } from '@/lib/api/errors';
import { ROUTES } from '@/constants';
import {
bookingRequestsMockApi,
mockMarkBookingRequestConverted,
@@ -8,9 +7,8 @@ import {
import { mockInsertConvertedBooking } from '@/services/bookings/apis/mockApi';
import type { BookingRequestDto } from '@/services/bookingRequests/types';
import {
CHECKOUT_QUERY_REQUEST_ID,
CHECKOUT_QUERY_TRANSACTION_ID,
MOCK_PLATFORM_FEE_RATE,
MOCK_SELLER_FISCAL_IDENTITY,
MOCK_VAT_RATE,
} from '../constants';
import type {
@@ -65,6 +63,9 @@ interface MockTransaction {
gatewayReferenceCode: string;
grossPriceIrr: string;
bookingId: number | null;
createdAt: string;
/** Set at capture — the receipt's «تاریخ پرداخت» (REQ-046). */
capturedAt: string | null;
}
// Module-level stores (one browser session) — the same singleton pattern as the f7/f8 mocks, so the
@@ -93,6 +94,8 @@ function toOutcome(request: BookingRequestDto, transaction: MockTransaction | un
requestStatus: request.status,
transactionStatus: transaction?.status ?? null,
bookingId: request.bookingId ?? transaction?.bookingId ?? null,
trackingCode: transaction?.gatewayReferenceCode ?? null,
paidAt: transaction?.capturedAt ?? null,
};
}
@@ -130,6 +133,7 @@ function capture(request: BookingRequestDto, transaction: MockTransaction): Paym
const converted = mockMarkBookingRequestConverted(request.id, booking.id);
transaction.status = 'succeeded';
transaction.bookingId = booking.id;
transaction.capturedAt = new Date().toISOString();
invoices[booking.id] = {
id: nextInvoiceId,
@@ -145,6 +149,9 @@ function capture(request: BookingRequestDto, transaction: MockTransaction): Paym
moadianStatus: 'pending',
pdfUrl: null,
issuedAt: new Date().toISOString(),
paymentMethod: 'card',
transactionReference: transaction.gatewayReferenceCode,
sellerFiscalIdentity: MOCK_SELLER_FISCAL_IDENTITY,
};
nextInvoiceId += 1;
@@ -168,6 +175,11 @@ export const paymentMockApi: PaymentApi = {
bookingRequestId: request.id,
requestStatus: request.status,
nurseName: request.nurseName,
// The mock stands in for REQ-046 (nurseAvatarUrl/nurseVerified aren't on BookingRequestDto) — every
// seeded request nurse is verified by construction; there is no avatar in the f7 mock store, so the
// C6 identity moment falls back to initials (never a fabricated image URL).
nurseAvatarUrl: null,
nurseVerified: true,
patientName: request.patientName,
variantLabel: request.variantLabel,
variantPriceUnit: request.variantPriceUnit,
@@ -213,6 +225,8 @@ export const paymentMockApi: PaymentApi = {
gatewayReferenceCode: `mock-ref-${bookingRequestId}-${idempotencyKey.slice(0, 8)}`,
grossPriceIrr: requestGross(request),
bookingId: null,
createdAt: new Date().toISOString(),
capturedAt: null,
};
transactions = [transaction, ...transactions];
return toInitiateResult(transaction);
@@ -252,16 +266,26 @@ export const paymentMockApi: PaymentApi = {
if (!invoice) throw new ApiError(404, 'Invoice not issued', 'not_issued');
return { ...invoice };
},
getPaymentHistory: async () => {
await sleep(MOCK_LATENCY_MS);
return transactions.map((t) => ({
transactionId: t.transactionId,
bookingRequestId: t.bookingRequestId,
bookingId: t.bookingId,
status: t.status,
amountIrr: t.grossPriceIrr,
createdAt: t.createdAt,
}));
},
};
function toInitiateResult(transaction: MockTransaction): InitiatePaymentResult {
const query = new URLSearchParams();
query.set(CHECKOUT_QUERY_TRANSACTION_ID, String(transaction.transactionId));
query.set(CHECKOUT_QUERY_REQUEST_ID, String(transaction.bookingRequestId));
return {
transactionId: transaction.transactionId,
// App-relative (the checkout prepends the locale); the real PSP returns an absolute https URL.
redirectUrl: `${ROUTES.CHECKOUT_GATEWAY}?${query.toString()}`,
// No gateway hop to make — the dev card-gateway harness was retired in refinement-phase-4. The
// checkout page's `!result.redirectUrl` branch already handles this: it reads the outcome directly.
redirectUrl: null,
gatewayReferenceCode: transaction.gatewayReferenceCode,
};
}
+10
View File
@@ -52,3 +52,13 @@ export const CHECKOUT_QUERY_OUTCOME = 'outcome';
*/
export const MOCK_PLATFORM_FEE_RATE = 0.12;
export const MOCK_VAT_RATE = 0.1;
/**
* Mock-only seller fiscal identity (REQ-049) — a fixed platform-level fact (not per-invoice data) the
* real path serves from platform config once registered. Placeholder values, never a real economic code.
*/
export const MOCK_SELLER_FISCAL_IDENTITY = {
legalName: 'شرکت بالین‌یار',
economicCode: null,
address: null,
};
@@ -0,0 +1,17 @@
import { useQuery } from '@tanstack/react-query';
import { paymentApi } from '../apis';
import { paymentKeys } from '../keys';
import { CHECKOUT_SUMMARY_STALE_TIME } from '../constants';
/**
* The customer's card payment history (wallet «پرداخت‌ها» tab, REQ-047). BNPL rows are sourced separately
* from `services/bnpl`'s wallet installments — the two domains stay independent seams; the wallet screen
* merges them for display.
*/
export function usePaymentHistory() {
return useQuery({
queryKey: paymentKeys.history(),
queryFn: () => paymentApi.getPaymentHistory(),
staleTime: CHECKOUT_SUMMARY_STALE_TIME,
});
}
+1
View File
@@ -7,3 +7,4 @@ export { useInitiatePayment } from './hooks/useInitiatePayment';
export { useConfirmGatewayReturn } from './hooks/useConfirmGatewayReturn';
export { usePaymentOutcome } from './hooks/usePaymentOutcome';
export { useInvoice } from './hooks/useInvoice';
export { usePaymentHistory } from './hooks/usePaymentHistory';
+1
View File
@@ -11,4 +11,5 @@ export const paymentKeys = {
outcome: (bookingRequestId: number) => [...paymentKeys.outcomes(), bookingRequestId] as const,
invoices: () => [...paymentKeys.all, 'invoice'] as const,
invoice: (bookingId: number) => [...paymentKeys.invoices(), bookingId] as const,
history: () => [...paymentKeys.all, 'history'] as const,
};
+32
View File
@@ -42,6 +42,9 @@ export interface CheckoutSummaryDto {
/** C6 renders only for `accepted_awaiting_payment`; other statuses get a convergence/terminal card. */
requestStatus: BookingRequestStatus;
nurseName: string;
/** The C6 identity moment (REQ-046) — `null` on the real path until served; the avatar/badge hide gracefully. */
nurseAvatarUrl: string | null;
nurseVerified: boolean;
patientName: string;
variantLabel: string;
variantPriceUnit: PriceUnit;
@@ -107,6 +110,21 @@ export interface PaymentOutcomeDto {
transactionStatus: PaymentTransactionStatus | null;
/** The confirmed booking to link to (client-augmented; `null` on the real path until REQ-017 lands). */
bookingId: number | null;
/** کد پیگیری — the receipt's copyable reference (REQ-046). `null` on the real path until served; the
* confirmation receipt hides the row rather than render a fabricated code. */
trackingCode: string | null;
/** UTC ISO timestamp of capture (REQ-046) — `null` until served on the real path. */
paidAt: string | null;
}
/** One row of the customer's card/BNPL payment history (REQ-047 — the wallet «پرداخت‌ها» tab). */
export interface PaymentHistoryItem {
transactionId: number;
bookingRequestId: number;
bookingId: number | null;
status: PaymentTransactionStatus;
amountIrr: string;
createdAt: string;
}
/** succeeded/failed transaction, or a request that left the payable state — nothing left to poll. */
@@ -118,6 +136,13 @@ export function isTerminalPaymentOutcome(outcome: PaymentOutcomeDto): boolean {
);
}
/** Seller fiscal-identity block (REQ-049) — a fixed platform-level fact, not per-invoice data. */
export interface InvoiceSellerFiscalIdentity {
legalName: string;
economicCode: string | null;
address: string | null;
}
/** `InvoiceDto` (b11 swagger, `GET invoices/{bookingId}`) — flat totals; VAT is on the commission line only. */
export interface InvoiceDto {
id: number;
@@ -135,6 +160,11 @@ export interface InvoiceDto {
moadianStatus: MoadianStatus | null;
pdfUrl: string | null;
issuedAt: string;
/** --- Fiscal-grade fields (REQ-049): `null` on the real path until served — the invoice hides the row. --- */
paymentMethod: 'card' | 'bnpl' | null;
/** Opaque payment/settlement reference — never parsed. */
transactionReference: string | null;
sellerFiscalIdentity: InvoiceSellerFiscalIdentity | null;
}
/**
@@ -147,4 +177,6 @@ export interface PaymentApi {
confirmGatewayReturn(input: ConfirmGatewayReturnInput): Promise<PaymentOutcomeDto>;
getPaymentOutcome(bookingRequestId: number): Promise<PaymentOutcomeDto>;
getInvoice(bookingId: number): Promise<InvoiceDto>;
/** The customer's card payment history (REQ-047 — wallet «پرداخت‌ها»; BNPL rows come from `services/bnpl`). */
getPaymentHistory(): Promise<PaymentHistoryItem[]>;
}
@@ -98,6 +98,13 @@ export const refundsClientApi: RefundsApi = {
getRefund: async (refundId: number) =>
toSummary(unwrap(await clientFetch<ApiEnvelope<RefundStatusWire>>(`${REFUNDS}/${refundId}/status`))),
// REQ-048 proposed slug — no "all my refunds" list exists yet (only by-booking/by-id reads); 404s
// until delivered (the wallet «استردادها» tab renders its empty state until then).
getMyRefunds: async () => {
const wire = unwrap(await clientFetch<ApiEnvelope<RefundStatusWire[]>>(`${REFUNDS}/my`));
return wire.map(toSummary);
},
// REQ-035: refund preview endpoint. b11 computes the fee-leg decomposition only *on create* (there is no
// read-only preview route), yet the admin console must disclose the split before initiating. Filed as a
// proposed `GET api/v1/admin_refunds/preview?booking_id=&ticket_id=`; the mock serves it today.
@@ -356,6 +356,16 @@ export const refundsMockApi: RefundsApi = {
return toRefundSummary(refund);
},
getMyRefunds: async () => {
await sleep(MOCK_LATENCY_MS);
const refunds = Object.values(refundsByBooking);
refunds.forEach(advanceRefund);
return refunds
.slice()
.sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? ''))
.map(toRefundSummary);
},
// --- Admin refund tooling (ticket-linked; the mock serves the whole console this phase). ---
getRefundPreview: async (bookingId, _ticketId) => {
@@ -0,0 +1,13 @@
import { useQuery } from '@tanstack/react-query';
import { refundsApi } from '../apis';
import { refundKeys } from '../keys';
import { REFUND_STATUS_STALE_TIME } from '../constants';
/** Every refund the customer owns (wallet «استردادها» tab, REQ-048) — newest first. */
export function useMyRefunds() {
return useQuery({
queryKey: refundKeys.mine(),
queryFn: () => refundsApi.getMyRefunds(),
staleTime: REFUND_STATUS_STALE_TIME,
});
}
+1
View File
@@ -5,6 +5,7 @@
export { useCancellationPolicyPreview } from './hooks/useCancellationPolicyPreview';
export { useCancelBooking } from './hooks/useCancelBooking';
export { useRefundStatus } from './hooks/useRefundStatus';
export { useMyRefunds } from './hooks/useMyRefunds';
// Admin refund tooling (b11 admin_refunds; ticket-linked).
export { useRefundPreview } from './hooks/useRefundPreview';
+2
View File
@@ -13,6 +13,8 @@ export const refundKeys = {
byBookings: () => [...refundKeys.all, 'by_booking'] as const,
byBooking: (bookingId: number) => [...refundKeys.byBookings(), bookingId] as const,
mine: () => [...refundKeys.all, 'mine'] as const,
details: () => [...refundKeys.all, 'detail'] as const,
detail: (refundId: number) => [...refundKeys.details(), refundId] as const,
+2
View File
@@ -250,6 +250,8 @@ export interface RefundsApi {
/** `null` when the booking has no refund (e.g. not cancelled) — a clean empty state, not an error. */
getRefundByBooking(bookingId: number): Promise<RefundSummary | null>;
getRefund(refundId: number): Promise<RefundSummary>;
/** Every refund the caller owns, newest first (REQ-048 — the wallet «استردادها» tab). */
getMyRefunds(): Promise<RefundSummary[]>;
/* --- Admin refund tooling (b11 admin_refunds; every initiate is ticket-linked). --- */
/** The server's fee-leg decomposition preview for a booking (`ticketId` = the linking ticket, or null). */