Skip to content

Customer Booking & Appointment Management

Complete guide to customer appointment booking, scheduling, rescheduling, and availability checking in the Reserva platform.


Overview

The customer booking system provides comprehensive appointment management with support for:

  • Multi-Service Bookings - Book multiple services in one appointment
  • Staff Selection - Choose preferred staff or auto-assign best available
  • Availability Grid - Multi-day calendar view for easy scheduling
  • Appointment Management - List, view, reschedule, and cancel bookings
  • Service Discovery - Browse services and outlets
  • Real-Time Validation - Instant availability checking and conflict prevention
  • Package Credit Redemption - Use prepaid package credits for zero-cost bookings

Key Concepts:

  • Server-Authoritative Pricing = All prices calculated server-side (prevents manipulation)
  • Auto-Assignment = Intelligent staff allocation based on skills and workload
  • Conflict Detection = Real-time double-booking prevention
  • Prorated Scheduling = Services scheduled sequentially with accurate time windows
  • Parallel Booking = Group services with capacity management (see Parallel Booking Guide)
  • Credit Redemption = Prepaid package credits for instant payment (see Package Credit Redemption)

Subscription Plan Limits

Appointment booking may be subject to subscription plan limits:

Plan Max Appointments/Month Max Services per Appointment Advanced Features
FREE 100 Unlimited Basic booking only
PRO 2,000 Unlimited Waitlist, API access
ENTERPRISE Unlimited Unlimited Custom integrations, SLA

Note: Subscription limit enforcement is planned for future implementation. When implemented, monthly appointment limits will block new bookings until next billing cycle or plan upgrade. See Subscription Management for upgrade options.


List Customer Appointments

Retrieve paginated list of customer's appointments with comprehensive filtering.

Endpoint

GET /api/v1/customer/appointments

Authentication: Required (Customer JWT token)

Query Parameters

  • status (optional) - Filter by appointment status: pending, confirmed, completed, cancelled, no_show
  • outlet_id (optional) - Filter by outlet ID (ObjectId format)
  • date_from (optional) - Filter appointments from this date (YYYY-MM-DD)
  • date_to (optional) - Filter appointments until this date (YYYY-MM-DD)
  • skip (optional) - Pagination offset (default: 0)
  • limit (optional) - Results per page (default: 20, max: 100)

Response

{
  "items": [
    {
      "_id": "68f6623832ea75cbb118a6ed",
      "appointment_date": "2025-10-21",
      "start_time": "09:00",
      "service_names": ["Premium Therapy Treatment"],
      "staff_names": ["Veronica L."],
      "status": "pending",
      "payment_status": "pending",
      "total_price": "150000.0",
      "outlet_name": "Downtown Beauty Spa",
      "paper_payment_url": "https://paper.id/checkout/abc123xyz"
    }
  ],
  "total": 1,
  "page": 1,
  "size": 20,
  "pages": 1
}

Response Fields:

  • items - Array of appointment summaries
  • total - Total number of appointments matching filters
  • page - Current page number (1-indexed)
  • size - Number of items per page
  • pages - Total number of pages
  • paper_payment_url - Payment checkout URL (only present when payment_status is "pending")

Appointment Statuses:

  • pending - Awaiting staff confirmation
  • confirmed - Confirmed by staff, ready to go
  • completed - Service completed successfully
  • cancelled - Cancelled by customer or staff
  • no_show - Customer did not attend

Example Request

# Get upcoming confirmed appointments
curl -X GET "http://localhost:8000/api/v1/customer/appointments?status=confirmed&date_from=2025-01-15" \
  -H "Authorization: Bearer YOUR_CUSTOMER_JWT_TOKEN"

# Get appointment history for specific outlet
curl -X GET "http://localhost:8000/api/v1/customer/appointments?outlet_id=507f1f77bcf86cd799439012&date_to=2025-01-01" \
  -H "Authorization: Bearer YOUR_CUSTOMER_JWT_TOKEN"

Create New Appointment

Create a new appointment booking with automatic validation and staff assignment.

Endpoint

POST /api/v1/customer/appointments

Authentication: Required (Customer JWT token)

Request Body

Standard Booking:

{
  "outlet_id": "507f1f77bcf86cd799439012",
  "appointment_date": "2025-01-15",
  "start_time": "14:30",
  "services": [
    {
      "service_id": "507f1f77bcf86cd799439013",
      "staff_id": "507f1f77bcf86cd799439014",
      "duration_minutes": 60
    }
  ],
  "notes": "First appointment, prefer quiet environment"
}

Booking with Package Credit (Auto-detect Package):

{
  "outlet_id": "507f1f77bcf86cd799439012",
  "appointment_date": "2025-01-15",
  "start_time": "14:30",
  "services": [
    {
      "service_id": "507f1f77bcf86cd799439013",
      "staff_id": "507f1f77bcf86cd799439014"
    }
  ],
  "credit_redeemed": true,
  "notes": "Using package credit"
}

Booking with Package Credit (Explicit Package):

{
  "outlet_id": "507f1f77bcf86cd799439012",
  "appointment_date": "2025-01-15",
  "start_time": "14:30",
  "services": [
    {
      "service_id": "507f1f77bcf86cd799439013",
      "staff_id": "507f1f77bcf86cd799439014"
    }
  ],
  "credit_redeemed": true,
  "customer_package_id": "507f1f77bcf86cd799439020",
  "notes": "Using specific package credit"
}

Parameters:

  • outlet_id (required) - Outlet where service will be performed
  • appointment_date (required) - Appointment date (YYYY-MM-DD)
  • start_time (required) - Start time in HH:MM format (24-hour)
  • services (required) - Array of services to book
  • service_id (required) - Service ObjectId
  • staff_id (optional) - Preferred staff member (auto-assigned if not provided)
  • duration_minutes (optional) - Service duration (defaults to service catalog duration)
  • notes (optional) - Special requests or notes (max 1000 characters)
  • credit_redeemed (optional) - Set to true to use package credits (see Package Credit Redemption)
  • customer_package_id (optional) - Specific package ID to use for credit redemption (auto-detected if omitted)

Response

Standard Booking Response:

{
  "_id": "507f1f77bcf86cd799439011",
  "tenant_id": "507f1f77bcf86cd799439010",
  "outlet_id": "507f1f77bcf86cd799439012",
  "customer_id": "507f1f77bcf86cd799439015",
  "appointment_date": "2025-01-15T00:00:00Z",
  "start_time": "14:30",
  "end_time": "15:30",
  "status": "pending",
  "payment_status": "pending",
  "services": [
    {
      "service_id": "507f1f77bcf86cd799439013",
      "service_name": "Premium Therapy Treatment",
      "staff_id": "507f1f77bcf86cd799439014",
      "staff_name": "Veronica L.",
      "duration_minutes": 60,
      "price": 125000,
      "start_time": "14:30",
      "end_time": "15:30"
    }
  ],
  "total_price": 125000,
  "currency": "IDR",
  "notes": "First appointment, prefer quiet environment",
  "created_at": "2025-01-10T10:00:00Z",
  "updated_at": "2025-01-10T10:00:00Z"
}

Credit Redemption Response:

{
  "_id": "507f1f77bcf86cd799439011",
  "tenant_id": "507f1f77bcf86cd799439010",
  "outlet_id": "507f1f77bcf86cd799439012",
  "customer_id": "507f1f77bcf86cd799439015",
  "appointment_date": "2025-01-15T00:00:00Z",
  "start_time": "14:30",
  "end_time": "15:30",
  "status": "confirmed",
  "payment_status": "paid",
  "services": [
    {
      "service_id": "507f1f77bcf86cd799439013",
      "service_name": "Premium Therapy Treatment",
      "staff_id": "507f1f77bcf86cd799439014",
      "staff_name": "Veronica L.",
      "duration_minutes": 60,
      "price": 125000,
      "start_time": "14:30",
      "end_time": "15:30"
    }
  ],
  "total_price": 125000,
  "currency": "IDR",
  "credit_redeemed": true,
  "credit_id": "507f1f77bcf86cd799439025",
  "customer_package_id": "507f1f77bcf86cd799439020",
  "notes": "Using package credit",
  "created_at": "2025-01-10T10:00:00Z",
  "updated_at": "2025-01-10T10:00:00Z"
}

