Appointments
This document describes the Appointments feature in the Web/Admin application and how it maps to the backend Appointment Module. Use this as a guide for product behavior, UI flows, and the underlying API interactions with critical business logic for scheduling, payment processing, and appointment lifecycle management.
The admin/web app provides operational tooling for:
- Viewing, searching, and filtering appointments with real-time status updates
- Creating appointments on behalf of parents/therapists with validation and conflict detection
- Rescheduling and cancelling appointments according to business rules and penalty calculations
- Initiating and monitoring payments (Razorpay) with reconciliation and refund handling
- Reviewing system notifications and background jobs with audit trails
The backend reference for all rules and endpoints is documented in the backend guide: "Appointment Module." The web layer consumes these via the SDK/GraphQL and/or HTTP endpoints exposed by the backend.
Core Concepts (from Backend)
- Appointment Types: ONE_ON_ONE, TEAM_MEETING with different business rules and constraints
- Appointment Statuses: SCHEDULED, STARTED, COMPLETED, CANCELLED, EXPIRED with state transition validation
- Payment Statuses: PENDING, SUCCESSFUL, FAILED, EXPIRED with automatic retry logic and webhook processing
- Time Windows: 24-hour rules for cancellation and rescheduling with penalty calculations
- Multi-therapist Support: Continuous slot validation and conflict detection across multiple therapists
- Fee Structure: Consultation fees, platform fees (5%), and taxes (18% GST) with dynamic calculation
Web/Admin Capabilities
1) Appointment List & Search
Business Rules:
- Filter by date range, therapist, parent/child, status, and payment status
- Show real-time appointment status with payment reconciliation indicators
- Display fee breakdown and net amounts for each appointment
- Highlight appointments requiring manual intervention or attention
Implementation:
// Filter logic with payment status and fee calculations
const filtered = rows.filter((appointment) => {
// Basic filtering
const matchesStatus = !statusFilter || appointment.status === statusFilter;
const matchesPayment =
!paymentFilter || appointment.paymentStatus === paymentFilter;
const matchesDateRange =
!dateRange ||
(appointment.start_time >= dateRange.start &&
appointment.start_time <= dateRange.end);
const matchesTherapist =
!therapistFilter || appointment.therapistIds.includes(therapistFilter);
// Calculate fee breakdown for display
const feeBreakdown = calculateFeeBreakdown(appointment);
appointment.feeBreakdown = feeBreakdown;
appointment.netAmount = calculateNetAmount(appointment);
// Check payment reconciliation status
const reconciliationStatus = checkPaymentReconciliation(appointment);
appointment.reconciliationStatus = reconciliationStatus;
// Check if appointment needs attention
appointment.needsAttention = checkNeedsAttention(appointment);
return (
matchesStatus && matchesPayment && matchesDateRange && matchesTherapist
);
});
// Fee calculation formula
const calculateFeeBreakdown = (appointment) => {
const consultationFee = appointment.consultation_fee || 0;
const platformFee = calculatePlatformFee(consultationFee);
const taxes = calculateTaxes(consultationFee + platformFee);
return {
consultation_fee: consultationFee,
platform_fee: platformFee,
taxes: taxes,
total_fees: platformFee + taxes,
net_amount: consultationFee - platformFee - taxes,
};
};
// Platform fee calculation (5%)
const calculatePlatformFee = (consultationFee) => {
return Math.round(consultationFee * 0.05 * 100) / 100;
};
// Tax calculation (18% GST)
const calculateTaxes = (amount) => {
return Math.round(amount * 0.18 * 100) / 100;
};
// Check if appointment needs manual attention
const checkNeedsAttention = (appointment) => {
return (
appointment.paymentStatus === 'FAILED' ||
appointment.reconciliationStatus === 'DISCREPANCY' ||
(appointment.status === 'SCHEDULED' &&
new Date(appointment.start_time) <
new Date(Date.now() + 24 * 60 * 60 * 1000) &&
appointment.paymentStatus === 'PENDING')
);
};
return (
<table>
<tbody>
{filtered.map((appointment) => (
<tr
key={appointment.id}
className={appointment.needsAttention ? 'attention-row' : ''}
>
<td>{appointment.childName}</td>
<td>{appointment.therapistNames.join(', ')}</td>
<td>
<PriceBreakup
consultation={appointment.feeBreakdown.consultation_fee}
platform={appointment.feeBreakdown.platform_fee}
taxes={appointment.feeBreakdown.taxes}
/>
</td>
<td className={`status-${appointment.paymentStatus.toLowerCase()}`}>
{appointment.paymentStatus}
</td>
</tr>
))}
</tbody>
</table>
);
2) Create Appointment (Operator Flow)
Appointment Creation Business Rules:
- Therapist Validation: Ensure therapists have availability and no conflicts
- Child-Therapist Matching: Verify therapist specialization matches child's needs
- Time Slot Validation: Check continuous availability across all selected therapists
- Meet Link Validation: Validate meeting platform compatibility and format
- Fee Calculation: Compute consultation, platform (5%), and tax (18%) fees
- Payment Link Generation: Create Razorpay payment link with smart expiry
Validation Logic:
// Validate appointment creation
const validateAppointmentCreation = async (input) => {
const errors = [];
// Check therapist availability
const therapistAvailability = await checkTherapistAvailability(
input.primary_therapist_id,
input.start_time,
input.end_time,
);
if (!therapistAvailability.isAvailable) {
errors.push(
`Primary therapist not available: ${therapistAvailability.reason}`,
);
}
// Check additional therapists
if (input.other_therapists && input.other_therapists.length > 0) {
for (const therapistId of input.other_therapists) {
const availability = await checkTherapistAvailability(
therapistId,
input.start_time,
input.end_time,
);
if (!availability.isAvailable) {
errors.push(
`Therapist ${therapistId} not available: ${availability.reason}`,
);
}
}
}
// Validate meet link format
const meetLinkValidation = validateMeetLink(input.meet_link);
if (!meetLinkValidation.valid) {
errors.push(`Invalid meet link: ${meetLinkValidation.reason}`);
}
// Check assessment belongs to child
if (input.assessment_id) {
const assessment = await getAssessmentDetails(input.assessment_id);
if (assessment.child_id !== input.child_id) {
errors.push('Assessment does not belong to the selected child');
}
}
// Validate time constraints
const timeValidation = validateTimeConstraints(
input.start_time,
input.end_time,
);
if (!timeValidation.valid) {
errors.push(timeValidation.reason);
}
return {
valid: errors.length === 0,
errors,
};
};
// Calculate appointment fees
const calculateAppointmentFees = (consultationFee) => {
const platformFee = Math.round(consultationFee * 0.05 * 100) / 100;
const taxes = Math.round((consultationFee + platformFee) * 0.18 * 100) / 100;
const totalAmount = consultationFee + platformFee + taxes;
return {
consultation_fee: consultationFee,
platform_fee: platformFee,
taxes: taxes,
total_amount: totalAmount,
net_amount: consultationFee - platformFee,
};
};
// Validate meet link format
const validateMeetLink = (meetLink) => {
const supportedPlatforms = ['zoom', 'google-meet', 'teams', 'custom'];
const urlPattern = /^https?:\/\/.+/;
if (!urlPattern.test(meetLink)) {
return { valid: false, reason: 'Invalid URL format' };
}
// Check if it's a supported platform
const platform = supportedPlatforms.find((p) =>
meetLink.toLowerCase().includes(p),
);
if (!platform) {
return { valid: false, reason: 'Unsupported meeting platform' };
}
return { valid: true, platform };
};
3) Payment Handling
Payment Processing Business Rules:
- Payment Initiation: Parent-initiated via Razorpay checkout with admin support capabilities
- Webhook Processing: Backend handles payment confirmation via provider webhooks with signature verification
- Reconciliation: Real-time sync with payment provider status and automatic retry logic
- Admin Tools: Copy/resend payment links, manual reconciliation, and payment status monitoring
Payment Status Logic:
// Payment status tracking and reconciliation
const trackPaymentStatus = (appointment) => {
const paymentHistory = appointment.payment_history || [];
const currentStatus = appointment.payment_status;
// Calculate payment timeline
const timeline = paymentHistory.map((entry) => ({
timestamp: entry.timestamp,
status: entry.status,
amount: entry.amount,
provider_reference: entry.provider_reference,
error_message: entry.error_message,
retry_count: entry.retry_count,
}));
// Determine if manual intervention is needed
const needsIntervention =
(currentStatus === 'FAILED' &&
paymentHistory.filter((h) => h.status === 'FAILED').length >= 3) ||
(currentStatus === 'PENDING' &&
new Date(appointment.payment_expiry) < new Date());
return {
current_status: currentStatus,
timeline: timeline,
needs_intervention: needsIntervention,
can_retry: currentStatus === 'FAILED' && paymentHistory.length < 5,
next_action: determineNextAction(currentStatus, appointment),
};
};
// Determine next action based on payment status
const determineNextAction = (status, appointment) => {
switch (status) {
case 'PENDING':
if (new Date(appointment.payment_expiry) < new Date()) {
return 'EXPIRED - Send new payment link';
}
return 'WAITING - Monitor for webhook confirmation';
case 'FAILED':
return 'RETRY - Resend payment link or manual intervention';
case 'SUCCESSFUL':
return 'COMPLETE - Payment confirmed';
case 'EXPIRED':
return 'RENEW - Generate new payment link';
default:
return 'UNKNOWN - Manual review required';
}
};
// Payment reconciliation logic
const reconcilePayment = async (appointmentId) => {
const appointment = await getAppointmentDetails(appointmentId);
const providerStatus = await fetchProviderPaymentStatus(
appointment.payment_id,
);
// Compare internal vs provider status
const reconciliation = {
internal_status: appointment.payment_status,
provider_status: providerStatus.status,
amount_match: appointment.amount === providerStatus.amount,
timestamp_match:
Math.abs(
new Date(appointment.payment_timestamp) -
new Date(providerStatus.timestamp),
) < 60000, // Within 1 minute
needs_sync: false,
};
// Determine if sync is needed
if (reconciliation.internal_status !== reconciliation.provider_status) {
reconciliation.needs_sync = true;
reconciliation.sync_action = 'UPDATE_STATUS';
}
if (!reconciliation.amount_match) {
reconciliation.needs_sync = true;
reconciliation.sync_action = 'INVESTIGATE_AMOUNT_DISCREPANCY';
}
return reconciliation;
};
4) Reschedule
Rescheduling Business Rules:
- 24-Hour Rule: Reschedule allowed only if >24 hours before start time
- Penalty Calculation: Based on time remaining, appointment type, and cancellation policy
- Therapist Availability: Validate new slot availability and conflict detection
- Payment Handling: Refund/charge difference based on fee changes and penalty calculations
- Notification System: Automatic notifications to all stakeholders about schedule changes
Rescheduling Logic:
// Reschedule validation and penalty calculation
const validateReschedule = (appointment, newStartTime) => {
const currentTime = new Date();
const appointmentTime = new Date(appointment.start_time);
const newAppointmentTime = new Date(newStartTime);
const hoursUntilAppointment =
(appointmentTime - currentTime) / (1000 * 60 * 60);
// 24-hour rule validation
const canReschedule = hoursUntilAppointment > 24;
if (!canReschedule) {
return {
valid: false,
reason: 'Reschedule not allowed within 24 hours of appointment',
penalty_applicable: true,
};
}
// Calculate penalty based on time remaining
const penalty = calculateReschedulePenalty(
hoursUntilAppointment,
appointment.type,
);
// Validate new time slot
const slotValidation = validateNewTimeSlot(
newStartTime,
appointment.therapist_ids,
);
return {
valid: slotValidation.available,
reason: slotValidation.reason,
penalty_amount: penalty,
new_slot_available: slotValidation.available,
reschedule_fee: calculateRescheduleFee(
penalty,
appointment.consultation_fee,
),
};
};
// Calculate reschedule penalty based on time remaining
const calculateReschedulePenalty = (hoursUntilAppointment, appointmentType) => {
let penaltyPercentage = 0;
if (hoursUntilAppointment > 72) {
penaltyPercentage = 0; // No penalty if >3 days
} else if (hoursUntilAppointment > 48) {
penaltyPercentage = 0.1; // 10% penalty if 2-3 days
} else if (hoursUntilAppointment > 24) {
penaltyPercentage = 0.2; // 20% penalty if 1-2 days
}
// Additional penalty for team meetings
if (appointmentType === 'TEAM_MEETING') {
penaltyPercentage += 0.05; // 5% additional penalty
}
return penaltyPercentage;
};
// Validate new time slot availability
const validateNewTimeSlot = async (newStartTime, therapistIds) => {
const newTime = new Date(newStartTime);
const dayOfWeek = newTime.getDay();
const hour = newTime.getHours();
// Check if it's a valid business day and time
if (dayOfWeek === 0 || dayOfWeek === 6) {
// Weekend
return {
available: false,
reason: 'Appointments not available on weekends',
};
}
if (hour < 9 || hour > 18) {
// Outside business hours
return {
available: false,
reason: 'Appointments only available 9 AM - 6 PM',
};
}
// Check therapist availability
for (const therapistId of therapistIds) {
const availability = await checkTherapistAvailability(
therapistId,
newStartTime,
);
if (!availability.available) {
return {
available: false,
reason: `Therapist ${therapistId} not available at this time`,
};
}
}
return { available: true, reason: 'Slot available' };
};
// Calculate reschedule fee
const calculateRescheduleFee = (penaltyPercentage, consultationFee) => {
const penaltyAmount = consultationFee * penaltyPercentage;
const platformFee = penaltyAmount * 0.05; // 5% platform fee on penalty
const taxes = (penaltyAmount + platformFee) * 0.18; // 18% GST
return {
penalty_amount: penaltyAmount,
platform_fee: platformFee,
taxes: taxes,
total_reschedule_fee: penaltyAmount + platformFee + taxes,
};
};
5) Cancellation
Cancellation Business Rules:
- 24-Hour Rule: No cancellations within 24 hours of appointment start time
- Parent Cancellation: ₹250 cancellation fee; therapist loses earning opportunity
- Therapist Cancellation: Full refund to parent; therapist penalty (consultation fee + ₹250)
- Team Meeting Restriction: Team meetings cannot be cancelled (one-on-one appointments only)
- Refund Processing: Automatic refund processing with wallet adjustments and penalty calculations
Cancellation Logic:
// Cancellation validation and penalty calculation
const validateCancellation = (appointment, initiator) => {
const currentTime = new Date();
const appointmentTime = new Date(appointment.start_time);
const hoursUntilAppointment =
(appointmentTime - currentTime) / (1000 * 60 * 60);
// 24-hour rule validation
const canCancel = hoursUntilAppointment > 24;
if (!canCancel) {
return {
valid: false,
reason: 'Cancellation not allowed within 24 hours of appointment',
penalty_applicable: true,
};
}
// Team meeting restriction
if (appointment.type === 'TEAM_MEETING') {
return {
valid: false,
reason: 'Team meetings cannot be cancelled',
penalty_applicable: false,
};
}
// Calculate cancellation penalties and refunds
const cancellationDetails = calculateCancellationDetails(
appointment,
initiator,
);
return {
valid: true,
reason: 'Cancellation allowed',
...cancellationDetails,
};
};
// Calculate cancellation details based on initiator
const calculateCancellationDetails = (appointment, initiator) => {
const consultationFee = appointment.consultation_fee || 0;
const platformFee = consultationFee * 0.05; // 5% platform fee
const taxes = (consultationFee + platformFee) * 0.18; // 18% GST
const totalPaid = consultationFee + platformFee + taxes;
let refundAmount = 0;
let penaltyAmount = 0;
let therapistPenalty = 0;
let cancellationFee = 0;
if (initiator === 'PARENT') {
// Parent cancellation: ₹250 fee, therapist loses earning
cancellationFee = 250;
refundAmount = totalPaid - cancellationFee;
therapistPenalty = consultationFee; // Therapist loses the consultation fee
penaltyAmount = cancellationFee;
} else if (initiator === 'THERAPIST') {
// Therapist cancellation: Full refund to parent, therapist penalty
refundAmount = totalPaid;
therapistPenalty = consultationFee + 250; // Consultation fee + ₹250 penalty
penaltyAmount = therapistPenalty;
} else if (initiator === 'ADMIN') {
// Admin cancellation: Full refund, no penalties
refundAmount = totalPaid;
penaltyAmount = 0;
therapistPenalty = 0;
}
return {
refund_amount: refundAmount,
penalty_amount: penaltyAmount,
therapist_penalty: therapistPenalty,
cancellation_fee: cancellationFee,
total_paid: totalPaid,
refund_breakdown: {
consultation_fee: consultationFee,
platform_fee: platformFee,
taxes: taxes,
total_refund: refundAmount,
},
};
};
// Process cancellation with wallet adjustments
const processCancellation = async (appointmentId, initiator, reason) => {
const appointment = await getAppointmentDetails(appointmentId);
const validation = validateCancellation(appointment, initiator);
if (!validation.valid) {
throw new Error(`Cancellation not allowed: ${validation.reason}`);
}
// Process refund to parent's wallet
if (validation.refund_amount > 0) {
await processWalletRefund(appointment.child_id, validation.refund_amount, {
type: 'APPOINTMENT_CANCELLATION',
appointment_id: appointmentId,
reason: reason,
});
}
// Process therapist penalty
if (validation.therapist_penalty > 0) {
await processTherapistPenalty(
appointment.primary_therapist_id,
validation.therapist_penalty,
{
type: 'CANCELLATION_PENALTY',
appointment_id: appointmentId,
initiator: initiator,
},
);
}
return {
appointment_id: appointmentId,
status: 'CANCELLED',
refund_processed: validation.refund_amount > 0,
penalty_applied: validation.penalty_amount > 0,
cancellation_details: validation,
};
};
6) Notifications & Background Jobs
Notification Business Rules:
- Upcoming Reminders: Automated notifications 24 hours, 1 hour, and 15 minutes before appointment
- Admin Notifications: Real-time alerts for new bookings, payment failures, and cancellations
- Payment Link Expiry: Automatic expiry handling with retry logic and user notifications
- Lifecycle Management: Automated start/end handling and post-session workflow triggers
Notification Logic:
// Notification scheduling and management
const scheduleAppointmentNotifications = (appointment) => {
const appointmentTime = new Date(appointment.start_time);
const notifications = [];
// 24-hour reminder
const reminder24h = new Date(appointmentTime.getTime() - 24 * 60 * 60 * 1000);
if (reminder24h > new Date()) {
notifications.push({
type: 'APPOINTMENT_REMINDER',
scheduled_for: reminder24h,
recipients: [appointment.child_id, appointment.primary_therapist_id],
template: 'appointment_reminder_24h',
data: { appointment_id: appointment.id, hours_until: 24 },
});
}
// 1-hour reminder
const reminder1h = new Date(appointmentTime.getTime() - 60 * 60 * 1000);
if (reminder1h > new Date()) {
notifications.push({
type: 'APPOINTMENT_REMINDER',
scheduled_for: reminder1h,
recipients: [appointment.child_id, appointment.primary_therapist_id],
template: 'appointment_reminder_1h',
data: { appointment_id: appointment.id, hours_until: 1 },
});
}
// 15-minute reminder
const reminder15m = new Date(appointmentTime.getTime() - 15 * 60 * 1000);
if (reminder15m > new Date()) {
notifications.push({
type: 'APPOINTMENT_REMINDER',
scheduled_for: reminder15m,
recipients: [appointment.child_id, appointment.primary_therapist_id],
template: 'appointment_reminder_15m',
data: { appointment_id: appointment.id, minutes_until: 15 },
});
}
return notifications;
};
// Admin notification triggers
const triggerAdminNotifications = (event, appointment) => {
const adminNotifications = [];
switch (event) {
case 'NEW_BOOKING':
adminNotifications.push({
type: 'ADMIN_ALERT',
priority: 'MEDIUM',
title: 'New Appointment Booked',
message: `New appointment booked for ${appointment.child_name} with ${appointment.therapist_name}`,
data: { appointment_id: appointment.id, event_type: 'NEW_BOOKING' },
});
break;
case 'PAYMENT_FAILED':
adminNotifications.push({
type: 'ADMIN_ALERT',
priority: 'HIGH',
title: 'Payment Failed',
message: `Payment failed for appointment ${appointment.id}. Manual intervention may be required.`,
data: { appointment_id: appointment.id, event_type: 'PAYMENT_FAILED' },
});
break;
case 'CANCELLATION':
adminNotifications.push({
type: 'ADMIN_ALERT',
priority: 'MEDIUM',
title: 'Appointment Cancelled',
message: `Appointment ${appointment.id} has been cancelled by ${appointment.cancelled_by}`,
data: { appointment_id: appointment.id, event_type: 'CANCELLATION' },
});
break;
}
return adminNotifications;
};
// Payment link expiry handling
const handlePaymentLinkExpiry = async (appointmentId) => {
const appointment = await getAppointmentDetails(appointmentId);
if (
appointment.payment_status === 'PENDING' &&
new Date(appointment.payment_expiry) < new Date()
) {
// Mark payment as expired
await updatePaymentStatus(appointmentId, 'EXPIRED');
// Send expiry notification
await sendNotification({
type: 'PAYMENT_EXPIRED',
recipients: [appointment.child_id],
template: 'payment_link_expired',
data: { appointment_id: appointmentId },
});
// Generate new payment link if within grace period
const gracePeriod =
new Date(appointment.start_time).getTime() - new Date().getTime();
if (gracePeriod > 0) {
const newPaymentLink = await generateNewPaymentLink(appointmentId);
await sendNotification({
type: 'NEW_PAYMENT_LINK',
recipients: [appointment.child_id],
template: 'new_payment_link_generated',
data: { appointment_id: appointmentId, payment_link: newPaymentLink },
});
}
}
};
Data Flow (Web ↔ Backend)
Critical Business Logic Flow:
- Appointment Creation: Validation → Fee calculation → Payment link generation → Notification scheduling
- Payment Processing: Webhook handling → Status reconciliation → Admin notifications → Wallet updates
- Rescheduling: Time validation → Penalty calculation → Slot availability → Fee adjustments
- Cancellation: Policy validation → Refund calculation → Wallet adjustments → Penalty processing
- Notification Management: Event triggers → Template selection → Delivery scheduling → Status tracking
GraphQL Operations with Business Rules:
| Action | GraphQL Operation | Business Rules Applied |
|---|---|---|
| Create Appointment | createAppointment(input) | Therapist validation, fee calculation, payment link generation |
| Start Payment | startPayment(input) | Payment link validation, expiry calculation |
| Reconcile Payment | reconcilePayment(input) | Status comparison, discrepancy detection |
| Resend Payment Link | resendPaymentLink(input) | Expiry validation, new link generation |
| Cancel Appointment | cancelAppointment(input) | 24-hour rule, penalty calculation, refund processing |
| Reschedule Appointment | rescheduleAppointment(input) | Time validation, penalty calculation, slot availability |
| Rebook | rebookAppointment(input) | Availability check, fee recalculation |
Error Handling & Validation:
- Input Validation: All GraphQL inputs validated against business rules before processing
- Conflict Detection: Real-time availability checking and conflict resolution
- Payment Reconciliation: Automatic status sync with payment providers
- Audit Trail: Complete logging of all appointment lifecycle events
Security & Access Control:
- Role-Based Access: Admin, therapist, and parent-specific permissions
- Data Privacy: Appointment data access restricted by user role and relationship
- Payment Security: Secure payment link generation and webhook signature verification
- Audit Logging: Complete audit trail for all appointment modifications
For backend REST details, refer to the Backend documentation; the Admin app generally consumes the GraphQL layer or SDK wrappers with comprehensive business rule validation.