Skip to main content

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

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:

  1. Appointment Creation: Validation → Fee calculation → Payment link generation → Notification scheduling
  2. Payment Processing: Webhook handling → Status reconciliation → Admin notifications → Wallet updates
  3. Rescheduling: Time validation → Penalty calculation → Slot availability → Fee adjustments
  4. Cancellation: Policy validation → Refund calculation → Wallet adjustments → Penalty processing
  5. Notification Management: Event triggers → Template selection → Delivery scheduling → Status tracking

GraphQL Operations with Business Rules:

ActionGraphQL OperationBusiness Rules Applied
Create AppointmentcreateAppointment(input)Therapist validation, fee calculation, payment link generation
Start PaymentstartPayment(input)Payment link validation, expiry calculation
Reconcile PaymentreconcilePayment(input)Status comparison, discrepancy detection
Resend Payment LinkresendPaymentLink(input)Expiry validation, new link generation
Cancel AppointmentcancelAppointment(input)24-hour rule, penalty calculation, refund processing
Reschedule AppointmentrescheduleAppointment(input)Time validation, penalty calculation, slot availability
RebookrebookAppointment(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.