Credit-Specific Response Fields:

Field Type Description
credit_redeemed boolean true if package credit was used
credit_id string ID of the redeemed credit record
customer_package_id string ID of the package from which credit was redeemed
payment_status string Always paid when credit is used

Booking Process (9 Steps)

  1. Customer ID Assignment - Set from authenticated customer
  2. Outlet Validation - Verify outlet belongs to customer's tenant
  3. Service Validation - Check services exist and belong to tenant
  4. Price Calculation - Server-side pricing (ignores client prices)
  5. Staff Assignment - Validate preferred staff or auto-assign best available
  6. Scheduling Validation - Check business hours, advance booking limits
  7. Conflict Detection - Prevent double-booking across all services
  8. Duplicate Prevention - Block identical bookings (same customer+service+staff+time)
  9. Appointment Creation - Create booking with PENDING status

SECURITY - Server-Authoritative Pricing:

# Client-provided prices are ALWAYS ignored
# Server calculates all prices to prevent manipulation
server_price = await pricing_service.get_service_price(
    service_id=svc.service_id,
    outlet_id=appointment_data.outlet_id,
    tenant_id=current_customer.tenant_id
)
svc.price = server_price  # Unconditionally overwrite

Business Rules Applied:

  1. Scheduling Constraints - Business hours, advance booking limits, minimum notice
  2. Staff Availability - Working hours and existing appointment conflicts
  3. Skill Validation - Staff must be qualified for service
  4. Auto-Assignment - Assigns staff with fewest bookings when not specified
  5. Load Balancing - Distributes bookings evenly across qualified staff
  6. Duplicate Prevention - Blocks same customer from booking identical service+staff+time
  7. Parallel Booking Support - Group services respect capacity limits (see Parallel Booking Guide)

Staff Assignment Options

Option 1: Preferred Staff (Customer Choice)

{
  "services": [
    {
      "service_id": "507f1f77bcf86cd799439013",
      "staff_id": "507f1f77bcf86cd799439014"  // Customer specifies
    }
  ]
}

System validates:

  • Staff is active and qualified for service
  • Staff is available at requested time
  • No scheduling conflicts exist

Option 2: Auto-Assignment (System Choice)

{
  "services": [
    {
      "service_id": "507f1f77bcf86cd799439013"
      // staff_id omitted - system auto-assigns
    }
  ]
}

System automatically:

  • Finds qualified staff for service
  • Checks availability at requested time
  • Selects staff with lowest current workload
  • Balances appointments across team

Error Responses

400 Bad Request - Invalid data format

{
  "detail": "Scheduling constraint violations: Outside business hours, Advance booking window exceeded"
}

403 Forbidden - Outlet access denied

{
  "detail": "Cannot book at this outlet"
}

404 Not Found - Service not found

{
  "detail": "Service not found"
}

409 Conflict - Staff Unavailable

{
  "detail": "Staff has conflicting appointment at this time"
}

409 Conflict - Duplicate Booking

{
  "detail": "You already have this appointment booked. Please check your existing appointments."
}

429 Too Many Requests - Subscription limit reached (Planned Feature)

{
  "detail": "Monthly appointment limit reached (100/100 on FREE plan). Upgrade to PRO for 2,000 appointments/month."
}

Note: Subscription limit enforcement is planned but not yet implemented in this endpoint.


Get Appointment Details

Retrieve detailed information about a specific customer appointment.

Endpoint

GET /api/v1/customer/appointments/{appointment_id}

Authentication: Required (Customer JWT token)

Path Parameters

  • appointment_id (required) - Unique appointment identifier (ObjectId)

Response

{
  "_id": "507f1f77bcf86cd799439011",
  "tenant_id": "507f1f77bcf86cd799439010",
  "outlet_id": "507f1f77bcf86cd799439012",
  "customer_id": "507f1f77bcf86cd799439015",
  "appointment_date": "2025-01-15T00:00:00Z",
  "start_time": "14:30",
  "end_time": "16:25",
  "status": "confirmed",
  "payment_status": "paid",
  "services": [
    {
      "service_id": "507f1f77bcf86cd799439013",
      "service_name": "Premium Therapy Treatment",
      "staff_id": "507f1f77bcf86cd799439014",
      "staff_name": "Veronica L.",
      "duration_minutes": 90,
      "price": 125000,
      "start_time": "14:30",
      "end_time": "16:00"
    },
    {
      "service_id": "507f1f77bcf86cd799439016",
      "service_name": "Relaxation Massage",
      "staff_id": "507f1f77bcf86cd799439017",
      "staff_name": "Sarah M.",
      "duration_minutes": 25,
      "price": 50000,
      "start_time": "16:00",
      "end_time": "16:25"
    }
  ],
  "total_price": 175000,
  "currency": "IDR",
  "notes": "First appointment, prefer quiet environment",
  "created_at": "2025-01-10T10:00:00Z",
  "updated_at": "2025-01-10T10:00:00Z",
  "rescheduled_from": null,
  "rescheduled_to": null,
  "rescheduled_at": null,
  "cancelled_at": null,
  "cancelled_by": null,
  "cancellation_reason": null
}

Optional Response Fields:

These fields are only present when applicable:

  • rescheduled_from - Original appointment time (object with date, start_time, end_time)
  • rescheduled_to - New appointment time after rescheduling (object with date, start_time, end_time)
  • rescheduled_at - Timestamp when appointment was rescheduled
  • cancelled_at - Timestamp when appointment was cancelled
  • cancelled_by - Who cancelled the appointment ("customer" or "staff")
  • cancellation_reason - Reason provided for cancellation

Access Control:

  • Customers can only view their own appointments
  • Attempting to access another customer's appointment returns 403 Forbidden

Reschedule Appointment

Reschedule an existing appointment to a new date and time with automatic validation.

Endpoint

PUT /api/v1/customer/appointments/{appointment_id}/reschedule

Authentication: Required (Customer JWT token)

Path Parameters

  • appointment_id (required) - Unique appointment identifier (ObjectId)

Request Body

{
  "new_date": "2025-01-20",
  "new_time": "15:30",
  "reason": "Schedule conflict, need later time"
}

Parameters:

  • new_date (required) - New appointment date (YYYY-MM-DD)
  • new_time (required) - New start time (HH:MM format, 24-hour)
  • reason (optional) - Reason for rescheduling (max 500 characters)

Response

{
  "_id": "507f1f77bcf86cd799439011",
  "appointment_date": "2025-01-20T00:00:00Z",
  "start_time": "15:30",
  "end_time": "16:30",
  "status": "pending",
  "rescheduled_from": {
    "date": "2025-01-15",
    "start_time": "14:30",
    "end_time": "15:30"
  },
  "rescheduled_to": {
    "date": "2025-01-20",
    "start_time": "15:30",
    "end_time": "16:30"
  },
  "rescheduled_at": "2025-01-12T09:00:00Z",
  "notes": "First appointment, prefer quiet environment\n[Rescheduled on 2025-01-12 09:00] Schedule conflict, need later time",
  "services": [
    {
      "service_id": "507f1f77bcf86cd799439013",
      "service_name": "Premium Therapy Treatment",
      "staff_id": "507f1f77bcf86cd799439014",
      "staff_name": "Veronica L.",
      "duration_minutes": 60,
      "price": 125000,
      "start_time": "15:30",
      "end_time": "16:30"
    }
  ],
  "updated_at": "2025-01-12T09:00:00Z"
}

