frontend phase 10
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
'use client';
|
||||
import { useState } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Checkbox, FormControlLabel, MenuItem, Paper, Stack, TextField, Typography } from '@mui/material';
|
||||
import AppButton from '@/components/common/AppButton';
|
||||
import AppAlert from '@/components/common/AppAlert';
|
||||
import AppLoading from '@/components/common/AppLoading';
|
||||
import StepperHeader from '@/components/StepperHeader';
|
||||
import CancellationPolicyDisclosure from '@/components/CancellationPolicyDisclosure';
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
import { bookingRefundStatusPath, ROUTES } from '@/constants';
|
||||
import { formatIrrToToman } from '@/utils';
|
||||
import { useCancelBooking, useCancellationPolicyPreview } from '@/services/refunds';
|
||||
import type { CancelReasonCategory } from '@/services/refunds/types';
|
||||
|
||||
const REASON_CATEGORIES: CancelReasonCategory[] = [
|
||||
'changed_mind',
|
||||
'schedule_conflict',
|
||||
'found_other_care',
|
||||
'other',
|
||||
];
|
||||
|
||||
/** Maps the cancel mutation's `409` code to a user-facing message; anything else is the generic failure. */
|
||||
function cancelErrorKey(error: unknown): string {
|
||||
if (error instanceof ApiError) {
|
||||
if (error.code === 'not_cancellable') return 'err_not_cancellable';
|
||||
if (error.code === 'nothing_refundable' || error.code === 'session_not_refundable') {
|
||||
return 'err_nothing_refundable';
|
||||
}
|
||||
}
|
||||
return 'err_generic';
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancellation flow (f10) — the trust-first exit. Step 1 **discloses** the resolved policy tier, the
|
||||
* refund % + fee %, and the concrete Toman amounts (refunded vs kept) **before** anything is submitted;
|
||||
* the confirm button is gated behind an explicit acknowledgement. Step 2 restates the numbers and submits
|
||||
* via `useCancelBooking` (which invalidates the booking + primes the refund cache), then routes to the
|
||||
* refund status. Refunds are admin-approved — the copy makes clear the request is *submitted* and
|
||||
* *processed by the team*, never self-issued.
|
||||
*/
|
||||
export default function CancelBookingPage() {
|
||||
const params = useParams<{ id: string }>();
|
||||
const router = useRouter();
|
||||
const locale = useLocale();
|
||||
const t = useTranslations('refunds');
|
||||
const tc = useTranslations('common');
|
||||
|
||||
const rawId = Number(params.id);
|
||||
const bookingId = Number.isInteger(rawId) && rawId > 0 ? rawId : undefined;
|
||||
|
||||
const { data: preview, isLoading, isError } = useCancellationPolicyPreview(bookingId);
|
||||
const cancel = useCancelBooking();
|
||||
|
||||
const [step, setStep] = useState<0 | 1>(0);
|
||||
const [acknowledged, setAcknowledged] = useState(false);
|
||||
const [reasonCategory, setReasonCategory] = useState<CancelReasonCategory>('changed_mind');
|
||||
const [reasonNotes, setReasonNotes] = useState('');
|
||||
|
||||
const bookingHref = `/${locale}${ROUTES.BOOKINGS}/${bookingId}`;
|
||||
|
||||
if (isLoading) return <AppLoading />;
|
||||
|
||||
if (isError || !preview || bookingId == null) {
|
||||
return (
|
||||
<Stack sx={{ gap: 2, maxWidth: 640, mx: 'auto', width: '100%', py: 2 }}>
|
||||
<AppAlert severity="error" variant="outlined" sx={{ marginY: 0 }}>
|
||||
<Stack sx={{ gap: 0.5 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||||
{t('error_title')}
|
||||
</Typography>
|
||||
<Typography variant="body2">{t('error_body')}</Typography>
|
||||
</Stack>
|
||||
</AppAlert>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (!preview.cancellable) {
|
||||
return (
|
||||
<Stack sx={{ gap: 2, maxWidth: 640, mx: 'auto', width: '100%', py: 2 }}>
|
||||
<AppAlert severity="info" variant="outlined" sx={{ marginY: 0 }}>
|
||||
<Stack sx={{ gap: 0.5 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||||
{t('not_cancellable_title')}
|
||||
</Typography>
|
||||
<Typography variant="body2">{t('not_cancellable_body')}</Typography>
|
||||
</Stack>
|
||||
</AppAlert>
|
||||
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
|
||||
<AppButton
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={() => router.push(`/${locale}${bookingRefundStatusPath(bookingId)}`)}
|
||||
sx={{ m: 0 }}
|
||||
>
|
||||
{t('view_refund_status')}
|
||||
</AppButton>
|
||||
<AppButton variant="text" color="inherit" onClick={() => router.push(bookingHref)} sx={{ m: 0 }}>
|
||||
{t('back_to_booking')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const refundToman = `${formatIrrToToman(preview.refundAmountIrr, locale)} ${tc('currency_toman')}`;
|
||||
const feeToman = `${formatIrrToToman(preview.feeAmountIrr, locale)} ${tc('currency_toman')}`;
|
||||
|
||||
const submit = () =>
|
||||
cancel.mutate(
|
||||
{
|
||||
bookingId,
|
||||
sessionIds: preview.refundableSessionIds,
|
||||
reasonCategory,
|
||||
reasonNotes: reasonNotes.trim() || undefined,
|
||||
},
|
||||
{ onSuccess: () => router.push(`/${locale}${bookingRefundStatusPath(bookingId)}`) },
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 3, maxWidth: 640, mx: 'auto', width: '100%', py: 2 }}>
|
||||
<Typography variant="h5" component="h1" sx={{ fontWeight: 800 }}>
|
||||
{t('cancel_title')}
|
||||
</Typography>
|
||||
<StepperHeader steps={[t('step_review'), t('step_confirm')]} activeStep={step} />
|
||||
|
||||
{step === 0 ? (
|
||||
<>
|
||||
<CancellationPolicyDisclosure preview={preview} />
|
||||
|
||||
<TextField
|
||||
select
|
||||
label={t('reason_field_label')}
|
||||
value={reasonCategory}
|
||||
onChange={(event) => setReasonCategory(event.target.value as CancelReasonCategory)}
|
||||
fullWidth
|
||||
>
|
||||
{REASON_CATEGORIES.map((category) => (
|
||||
<MenuItem key={category} value={category}>
|
||||
{t(`reason_cat_${category}`)}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
label={t('reason_notes_label')}
|
||||
value={reasonNotes}
|
||||
onChange={(event) => setReasonNotes(event.target.value)}
|
||||
multiline
|
||||
minRows={2}
|
||||
fullWidth
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={<Checkbox checked={acknowledged} onChange={(event) => setAcknowledged(event.target.checked)} />}
|
||||
label={t('acknowledge_label')}
|
||||
/>
|
||||
|
||||
<Stack direction="row" sx={{ gap: 1, justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
||||
<AppButton variant="text" color="inherit" onClick={() => router.push(bookingHref)} sx={{ m: 0 }}>
|
||||
{t('back_to_booking')}
|
||||
</AppButton>
|
||||
<AppButton
|
||||
variant="contained"
|
||||
color="primary"
|
||||
disabled={!acknowledged}
|
||||
onClick={() => setStep(1)}
|
||||
sx={{ m: 0 }}
|
||||
>
|
||||
{t('continue_cta')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 800, mb: 1 }}>
|
||||
{t('confirm_title')}
|
||||
</Typography>
|
||||
<Typography variant="body2">{t('confirm_restate', { refund: refundToman, fee: feeToman })}</Typography>
|
||||
</Paper>
|
||||
|
||||
{cancel.isError && (
|
||||
<AppAlert severity="error" variant="outlined" sx={{ marginY: 0 }}>
|
||||
{t(cancelErrorKey(cancel.error))}
|
||||
</AppAlert>
|
||||
)}
|
||||
|
||||
<Stack direction="row" sx={{ gap: 1, justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
||||
<AppButton
|
||||
variant="text"
|
||||
color="inherit"
|
||||
onClick={() => setStep(0)}
|
||||
disabled={cancel.isPending}
|
||||
sx={{ m: 0 }}
|
||||
>
|
||||
{tc('back')}
|
||||
</AppButton>
|
||||
<AppButton
|
||||
variant="contained"
|
||||
color="error"
|
||||
onClick={submit}
|
||||
disabled={cancel.isPending}
|
||||
sx={{ m: 0 }}
|
||||
>
|
||||
{cancel.isPending ? t('submitting') : t('confirm_cta')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,87 @@
|
||||
'use client';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Stack, Typography } from '@mui/material';
|
||||
import { BookingDetailView } from '@/components/booking';
|
||||
import RefundStatusCard from '@/components/RefundStatusCard';
|
||||
import AppButton from '@/components/common/AppButton';
|
||||
import { bookingCancelPath, bookingRefundStatusPath } from '@/constants';
|
||||
import { useBookingDetail } from '@/services/bookings';
|
||||
import { useRefundStatus } from '@/services/refunds';
|
||||
import { isBookingCancellable } from '@/services/refunds/types';
|
||||
|
||||
/**
|
||||
* Customer booking detail (`/bookings/{id}`) — the read-only both-roles view in the **customer** shell:
|
||||
* server-truth status timeline, session schedule, and money summary. Care instructions are gated to the
|
||||
* assigned nurse, so the customer sees the "visible to your nurse only" affordance (the query never fires).
|
||||
*
|
||||
* f10 hangs the cancellation/refund entry off this screen: a **Cancel booking** CTA while the booking is
|
||||
* cancellable, or the **refund status** section once it's cancelled — both page-only glue (the booking
|
||||
* domain stays decoupled from refunds).
|
||||
*/
|
||||
export default function CustomerBookingDetailPage() {
|
||||
const params = useParams<{ id: string }>();
|
||||
const id = Number(params.id);
|
||||
return <BookingDetailView bookingId={Number.isInteger(id) && id > 0 ? id : -1} viewerRole="customer" />;
|
||||
const bookingId = Number.isInteger(id) && id > 0 ? id : -1;
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 3 }}>
|
||||
<BookingDetailView bookingId={bookingId} viewerRole="customer" />
|
||||
{bookingId > 0 && <CustomerBookingActions bookingId={bookingId} />}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The customer's cancel/refund entry — reads the already-cached booking detail (same query key as
|
||||
* `BookingDetailView`, so no extra fetch) to decide between the Cancel CTA and the refund section. The
|
||||
* refund read is enabled only once the booking is cancelled, so an active booking triggers no refund query.
|
||||
*/
|
||||
function CustomerBookingActions({ bookingId }: { bookingId: number }) {
|
||||
const router = useRouter();
|
||||
const locale = useLocale();
|
||||
const t = useTranslations('refunds');
|
||||
|
||||
const { data: booking } = useBookingDetail(bookingId, 'customer');
|
||||
const isCancelled = booking?.status === 'cancelled';
|
||||
const { data: refund } = useRefundStatus(bookingId, { enabled: isCancelled });
|
||||
|
||||
if (!booking) return null;
|
||||
|
||||
if (isBookingCancellable(booking.status)) {
|
||||
return (
|
||||
<Stack sx={{ maxWidth: 640, mx: 'auto', width: '100%' }}>
|
||||
<AppButton
|
||||
variant="outlined"
|
||||
color="error"
|
||||
startIcon="rejected"
|
||||
onClick={() => router.push(`/${locale}${bookingCancelPath(bookingId)}`)}
|
||||
sx={{ m: 0 }}
|
||||
>
|
||||
{t('cancel_booking_cta')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (isCancelled && refund) {
|
||||
return (
|
||||
<Stack sx={{ gap: 1.5, maxWidth: 640, mx: 'auto', width: '100%' }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{t('refund_section_title')}
|
||||
</Typography>
|
||||
<RefundStatusCard refund={refund} />
|
||||
<AppButton
|
||||
variant="text"
|
||||
color="primary"
|
||||
onClick={() => router.push(`/${locale}${bookingRefundStatusPath(bookingId)}`)}
|
||||
sx={{ m: 0, alignSelf: 'flex-start' }}
|
||||
>
|
||||
{t('view_refund_status')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
'use client';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Stack, Typography } from '@mui/material';
|
||||
import AppButton from '@/components/common/AppButton';
|
||||
import AppAlert from '@/components/common/AppAlert';
|
||||
import AppLoading from '@/components/common/AppLoading';
|
||||
import RefundStatusCard from '@/components/RefundStatusCard';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { useRefundStatus } from '@/services/refunds';
|
||||
|
||||
/**
|
||||
* Customer refund status (f10) — read-only. Renders the three-step progress (pending → on-its-way →
|
||||
* completed), the refunded amount, the honest per-channel ETA (BNPL's ~7–10-day window), and — where the
|
||||
* backend serves it — the fee-leg split. `failed` shows a needs-attention / contact-support state, never a
|
||||
* retry (retry is admin-only, DEFERRED to f15). Polling runs only while the refund is non-terminal (see
|
||||
* `useRefundStatus`). An empty state renders when the booking has no refund (e.g. it wasn't cancelled).
|
||||
*/
|
||||
export default function RefundStatusPage() {
|
||||
const params = useParams<{ id: string }>();
|
||||
const router = useRouter();
|
||||
const locale = useLocale();
|
||||
const t = useTranslations('refunds');
|
||||
|
||||
const rawId = Number(params.id);
|
||||
const bookingId = Number.isInteger(rawId) && rawId > 0 ? rawId : undefined;
|
||||
|
||||
const { data: refund, isLoading, isError } = useRefundStatus(bookingId);
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 3, maxWidth: 640, mx: 'auto', width: '100%', py: 2 }}>
|
||||
<Typography variant="h5" component="h1" sx={{ fontWeight: 800 }}>
|
||||
{t('status_title')}
|
||||
</Typography>
|
||||
|
||||
{isLoading ? (
|
||||
<AppLoading />
|
||||
) : isError ? (
|
||||
<AppAlert severity="error" variant="outlined" sx={{ marginY: 0 }}>
|
||||
<Stack sx={{ gap: 0.5 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||||
{t('error_title')}
|
||||
</Typography>
|
||||
<Typography variant="body2">{t('error_body')}</Typography>
|
||||
</Stack>
|
||||
</AppAlert>
|
||||
) : refund ? (
|
||||
<>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('status_subtitle')}
|
||||
</Typography>
|
||||
<RefundStatusCard refund={refund} />
|
||||
</>
|
||||
) : (
|
||||
<AppAlert severity="info" variant="outlined" sx={{ marginY: 0 }}>
|
||||
<Stack sx={{ gap: 0.5 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||||
{t('no_refund_title')}
|
||||
</Typography>
|
||||
<Typography variant="body2">{t('no_refund_body')}</Typography>
|
||||
</Stack>
|
||||
</AppAlert>
|
||||
)}
|
||||
|
||||
<AppButton
|
||||
variant="text"
|
||||
color="inherit"
|
||||
onClick={() => router.push(`/${locale}${ROUTES.BOOKINGS}/${bookingId}`)}
|
||||
sx={{ m: 0, alignSelf: 'flex-start' }}
|
||||
>
|
||||
{t('back_to_booking')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
import { FunctionComponent } from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { ThemeProvider } from '../../theme';
|
||||
import { formatIrrToToman } from '@/utils';
|
||||
import type { CancellationPolicyPreview } from '@/services/refunds/types';
|
||||
|
||||
jest.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
useLocale: () => 'en',
|
||||
}));
|
||||
|
||||
import CancellationPolicyDisclosure, {
|
||||
CancellationPolicyDisclosureProps,
|
||||
} from './CancellationPolicyDisclosure';
|
||||
|
||||
const ComponentToTest: FunctionComponent<CancellationPolicyDisclosureProps> = (props) => (
|
||||
<ThemeProvider>
|
||||
<CancellationPolicyDisclosure {...props} />
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
// Multi-session, free tier: two un-started (refundable) + one completed (locked). refund + fee = total.
|
||||
const PREVIEW: CancellationPolicyPreview = {
|
||||
bookingId: 5003,
|
||||
cancellable: true,
|
||||
cancellationPolicyCode: 'free_24h',
|
||||
refundPercentageApplied: 1,
|
||||
feePercentage: 0,
|
||||
refundAmountIrr: '17600000',
|
||||
feeAmountIrr: '0',
|
||||
refundableAmountIrr: '17600000',
|
||||
platformFeeRefundedIrr: '2400000',
|
||||
nursePayoutRefundedIrr: '15200000',
|
||||
appliesTo: 'remaining_sessions',
|
||||
leadTimeLabel: 'gt_24h',
|
||||
refundChannel: 'psp_card',
|
||||
expectedCustomerRefundEta: null,
|
||||
refundableSessionIds: [70032, 70033],
|
||||
sessions: [
|
||||
{ bookingSessionId: 70031, sessionIndex: 1, scheduledDate: '2026-08-01', refundable: false, reasonCode: 'completed' },
|
||||
{ bookingSessionId: 70032, sessionIndex: 2, scheduledDate: '2026-08-05', refundable: true, reasonCode: 'un_started' },
|
||||
{ bookingSessionId: 70033, sessionIndex: 3, scheduledDate: '2026-08-07', refundable: true, reasonCode: 'un_started' },
|
||||
],
|
||||
};
|
||||
|
||||
describe('<CancellationPolicyDisclosure/> component', () => {
|
||||
it('renders the tier label off the policy code (never the raw code) and the fee %', () => {
|
||||
const { container } = render(<ComponentToTest preview={PREVIEW} />);
|
||||
expect(container.querySelector('[data-policy-code="free_24h"]')).toBeInTheDocument();
|
||||
expect(screen.getByText('policy_free_24h')).toBeInTheDocument();
|
||||
expect(screen.getByText('fee_percent')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a refund-vs-fee breakdown that reconciles to the refundable total', () => {
|
||||
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const { container } = render(<ComponentToTest preview={PREVIEW} />);
|
||||
expect(container.querySelector('[data-row="refund"]')).toBeInTheDocument();
|
||||
expect(container.querySelector('[data-row="fee"]')).toBeInTheDocument();
|
||||
expect(container.querySelector('[data-row="total"]')).toBeInTheDocument();
|
||||
// Free tier: the full refundable amount is returned, so it appears on both the refund row and the total.
|
||||
expect(screen.getAllByText(new RegExp(formatIrrToToman('17600000', 'en'))).length).toBeGreaterThanOrEqual(1);
|
||||
// No reconciliation warning — refund + fee equals the refundable total.
|
||||
expect(errorSpy).not.toHaveBeenCalled();
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('marks un-started sessions refundable and completed sessions locked', () => {
|
||||
const { container } = render(<ComponentToTest preview={PREVIEW} />);
|
||||
expect(container.querySelector('[data-session-id="70031"][data-refundable="false"]')).toBeInTheDocument();
|
||||
expect(container.querySelector('[data-session-id="70032"][data-refundable="true"]')).toBeInTheDocument();
|
||||
expect(screen.getByText('reason_completed')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('session_refundable').length).toBe(2);
|
||||
});
|
||||
|
||||
it('shows the admin-approval explainer and the channel ETA banner', () => {
|
||||
render(<ComponentToTest preview={PREVIEW} />);
|
||||
expect(screen.getByTestId('admin-approval-explainer')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('refund-eta-banner')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
'use client';
|
||||
import { FunctionComponent } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Divider, Paper, Stack, Typography } from '@mui/material';
|
||||
import PriceBreakdown from '@/components/PriceBreakdown';
|
||||
import StatusChip from '@/components/StatusChip';
|
||||
import AppAlert from '@/components/common/AppAlert';
|
||||
import AppIcon from '@/components/common/AppIcon';
|
||||
import RefundEtaBanner from '@/components/RefundEtaBanner';
|
||||
import { formatShamsiDate } from '@/utils';
|
||||
import type { CancellationPolicyPreview } from '@/services/refunds/types';
|
||||
|
||||
export interface CancellationPolicyDisclosureProps {
|
||||
preview: CancellationPolicyPreview;
|
||||
}
|
||||
|
||||
/** Percent (integer) from a 0–1 fraction — a small display number, never money, so JS math is safe. */
|
||||
function toPercent(fraction: number): number {
|
||||
return Math.round(fraction * 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* The pre-confirm cancellation disclosure (f10) — the whole point of the cancel screen: the applicable
|
||||
* policy tier (label off the `cancellation_policy_code` i18n key, never the raw code), the **refund % + fee
|
||||
* %**, the concrete refund-vs-fee split (reusing `PriceBreakdown`, which reconciles to the rial), the
|
||||
* multi-session refundable/locked breakdown, the admin-approved reality, and a per-channel ETA preview
|
||||
* (BNPL surfaces the ~7–10-day window honestly). Display-only — the reason field, acknowledgement and
|
||||
* confirm live in the page; shared so the confirm dialog and any future per-session cancel reuse it.
|
||||
* @component CancellationPolicyDisclosure
|
||||
*/
|
||||
const CancellationPolicyDisclosure: FunctionComponent<CancellationPolicyDisclosureProps> = ({ preview }) => {
|
||||
const t = useTranslations('refunds');
|
||||
const locale = useLocale();
|
||||
|
||||
const refundPercent = toPercent(preview.refundPercentageApplied);
|
||||
const feePercent = toPercent(preview.feePercentage);
|
||||
const isMultiSession = preview.sessions.length > 1;
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 2.5 }} data-testid="cancellation-disclosure" data-policy-code={preview.cancellationPolicyCode}>
|
||||
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
<Stack direction="row" sx={{ alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 800 }}>
|
||||
{t(`policy_${preview.cancellationPolicyCode}`)}
|
||||
</Typography>
|
||||
<StatusChip
|
||||
status={refundPercent > 0 ? 'active' : 'rejected'}
|
||||
label={t('refund_percent', { percent: refundPercent })}
|
||||
/>
|
||||
</Stack>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t(`lead_${preview.leadTimeLabel}`)}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'var(--bal-secondary)', fontWeight: 600 }}>
|
||||
{t('fee_percent', { percent: feePercent })}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<PriceBreakdown
|
||||
rows={[
|
||||
{ key: 'refund', label: t('row_refund'), amountIrr: preview.refundAmountIrr },
|
||||
{ key: 'fee', label: t('row_fee'), amountIrr: preview.feeAmountIrr },
|
||||
]}
|
||||
totalLabel={t('row_refundable_total')}
|
||||
totalAmountIrr={preview.refundableAmountIrr}
|
||||
/>
|
||||
|
||||
{isMultiSession && (
|
||||
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Stack sx={{ gap: 1.25 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||||
{t('sessions_title')}
|
||||
</Typography>
|
||||
<Divider />
|
||||
{preview.sessions.map((session) => (
|
||||
<Stack
|
||||
key={session.bookingSessionId}
|
||||
data-session-id={session.bookingSessionId}
|
||||
data-refundable={session.refundable}
|
||||
direction="row"
|
||||
sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 2, opacity: session.refundable ? 1 : 0.6 }}
|
||||
>
|
||||
<Typography variant="body2">
|
||||
{`${session.sessionIndex}. ${formatShamsiDate(session.scheduledDate, locale)}`}
|
||||
</Typography>
|
||||
<StatusChip
|
||||
status={session.refundable ? 'active' : 'neutral'}
|
||||
label={session.refundable ? t('session_refundable') : t(`reason_${session.reasonCode}`)}
|
||||
/>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<RefundEtaBanner channel={preview.refundChannel} eta={preview.expectedCustomerRefundEta} />
|
||||
|
||||
<AppAlert
|
||||
severity="info"
|
||||
variant="outlined"
|
||||
icon={<AppIcon icon="info" size={20} color="var(--bal-info)" />}
|
||||
data-testid="admin-approval-explainer"
|
||||
sx={{ marginY: 0 }}
|
||||
>
|
||||
{t('admin_approval_explainer')}
|
||||
</AppAlert>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default CancellationPolicyDisclosure;
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default } from './CancellationPolicyDisclosure';
|
||||
export type { CancellationPolicyDisclosureProps } from './CancellationPolicyDisclosure';
|
||||
@@ -0,0 +1,39 @@
|
||||
import { FunctionComponent } from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { ThemeProvider } from '../../theme';
|
||||
|
||||
// next-intl mocked to echo keys; locale = en so any date formats with ASCII we don't assert on.
|
||||
jest.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
useLocale: () => 'en',
|
||||
}));
|
||||
|
||||
import RefundEtaBanner, { RefundEtaBannerProps } from './RefundEtaBanner';
|
||||
|
||||
const ComponentToTest: FunctionComponent<RefundEtaBannerProps> = (props) => (
|
||||
<ThemeProvider>
|
||||
<RefundEtaBanner {...props} />
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
describe('<RefundEtaBanner/> component', () => {
|
||||
it('renders the BNPL wording + the ~7–10 business-day window and the ETA', () => {
|
||||
const { container } = render(<ComponentToTest channel="bnpl_revert" eta="2026-08-24" />);
|
||||
expect(container.querySelector('[data-channel="bnpl_revert"]')).toBeInTheDocument();
|
||||
expect(screen.getByText('eta_bnpl_title')).toBeInTheDocument();
|
||||
// The honest window note only renders for the BNPL channel.
|
||||
expect(screen.getByTestId('refund-eta-window')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the card wording and no BNPL window for a card refund', () => {
|
||||
render(<ComponentToTest channel="psp_card" eta={null} />);
|
||||
expect(screen.getByText('eta_card_title')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('refund-eta-window')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the manual-transfer wording for a manual refund', () => {
|
||||
render(<ComponentToTest channel="manual" eta={null} />);
|
||||
expect(screen.getByText('eta_manual_title')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('refund-eta-window')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
'use client';
|
||||
import { FunctionComponent } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Stack, Typography } from '@mui/material';
|
||||
import AppAlert from '@/components/common/AppAlert';
|
||||
import AppIcon from '@/components/common/AppIcon';
|
||||
import { formatShamsiDate } from '@/utils';
|
||||
import type { RefundChannel } from '@/services/refunds/types';
|
||||
|
||||
export interface RefundEtaBannerProps {
|
||||
channel: RefundChannel;
|
||||
/** Populated for `bnpl_revert` (the ~7–10 business-day window); a date `YYYY-MM-DD`. */
|
||||
eta: string | null;
|
||||
}
|
||||
|
||||
/** Per-channel title/body i18n keys + the icon glyph. One branch drives all three refund channels. */
|
||||
const CHANNEL_COPY: Record<RefundChannel, { titleKey: string; bodyKey: string; icon: string }> = {
|
||||
psp_card: { titleKey: 'eta_card_title', bodyKey: 'eta_card_body', icon: 'payment' },
|
||||
bnpl_revert: { titleKey: 'eta_bnpl_title', bodyKey: 'eta_bnpl_body', icon: 'schedule' },
|
||||
manual: { titleKey: 'eta_manual_title', bodyKey: 'eta_manual_body', icon: 'bank' },
|
||||
};
|
||||
|
||||
/**
|
||||
* How the refund reaches the customer, told honestly per channel. For `bnpl_revert` it surfaces the
|
||||
* `expected_customer_refund_eta` and the ~7–10 business-day window in plain language (the money returns
|
||||
* *through the provider*, never instantly) — the phase's load-bearing BNPL honesty rule. `psp_card` shows
|
||||
* the card-refund wording; `manual` the manual-transfer wording. Info tone (never error), tokens only.
|
||||
* @component RefundEtaBanner
|
||||
*/
|
||||
const RefundEtaBanner: FunctionComponent<RefundEtaBannerProps> = ({ channel, eta }) => {
|
||||
const t = useTranslations('refunds');
|
||||
const locale = useLocale();
|
||||
const copy = CHANNEL_COPY[channel];
|
||||
|
||||
return (
|
||||
<AppAlert
|
||||
severity="info"
|
||||
variant="outlined"
|
||||
icon={<AppIcon icon={copy.icon} size={20} color="var(--bal-primary)" />}
|
||||
data-testid="refund-eta-banner"
|
||||
data-channel={channel}
|
||||
sx={{
|
||||
marginY: 0,
|
||||
borderColor: 'var(--bal-primary)',
|
||||
color: 'var(--bal-primary)',
|
||||
backgroundColor: 'var(--bal-primary-soft)',
|
||||
}}
|
||||
>
|
||||
<Stack sx={{ gap: 0.5 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||||
{t(copy.titleKey)}
|
||||
</Typography>
|
||||
<Typography variant="body2">{t(copy.bodyKey)}</Typography>
|
||||
{channel === 'bnpl_revert' && (
|
||||
<Typography variant="caption" sx={{ fontWeight: 600 }} data-testid="refund-eta-window">
|
||||
{t('eta_business_days')}
|
||||
{eta ? ` · ${t('eta_expected_label', { date: formatShamsiDate(eta, locale) })}` : ''}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
</AppAlert>
|
||||
);
|
||||
};
|
||||
|
||||
export default RefundEtaBanner;
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default } from './RefundEtaBanner';
|
||||
export type { RefundEtaBannerProps } from './RefundEtaBanner';
|
||||
@@ -0,0 +1,83 @@
|
||||
import { FunctionComponent } from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { ThemeProvider } from '../../theme';
|
||||
import type { RefundSummary } from '@/services/refunds/types';
|
||||
|
||||
jest.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
useLocale: () => 'en',
|
||||
}));
|
||||
|
||||
import RefundStatusCard, { RefundStatusCardProps } from './RefundStatusCard';
|
||||
|
||||
const ComponentToTest: FunctionComponent<RefundStatusCardProps> = (props) => (
|
||||
<ThemeProvider>
|
||||
<RefundStatusCard {...props} />
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
const base: RefundSummary = {
|
||||
id: 7,
|
||||
bookingId: 5001,
|
||||
refundStatus: 'succeeded',
|
||||
refundChannel: 'psp_card',
|
||||
totalRefundedIrr: '45000000',
|
||||
expectedCustomerRefundEta: null,
|
||||
externalRevertReference: null,
|
||||
refundPercentageApplied: 1,
|
||||
cancellationPolicyCode: 'free_24h',
|
||||
platformFeeRefundedIrr: '5400000',
|
||||
nursePayoutRefundedIrr: '39600000',
|
||||
createdAt: '2026-07-10T09:00:00Z',
|
||||
completedAt: '2026-07-10T09:00:00Z',
|
||||
};
|
||||
|
||||
describe('<RefundStatusCard/> component', () => {
|
||||
it('renders the three-step stepper + a reconciling fee-leg split for a succeeded card refund', () => {
|
||||
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const { container } = render(<ComponentToTest refund={base} />);
|
||||
expect(container.querySelector('[data-status="succeeded"]')).toBeInTheDocument();
|
||||
expect(screen.getByText('step_submitted')).toBeInTheDocument();
|
||||
expect(screen.getByText('step_completed')).toBeInTheDocument();
|
||||
// Fee-leg split renders when the decomposition is present, reconciling to the total.
|
||||
expect(container.querySelector('[data-row="platform_fee_refunded"]')).toBeInTheDocument();
|
||||
expect(container.querySelector('[data-row="nurse_payout_refunded"]')).toBeInTheDocument();
|
||||
expect(errorSpy).not.toHaveBeenCalled();
|
||||
expect(screen.queryByTestId('refund-failed')).not.toBeInTheDocument();
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('shows the BNPL ETA banner while a refund is on its way', () => {
|
||||
render(
|
||||
<ComponentToTest
|
||||
refund={{
|
||||
...base,
|
||||
refundStatus: 'processing',
|
||||
refundChannel: 'bnpl_revert',
|
||||
totalRefundedIrr: '9000000',
|
||||
expectedCustomerRefundEta: '2026-08-24',
|
||||
externalRevertReference: '••••••5002',
|
||||
platformFeeRefundedIrr: '1080000',
|
||||
nursePayoutRefundedIrr: '7920000',
|
||||
completedAt: null,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId('refund-status-card')).toHaveAttribute('data-status', 'processing');
|
||||
expect(screen.getByText('rstatus_on_its_way')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('refund-eta-window')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a contact-support failed state with no stepper, no ETA banner, and no retry control', () => {
|
||||
const { container } = render(<ComponentToTest refund={{ ...base, refundStatus: 'failed', completedAt: null }} />);
|
||||
expect(screen.getByTestId('refund-failed')).toBeInTheDocument();
|
||||
expect(screen.getByText('failed_title')).toBeInTheDocument();
|
||||
// Failed collapses the happy stepper (no fourth step) and offers no retry — retry is admin-only.
|
||||
expect(screen.queryByText('step_submitted')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
||||
// No success-framed 'money is on its way' surfaces on a failed refund (BNPL-honesty rule).
|
||||
expect(screen.queryByTestId('refund-eta-banner')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('total_refunded_label')).not.toBeInTheDocument();
|
||||
expect(container.querySelector('[data-row="platform_fee_refunded"]')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
'use client';
|
||||
import { FunctionComponent } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Divider, Paper, Stack, Typography } from '@mui/material';
|
||||
import StepperHeader from '@/components/StepperHeader';
|
||||
import StatusChip, { StatusKind } from '@/components/StatusChip';
|
||||
import PriceBreakdown from '@/components/PriceBreakdown';
|
||||
import RefundEtaBanner from '@/components/RefundEtaBanner';
|
||||
import AppAlert from '@/components/common/AppAlert';
|
||||
import { formatIrrToToman } from '@/utils';
|
||||
import {
|
||||
CUSTOMER_REFUND_STEP_ORDER,
|
||||
refundCustomerStep,
|
||||
refundStepIndex,
|
||||
type CustomerRefundStep,
|
||||
type RefundSummary,
|
||||
} from '@/services/refunds/types';
|
||||
|
||||
export interface RefundStatusCardProps {
|
||||
refund: RefundSummary;
|
||||
}
|
||||
|
||||
/** The three happy steps → the semantic chip kind. `failed` is handled distinctly (never a fourth step). */
|
||||
const STEP_KIND: Record<CustomerRefundStep, StatusKind> = {
|
||||
submitted: 'pending',
|
||||
on_its_way: 'info',
|
||||
completed: 'verified',
|
||||
};
|
||||
|
||||
/**
|
||||
* The customer's read-only refund view (f10): the three-step progress (submitted → on its way →
|
||||
* completed), the refunded amount, the per-channel ETA (BNPL's honest ~7–10-day window), and — when the
|
||||
* backend serves the decomposition — the fee-leg split for transparency. `failed`/`rejected` render a
|
||||
* distinct needs-attention state with contact-support copy, **never a retry button** (retry is admin-only,
|
||||
* DEFERRED to f15). Shared: the dedicated refund-status screen and the booking-detail refund section both
|
||||
* render it. Money is display-only via the money util; labels are i18n keys, never raw codes.
|
||||
* @component RefundStatusCard
|
||||
*/
|
||||
const RefundStatusCard: FunctionComponent<RefundStatusCardProps> = ({ refund }) => {
|
||||
const t = useTranslations('refunds');
|
||||
const tc = useTranslations('common');
|
||||
const locale = useLocale();
|
||||
|
||||
const step = refundCustomerStep(refund.refundStatus);
|
||||
const isFailed = step === 'failed';
|
||||
// Succeeded → mark all steps complete (activeStep past the last); otherwise sit on the mapped step.
|
||||
const activeStep =
|
||||
refund.refundStatus === 'succeeded' ? CUSTOMER_REFUND_STEP_ORDER.length : refundStepIndex(refund.refundStatus);
|
||||
|
||||
const hasDecomposition = refund.platformFeeRefundedIrr != null && refund.nursePayoutRefundedIrr != null;
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 2.5 }} data-testid="refund-status-card" data-status={refund.refundStatus}>
|
||||
{isFailed ? (
|
||||
// A failed/rejected refund must NOT show any success-framed progress, amount, or ETA — that would
|
||||
// contradict "needs attention" and, for BNPL, falsely imply the money is on its way. Only the
|
||||
// contact-support copy (+ the reference, for support) renders.
|
||||
<AppAlert severity="error" variant="outlined" data-testid="refund-failed" sx={{ marginY: 0 }}>
|
||||
<Stack sx={{ gap: 0.5 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||||
{t('failed_title')}
|
||||
</Typography>
|
||||
<Typography variant="body2">{t('failed_body')}</Typography>
|
||||
{refund.externalRevertReference && (
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, direction: 'ltr' }}>
|
||||
{t('reference_label')}: {refund.externalRevertReference}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
</AppAlert>
|
||||
) : (
|
||||
<>
|
||||
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Stack sx={{ gap: 1.5, alignItems: 'center' }}>
|
||||
<StatusChip status={STEP_KIND[step]} label={t(`rstatus_${step}`)} />
|
||||
<StepperHeader
|
||||
steps={[t('step_submitted'), t('step_on_its_way'), t('step_completed')]}
|
||||
activeStep={activeStep}
|
||||
/>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Stack sx={{ gap: 1.25 }}>
|
||||
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'baseline', gap: 2 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||||
{t('total_refunded_label')}
|
||||
</Typography>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 800, color: 'var(--bal-secondary)' }}>
|
||||
{formatIrrToToman(refund.totalRefundedIrr, locale)} {tc('currency_toman')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
{refund.externalRevertReference && (
|
||||
<>
|
||||
<Divider />
|
||||
<Stack direction="row" sx={{ justifyContent: 'space-between', gap: 2 }}>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('reference_label')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, direction: 'ltr' }}>
|
||||
{refund.externalRevertReference}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{hasDecomposition && (
|
||||
<PriceBreakdown
|
||||
rows={[
|
||||
{
|
||||
key: 'platform_fee_refunded',
|
||||
label: t('row_platform_fee_refunded'),
|
||||
amountIrr: refund.platformFeeRefundedIrr as string,
|
||||
},
|
||||
{
|
||||
key: 'nurse_payout_refunded',
|
||||
label: t('row_nurse_payout_refunded'),
|
||||
amountIrr: refund.nursePayoutRefundedIrr as string,
|
||||
},
|
||||
]}
|
||||
totalLabel={t('fee_split_title')}
|
||||
totalAmountIrr={refund.totalRefundedIrr}
|
||||
/>
|
||||
)}
|
||||
|
||||
<RefundEtaBanner channel={refund.refundChannel} eta={refund.expectedCustomerRefundEta} />
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default RefundStatusCard;
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default } from './RefundStatusCard';
|
||||
export type { RefundStatusCardProps } from './RefundStatusCard';
|
||||
@@ -59,5 +59,13 @@ export const ROUTES = {
|
||||
export const bookingInvoicePath = (bookingId: number | string): string =>
|
||||
`${ROUTES.BOOKINGS}/${bookingId}/invoice`;
|
||||
|
||||
/** The cancellation flow (f10) — policy-fee disclosure → confirm; keyed by the booking being cancelled. */
|
||||
export const bookingCancelPath = (bookingId: number | string): string =>
|
||||
`${ROUTES.BOOKINGS}/${bookingId}/cancel`;
|
||||
|
||||
/** The customer refund-status view (f10) — pending → on-its-way → completed; keyed by the booking. */
|
||||
export const bookingRefundStatusPath = (bookingId: number | string): string =>
|
||||
`${ROUTES.BOOKINGS}/${bookingId}/refund_status`;
|
||||
|
||||
/** Paths (without locale prefix) that bypass auth in middleware. */
|
||||
export const PUBLIC_PATHS: string[] = [ROUTES.LOGIN];
|
||||
|
||||
@@ -160,6 +160,109 @@ function seed(): void {
|
||||
createdAt: new Date().toISOString(),
|
||||
sessions: [{ ...makeSession(70021, 1, 0, '15840000'), scheduledTimeStart: '15:00:00', scheduledTimeEnd: '19:00:00' }],
|
||||
},
|
||||
// Mid-engagement multi-session booking (f10 refund demo): session 1 is completed-and-verified (locked,
|
||||
// stays payout-eligible) while sessions 2 & 3 are un-started and > 24h out — so the cancellation flow
|
||||
// shows a mixed refundable/locked breakdown at the free-cancellation tier out of the box.
|
||||
{
|
||||
id: 5003,
|
||||
bookingRequestId: 9003,
|
||||
status: 'in_progress',
|
||||
nurseId: NURSE_ID,
|
||||
nurseName: NURSE_NAME,
|
||||
patientId: 903,
|
||||
patientName: 'آقای کریمی',
|
||||
variantId: 13,
|
||||
variantSnapshotJson: JSON.stringify({ displayName: 'مراقبت سالمند — شیفت روز', priceUnit: 'per_day' }),
|
||||
customerAddressId: 803,
|
||||
addressSnapshotJson: JSON.stringify({
|
||||
title: 'منزل',
|
||||
city: 'تهران',
|
||||
district: 'پونک',
|
||||
line: 'بلوار عدل، کوچه سوم، پلاک ۸',
|
||||
postalCode: '1477889900',
|
||||
}),
|
||||
grossPriceIrr: '30000000',
|
||||
balinyaarCommissionIrr: '3600000',
|
||||
nursePayoutAmount: '26400000',
|
||||
pspFeeAmount: '600000',
|
||||
platformFeeRate: 0.12,
|
||||
sessionCount: 3,
|
||||
scheduledDate: isoDate(3),
|
||||
scheduledTimeStart: '09:00:00',
|
||||
scheduledTimeEnd: '13:00:00',
|
||||
confirmedAt: new Date(Date.now() - 2 * 86_400_000).toISOString(),
|
||||
completedAt: null,
|
||||
cancelledAt: null,
|
||||
cancelledBy: null,
|
||||
cancellationReason: null,
|
||||
cancellationPolicyCode: null,
|
||||
cancellationRefundPercentage: null,
|
||||
refundableAmountIrr: null,
|
||||
disputeWindowEndsAt: null,
|
||||
createdAt: new Date(Date.now() - 3 * 86_400_000).toISOString(),
|
||||
sessions: [
|
||||
{
|
||||
...makeSession(70031, 1, -1, '8800000'),
|
||||
status: 'completed',
|
||||
evvStatus: 'completed',
|
||||
checkInAt: new Date(Date.now() - 86_400_000).toISOString(),
|
||||
checkOutAt: new Date(Date.now() - 72_000_000).toISOString(),
|
||||
payoutEligibleAt: new Date(Date.now() + DISPUTE_WINDOW_HOURS * 3_600_000).toISOString(),
|
||||
checkInAddressMatch: true,
|
||||
},
|
||||
makeSession(70032, 2, 3, '8800000'),
|
||||
makeSession(70033, 3, 5, '8800000'),
|
||||
],
|
||||
},
|
||||
// Already-cancelled booking whose refund FAILED (f10 refund-status demo): the customer sees the
|
||||
// needs-attention / contact-support state (never a retry — retry is admin-only, DEFERRED to f15). Its
|
||||
// failed refund is seeded in the refunds mock; here it just carries the cancellation snapshot b9 stamps.
|
||||
{
|
||||
id: 5004,
|
||||
bookingRequestId: 9004,
|
||||
status: 'cancelled',
|
||||
nurseId: NURSE_ID,
|
||||
nurseName: NURSE_NAME,
|
||||
patientId: 904,
|
||||
patientName: 'خانم صادقی',
|
||||
variantId: 14,
|
||||
variantSnapshotJson: JSON.stringify({ displayName: 'مراقبت پس از جراحی', priceUnit: 'per_session' }),
|
||||
customerAddressId: 804,
|
||||
addressSnapshotJson: JSON.stringify({
|
||||
title: 'منزل',
|
||||
city: 'تهران',
|
||||
district: 'جنتآباد',
|
||||
line: 'خیابان لاله، پلاک ۲۲، واحد ۳',
|
||||
postalCode: '1476612345',
|
||||
}),
|
||||
grossPriceIrr: '12000000',
|
||||
balinyaarCommissionIrr: '1440000',
|
||||
nursePayoutAmount: '10560000',
|
||||
pspFeeAmount: '240000',
|
||||
platformFeeRate: 0.12,
|
||||
sessionCount: 1,
|
||||
scheduledDate: isoDate(-3),
|
||||
scheduledTimeStart: '10:00:00',
|
||||
scheduledTimeEnd: '14:00:00',
|
||||
confirmedAt: new Date(Date.now() - 5 * 86_400_000).toISOString(),
|
||||
completedAt: null,
|
||||
cancelledAt: new Date(Date.now() - 2 * 86_400_000).toISOString(),
|
||||
cancelledBy: 'customer',
|
||||
cancellationReason: 'changed_mind',
|
||||
cancellationPolicyCode: 'partial_under_24h',
|
||||
cancellationRefundPercentage: 0.5,
|
||||
refundableAmountIrr: '12000000',
|
||||
disputeWindowEndsAt: null,
|
||||
createdAt: new Date(Date.now() - 6 * 86_400_000).toISOString(),
|
||||
sessions: [
|
||||
{
|
||||
...makeSession(70041, 1, -3, '10560000'),
|
||||
status: 'cancelled',
|
||||
scheduledTimeStart: '10:00:00',
|
||||
scheduledTimeEnd: '14:00:00',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
care[5001] = {
|
||||
@@ -461,3 +564,52 @@ export function mockInsertConvertedBooking(seed: ConvertedBookingSeed): BookingD
|
||||
bookings = [booking, ...bookings];
|
||||
return cloneBooking(booking);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock-only read for the refunds domain (f10): the booking + its sessions (a safe clone), so the refunds
|
||||
* mock can resolve the cancellation tier by lead time and per-session refundability without the
|
||||
* viewer-masking `getBookingDetail`. Throws `404` if the booking is unknown. NOT part of the `BookingsApi`
|
||||
* seam — only `services/refunds`' mock imports it.
|
||||
*/
|
||||
export function mockGetBookingForRefund(bookingId: number): BookingDetailDto {
|
||||
return cloneBooking(findBooking(bookingId));
|
||||
}
|
||||
|
||||
/** The cancellation snapshot the refunds mock writes onto a booking when a customer cancels (f10). */
|
||||
export interface CancelBookingSnapshot {
|
||||
cancelledBy: string;
|
||||
cancellationReason: string | null;
|
||||
cancellationPolicyCode: string;
|
||||
cancellationRefundPercentage: number;
|
||||
refundableAmountIrr: string;
|
||||
/** The un-started sessions being cancelled; completed-and-verified sessions stay payout-eligible. */
|
||||
cancelledSessionIds: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock-only cancellation bridge (f10): flip a booking to `cancelled` and stamp the cancellation snapshot
|
||||
* the b9 `BookingDetailDto` already declares (currently only ever read, never written). Mirrors the
|
||||
* in-place mutation pattern of `checkOutVisit` — it mutates the live store object (a reference into
|
||||
* `bookings`), so the next `getBookingDetail`/`listBookings` reflects it once the cancel mutation
|
||||
* invalidates the caches. Only still-`scheduled` sessions in `cancelledSessionIds` are marked `cancelled`
|
||||
* (per-remaining-session cancellation). NOT part of the `BookingsApi` seam.
|
||||
*/
|
||||
export function mockMarkBookingCancelled(
|
||||
bookingId: number,
|
||||
snapshot: CancelBookingSnapshot,
|
||||
): BookingDetailDto {
|
||||
const booking = findBooking(bookingId);
|
||||
booking.status = 'cancelled';
|
||||
booking.cancelledAt = new Date().toISOString();
|
||||
booking.cancelledBy = snapshot.cancelledBy;
|
||||
booking.cancellationReason = snapshot.cancellationReason;
|
||||
booking.cancellationPolicyCode = snapshot.cancellationPolicyCode;
|
||||
booking.cancellationRefundPercentage = snapshot.cancellationRefundPercentage;
|
||||
booking.refundableAmountIrr = snapshot.refundableAmountIrr;
|
||||
for (const session of booking.sessions) {
|
||||
if (snapshot.cancelledSessionIds.includes(session.id) && session.status === 'scheduled') {
|
||||
session.status = 'cancelled';
|
||||
}
|
||||
}
|
||||
return cloneBooking(booking);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { clientFetch } from '@/lib/api/client';
|
||||
import { unwrap, type ApiEnvelope } from '@/lib/api/types';
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
import type {
|
||||
CancelBookingInput,
|
||||
CancellationPolicyPreview,
|
||||
RefundChannel,
|
||||
RefundStatus,
|
||||
RefundSummary,
|
||||
RefundsApi,
|
||||
} from '../types';
|
||||
|
||||
const BOOKINGS = '/api/v1/bookings';
|
||||
const REFUNDS = '/api/v1/refunds';
|
||||
|
||||
/**
|
||||
* The thin b11 customer refund payload (`GET refunds/{id}/status`) — the only refund shape the contract
|
||||
* exposes to a customer. The fee-leg decomposition + policy snapshot live on the admin-only
|
||||
* `RefundListItem`, so this maps into `RefundSummary` with those fields `null` until REQ-021 serves them.
|
||||
*/
|
||||
interface RefundStatusWire {
|
||||
id: number;
|
||||
bookingId: number;
|
||||
status: RefundStatus;
|
||||
refundChannel: RefundChannel;
|
||||
amount: string;
|
||||
expectedCustomerRefundEta: string | null;
|
||||
reference: string | null;
|
||||
}
|
||||
|
||||
function toSummary(wire: RefundStatusWire): RefundSummary {
|
||||
return {
|
||||
id: wire.id,
|
||||
bookingId: wire.bookingId,
|
||||
refundStatus: wire.status,
|
||||
refundChannel: wire.refundChannel,
|
||||
totalRefundedIrr: wire.amount,
|
||||
expectedCustomerRefundEta: wire.expectedCustomerRefundEta,
|
||||
externalRevertReference: wire.reference,
|
||||
// REQ-021: the customer status carries no decomposition/policy/timestamps yet — the fee-split section
|
||||
// is hidden until these are served (the mock fills them so the transparency split demos end-to-end).
|
||||
refundPercentageApplied: null,
|
||||
cancellationPolicyCode: null,
|
||||
platformFeeRefundedIrr: null,
|
||||
nursePayoutRefundedIrr: null,
|
||||
createdAt: null,
|
||||
completedAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Real HTTP implementation of the `RefundsApi` seam. Only `getRefund` maps a **published** b11 route
|
||||
* (`GET refunds/{id}/status`, tenancy-scoped); the other three target contract gaps the frontend filed
|
||||
* (which is why the domain stays mock-primary — see `constants.ts`):
|
||||
* - `resolveCancellationPolicy` → REQ-020 (`GET bookings/{id}/cancellation_policy`): b9 snapshots the
|
||||
* policy only *after* a cancel; there is no pre-cancel preview resolving the tier by current lead time
|
||||
* + per-session refundability.
|
||||
* - `cancelBooking` → REQ-019 (`POST bookings/{id}/cancel`): b11 refunds are admin-only, no customer path.
|
||||
* - `getRefundByBooking` → REQ-021 (`GET refunds/by_booking/{id}`): the customer cannot obtain a refund
|
||||
* id from the admin-only worklist, so it needs to reach its refund from the booking. `404` = no refund.
|
||||
*
|
||||
* NOT the primary implementation this phase (`USE_REFUNDS_MOCK = true`).
|
||||
*/
|
||||
export const refundsClientApi: RefundsApi = {
|
||||
resolveCancellationPolicy: async (bookingId: number) =>
|
||||
unwrap(
|
||||
await clientFetch<ApiEnvelope<CancellationPolicyPreview>>(
|
||||
`${BOOKINGS}/${bookingId}/cancellation_policy`,
|
||||
),
|
||||
),
|
||||
|
||||
cancelBooking: async ({ bookingId, sessionIds, reasonCategory, reasonNotes }: CancelBookingInput) =>
|
||||
toSummary(
|
||||
unwrap(
|
||||
await clientFetch<ApiEnvelope<RefundStatusWire>>(`${BOOKINGS}/${bookingId}/cancel`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ sessionIds, reasonCategory, reasonNotes }),
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
||||
getRefundByBooking: async (bookingId: number) => {
|
||||
try {
|
||||
return toSummary(
|
||||
unwrap(await clientFetch<ApiEnvelope<RefundStatusWire>>(`${REFUNDS}/by_booking/${bookingId}`)),
|
||||
);
|
||||
} catch (error) {
|
||||
// No refund for this booking (e.g. not cancelled) is a clean empty state, not a failure.
|
||||
if (error instanceof ApiError && error.status === 404) return null;
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
getRefund: async (refundId: number) =>
|
||||
toSummary(unwrap(await clientFetch<ApiEnvelope<RefundStatusWire>>(`${REFUNDS}/${refundId}/status`))),
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import { USE_REFUNDS_MOCK } from '../constants';
|
||||
import type { RefundsApi } from '../types';
|
||||
import { refundsClientApi } from './clientApi';
|
||||
import { refundsMockApi } from './mockApi';
|
||||
|
||||
/**
|
||||
* The selected `RefundsApi` implementation — the single seam the hooks import. Selection is by config
|
||||
* (`USE_REFUNDS_MOCK`), never by scattered `if (mock)` checks.
|
||||
*/
|
||||
export const refundsApi: RefundsApi = USE_REFUNDS_MOCK ? refundsMockApi : refundsClientApi;
|
||||
@@ -0,0 +1,261 @@
|
||||
import { parseIrr, sleep } from '@/utils';
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
import { mockGetBookingForRefund, mockMarkBookingCancelled } from '@/services/bookings/apis/mockApi';
|
||||
import { BNPL_REFUND_ETA_BUSINESS_DAYS, MOCK_POLICY_TIERS } from '../constants';
|
||||
import {
|
||||
isBookingCancellable,
|
||||
isTerminalRefundStatus,
|
||||
type CancelBookingInput,
|
||||
type CancellationPolicyCode,
|
||||
type CancellationPolicyPreview,
|
||||
type CancellationSessionPreview,
|
||||
type RefundChannel,
|
||||
type RefundSummary,
|
||||
type RefundsApi,
|
||||
} from '../types';
|
||||
|
||||
const MOCK_LATENCY_MS = 350;
|
||||
|
||||
// Integer-only rate math: fractions as parts-per-10000 so the money path never touches a float.
|
||||
// (BigInt via the constructor — the tsconfig target predates ES2020 literals, matching utils/money.ts.)
|
||||
const RATE_SCALE = BigInt(10_000);
|
||||
const ZERO = BigInt(0);
|
||||
|
||||
function fractionPpm(fraction: number): bigint {
|
||||
return BigInt(Math.round(fraction * Number(RATE_SCALE)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Which channel a booking's refund runs through. In production this is derived from the original payment
|
||||
* method; the mock pins booking 5002 to BNPL so the ~7–10-business-day ETA banner is demoable, and defaults
|
||||
* everything else (incl. f9-converted bookings, which were paid by card) to `psp_card`.
|
||||
*/
|
||||
const CHANNEL_BY_BOOKING: Record<number, RefundChannel> = {
|
||||
5002: 'bnpl_revert',
|
||||
};
|
||||
|
||||
function channelFor(bookingId: number): RefundChannel {
|
||||
return CHANNEL_BY_BOOKING[bookingId] ?? 'psp_card';
|
||||
}
|
||||
|
||||
/**
|
||||
* Integer day difference between a `YYYY-MM-DD` date and today, both on the **UTC calendar day** — the
|
||||
* bookings mock seeds dates via `new Date().toISOString().slice(0,10)` (UTC), so the tier resolution must
|
||||
* use the same basis or a positive-offset timezone (e.g. Asia/Tehran +3:30, just after local midnight)
|
||||
* would compute a "today" session as -1 day and flip the tier from partial to no-show.
|
||||
*/
|
||||
function daysUntil(dateStr: string): number {
|
||||
const todayKey = new Date().toISOString().slice(0, 10);
|
||||
const target = Date.parse(`${dateStr}T00:00:00Z`);
|
||||
const today = Date.parse(`${todayKey}T00:00:00Z`);
|
||||
return Math.round((target - today) / 86_400_000);
|
||||
}
|
||||
|
||||
/** Resolves the tier code from the earliest un-started session's lead time (mock stand-in for the server). */
|
||||
function policyCodeForLead(days: number): CancellationPolicyCode {
|
||||
if (days >= 1) return 'free_24h';
|
||||
if (days === 0) return 'partial_under_24h';
|
||||
return 'customer_no_show';
|
||||
}
|
||||
|
||||
/** The BNPL customer cash-back ETA: N business days out, Fridays skipped (product: ~7–10 business days). */
|
||||
function businessDaysFromNow(days: number): string {
|
||||
const d = new Date();
|
||||
let added = 0;
|
||||
while (added < days) {
|
||||
d.setDate(d.getDate() + 1);
|
||||
if (d.getDay() !== 5) added += 1; // getDay() 5 = Friday
|
||||
}
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/** A plausible masked (last-4) external reference for a non-card revert — opaque, never parsed. */
|
||||
function maskedReference(bookingId: number): string {
|
||||
return `••••••${String(bookingId % 10000).padStart(4, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the cancellation preview from the shared f8 bookings store: which sessions are refundable
|
||||
* (un-started) vs locked (completed-and-verified, still payout-eligible), the tier by lead time, and the
|
||||
* refund-vs-fee split decomposed across the two fee legs. All money is BigInt; `refundAmount + fee =
|
||||
* refundableGross` by construction, so the disclosure's `PriceBreakdown` reconciles to the rial.
|
||||
*/
|
||||
function computePreview(bookingId: number): CancellationPolicyPreview {
|
||||
const booking = mockGetBookingForRefund(bookingId); // throws 404 if unknown
|
||||
const channel = channelFor(bookingId);
|
||||
|
||||
const sessions: CancellationSessionPreview[] = booking.sessions.map((s) => ({
|
||||
bookingSessionId: s.id,
|
||||
sessionIndex: s.sessionIndex,
|
||||
scheduledDate: s.scheduledDate,
|
||||
refundable: s.status === 'scheduled',
|
||||
reasonCode: s.status === 'scheduled' ? 'un_started' : s.status,
|
||||
}));
|
||||
|
||||
const refundableSessions = booking.sessions.filter((s) => s.status === 'scheduled');
|
||||
const cancellable = isBookingCancellable(booking.status) && refundableSessions.length > 0;
|
||||
|
||||
// Proportional decomposition: the refundable slice of the payout leg is exact (Σ per-session payout);
|
||||
// its commission share is proportional. refundableGross = refundablePayout + refundableCommission.
|
||||
const totalPayout = parseIrr(booking.nursePayoutAmount);
|
||||
const commission = parseIrr(booking.balinyaarCommissionIrr);
|
||||
const refundablePayout = refundableSessions.reduce((acc, s) => acc + parseIrr(s.visitPayoutAmount), ZERO);
|
||||
const refundableCommission = totalPayout > ZERO ? (commission * refundablePayout) / totalPayout : ZERO;
|
||||
const refundableGross = refundablePayout + refundableCommission;
|
||||
|
||||
const earliestDate = refundableSessions
|
||||
.map((s) => s.scheduledDate)
|
||||
.sort()
|
||||
.at(0);
|
||||
const policyCode = policyCodeForLead(earliestDate ? daysUntil(earliestDate) : 0);
|
||||
const tier = MOCK_POLICY_TIERS[policyCode];
|
||||
const ppm = fractionPpm(tier.refundFraction);
|
||||
|
||||
const nursePayoutRefunded = (refundablePayout * ppm) / RATE_SCALE;
|
||||
const platformFeeRefunded = (refundableCommission * ppm) / RATE_SCALE;
|
||||
const refundAmount = nursePayoutRefunded + platformFeeRefunded;
|
||||
const feeAmount = refundableGross - refundAmount; // the retained remainder — reconciles by construction
|
||||
|
||||
return {
|
||||
bookingId,
|
||||
cancellable,
|
||||
cancellationPolicyCode: policyCode,
|
||||
refundPercentageApplied: tier.refundFraction,
|
||||
feePercentage: Math.round((1 - tier.refundFraction) * 100) / 100,
|
||||
refundAmountIrr: refundAmount.toString(),
|
||||
feeAmountIrr: feeAmount.toString(),
|
||||
refundableAmountIrr: refundableGross.toString(),
|
||||
platformFeeRefundedIrr: platformFeeRefunded.toString(),
|
||||
nursePayoutRefundedIrr: nursePayoutRefunded.toString(),
|
||||
appliesTo: refundableSessions.length === booking.sessions.length ? 'whole_booking' : 'remaining_sessions',
|
||||
leadTimeLabel: tier.leadTimeLabel,
|
||||
refundChannel: channel,
|
||||
expectedCustomerRefundEta: channel === 'bnpl_revert' ? businessDaysFromNow(BNPL_REFUND_ETA_BUSINESS_DAYS) : null,
|
||||
refundableSessionIds: refundableSessions.map((s) => s.id),
|
||||
sessions,
|
||||
};
|
||||
}
|
||||
|
||||
/** The mock's stored refund — a `RefundSummary` plus a read counter that drives the BNPL walk. */
|
||||
interface MockRefund extends RefundSummary {
|
||||
reads: number;
|
||||
}
|
||||
|
||||
let nextRefundId = 7001;
|
||||
const refundsByBooking: Record<number, MockRefund> = {};
|
||||
|
||||
// Seed: a FAILED refund on the already-cancelled booking 5004, so the refund-status screen shows the
|
||||
// needs-attention / contact-support state out of the box (retry is admin-only — DEFERRED to f15).
|
||||
refundsByBooking[5004] = {
|
||||
id: nextRefundId++,
|
||||
bookingId: 5004,
|
||||
refundStatus: 'failed',
|
||||
refundChannel: 'psp_card',
|
||||
totalRefundedIrr: '6000000',
|
||||
expectedCustomerRefundEta: null,
|
||||
externalRevertReference: maskedReference(5004),
|
||||
refundPercentageApplied: 0.5,
|
||||
cancellationPolicyCode: 'partial_under_24h',
|
||||
platformFeeRefundedIrr: '720000',
|
||||
nursePayoutRefundedIrr: '5280000',
|
||||
createdAt: new Date(Date.now() - 2 * 86_400_000).toISOString(),
|
||||
completedAt: null,
|
||||
reads: 0,
|
||||
};
|
||||
|
||||
function toRefundSummary(refund: MockRefund): RefundSummary {
|
||||
const { reads: _reads, ...summary } = refund;
|
||||
return { ...summary };
|
||||
}
|
||||
|
||||
/**
|
||||
* Accelerated mock reconciliation so a BNPL refund's stepper visibly walks *submitted → on its way →
|
||||
* completed* as the status poll ticks (a real BNPL revert takes ~7–10 business days). A card refund is
|
||||
* already `succeeded` at creation — nothing to advance. Forward-only, and stops once terminal.
|
||||
*/
|
||||
function advanceRefund(refund: MockRefund): void {
|
||||
if (refund.refundChannel !== 'bnpl_revert' || isTerminalRefundStatus(refund.refundStatus)) return;
|
||||
refund.reads += 1;
|
||||
if (refund.reads >= 4) {
|
||||
refund.refundStatus = 'succeeded';
|
||||
refund.completedAt = new Date().toISOString();
|
||||
} else {
|
||||
refund.refundStatus = 'processing';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory mock behind the `RefundsApi` seam — the whole customer cancel + refund surface b11 doesn't
|
||||
* serve (admin-only refunds; no cancel command / policy preview / refund-by-booking / decomposition on the
|
||||
* customer status → REQ-019/020/021). It reads the shared f8 bookings store to resolve the tier + per-
|
||||
* session refundability, flips the booking to `cancelled` on confirm (so the booking-detail cache reflects
|
||||
* it after invalidation), enforces the outside-policy `409`, and drives the refund through the customer
|
||||
* steps (card immediate `succeeded`; BNPL `processing` with an ETA that reconciles over polls). Swap to the
|
||||
* real `clientApi` once REQ-019/020/021 land (`USE_REFUNDS_MOCK = false`).
|
||||
*/
|
||||
export const refundsMockApi: RefundsApi = {
|
||||
resolveCancellationPolicy: async (bookingId) => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
return computePreview(bookingId);
|
||||
},
|
||||
|
||||
cancelBooking: async ({ bookingId, sessionIds, reasonCategory, reasonNotes }: CancelBookingInput) => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
const preview = computePreview(bookingId);
|
||||
if (!preview.cancellable) {
|
||||
throw new ApiError(409, 'Booking cannot be cancelled', 'not_cancellable');
|
||||
}
|
||||
if (sessionIds && sessionIds.some((id) => !preview.refundableSessionIds.includes(id))) {
|
||||
// Never offer to refund a session the policy marks non-refundable (completed-and-verified).
|
||||
throw new ApiError(409, 'Session is not refundable', 'session_not_refundable');
|
||||
}
|
||||
|
||||
const channel = preview.refundChannel;
|
||||
const now = new Date().toISOString();
|
||||
mockMarkBookingCancelled(bookingId, {
|
||||
cancelledBy: 'customer',
|
||||
cancellationReason: reasonNotes?.trim() || reasonCategory,
|
||||
cancellationPolicyCode: preview.cancellationPolicyCode,
|
||||
cancellationRefundPercentage: preview.refundPercentageApplied,
|
||||
refundableAmountIrr: preview.refundableAmountIrr,
|
||||
cancelledSessionIds: preview.refundableSessionIds,
|
||||
});
|
||||
|
||||
const refund: MockRefund = {
|
||||
id: nextRefundId++,
|
||||
bookingId,
|
||||
// Card refunds succeed immediately; BNPL/manual sit in the reconciliation window (start approved →
|
||||
// processing → succeeded so the customer sees the walk).
|
||||
refundStatus: channel === 'psp_card' ? 'succeeded' : 'approved',
|
||||
refundChannel: channel,
|
||||
totalRefundedIrr: preview.refundAmountIrr,
|
||||
expectedCustomerRefundEta: preview.expectedCustomerRefundEta,
|
||||
externalRevertReference: channel === 'psp_card' ? null : maskedReference(bookingId),
|
||||
refundPercentageApplied: preview.refundPercentageApplied,
|
||||
cancellationPolicyCode: preview.cancellationPolicyCode,
|
||||
platformFeeRefundedIrr: preview.platformFeeRefundedIrr,
|
||||
nursePayoutRefundedIrr: preview.nursePayoutRefundedIrr,
|
||||
createdAt: now,
|
||||
completedAt: channel === 'psp_card' ? now : null,
|
||||
reads: 0,
|
||||
};
|
||||
refundsByBooking[bookingId] = refund;
|
||||
return toRefundSummary(refund);
|
||||
},
|
||||
|
||||
getRefundByBooking: async (bookingId) => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
const refund = refundsByBooking[bookingId];
|
||||
if (!refund) return null; // no refund (e.g. not cancelled) — a clean empty state, not an error
|
||||
advanceRefund(refund);
|
||||
return toRefundSummary(refund);
|
||||
},
|
||||
|
||||
getRefund: async (refundId) => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
const refund = Object.values(refundsByBooking).find((r) => r.id === refundId);
|
||||
if (!refund) throw new ApiError(404, 'Refund not found', 'not_found');
|
||||
advanceRefund(refund);
|
||||
return toRefundSummary(refund);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { CancellationPolicyCode } from './types';
|
||||
|
||||
/**
|
||||
* When true, the refunds domain is served by the in-memory mock (`apis/mockApi.ts`) behind the
|
||||
* `RefundsApi` seam.
|
||||
*
|
||||
* **Mock is primary this phase.** b11 shipped the refund lifecycle **admin-only**: the only
|
||||
* customer-visible surface is `GET refunds/{id}/status` (thin: status/channel/amount/ETA/masked ref).
|
||||
* There is **no** customer cancel command, **no** cancellation-policy preview, **no** refund-by-booking
|
||||
* lookup, and the customer status carries **no** fee-leg decomposition — all filed as REQ-019/020/021.
|
||||
* So the whole cancel + policy-disclosure + fee-split surface is mocked behind this seam. The mock reads
|
||||
* the shared f8 bookings store (lead time + per-session refundability), flips the booking to `cancelled`
|
||||
* on confirm (so the booking-detail cache reflects it), and drives a refund through
|
||||
* `submitted → on_its_way → completed` (card immediate; BNPL processing with an ETA). Flip to `false`
|
||||
* once REQ-019/020/021 land — no hook/component change.
|
||||
*/
|
||||
export const USE_REFUNDS_MOCK = true;
|
||||
|
||||
/**
|
||||
* The cancellation preview depends on `now` vs the booking start (the resolved tier moves as the visit
|
||||
* approaches), so keep it short-lived — never serve a stale tier that under/over-states the fee.
|
||||
*/
|
||||
export const POLICY_PREVIEW_STALE_TIME = 10 * 1000;
|
||||
|
||||
/**
|
||||
* Refund status is read-heavy and mostly stable between visits; a modest stale window avoids a refetch on
|
||||
* re-entry, while the poll (below) keeps a non-terminal refund fresh.
|
||||
*/
|
||||
export const REFUND_STATUS_STALE_TIME = 15 * 1000;
|
||||
export const REFUND_STATUS_GC_TIME = 5 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* The refund-status poll runs **only while the refund is non-terminal** (`requested`/`approved`/
|
||||
* `processing`); it stops at `succeeded`/`failed`/`rejected`. A calm, fixed interval — a refund moves on
|
||||
* the order of days (BNPL) or is already terminal (card), so there is no need for tight backoff.
|
||||
*/
|
||||
export const REFUND_STATUS_POLL_INTERVAL_MS = 5 * 1000;
|
||||
|
||||
/**
|
||||
* Mock-only policy tiers. The product doc pins the shape (free > 24h = 100%/0%; partial < 24h ≈ 50%;
|
||||
* customer no-show up to 100% charge) but flags the 50% as **illustrative/config**, so these are the
|
||||
* mock's stand-in figures until the backend serves `cancellation_policies`. `refundFraction` is 0–1.
|
||||
*/
|
||||
export const MOCK_POLICY_TIERS: Record<
|
||||
CancellationPolicyCode,
|
||||
{ refundFraction: number; leadTimeLabel: 'gt_24h' | 'lt_24h' | 'started' }
|
||||
> = {
|
||||
free_24h: { refundFraction: 1, leadTimeLabel: 'gt_24h' },
|
||||
partial_under_24h: { refundFraction: 0.5, leadTimeLabel: 'lt_24h' },
|
||||
customer_no_show: { refundFraction: 0, leadTimeLabel: 'started' },
|
||||
};
|
||||
|
||||
/**
|
||||
* The BNPL customer cash-back window the mock projects onto `expectedCustomerRefundEta` — the product's
|
||||
* ~7–10 business-day truth, Fridays skipped (see `cancellation-and-payout.md`). Surface it honestly;
|
||||
* never imply the money is back instantly.
|
||||
*/
|
||||
export const BNPL_REFUND_ETA_BUSINESS_DAYS = 10;
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { refundsApi } from '../apis';
|
||||
import { invalidateAfterCancellation } from '../invalidations';
|
||||
import type { CancelBookingInput } from '../types';
|
||||
|
||||
/**
|
||||
* Submit a customer-initiated cancellation. On success the returned refund is primed into its `byBooking`
|
||||
* key and the affected booking + refund caches are invalidated (so the booking-detail screen reflects the
|
||||
* new cancelled/refund state without a manual refetch, and the refund-status screen renders warm). A `409`
|
||||
* (outside-policy / already-cancelled / nothing-refundable) surfaces inline via `mutation.error` — the
|
||||
* fetch layer already toasts 401/403/5xx, so this hook never double-toasts.
|
||||
*/
|
||||
export function useCancelBooking() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (input: CancelBookingInput) => refundsApi.cancelBooking(input),
|
||||
onSuccess: (refund, input) => invalidateAfterCancellation(queryClient, input.bookingId, refund),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { refundsApi } from '../apis';
|
||||
import { refundKeys } from '../keys';
|
||||
import { POLICY_PREVIEW_STALE_TIME } from '../constants';
|
||||
|
||||
/**
|
||||
* Resolve the cancellation policy for a booking **before** the customer confirms — the tier (by lead time),
|
||||
* its refund % + fee %, the concrete refund-vs-fee amounts, and the per-session refundable/locked
|
||||
* breakdown. Short `staleTime` because the tier depends on `now` vs the booking start (a stale preview
|
||||
* could under/over-state the fee); enabled only when a booking id is present.
|
||||
*/
|
||||
export function useCancellationPolicyPreview(bookingId: number | undefined, options?: { enabled?: boolean }) {
|
||||
return useQuery({
|
||||
queryKey: refundKeys.policyPreview(bookingId ?? -1),
|
||||
queryFn: () => refundsApi.resolveCancellationPolicy(bookingId as number),
|
||||
enabled: (options?.enabled ?? true) && bookingId != null && bookingId > 0,
|
||||
staleTime: POLICY_PREVIEW_STALE_TIME,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { refundsApi } from '../apis';
|
||||
import { refundKeys } from '../keys';
|
||||
import {
|
||||
REFUND_STATUS_GC_TIME,
|
||||
REFUND_STATUS_POLL_INTERVAL_MS,
|
||||
REFUND_STATUS_STALE_TIME,
|
||||
} from '../constants';
|
||||
import { isTerminalRefundStatus } from '../types';
|
||||
|
||||
/**
|
||||
* The customer's read-only refund status for a booking. Polls (`refetchInterval`) **only while the refund
|
||||
* is non-terminal** (`requested`/`approved`/`processing`) and **stops** at `succeeded`/`failed`/`rejected`
|
||||
* — and never polls the empty state (no refund → `null`). A modest `staleTime`/`gcTime` means re-entering
|
||||
* the screen doesn't re-hit the network needlessly; the cancel mutation primes this key so the first render
|
||||
* is warm. `data` is `null` when the booking has no refund.
|
||||
*/
|
||||
export function useRefundStatus(bookingId: number | undefined, options?: { enabled?: boolean }) {
|
||||
return useQuery({
|
||||
queryKey: refundKeys.byBooking(bookingId ?? -1),
|
||||
queryFn: () => refundsApi.getRefundByBooking(bookingId as number),
|
||||
enabled: (options?.enabled ?? true) && bookingId != null && bookingId > 0,
|
||||
staleTime: REFUND_STATUS_STALE_TIME,
|
||||
gcTime: REFUND_STATUS_GC_TIME,
|
||||
refetchInterval: (query) => {
|
||||
const refund = query.state.data;
|
||||
if (!refund) return false;
|
||||
return isTerminalRefundStatus(refund.refundStatus) ? false : REFUND_STATUS_POLL_INTERVAL_MS;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Refunds domain barrel — re-exports **hooks only** (per the `services/{domain}` convention).
|
||||
* Import types/keys/apis directly from their files when needed.
|
||||
*/
|
||||
export { useCancellationPolicyPreview } from './hooks/useCancellationPolicyPreview';
|
||||
export { useCancelBooking } from './hooks/useCancelBooking';
|
||||
export { useRefundStatus } from './hooks/useRefundStatus';
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { QueryClient } from '@tanstack/react-query';
|
||||
import { bookingKeys } from '@/services/bookings/keys';
|
||||
import { refundKeys } from './keys';
|
||||
import type { RefundSummary } from './types';
|
||||
|
||||
/**
|
||||
* The one cache transition a successful cancellation causes: the booking flipped `cancelled` and a refund
|
||||
* now exists. Prime the fresh refund into its `byBooking` key (so the refund-status screen renders warm,
|
||||
* no first-render spinner) and invalidate exactly the affected keys — the booking detail (its status/note
|
||||
* changed), the bookings lists (the row moved to cancelled), the policy preview (no longer cancellable),
|
||||
* and the refund's `byBooking` — never a blanket refetch. Called from `useCancelBooking`.
|
||||
*/
|
||||
export function invalidateAfterCancellation(
|
||||
queryClient: QueryClient,
|
||||
bookingId: number,
|
||||
refund: RefundSummary,
|
||||
): void {
|
||||
queryClient.setQueryData(refundKeys.byBooking(bookingId), refund);
|
||||
queryClient.invalidateQueries({ queryKey: bookingKeys.bookingDetail(bookingId) });
|
||||
queryClient.invalidateQueries({ queryKey: bookingKeys.lists() });
|
||||
queryClient.invalidateQueries({ queryKey: refundKeys.policyPreview(bookingId) });
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* React Query key factory for the refunds domain (hierarchical, per the `services/{domain}` pattern).
|
||||
* The cancellation preview and the refund status are keyed by the **booking** id (the customer reaches a
|
||||
* refund through its booking, never through an admin-only refund id); `detail` keys the by-refund-id read
|
||||
* used by the real `refunds/{id}/status` route.
|
||||
*/
|
||||
export const refundKeys = {
|
||||
all: ['refunds'] as const,
|
||||
|
||||
policyPreviews: () => [...refundKeys.all, 'policy_preview'] as const,
|
||||
policyPreview: (bookingId: number) => [...refundKeys.policyPreviews(), bookingId] as const,
|
||||
|
||||
byBookings: () => [...refundKeys.all, 'by_booking'] as const,
|
||||
byBooking: (bookingId: number) => [...refundKeys.byBookings(), bookingId] as const,
|
||||
|
||||
details: () => [...refundKeys.all, 'detail'] as const,
|
||||
detail: (refundId: number) => [...refundKeys.details(), refundId] as const,
|
||||
};
|
||||
@@ -0,0 +1,195 @@
|
||||
import type { BookingSessionStatus, BookingStatus } from '@/services/bookings/types';
|
||||
|
||||
/**
|
||||
* Refunds & cancellation domain (b11 contract `dev/contracts/domains/refunds-invoices.md`). This is the
|
||||
* **customer** half of the refund story: resolve the applicable cancellation policy by lead time, disclose
|
||||
* the fee/refund split before confirming, request the cancellation, then follow the read-only refund
|
||||
* status. The admin refund console (create/approve, leg-split editor, clawback banner, retry) is DEFERRED
|
||||
* to f15-b15.
|
||||
*
|
||||
* Load-bearing semantics (contract + product `07-cancellation-and-refunds.md` / `cancellation-and-payout.md`):
|
||||
* - **Refunds are admin-only.** The customer can *request* a cancellation and *read* the refund's progress;
|
||||
* it can never self-issue money. The copy reflects the admin-approved, ticket-linked reality.
|
||||
* - **Money is IRR integer, on the wire as a digit-string.** Parse/format only via the `@/utils` BigInt
|
||||
* helpers; Toman is display-only. The refund is the decomposition of `gross = commission + payout` —
|
||||
* render `platformFeeRefundedIrr` / `nursePayoutRefundedIrr` as served; never recompute the split.
|
||||
* - **Disclose the fee/refund % BEFORE confirm.** The policy (resolved by lead time + actor) and its
|
||||
* refund % + fee % must be on screen and acknowledged before the cancellation can be submitted.
|
||||
* - **BNPL is asynchronous.** For `bnpl_revert`, surface the `expectedCustomerRefundEta` (~7–10 business
|
||||
* days) honestly — the money returns *through the provider*, never instantly, never Balinyaar → customer.
|
||||
* - **Per-session, not all-or-nothing.** Only un-started sessions are refundable; completed-and-verified
|
||||
* sessions stay payout-eligible and render as locked.
|
||||
* - **Never render a label off a raw enum code** — codes map to i18n keys in both locales.
|
||||
*/
|
||||
|
||||
/**
|
||||
* `refunds.status` (b11 contract enum, forward-only). A card refund goes `approved → succeeded`
|
||||
* immediately; a BNPL/manual refund sits in `processing` until the async customer cash-back reconciles.
|
||||
* The customer-facing UI maps these six codes onto three steps (see `refundCustomerStep`).
|
||||
*/
|
||||
export type RefundStatus = 'requested' | 'approved' | 'processing' | 'succeeded' | 'failed' | 'rejected';
|
||||
|
||||
/** `refunds.refund_channel` (b11). The data-model's `manual_bank` is served as the canonical `manual`. */
|
||||
export type RefundChannel = 'psp_card' | 'bnpl_revert' | 'manual';
|
||||
|
||||
/** The three customer-facing refund steps the six contract statuses collapse onto. */
|
||||
export const CUSTOMER_REFUND_STEP_ORDER = ['submitted', 'on_its_way', 'completed'] as const;
|
||||
export type CustomerRefundStep = (typeof CUSTOMER_REFUND_STEP_ORDER)[number];
|
||||
|
||||
/**
|
||||
* Maps the contract `RefundStatus` onto the customer's mental model: *submitted → on its way → completed*,
|
||||
* with `failed`/`rejected` collapsing to a distinct error state (never a fourth happy step).
|
||||
*/
|
||||
export function refundCustomerStep(status: RefundStatus): CustomerRefundStep | 'failed' {
|
||||
switch (status) {
|
||||
case 'requested':
|
||||
case 'approved':
|
||||
return 'submitted';
|
||||
case 'processing':
|
||||
return 'on_its_way';
|
||||
case 'succeeded':
|
||||
return 'completed';
|
||||
case 'failed':
|
||||
case 'rejected':
|
||||
return 'failed';
|
||||
}
|
||||
}
|
||||
|
||||
/** Zero-based `activeStep` for the three-step refund stepper (only meaningful for the non-`failed` steps). */
|
||||
export function refundStepIndex(status: RefundStatus): number {
|
||||
const step = refundCustomerStep(status);
|
||||
if (step === 'failed') return CUSTOMER_REFUND_STEP_ORDER.length - 1;
|
||||
return CUSTOMER_REFUND_STEP_ORDER.indexOf(step);
|
||||
}
|
||||
|
||||
/** `succeeded` (completed) or `failed`/`rejected` (dead) — nothing left to poll. */
|
||||
export function isTerminalRefundStatus(status: RefundStatus): boolean {
|
||||
return status === 'succeeded' || status === 'failed' || status === 'rejected';
|
||||
}
|
||||
|
||||
/**
|
||||
* Proposed cancellation-policy tier codes (REQ-020). The product docs describe the tiers (free > 24h,
|
||||
* partial < 24h, customer no-show) but pin **no** wire code-names, so these are the client-mock codes the
|
||||
* UI maps to i18n keys; when the backend defines the real `cancellation_policy_code` set the map updates.
|
||||
* **Never** render a label off the raw code.
|
||||
*/
|
||||
export type CancellationPolicyCode = 'free_24h' | 'partial_under_24h' | 'customer_no_show';
|
||||
|
||||
/** The lead-time bucket that resolved the tier — drives an explanatory i18n label, not the money. */
|
||||
export type CancellationLeadTime = 'gt_24h' | 'lt_24h' | 'started';
|
||||
|
||||
/** Whether the whole booking or only the remaining (un-started) sessions are being cancelled. */
|
||||
export type CancellationScope = 'whole_booking' | 'remaining_sessions';
|
||||
|
||||
/** Why a session is refundable or locked — maps to an i18n reason chip. `un_started` ⇔ refundable. */
|
||||
export type CancellationSessionReason = 'un_started' | BookingSessionStatus;
|
||||
|
||||
/** One session's refundability in the cancellation preview (refundable ⇔ un-started; locked otherwise). */
|
||||
export interface CancellationSessionPreview {
|
||||
bookingSessionId: number;
|
||||
sessionIndex: number;
|
||||
/** ISO date `YYYY-MM-DD`. */
|
||||
scheduledDate: string;
|
||||
refundable: boolean;
|
||||
/** `un_started` when refundable; otherwise the blocking session status (`completed`/`in_progress`/…). */
|
||||
reasonCode: CancellationSessionReason;
|
||||
}
|
||||
|
||||
/**
|
||||
* The resolved cancellation preview shown BEFORE confirm (REQ-020 — not served by b11 yet, mock-primary).
|
||||
* `refundAmountIrr + feeAmountIrr = refundableAmountIrr` by construction, so the disclosure's
|
||||
* `PriceBreakdown` (refund-vs-fee split) reconciles to the rial. The fee-leg decomposition
|
||||
* (`platformFeeRefundedIrr` / `nursePayoutRefundedIrr`) is served, never recomputed client-side.
|
||||
*/
|
||||
export interface CancellationPolicyPreview {
|
||||
bookingId: number;
|
||||
/** `false` when nothing is refundable (already cancelled/completed, or no un-started sessions). */
|
||||
cancellable: boolean;
|
||||
cancellationPolicyCode: CancellationPolicyCode;
|
||||
/** 0–1 fraction of the refundable amount returned to the customer. */
|
||||
refundPercentageApplied: number;
|
||||
/** 0–1 fraction retained as the cancellation fee/penalty (`= 1 - refundPercentageApplied`). */
|
||||
feePercentage: number;
|
||||
/** IRR digit-string — the amount refunded to the customer. */
|
||||
refundAmountIrr: string;
|
||||
/** IRR digit-string — the amount retained as the fee. */
|
||||
feeAmountIrr: string;
|
||||
/** IRR digit-string — the base being decided (`refundAmountIrr + feeAmountIrr`; the un-started gross). */
|
||||
refundableAmountIrr: string;
|
||||
/** Decomposition of `refundAmountIrr` across the two fee legs (served, never recomputed). */
|
||||
platformFeeRefundedIrr: string;
|
||||
nursePayoutRefundedIrr: string;
|
||||
appliesTo: CancellationScope;
|
||||
leadTimeLabel: CancellationLeadTime;
|
||||
/** The channel the refund will run through — drives the ETA preview wording. */
|
||||
refundChannel: RefundChannel;
|
||||
/** Populated only for `bnpl_revert` (the ~7–10 business-day window); a date `YYYY-MM-DD`. */
|
||||
expectedCustomerRefundEta: string | null;
|
||||
/** The refundable session ids the confirm submits (all un-started sessions). */
|
||||
refundableSessionIds: number[];
|
||||
sessions: CancellationSessionPreview[];
|
||||
}
|
||||
|
||||
/** The customer's stated reason category for cancelling — maps to an i18n label, never rendered raw. */
|
||||
export type CancelReasonCategory =
|
||||
| 'changed_mind'
|
||||
| 'schedule_conflict'
|
||||
| 'found_other_care'
|
||||
| 'other';
|
||||
|
||||
/** `POST bookings/{bookingId}/cancel` input (REQ-019 — customer-initiated; not served by b11 yet). */
|
||||
export interface CancelBookingInput {
|
||||
bookingId: number;
|
||||
/** The un-started sessions to cancel (whole-remaining by default); omitted = all refundable. */
|
||||
sessionIds?: number[];
|
||||
reasonCategory: CancelReasonCategory;
|
||||
reasonNotes?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The customer-facing refund view. A superset of the thin b11 `GET refunds/{id}/status` payload
|
||||
* (`{ id, bookingId, status, refundChannel, amount, expectedCustomerRefundEta, reference }`) — the mock
|
||||
* fills the whole shape; the real `clientApi` maps the thin contract and leaves the decomposition fields
|
||||
* `null` until REQ-021 exposes them to the customer. The UI renders the fee-leg split only when present.
|
||||
*/
|
||||
export interface RefundSummary {
|
||||
id: number;
|
||||
bookingId: number;
|
||||
refundStatus: RefundStatus;
|
||||
refundChannel: RefundChannel;
|
||||
/** IRR digit-string — the total refunded to the customer (the contract's `amount`). */
|
||||
totalRefundedIrr: string;
|
||||
/** Populated for `bnpl_revert` (the ~7–10 business-day window); a date `YYYY-MM-DD`. */
|
||||
expectedCustomerRefundEta: string | null;
|
||||
/** Opaque, **masked** (last 4 only) external reference — never parse it. */
|
||||
externalRevertReference: string | null;
|
||||
/** --- Fee-leg decomposition + policy snapshot (REQ-021: `null` on the real path until served). --- */
|
||||
refundPercentageApplied: number | null;
|
||||
cancellationPolicyCode: string | null;
|
||||
platformFeeRefundedIrr: string | null;
|
||||
nursePayoutRefundedIrr: string | null;
|
||||
createdAt: string | null;
|
||||
completedAt: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a booking can still be cancelled by the customer (has an un-started remainder). `confirmed` or
|
||||
* `in_progress` are candidates; the resolved preview reports `cancellable: false` if no un-started session
|
||||
* actually remains. Terminal/settled states (`completed`/`closed`/`disputed`/`cancelled`/`pending_payment`)
|
||||
* are never customer-cancellable here.
|
||||
*/
|
||||
export function isBookingCancellable(status: BookingStatus): boolean {
|
||||
return status === 'confirmed' || status === 'in_progress';
|
||||
}
|
||||
|
||||
/**
|
||||
* The refunds API seam — the real HTTP client and the in-memory mock both implement this interface;
|
||||
* selection is by config (`USE_REFUNDS_MOCK`), never scattered `if (mock)` checks.
|
||||
*/
|
||||
export interface RefundsApi {
|
||||
resolveCancellationPolicy(bookingId: number): Promise<CancellationPolicyPreview>;
|
||||
cancelBooking(input: CancelBookingInput): Promise<RefundSummary>;
|
||||
/** `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>;
|
||||
}
|
||||
Reference in New Issue
Block a user