ui phase 6
This commit is contained in:
@@ -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`)),
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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). */
|
||||
|
||||
Reference in New Issue
Block a user