Rescheduling Process

  1. Ownership Validation - Verify appointment belongs to customer
  2. Status Check - Only PENDING or CONFIRMED appointments can be rescheduled
  3. Time Parsing - Validate new time format (HH:MM)
  4. Scheduling Validation - Check business hours, advance booking limits
  5. Staff Revalidation - Ensure assigned staff still qualified and available
  6. Conflict Detection - Check for double-booking at new time (excluding current appointment)
  7. Service Time Recalculation - Update all service start/end times
  8. History Tracking - Store original time in rescheduled_from field
  9. Notification - Trigger staff notification workflows

Business Rules:

  • Cannot reschedule COMPLETED or CANCELLED appointments
  • New time must meet same scheduling constraints as original booking
  • Staff assignments preserved but revalidated for new time
  • Original appointment time stored in rescheduled_from (preserved on subsequent reschedules)
  • Reason appended to notes with timestamp
  • Parallel Booking Capacity - Destination slot must have available capacity for group services (see Parallel Booking Guide)

Error Responses

400 Bad Request - Invalid status or constraints

{
  "detail": "Cannot reschedule appointment with status: completed"
}
{
  "detail": "Scheduling constraint violations: Outside business hours"
}

403 Forbidden - Access denied

{
  "detail": "Access denied to this appointment"
}

409 Conflict - Staff Unavailable

{
  "detail": "Staff has conflicting appointment at the new time"
}

409 Conflict - Duplicate Booking

{
  "detail": "You already have this appointment booked. Please check your existing appointments."
}

422 Unprocessable Entity - Invalid time format

{
  "detail": "Invalid time format. Use HH:MM format (e.g., 14:30)"
}

Cancel Appointment

Cancel an existing customer appointment with optional reason recording.

Endpoint

DELETE /api/v1/customer/appointments/{appointment_id}/cancel

Authentication: Required (Customer JWT token)

Path Parameters

  • appointment_id (required) - Unique appointment identifier (ObjectId)

Request Body

{
  "reason": "Emergency schedule conflict"
}

Parameters:

  • reason (optional) - Cancellation reason (max 500 characters)

Note: This endpoint uses the DELETE method but accepts an optional JSON request body with cancellation details. This is a non-standard but supported pattern for recording cancellation metadata.

Response

{
  "_id": "507f1f77bcf86cd799439011",
  "appointment_date": "2025-01-15T00:00:00Z",
  "start_time": "14:30",
  "end_time": "15:30",
  "status": "cancelled",
  "cancelled_at": "2025-01-13T16:00:00Z",
  "cancelled_by": "customer",
  "cancellation_reason": "Emergency schedule conflict",
  "services": [
    {
      "service_id": "507f1f77bcf86cd799439013",
      "service_name": "Premium Therapy Treatment",
      "staff_id": "507f1f77bcf86cd799439014",
      "staff_name": "Veronica L.",
      "duration_minutes": 60,
      "price": 125000
    }
  ],
  "total_price": 125000,
  "updated_at": "2025-01-13T16:00:00Z"
}

Cancellation Process

  1. Ownership Validation - Verify appointment belongs to customer
  2. Status Check - Cannot cancel COMPLETED or already CANCELLED appointments
  3. Status Update - Set status to CANCELLED
  4. Metadata Recording - Store cancellation reason, timestamp, and actor
  5. Refund Processing - Process refunds according to cancellation policy (if applicable)
  6. Staff Notification - Notify staff and release time slot

Business Rules:

  • Customers can only cancel their own appointments
  • Cannot cancel COMPLETED or already CANCELLED appointments
  • Cancellation reason is optional but recommended
  • Cancelled appointments remain in system for history tracking
  • Time slot becomes available for other customers
  • Refund policy depends on cancellation timing (see Payment History)
  • Parallel Booking Capacity - Cancellation immediately frees capacity for group services (see Parallel Booking Guide)

Cancellation Policies (Typical):

  • 24+ hours before - Full refund
  • 12-24 hours before - 50% refund
  • Less than 12 hours - No refund (unless special circumstances)

Error Responses

400 Bad Request - Cannot cancel

{
  "detail": "Cannot cancel appointment with status: completed"
}

403 Forbidden - Access denied

{
  "detail": "Access denied to this appointment"
}

Package Credit Redemption

Use prepaid package credits to pay for appointments instantly.

Overview

Package credit redemption allows customers to use their prepaid service credits when booking appointments. This feature:

  • Zero-Cost Appointments - Credits from purchased packages cover service costs
  • Automatic Payment - Payment status set to PAID instantly when using credits
  • FIFO Ordering - Oldest credits (closest to expiry) used first
  • Auto-Detection - Just set credit_redeemed=true, backend auto-detects package
  • Atomic Operations - Credit redemption and appointment creation are transactional

Related Documentation:

How Credit Redemption Works

graph TD
    A[Create Appointment Request] --> B{credit_redeemed = true?}
    B -->|No| C[Standard Flow - Payment Pending]
    B -->|Yes| D[Check Credit Availability]
    D -->|No Credits| E[400 Error - No Available Credits]
    D -->|Has Credits| F{customer_package_id provided?}
    F -->|No| G[Auto-detect from FIFO credit]
    F -->|Yes| H[Use provided package]
    G --> I[Create Appointment]
    H --> I
    I --> J[Redeem Credit - FIFO]
    J -->|Success| K[Set Payment Status = PAID]
    K --> L[Return Appointment with credit_id + customer_package_id]
    J -->|Failure| M[Rollback - Delete Appointment]
    M --> N[400 Error - Credit Redemption Failed]

Request Format

To create an appointment with credit redemption, simply set credit_redeemed to true:

Simple Request (Recommended):

{
  "outlet_id": "507f1f77bcf86cd799439012",
  "appointment_date": "2025-01-15",
  "start_time": "14:30",
  "services": [
    {
      "service_id": "507f1f77bcf86cd799439013",
      "staff_id": "507f1f77bcf86cd799439014"
    }
  ],
  "credit_redeemed": true,
  "notes": "Using package credit"
}

The system automatically detects the appropriate customer_package_id using FIFO ordering (oldest credits used first).

Explicit Request (Optional):

If you need to specify a particular package, you can still include customer_package_id:

{
  "outlet_id": "507f1f77bcf86cd799439012",
  "appointment_date": "2025-01-15",
  "start_time": "14:30",
  "services": [
    {
      "service_id": "507f1f77bcf86cd799439013",
      "staff_id": "507f1f77bcf86cd799439014"
    }
  ],
  "credit_redeemed": true,
  "customer_package_id": "507f1f77bcf86cd799439020",
  "notes": "Using specific package credit"
}

Parameters:

Field Type Required Description
credit_redeemed boolean Yes* Set to true to use package credits
customer_package_id string No Customer's purchased package ID (auto-detected if omitted)

*Required only when using credit redemption

Auto-Detection Behavior:

When customer_package_id is omitted:

  1. System calls check_credit_availability() to find available credits
  2. Credits are sorted by expiry date (FIFO - oldest first)
  3. First available credit's package is automatically selected
  4. Prevents credit waste from expiration

Response Format

Successful credit redemption returns appointment with credit information:

{
  "id": "507f1f77bcf86cd799439030",
  "tenant_id": "507f1f77bcf86cd799439010",
  "customer_id": "507f1f77bcf86cd799439011",
  "outlet_id": "507f1f77bcf86cd799439012",
  "appointment_date": "2025-01-15T00:00:00Z",
  "start_time": "14:30",
  "end_time": "15:30",
  "status": "confirmed",
  "payment_status": "paid",
  "services": [...],
  "total_price": 75000,
  "credit_redeemed": true,
  "credit_id": "507f1f77bcf86cd799439025",
  "customer_package_id": "507f1f77bcf86cd799439020",
  "created_at": "2025-01-10T10:30:00Z"
}

Credit-Specific Response Fields:

Field Type Description
credit_redeemed boolean true if package credit was used
credit_id string ID of the redeemed credit record
customer_package_id string ID of the package from which credit was redeemed
payment_status string Always paid when credit is used

Credit Validation Rules

The system validates credits before allowing redemption:

  1. Customer Ownership - Credit must belong to the authenticated customer
  2. Service Match - Credit must be for the booked service
  3. Available Balance - Credit must have remaining_credits > 0
  4. Not Expired - Credit expires_at must be in the future (or null)
  5. Payment Confirmed - Parent package must have payment_status = PAID
  6. Tenant Isolation - Credit must belong to the same tenant
  7. Single Service - Credit redemption only supports single-service appointments

FIFO Credit Selection

Credits are automatically selected using First In, First Out ordering:

  1. System finds all available credits for the customer + service
  2. Credits ordered by expires_at ascending (closest to expiry first)
  3. First credit in list is selected for redemption
  4. Prevents credit waste from expiration

Example:

Customer has 3 credits for "Hair Cut" service:
- Credit A: expires 2025-02-01 (selected first - closest to expiry)
- Credit B: expires 2025-03-15
- Credit C: expires 2025-06-30

Atomic Transaction & Rollback

Credit redemption is atomic with appointment creation:

1. Create appointment record
2. Try to redeem credit
   - If SUCCESS: Update appointment with credit_id, set payment_status=PAID
   - If FAILURE: Delete appointment (hard delete), raise error
3. Return appointment

Rollback Scenarios:

  • Credit already fully used
  • Credit expired during process
  • Database write failure
  • Concurrent redemption conflict

Cancellation with Credit Refund

When a credit-paid appointment is cancelled, the credit is automatically refunded:

  1. Get appointment
  2. If credit_redeemed and credit_id:
  3. Get credit record
  4. Increment remaining_credits
  5. Decrement used_credits
  6. Update customer_package remaining_credits
  7. If package was DEPLETED, set back to ACTIVE
  8. Set appointment status = CANCELLED

Refund Rules:

  • Credit returned to original credit record
  • Customer package balance updated
  • DEPLETED packages become ACTIVE again
  • Refund is immediate (no pending state)

Checking Your Credit Availability

Before booking, check if you have available credits:

Via Customer Portal:

GET /api/v1/customer/packages/my-packages

This returns your purchased packages with credit details for each service.

Error Handling

No Available Credits:

{
  "detail": "No available credits for service 'Hair Styling'. Customer has no valid, unexpired credits for this service."
}

Invalid Package ID:

{
  "detail": "Customer package not found or does not belong to customer"
}

Credit Already Redeemed:

{
  "detail": "Credit redemption failed: Credit has no remaining balance. Appointment has been cancelled."
}

Expired Credit:

{
  "detail": "Credit redemption failed: Credit has expired. Appointment has been cancelled."
}

Multiple Services Not Supported:

{
  "detail": "Credit redemption only supports single-service appointments. Please book one service at a time when using credits."
}

Frontend UI Suggestions

Use Case 1: Customer Booking with Credit Option

Scenario: Customer books appointment on customer portal and has package credits available.

Recommended UI Flow:

┌─────────────────────────────────────────────────────────────┐
│  🗓️ Book Appointment                                        │
├─────────────────────────────────────────────────────────────┤
│  Service: Hair Cut & Style                                  │
│  Duration: 60 minutes                                       │
│  Price: IDR 75,000                                          │
├─────────────────────────────────────────────────────────────┤
│  ✨ YOU HAVE CREDITS FOR THIS SERVICE!                      │
│  ┌─────────────────────────────────────────────────────┐    │
│  │  📦 Hair Care Premium Package                       │    │
│  │  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 3/5 used       │    │
│  │  2 credits remaining                                │    │
│  │  Expires: Feb 15, 2025 (25 days)                    │    │
│  │                                                     │    │
│  │  [Use 1 Credit - FREE] ← Primary CTA                │    │
│  └─────────────────────────────────────────────────────┘    │
│                                                             │
│  Or pay IDR 75,000 →                                        │
├─────────────────────────────────────────────────────────────┤
│  Select Date & Time:                                        │
│  [Calendar Component]                                       │
├─────────────────────────────────────────────────────────────┤
│                                      [Confirm Booking]      │
└─────────────────────────────────────────────────────────────┘

Implementation Steps:

  1. Fetch Credits on Service Selection:

    // Customer portal - fetch own credits
    const myPackages = await fetch('/api/v1/customer/packages/my-packages');
    const creditsForService = myPackages.filter(
      pkg => pkg.credits.some(c => c.service_id === serviceId && c.remaining > 0)
    );
    

  2. Prominent Credit Display:

    {hasCredits && (
      <CreditBanner variant="success">
        <PackageIcon />
        <div>
          <h4>You have credits for this service!</h4>
          <p>{remainingCredits} credits from {packageName}</p>
          {isExpiringSoon && <WarningBadge>Expires soon</WarningBadge>}
        </div>
        <Button primary onClick={() => setUseCredit(true)}>
          Use Credit - FREE
        </Button>
      </CreditBanner>
    )}
    

  3. On Submit - Include credit field (simplified):

    const appointmentData = {
      outlet_id: outletId,
      appointment_date: date,
      start_time: time,
      services: [{ service_id, staff_id }],
      // Credit redemption - just set credit_redeemed: true
      // customer_package_id is auto-detected by backend using FIFO
      credit_redeemed: useCredit,
      notes: "Using package credit"
    };
    

  4. Confirmation Screen:

    ┌─────────────────────────────────────────────────────┐
    │  ✅ Booking Confirmed!                              │
    │                                                     │
    │  Hair Cut & Style with Jane Smith                   │
    │  Jan 20, 2025 at 2:30 PM                            │
    │                                                     │
    │  Payment: ✨ Package Credit Used                    │
    │  Status: PAID                                       │
    │                                                     │
    │  📦 Hair Care Premium Package                       │
    │     1 credit remaining after this booking           │
    └─────────────────────────────────────────────────────┘
    


Use Case 2: My Appointments List with Credit Indicators

Scenario: Customer views their appointment list showing which appointments used credits.

Recommended UI:

┌──────────────────────────────────────────────────────────────────────┐
│  📋 My Appointments                                     [+ Book New]  │
├──────────────────────────────────────────────────────────────────────┤
│  ┌────────────────────────────────────────────────────────────────┐  │
│  │ Jan 20  Hair Cut           Downtown Spa     ✨ Credit Used      │  │
│  │ 2:30 PM  with Jane S.      Confirmed        [View] [Reschedule]│  │
│  │          📦 Hair Care Package                                  │  │
│  └────────────────────────────────────────────────────────────────┘  │
│  ┌────────────────────────────────────────────────────────────────┐  │
│  │ Jan 25  Manicure           Sunset Salon     💳 IDR 50,000       │  │
│  │ 10:00 AM with Alice W.     Pending Payment  [View] [Pay Now]    │  │
│  └────────────────────────────────────────────────────────────────┘  │
│  ┌────────────────────────────────────────────────────────────────┐  │
│  │ Jan 30  Facial             Downtown Spa     ✨ Credit Used      │  │
│  │ 3:00 PM  with Maria R.     Confirmed        [View] [Reschedule]│  │
│  │          📦 Spa Bundle Package                                 │  │
│  └────────────────────────────────────────────────────────────────┘  │
└──────────────────────────────────────────────────────────────────────┘

Implementation:

// Appointment list item component
const AppointmentCard = ({ appointment }) => {
  const paymentBadge = () => {
    if (appointment.credit_redeemed) {
      return (
        <Badge variant="purple">
          <SparklesIcon /> Credit Used
        </Badge>
      );
    }
    if (appointment.payment_status === 'paid') {
      return <Badge variant="green">💳 {formatPrice(appointment.total_price)}</Badge>;
    }
    return <Badge variant="yellow"> Payment Pending</Badge>;
  };

  return (
    <Card>
      <DateTimeSlot>{appointment.appointment_date} at {appointment.start_time}</DateTimeSlot>
      <ServiceName>{appointment.services[0].service_name}</ServiceName>
      <StaffName>with {appointment.services[0].staff_name}</StaffName>
      {paymentBadge()}
      {appointment.credit_redeemed && (
        <PackageInfo>📦 {appointment.package_name}</PackageInfo>
      )}
    </Card>
  );
};

Use Case 3: Appointment Detail with Credit Information

Scenario: Customer views appointment detail showing credit redemption info.

Recommended UI:

┌─────────────────────────────────────────────────────────────┐
│  📅 Appointment Details                                [×]   │
├─────────────────────────────────────────────────────────────┤
│  Service:  Hair Cut & Style (60 min)                        │
│  Stylist:  Jane Smith                                       │
│  Location: Downtown Beauty Spa                              │
│  Date:     Jan 20, 2025 at 2:30 PM                          │
│  Status:   ● Confirmed                                      │
├─────────────────────────────────────────────────────────────┤
│  💳 Payment Information                                     │
│  ┌─────────────────────────────────────────────────────┐    │
│  │  Status: ✅ PAID                                    │    │
│  │  Method: ✨ Package Credit                          │    │
│  │                                                     │    │
│  │  📦 Hair Care Premium Package                       │    │
│  │  └─ Purchased: Jan 5, 2025                          │    │
│  │                                                     │    │
│  │  ℹ️ No payment needed - prepaid via package         │    │
│  └─────────────────────────────────────────────────────┘    │
├─────────────────────────────────────────────────────────────┤
│  [Reschedule]                        [Cancel Appointment]   │
│                                                             │
│  ⚠️ Cancelling will refund the credit to your package       │
└─────────────────────────────────────────────────────────────┘

Use Case 4: Cancel Credit-Paid Appointment

Scenario: Customer cancels appointment and credit is refunded.

Recommended UI Flow:

┌─────────────────────────────────────────────────────────────┐
│  ⚠️ Cancel Appointment                                      │
├─────────────────────────────────────────────────────────────┤
│  You are about to cancel this appointment:                  │
│                                                             │
│  Service:  Hair Cut & Style                                 │
│  Date:     Jan 20, 2025 at 2:30 PM                          │
├─────────────────────────────────────────────────────────────┤
│  💳 Credit Refund Notice                                    │
│  ┌─────────────────────────────────────────────────────┐    │
│  │  This appointment was paid with a package credit.   │    │
│  │                                                     │    │
│  │  ✅ Your credit will be automatically refunded to:  │    │
│  │     📦 Hair Care Premium Package                    │    │
│  │     Current: 1 credit → After: 2 credits            │    │
│  └─────────────────────────────────────────────────────┘    │
├─────────────────────────────────────────────────────────────┤
│  Cancellation Reason (optional):                            │
│  [_____________________________________________________ ]   │
│                                                             │
│                    [Keep Appointment]  [Cancel & Refund]    │
└─────────────────────────────────────────────────────────────┘

After Cancellation:

┌─────────────────────────────────────────────────────────────┐
│  ✅ Appointment Cancelled                                   │
├─────────────────────────────────────────────────────────────┤
│  Your appointment has been cancelled successfully.          │
│                                                             │
│  💳 Credit Refund Complete                                  │
│  ┌─────────────────────────────────────────────────────┐    │
│  │  ✅ 1 credit refunded to Hair Care Premium Package  │    │
│  │  You now have 2 credits remaining                   │    │
│  └─────────────────────────────────────────────────────┘    │
│                                                             │
│                               [Back to My Appointments]     │
└─────────────────────────────────────────────────────────────┘

UI Component Suggestions

Credit Availability Banner:

// Show in service selection when customer has credits
const CreditAvailabilityBanner = ({ credits, serviceName }) => {
  if (!credits?.length) return null;

  const firstCredit = credits[0]; // FIFO - will be used first

  return (
    <Banner variant={firstCredit.is_expiring_soon ? 'warning' : 'success'}>
      <SparklesIcon />
      <div>
        <strong>You have {firstCredit.remaining_credits} credits for {serviceName}</strong>
        <p>From: {firstCredit.package_name}</p>
        {firstCredit.is_expiring_soon && (
          <WarningText>⚠️ Expires {formatDate(firstCredit.expires_at)}</WarningText>
        )}
      </div>
      <Button onClick={onUseCredit}>Use Credit - FREE</Button>
    </Banner>
  );
};

Payment Method Selector:

// Radio group for payment method selection
const PaymentMethodSelector = ({ credits, price, onChange }) => (
  <RadioGroup onChange={onChange}>
    {credits?.length > 0 && (
      <RadioOption
        value="credit"
        label="Use Package Credit"
        description={`${credits[0].remaining_credits} credits from ${credits[0].package_name}`}
        badge={<Badge variant="green">FREE</Badge>}
        highlighted
      />
    )}
    <RadioOption
      value="pay_later"
      label="Pay at checkout"
      description={formatPrice(price)}
    />
    <RadioOption
      value="payment_link"
      label="Pay online now"
      description="Pay via secure payment link"
    />
  </RadioGroup>
);

Credit Badge Component:

// Reusable component for showing credit payment status
const CreditBadge = ({ appointment }) => {
  if (!appointment.credit_redeemed) return null;

  return (
    <Badge
      variant="purple"
      icon={<SparklesIcon />}
      tooltip={`Credit from ${appointment.package_name}`}
    >
      Credit Used
    </Badge>
  );
};

Error Handling UI

// Handle credit redemption errors gracefully
const handleBookingError = (error) => {
  if (error.detail?.includes('No available credits')) {
    toast.error('Credit Unavailable', {
      description: 'You don\'t have any credits for this service. Please select a different payment method.',
      action: {
        label: 'View Packages',
        onClick: () => navigate('/my-packages')
      }
    });
  } else if (error.detail?.includes('Credit redemption failed')) {
    toast.error('Booking Failed', {
      description: 'Credit redemption failed. The booking was not created. Please try again.',
    });
  } else if (error.detail?.includes('single-service')) {
    toast.error('Multiple Services', {
      description: 'Credits can only be used for single-service bookings. Please book services separately.',
    });
  } else {
    toast.error('Booking Error', { description: error.detail });
  }
};

Best Practices

DO:

  • Just set credit_redeemed=true - let backend auto-detect package (simplest approach)
  • Check your packages before booking to see available credits
  • Use expiring credits first (system does this automatically via FIFO)
  • Look for the credit availability banner when selecting services
  • Note remaining credits after successful booking

DON'T:

  • Try to use credits for multi-service appointments (not supported)
  • Assume credits are available without checking your packages
  • Ignore expiry dates on your package credits
  • Cancel credit-paid appointments unnecessarily (though refunds are automatic)

Testing Checklist

  • [ ] Create appointment with credit_redeemed=true only (auto-detect package)
  • [ ] Create appointment with explicit customer_package_id
  • [ ] Verify response includes auto-detected customer_package_id
  • [ ] Reject redemption when no credits available
  • [ ] Reject redemption for wrong service
  • [ ] Reject redemption with expired credits
  • [ ] Reject redemption for multi-service appointments
  • [ ] Verify FIFO ordering selects oldest credit
  • [ ] Verify atomic rollback on failure
  • [ ] Verify payment_status = PAID after credit use
  • [ ] Cancel credit-paid appointment and verify refund
  • [ ] Verify DEPLETED package becomes ACTIVE after refund
  • [ ] List appointments shows credit_redeemed flag
  • [ ] Appointment details shows credit_id

Availability Grid (Multi-Day Calendar)

Get availability grid showing available time slots across multiple days for efficient booking UX.

Endpoint

GET /api/v1/customer/availability-grid

Authentication: Required (Customer JWT token)

Query Parameters

  • service_id (required) - Service ID to check availability for (ObjectId)
  • outlet_id (required) - Outlet ID where service will be performed (ObjectId)
  • start_date (required) - First date to check (YYYY-MM-DD, cannot be past)
  • num_days (optional) - Number of days to retrieve (default: 7, min: 1, max: 30)
  • slot_interval_minutes (optional) - Slot interval in minutes (default: 30, must be positive integer)
  • staff_id (optional) - Filter by specific staff member (ObjectId)

Response

{
  "start_date": "2025-10-16",
  "end_date": "2025-10-22",
  "num_days": 7,
  "slot_interval_minutes": 30,
  "availability_grid": {
    "2025-10-16": [
      {
        "start_time": "12:00",
        "end_time": "13:55",
        "staff_id": "68e7809c87abbe62ec9cdc20",
        "staff_name": "Veronica L.",
        "gender": "female",
        "service_id": "68e63f26241da4ebe30521c8",
        "service_name": "Premium Therapy Treatment",
        "is_available": true,
        "allows_parallel_bookings": false,
        "available_capacity": null,
        "max_capacity": null
      },
      {
        "start_time": "12:30",
        "end_time": "14:25",
        "staff_id": "68e7809c87abbe62ec9cdc20",
        "staff_name": "Veronica L.",
        "gender": "female",
        "service_id": "68e63f26241da4ebe30521c8",
        "service_name": "Premium Therapy Treatment",
        "is_available": true,
        "allows_parallel_bookings": false,
        "available_capacity": null,
        "max_capacity": null
      },
      {
        "start_time": "13:00",
        "end_time": "14:55",
        "staff_id": "68e7809c87abbe62ec9cdc20",
        "staff_name": "Veronica L.",
        "gender": "female",
        "service_id": "68e63f26241da4ebe30521c8",
        "service_name": "Premium Therapy Treatment",
        "is_available": true,
        "allows_parallel_bookings": false,
        "available_capacity": null,
        "max_capacity": null
      }
    ],
    "2025-10-17": [
      {
        "start_time": "09:00",
        "end_time": "10:55",
        "staff_id": "68e7809c87abbe62ec9cdc20",
        "staff_name": "Veronica L.",
        "gender": "female",
        "service_id": "68e63f26241da4ebe30521c8",
        "service_name": "Premium Therapy Treatment",
        "is_available": true,
        "allows_parallel_bookings": false,
        "available_capacity": null,
        "max_capacity": null
      },
      {
        "start_time": "09:30",
        "end_time": "11:25",
        "staff_id": "68e7809c87abbe62ec9cdc20",
        "staff_name": "Veronica L.",
        "gender": "female",
        "service_id": "68e63f26241da4ebe30521c8",
        "service_name": "Premium Therapy Treatment",
        "is_available": true,
        "allows_parallel_bookings": true,
        "available_capacity": 8,
        "max_capacity": 12
      }
    ],
    "2025-10-18": [],
    "2025-10-19": [],
    "2025-10-20": [],
    "2025-10-21": [],
    "2025-10-22": []
  },
  "metadata": {
    "service_id": "68e63f26241da4ebe30521c8",
    "service_name": "Premium Therapy Treatment",
    "outlet_id": "68e4d035886b6f295471fd51",
    "outlet_name": "Downtown Beauty Spa",
    "staff_id": null,
    "total_available_slots": 20,
    "service_duration_minutes": 90
  }
}

Response Structure:

  • availability_grid - Object with dates as keys, slots as values
  • Empty array [] = Day unavailable (outlet closed or no staff)
  • Array with slots = Available booking times

  • metadata - Context information about the query

Slot Fields:

Each slot object contains:

  • start_time - Slot start time (HH:MM format)
  • end_time - Slot end time (HH:MM format)
  • staff_id - Staff member ObjectId
  • staff_name - Staff member display name
  • gender - Staff member's gender (male, female, other, prefer_not_to_say, or null)
  • service_id - Service ObjectId
  • service_name - Service display name
  • is_available - Slot availability status (boolean)
  • allows_parallel_bookings - Whether service supports multiple simultaneous bookings (boolean)
  • available_capacity - Remaining spots available (integer or null for non-parallel services)
  • max_capacity - Maximum capacity for parallel services (integer or null)

Gender Field Use Cases:

The gender field enables customers to filter or select staff based on gender preference, which is important for:

  • Massage therapy - Customer comfort with same-gender therapist
  • Waxing services - Personal care requiring gender-specific staff
  • Personal treatments - Any service where gender preference matters

Grid Interpretation

Empty Array Meanings:

"2025-10-18": []  // One of:
                   // - Outlet closed that day
                   // - No staff available
                   // - All time slots fully booked
                   // - Day outside business hours

Slot Availability:

  • Each slot shows start/end time based on service duration
  • Slots overlap (e.g., 12:00, 12:30) to allow flexible booking
  • is_available: true means time slot is confirmed open
  • Missing times indicate unavailability or conflicts

Parallel Booking Capacity:

For services with allows_parallel_bookings: true:

  • available_capacity shows remaining spots (e.g., 8 means "8 spots left")
  • max_capacity shows total capacity (e.g., 12 means max 12 simultaneous bookings)
  • When available_capacity reaches 0, the slot is no longer returned in the grid
  • Examples: Group yoga class (10 spots), Workshop (15 spots), Spa treatment (1 spot)

For traditional 1-on-1 services (allows_parallel_bookings: false):

  • available_capacity and max_capacity are null
  • Slot is either available or not (binary availability)

📘 Learn More: See the Parallel Booking Guide for detailed concepts and configuration, or the Parallel Booking Testing Guide for hands-on API testing scenarios.

Business Rules Applied

  1. Date Validation - start_date cannot be in the past
  2. Booking Window - Limited to 90 days in advance for customers
  3. Business Hours - Respects outlet daily operating hours
  4. Staff Availability - Validates working hours and breaks
  5. Conflict Detection - Excludes already booked time slots
  6. Service Duration - Calculates end times based on service length

Frontend Integration Example

// Fetch 7-day availability grid
const response = await fetch(
  '/api/v1/customer/availability-grid?' +
  'service_id=507f1f77bcf86cd799439013&' +
  'outlet_id=507f1f77bcf86cd799439012&' +
  'start_date=2025-10-16&' +
  'num_days=7&' +
  'slot_interval_minutes=30',
  {
    headers: {
      'Authorization': 'Bearer YOUR_CUSTOMER_JWT_TOKEN'
    }
  }
);

const grid = await response.json();

// Render calendar view
Object.entries(grid.availability_grid).forEach(([date, slots]) => {
  if (slots.length === 0) {
    // Day unavailable - show as disabled/grayed out
    disableDateButton(date);
  } else {
    // Day has availability - show as clickable
    enableDateButton(date);

    // Populate time slots when date is selected
    slots.forEach(slot => {
      if (slot.allows_parallel_bookings) {
        // Group service - show capacity info with gender
        renderTimeSlot(
          slot.start_time,
          slot.staff_name,
          `${slot.available_capacity}/${slot.max_capacity} spots`,
          slot.gender  // For gender-preference filtering
        );
      } else {
        // Single-booking service - display with gender info
        renderTimeSlot(slot.start_time, slot.staff_name, null, slot.gender);
      }
    });
  }
});

// Show total available appointments
displayAvailableCount(grid.metadata.total_available_slots);

// Optional: Filter slots by gender preference
const filterByGender = (slots, preferredGender) => {
  if (!preferredGender) return slots;
  return slots.filter(slot => slot.gender === preferredGender);
};

Use Cases

1. Calendar Picker UI:

  • Show 7 days at once
  • Disable unavailable dates visually
  • Display slot counts per day

2. "Next Available" Feature:

// Find first available slot
for (const [date, slots] of Object.entries(grid.availability_grid)) {
  if (slots.length > 0) {
    console.log(`Next available: ${date} at ${slots[0].start_time}`);
    break;
  }
}

3. Staff-Specific Booking:

# Get availability for preferred stylist
curl -X GET "http://localhost:8000/api/v1/customer/availability-grid?\
service_id=507f1f77bcf86cd799439013&\
outlet_id=507f1f77bcf86cd799439012&\
start_date=2025-10-16&\
num_days=14&\
staff_id=507f1f77bcf86cd799439014" \
  -H "Authorization: Bearer YOUR_CUSTOMER_JWT_TOKEN"

Performance Optimization

Single API Call vs Multiple:

Bad Approach - N calls for N days:

// DON'T DO THIS - 7 API calls for 7 days
for (let i = 0; i < 7; i++) {
  await fetch(`/availability?date=${addDays(today, i)}`);
}

Good Approach - 1 call for 7 days:

// DO THIS - 1 API call for entire week
const grid = await fetch('/availability-grid?start_date=2025-10-16&num_days=7');

Benefits:

  • Reduced network latency (1 round trip vs 7)
  • Lower server load (1 query vs 7)
  • Faster page rendering
  • Better mobile experience

Browse Available Services

Browse all services available to the customer with comprehensive filtering.

Endpoint

GET /api/v1/customer/services

Authentication: Required (Customer JWT token)

Query Parameters

  • category (optional) - Filter by service category (e.g., "hair", "nails", "spa")
  • min_price (optional) - Minimum display_price filter after discounts (Decimal)
  • max_price (optional) - Maximum display_price filter after discounts (Decimal)
  • outlet_id (optional) - Filter by outlet availability and get outlet-specific pricing (ObjectId)
  • skip (optional) - Pagination offset (default: 0)
  • limit (optional) - Results per page (default: 20, max: 100)

Response

{
  "items": [
    {
      "_id": "68ff526691f9eb31e48653d5",
      "name": "Premium Facial Treatment",
      "category": "facial",
      "duration_minutes": 60,
      "pricing": {
        "base_price": "350000.00",
        "display_price": "280000.00",
        "strikethrough_price": "350000.00",
        "discount_type": "promotional",
        "promotional_valid_until": "2025-12-31T23:59:59Z",
        "price_varies_by_outlet": true
      },
      "currency": "IDR",
      "average_rating": 4.8,
      "image_url": "https://example.com/images/facial.jpg",
      "is_active": true,
      "allows_parallel_bookings": false
    },
    {
      "_id": "68ff546e91f9eb31e48653da",
      "name": "Yoga Class",
      "category": "therapy",
      "duration_minutes": 120,
      "pricing": {
        "base_price": "150000.00",
        "display_price": "150000.00",
        "strikethrough_price": null,
        "discount_type": null,
        "promotional_valid_until": null,
        "price_varies_by_outlet": false
      },
      "currency": "IDR",
      "average_rating": null,
      "image_url": "https://example.com/images/yoga.jpg",
      "is_active": true,
      "allows_parallel_bookings": true
    }
  ],
  "total": 2,
  "page": 1,
  "size": 20,
  "pages": 1
}

Response Fields:

  • items - Array of service summaries
  • total - Total number of services matching filters
  • page - Current page number (1-indexed)
  • size - Number of items per page
  • pages - Total number of pages
  • allows_parallel_bookings - Whether service supports multiple simultaneous bookings (e.g., group classes)

Pricing Object Fields:

Field Type Description
base_price Decimal Always the service's base price (for reference)
display_price Decimal The actual price to charge (calculated using pricing hierarchy)
strikethrough_price Decimal or null The "was" price - only set when display_price < regular price
discount_type string or null Type of discount applied: "promotional" or null
promotional_valid_until datetime or null Promo expiry date for urgency UI (e.g., "Ends in 3 days!")
price_varies_by_outlet boolean Hint that price may change after outlet selection

Pricing Hierarchy:

Prices are calculated using the following priority (highest to lowest):

  1. Promotional Price - Global promotional pricing (applies to all outlets)
  2. Outlet-Specific Price - Location-specific pricing when outlet_id is provided
  3. Base Price - Default fallback price

Frontend Display Examples:

┌─────────────────────────────────────────────┐
│  Premium Facial Treatment                   │
│  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │
│  IDR 350,000  →  IDR 280,000               │  ← strikethrough_price + display_price
│  🏷️ PROMO - Ends Dec 31!                   │  ← discount_type + promotional_valid_until
│  ⚠️ Price may vary by outlet               │  ← price_varies_by_outlet
└─────────────────────────────────────────────┘

┌─────────────────────────────────────────────┐
│  Yoga Class                                 │
│  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │
│  IDR 150,000                               │  ← display_price only (no strikethrough)
└─────────────────────────────────────────────┘

Parallel Booking Note:

The allows_parallel_bookings field indicates whether a service supports multiple simultaneous bookings (e.g., group classes, workshops). Services with this enabled will show capacity information in the availability grid. See Parallel Booking Guide for details.

Service Categories (Common):

  • hair - Haircuts, styling, coloring
  • nails - Manicure, pedicure, nail art
  • spa - Massage, facials, body treatments
  • makeup - Makeup application, styling
  • waxing - Hair removal services
  • skincare - Facials, treatments, consultations
  • therapy - Wellness treatments, therapy sessions

Example Requests

# Browse spa services under 100,000 IDR (filters by display_price after discounts)
curl -X GET "http://localhost:8000/api/v1/customer/services?\
category=spa&max_price=100000" \
  -H "Authorization: Bearer YOUR_CUSTOMER_JWT_TOKEN"

# Get services at specific outlet with outlet-specific pricing
curl -X GET "http://localhost:8000/api/v1/customer/services?\
outlet_id=507f1f77bcf86cd799439012" \
  -H "Authorization: Bearer YOUR_CUSTOMER_JWT_TOKEN"

# Filter by price range (50,000 - 200,000 IDR after promotions)
curl -X GET "http://localhost:8000/api/v1/customer/services?\
min_price=50000&max_price=200000" \
  -H "Authorization: Bearer YOUR_CUSTOMER_JWT_TOKEN"

Browse Available Outlets

Browse all outlets available to the customer for booking services.

Endpoint

GET /api/v1/customer/outlets

Authentication: Required (Customer JWT token)

Query Parameters

  • city (optional) - Filter by city name (case-insensitive partial match)
  • service_id (optional) - Filter outlets offering specific service (Not yet implemented - see note below)
  • skip (optional) - Pagination offset (default: 0)
  • limit (optional) - Results per page (default: 20, max: 100)

Note: Service-based outlet filtering (service_id parameter) is not yet implemented. All outlets matching other filters are returned.

Response

{
  "items": [
    {
      "id": "507f1f77bcf86cd799439012",
      "name": "Downtown Beauty Spa",
      "slug": "downtown-beauty-spa",
      "status": "active",
      "city": "Jakarta",
      "phone": "+62-21-1234-5678",
      "accepts_online_booking": true
    },
    {
      "id": "507f1f77bcf86cd799439018",
      "name": "Sunset Salon & Spa",
      "slug": "sunset-salon-spa",
      "status": "active",
      "city": "Jakarta",
      "phone": "+62-21-8765-4321",
      "accepts_online_booking": true
    }
  ],
  "total": 12,
  "page": 1,
  "size": 20,
  "pages": 1
}

Example Requests

# Find outlets in Jakarta
curl -X GET "http://localhost:8000/api/v1/customer/outlets?city=Jakarta" \
  -H "Authorization: Bearer YOUR_CUSTOMER_JWT_TOKEN"

Note: The service_id filter example has been removed as the feature is not yet implemented.


Get Outlet Services

Get all services available for booking at a specific outlet with outlet-specific pricing.

Endpoint

GET /api/v1/customer/booking/outlets/{outlet_id}/services

Authentication: Required (Customer JWT token)

Path Parameters

  • outlet_id (required) - Unique outlet identifier (ObjectId)

Query Parameters

  • category (optional) - Filter by service category

Response

[
  {
    "_id": "507f1f77bcf86cd799439013",
    "tenant_id": "507f1f77bcf86cd799439010",
    "name": "Premium Therapy Treatment",
    "category": "spa",
    "description": "Luxury spa treatment with aromatherapy",
    "duration_minutes": 90,
    "pricing": {
      "base_price": 125000,
      "currency": "IDR",
      "outlet_specific_pricing": [
        {
          "outlet_id": "507f1f77bcf86cd799439012",
          "price": 135000
        }
      ]
    },
    "is_active": true,
    "available_outlet_ids": [],
    "created_at": "2025-01-01T00:00:00Z",
    "updated_at": "2025-01-01T00:00:00Z"
  }
]

Notes:

  • Services may have outlet-specific pricing that differs from base price. Always use the price returned for accurate booking calculations.
  • The available_outlet_ids field shows which outlets can offer this service:
  • Empty array [] = Service available at all outlets
  • Array with outlet IDs = Service only available at specified outlets

Get Outlet Staff

Get all staff members available for booking at a specific outlet, optionally filtered by service capability.

Endpoint

GET /api/v1/customer/booking/outlets/{outlet_id}/staff

Authentication: Required (Customer JWT token)

Path Parameters

  • outlet_id (required) - Unique outlet identifier (ObjectId)

Query Parameters

  • service_id (optional) - Filter staff by service capability (ObjectId)

Response

[
  {
    "_id": "507f1f77bcf86cd799439014",
    "display_name": "Veronica L.",
    "specializations": ["spa", "massage"],
    "average_rating": 4.8,
    "total_reviews": 127,
    "is_bookable": true,
    "profile_image_url": "https://cdn.example.com/staff/veronica.jpg"
  },
  {
    "_id": "507f1f77bcf86cd799439017",
    "display_name": "Sarah M.",
    "specializations": ["spa", "facial"],
    "average_rating": 4.7,
    "total_reviews": 89,
    "is_bookable": true,
    "profile_image_url": "https://cdn.example.com/staff/sarah.jpg"
  }
]

Example Requests

# Get all staff at outlet
curl -X GET "http://localhost:8000/api/v1/customer/booking/outlets/507f1f77bcf86cd799439012/staff" \
  -H "Authorization: Bearer YOUR_CUSTOMER_JWT_TOKEN"

# Get staff qualified for specific service
curl -X GET "http://localhost:8000/api/v1/customer/booking/outlets/507f1f77bcf86cd799439012/staff?\
service_id=507f1f77bcf86cd799439013" \
  -H "Authorization: Bearer YOUR_CUSTOMER_JWT_TOKEN"

Typical Booking Flow

Frontend User Journey

Step 1: Browse Services

GET /customer/services?category=spa
→ Customer selects "Premium Therapy Treatment"

Step 2: Find Outlet

GET /customer/outlets?city=Jakarta
→ Customer selects "Downtown Beauty Spa"

Step 3: Check Availability Grid

GET /customer/availability-grid?
    service_id=507f1f77bcf86cd799439013&
    outlet_id=507f1f77bcf86cd799439012&
    start_date=2025-10-16&
    num_days=7
→ Customer sees calendar with available slots
→ Customer selects "2025-10-16 at 14:30"

Step 4: Optional - Choose Preferred Staff

GET /customer/booking/outlets/507f1f77bcf86cd799439012/staff?
    service_id=507f1f77bcf86cd799439013
→ Customer sees qualified staff
→ Customer selects "Veronica L." (or skips for auto-assignment)

Step 5: Create Booking

POST /customer/appointments
{
  "outlet_id": "507f1f77bcf86cd799439012",
  "appointment_date": "2025-10-16",
  "start_time": "14:30",
  "services": [
    {
      "service_id": "507f1f77bcf86cd799439013",
      "staff_id": "507f1f77bcf86cd799439014"  // or omit for auto-assign
    }
  ],
  "notes": "First time customer"
}
→ Appointment created with PENDING status
→ Customer receives confirmation

Step 6: Payment (if required)

See Payment History documentation for payment flow
→ Payment completed
→ Appointment status updated to CONFIRMED


Best Practices

For Customers

DO:

  • Check availability grid before booking to see multiple options
  • Provide cancellation reason for better service
  • Book well in advance during peak seasons
  • Review staff profiles and ratings when choosing provider
  • Add special requests in notes field for better service
  • Review your existing appointments before booking to avoid duplicates
  • Check your package credits before booking - you may have free credits available!
  • Use expiring credits first (system does this automatically via FIFO)

DON'T:

  • Book multiple slots for same time (will be blocked)
  • Book identical appointments (same service+staff+time) - system prevents duplicates
  • Cancel repeatedly (may affect booking privileges)
  • Assume prices without checking (outlets may have different pricing)
  • Book outside subscription plan limits without upgrading
  • Submit booking forms multiple times (duplicate prevention blocks this)
  • Try to use package credits for multi-service appointments (not supported)

For Developers

DO:

  • Use availability grid endpoint for calendar UIs (more efficient)
  • Implement retry logic for 409 Conflict errors
  • Cache service/outlet data on frontend (changes infrequently)
  • Show loading states during booking validation (can take 1-2 seconds)
  • Display clear error messages from API responses
  • Check customer package credits on service selection and show credit availability banner
  • Just set credit_redeemed=true - let backend auto-detect package (simplest approach)
  • Handle credit redemption errors gracefully in UI

DON'T:

  • Make separate availability calls for each day (use grid endpoint)
  • Trust client-side pricing calculations (server always authoritative)
  • Skip validation error handling (users need clear feedback)
  • Allow booking without checking subscription limits first
  • Manually lookup customer_package_id when not needed (backend auto-detects)

Error Handling Guide

Common Errors and Solutions

400 Bad Request - Scheduling Violations

{
  "detail": "Scheduling constraint violations: Outside business hours, Advance booking window exceeded"
}

Solution: Check outlet business hours and booking advance limits. Guide customer to valid times.

403 Forbidden - Access Denied

{
  "detail": "Cannot book at this outlet"
}

Solution: Verify outlet belongs to customer's tenant. May indicate data corruption or wrong tenant.

409 Conflict - Staff Unavailable

{
  "detail": "Staff has conflicting appointment at this time"
}

Solution: Time slot was booked between availability check and booking attempt. Refresh availability grid and prompt customer to select another time.

409 Conflict - Duplicate Booking

{
  "detail": "You already have this appointment booked. Please check your existing appointments."
}

Solution: Customer attempted to book an identical appointment (same service, staff, date, and time) they already have. This prevents accidental double-clicks or duplicate submissions. Show customer their existing appointments and guide them to reschedule if needed.

429 Too Many Requests - Plan Limit Reached

{
  "detail": "Monthly appointment limit reached (100/100 on FREE plan). Upgrade to PRO for 2,000 appointments/month."
}

Solution: Display upgrade prompt. Link to Subscription Management for plan upgrade flow.



API Reference Summary

Endpoint Method Purpose Auth Required
/customer/appointments GET List customer appointments Customer JWT
/customer/appointments POST Create new booking (supports credit redemption) Customer JWT
/customer/appointments/{id} GET Get appointment details Customer JWT
/customer/appointments/{id}/reschedule PUT Reschedule appointment Customer JWT
/customer/appointments/{id}/cancel DELETE Cancel appointment (auto-refunds credits) Customer JWT
/customer/availability-grid GET Multi-day availability with capacity info Customer JWT
/customer/services GET Browse services with parallel booking info Customer JWT
/customer/outlets GET Browse outlets Customer JWT
/customer/booking/outlets/{id}/services GET Outlet-specific services Customer JWT
/customer/booking/outlets/{id}/staff GET Outlet-specific staff Customer JWT
/customer/packages/my-packages GET View your purchased packages and credits Customer JWT
/customer/booking/book POST Complete booking process (alternative) Customer JWT

Note: The /customer/booking/book endpoint is an alternative booking flow that may include additional payment processing logic. For most use cases, use the standard /customer/appointments POST endpoint.

Credit Redemption: To book with package credits, include credit_redeemed: true in your POST /customer/appointments request body. See Package Credit Redemption for details.


Next Steps:

  1. Authenticate customer: Customer Authentication
  2. Browse available services and outlets
  3. Check availability grid for desired date range
  4. Create appointment with selected services and staff
  5. Complete payment if required: Payment History

For complete API documentation with interactive testing, visit: - Swagger UI: /api/v1/docs - ReDoc: /api/v1/redoc