Services Feature
A catalog of what your clinic offers—consultation sessions, procedures, therapy programs.
What this enables
One-off bookings: Define any service your clinic offers—a single consultation, a physiotherapy session, an examination—and let patients book it directly through a calendar. No plan or package required.
Multi-calendar promotion: Run one service (Mobility Eval) as multiple calendars—default 30-min at 200 RON, or promotional "Quick 20-min Check" at 100 RON.
Specialist credentials: Mark which specialists are trained to do each service type.
Service plans: Offer session packages ("10 kinetotherapy sessions over 90 days"), time-based subscriptions ("3-month telerehab access"), or hybrid bundles that combine both. Plans layer on top of services — a service can be booked standalone or as part of a plan.
Billing tracking: Know exactly when sessions expire, when patient access is ending, and generate reports per service.
Bundled products: Package physical goods (elastic bands, devices) with service plans—patient gets the device shipped automatically when they enroll.
How it works
- Admin creates a service: "Mobility Evaluation—30 min, 200 RON, requires intake form"
- Admin links specialists: "Dr. Smith and Dr. Jones can do Mobility Evals"
- Admin creates calendars (booking windows): "Mobility Eval" (standard), "Free Mobility Campaign" (free, Wed 10-12), "Quick Check" (20 min, cheaper)
- Patient books: Books from a calendar → creates appointment using that service's rules
- Program enrollment (optional): Patient buys "10-session package" → system tracks sessions, expires after 90 days or 10 appointments
- Bundled products (optional): Enroll in "Cervical Recovery Kit" → automatically ships pillow to home, unlocks telerehab access
Technical Reference
Overview
The service catalog defines what the organization offers. A Service is a pure business/catalog entity that knows nothing about booking rules, scheduling, or holds. It answers: "What is this offering?"
This feature replaces the old appointment_templates table, which conflated service definition with scheduling configuration.
Core Concept
Service = What we offer (business domain)
Calendar = When/how to book it (scheduling domain) → See scheduling/
Appointment = What was booked (clinical domain) → See appointments/A service defines:
- What it is (name, description, category)
- How long it takes (duration + buffer are intrinsic to the procedure)
- How much it costs (base price)
- What forms are needed (survey, disclaimer, etc.)
- Who can provide it (service_specialists)
A service does NOT define:
- Booking horizon or cooldown (that's on calendars)
- Specialist availability hours (that's on specialist_weekly_hours)
- Holds or slot reservations (that's in the scheduling feature)
Key Tables
| Table | Purpose |
|---|---|
services | Service catalog entries (name, duration, price, forms) |
service_specialists | Who CAN provide this service (capability, not assignment) |
service_forms | Which form templates to generate on booking |
service_attachments | Downloadable files (PDFs, consent forms) |
service_plans | Session packages, subscriptions, and hybrid plans (3 billing models) |
patient_service_plans | Patient enrollment, session tracking, and access window tracking |
products | Physical goods (elastic bands, supplements) for standalone sales or bundling |
service_plan_products | Junction: which products are bundled with a service plan (template) |
patient_product_orders | Unified fulfillment: all product orders (bundled + standalone) |
Service Catalog vs Scheduling
┌─────────────────────────────────────────┐
│ SERVICE CATALOG │
│ "What we offer" │
├─────────────────────────────────────────┤
│ services │
│ ├── Mobility Evaluation │
│ │ duration: 30min, price: 200 RON │
│ ├── Flexibility Measuring │
│ │ duration: 30min, price: 300 RON │
│ └── Pain Psychology │
│ duration: 20min, price: 100 RON │
└─────────────────────────────────────────┘
↓ linked via service_id
┌─────────────────────────────────────────┐
│ CALENDARS (Scheduling) │
│ "When/how to book" │
├─────────────────────────────────────────┤
│ calendars │
│ ├── Mobility Eval (default) │
│ │ uses service defaults │
│ ├── Free Mobility Campaign │
│ │ override: is_free=true │
│ │ override: hours Wed 10-12 only │
│ └── Flexibility Promo │
│ override: duration=20min, price=100│
└─────────────────────────────────────────┘Duration and Buffer on Services
Why duration/buffer are here: They are intrinsic to the service type. "Mobility Evaluation takes 30 minutes" is a property of the procedure itself, not a scheduling configuration.
How it works:
- Service defines default duration:
duration_minutes = 30 - Calendars can override for promotions:
override_duration_minutes = 20 - Scheduling engine reads:
calendar.override_duration_minutes ?? service.duration_minutes
This allows promotional calendars like "Quick 20-Minute Eval" that use a shorter version of the same service.
Service Specialists vs Calendar Specialists
Two different relationships:
service_specialists (this feature)
Capability: "Dr. Smith CAN do mobility evaluations"
- Catalog-level relationship
- Defines who is qualified/trained for this service
- Optional custom pricing per specialist
calendar_specialists (scheduling feature)
Assignment: "Dr. Smith IS assigned to the Free Mobility Campaign calendar"
- Scheduling-level relationship
- Defines who is actively taking bookings on this calendar
- Has priority, round-robin tracking, optional hour overrides
Service Plans & Billing Models
Service plans define how patients pay for and access services. Three billing models are supported:
Session-Based Plans (Traditional)
Count-down session packages linked to a specific service:
-- Service: Individual kinetotherapy session
INSERT INTO services (name, duration_minutes, base_price)
VALUES ('Kinetotherapy', 30, 100);
-- Service Plan: 10-session package
INSERT INTO service_plans (name, service_id, billing_model, sessions_total, validity_days, total_price)
VALUES ('Kinetotherapy 10 Sessions', service_e_id, 'session_based', 10, 90, 1000);Patient books appointments → sessions_completed increments → exhausted or expired.
Time-Based Plans (Subscriptions)
Access window without session counting. Used for telerehab subscriptions and library access:
-- Telerehab subscription: specialist assigns treatment plans for 3 months
INSERT INTO service_plans (name, billing_model, access_months, telerehab_access, total_price)
VALUES ('Telerehab 3 Months', 'time_based', 3, TRUE, 500);
-- Full library access: patient self-browses and self-assigns premade plans for 1 year
INSERT INTO service_plans (name, billing_model, access_months, telerehab_access, library_access, total_price)
VALUES ('Full Access 1 Year', 'time_based', 12, TRUE, TRUE, 1200);Note: service_id is NULL for platform-level plans not tied to a specific service.
Hybrid Plans (Appointments + Telerehab)
Bundles clinic appointment credits with telerehab time access:
-- Recovery Package: 10 clinic sessions + 1 month telerehab
INSERT INTO service_plans (
name, service_id, billing_model,
sessions_total, validity_days,
access_months, telerehab_access,
total_price
) VALUES (
'Recovery Package', service_e_id, 'hybrid',
10, 120, -- 10 appointments within 120 days
1, TRUE, -- + 1 month telerehab access
1500
);Patient gets clinic appointments AND telerehab from day one. Specialist can transition the patient from in-clinic to home exercises seamlessly.
Access Grants
Service plans control what the patient can do via two boolean flags:
| Flag | Meaning | Who Uses It |
|---|---|---|
telerehab_access | Specialist can assign telerehab treatment plans to this patient | Specialist assigns, patient executes |
library_access | Patient can browse premade treatment plans and self-assign | Patient browses, picks, and starts |
Access check flow:
- Patient (or specialist) tries to assign a treatment plan
- Backend checks: does patient have an active
patient_service_planwith the required access grant? - For telerehab:
telerehab_access = TRUEandaccess_expires_at > NOW() - For library self-assign:
library_access = TRUEandaccess_expires_at > NOW() - If no valid plan found → 403 "Active plan with telerehab/library access required"
Patient Enrollment Flow
- Specialist recommends program after initial consultation (or patient buys directly)
- Patient enrolls → creates
patient_service_planrecord - Access starts immediately —
access_starts_atset to enrollment time - For session-based: patient books appointments,
sessions_completedincrements - For time-based: patient has access until
access_expires_at - For hybrid: both tracking mechanisms run in parallel
- Completion/Expiry → status changes to
completedorexpired
Progress Tracking
SELECT
psp.id,
psp.sessions_total,
psp.sessions_completed,
psp.sessions_cancelled,
CASE WHEN psp.sessions_total IS NOT NULL
THEN (psp.sessions_total - psp.sessions_completed - psp.sessions_cancelled)
ELSE NULL
END AS sessions_remaining,
psp.status,
psp.access_starts_at,
psp.access_expires_at,
psp.expires_at
FROM patient_service_plans psp
WHERE psp.patient_id = 123 AND psp.status = 'active';Add-On Services
Services marked with is_addon = TRUE can be added to appointments in progress:
-- During appointment, specialist notices heart issue
PATCH /v1/appointments/456
{
"additional_service_ids": [2] -- Heart Examination (is_addon: true)
}Backend:
- Validates
service.is_addon = TRUE - Updates
appointment.additional_service_ids - Extends
appointment.ended_atby service duration - Generates forms for the added service
- Updates billing total
Products & Bundling
Physical goods that can be sold standalone or bundled with service plans.
Standalone Sales
Staff sells a product directly to a patient (no service plan involved):
Staff opens patient profile → Sells "Cervical Pillow" → patient_product_orders created
→ status: pending → confirmed → shipped → deliveredBundled with Service Plans
Admin defines bundles at the service plan level:
-- Service Plan: "Cervical Recovery Kit" (device + lifetime telerehab)
INSERT INTO service_plans (name, billing_model, access_months, telerehab_access, library_access, total_price)
VALUES ('Cervical Recovery Kit', 'time_based', NULL, TRUE, TRUE, 89);
-- access_months = NULL → lifetime access, total_price includes the product
-- Bundle the pillow with this plan
INSERT INTO service_plan_products (service_plan_id, product_id, quantity)
VALUES (plan_id, cervical_pillow_id, 1);Enrollment flow with bundled products:
- Patient enrolls in "Cervical Recovery Kit"
- Backend creates
patient_service_plan(access active immediately) - Backend auto-creates
patient_product_ordersfor each bundled product (status = 'pending') - Staff sees pending product orders in fulfillment queue
- Staff ships product → marks
shipped→ marksdelivered - Meanwhile, patient already has telerehab/library access from day one
Product Fulfillment Lifecycle
pending → confirmed → shipped → delivered
pending → cancelledAll product orders (bundled and standalone) use the same patient_product_orders table and the same fulfillment workflow. The patient_service_plan_id column distinguishes them:
- NULL = standalone purchase
- NOT NULL = bundled with service plan enrollment
Bundled Product Pricing
- Bundled products:
unit_price = NULL(cost included in the service plan price) - Standalone products:
unit_pricefrozen fromproducts.priceat time of sale
Products in Appointments
Products can also be recommended during appointments via appointment.additional_product_ids[]. This is a recommendation — it doesn't create an order automatically. Staff can convert recommendations to standalone orders.
Service Categories
category ENUM:
- consultation -- Initial evaluations, assessments
- therapy -- Treatment sessions (PT, kinetotherapy)
- examination -- Diagnostic procedures
- procedure -- Clinical proceduresForms
Services define which form templates to auto-generate:
INSERT INTO service_forms (service_id, form_template_id, form_type, sort_order) VALUES
(service_a_id, survey_template_id, 'survey', 1),
(service_a_id, disclaimer_template_id, 'disclaimer', 2);When appointment is created:
- Collect
service_formsWHEREservice_id = appointment.service_id - Collect
calendar_formsWHEREcalendar_id = appointment.calendar_id - Merge and deduplicate
- Generate form instances with snapshotted fields
Example: Service with Multiple Calendars
-- Service A: Mobility Evaluation (30min, 200 RON)
INSERT INTO services (name, slug, duration_minutes, base_price, published, is_public)
VALUES ('Mobility Evaluation', 'mobility-evaluation', 30, 200, true, true);
-- Default Calendar (uses service defaults)
INSERT INTO calendars (name, slug, service_id, published, is_public)
VALUES ('Mobility Evaluation', 'mobility-evaluation', service_a_id, true, true);
-- Campaign Calendar (free, limited hours)
INSERT INTO calendars (name, slug, service_id, is_free, slots_open_at, slots_close_at, published, is_public)
VALUES ('Free Mobility Feb', 'free-mobility-feb', service_a_id, true, '2026-02-15', '2026-02-20', true, true);
INSERT INTO calendar_specialists (calendar_id, specialist_id, priority, override_weekly_hours)
VALUES (campaign_cal_id, specialist_a_id, 1, '{"wed": [{"start": "10:00", "end": "12:00"}]}');
-- Specialist available ONLY Wed 10-12 on this campaign (not their default hours)
-- Promotional Calendar (shorter, cheaper)
INSERT INTO calendars (name, slug, service_id, override_duration_minutes, override_price, published, is_public)
VALUES ('Quick Mobility Check', 'quick-mobility', service_a_id, 20, 100, true, true);
-- 20min instead of 30min, 100 RON instead of 200 RONAPI Endpoints
See api.md for full API documentation.
Key endpoints:
GET /v1/services- Browse service catalogPOST /v1/services- Create service (admin)GET /v1/services/{id}/specialists- Who can provide this servicePOST /v1/service-plans/{id}/enroll- Enroll patient in programGET /v1/patient-service-plans/{id}- View program progress
Integration Points
With Scheduling Feature
- Services linked to calendars via
calendar.service_id(required FK) - Scheduling engine reads
service.duration_minutesandservice.buffer_minutes - Calendars can override duration/buffer for promotions
- Service specialists feed into calendar specialist assignments
With Appointments Feature
- Appointments reference service via
appointment.service_id - Add-on services tracked in
appointment.additional_service_ids[] - Service plans linked via
appointment.patient_service_plan_id - Forms generated from
service_forms+calendar_forms
With Treatment Plans Feature
- Service plans with
telerehab_access = TRUEunlock specialist-assigned treatment plans - Service plans with
library_access = TRUEunlock patient self-service plan browsing patient_treatment_plans.patient_service_plan_idlinks enrollment to billing- Completing treatment sessions can sync progress to
patient_service_plans - See treatment-plans/ for full integration details
With Forms Feature
service_formsjunction defines which forms to generate- Form generation happens during appointment onboarding
- Forms auto-filled from patient custom fields
Migration from appointment_templates
appointment_templates → services (1:1 rename + add fields)
- Keep: title → name, slug, description, duration, specialty_id, published, is_public, cover_url, video_url
- Add: category, base_price, is_addon, buffer_minutes
- Remove: use_even_distribution (becomes calendar.assignment_strategy), minicrm_title
appointment_template_forms → service_forms (direct rename) appointment_template_specialists → service_specialists (direct rename) appointment_template_attachments → service_attachments (direct rename)
See unified-architecture.md (archived) for full migration details.
Design Principles
- Services are catalog, not scheduling - No booking horizons, cooldowns, or holds on services
- Duration is intrinsic - "This procedure takes 30 minutes" is a service property
- Calendars provide booking layer - One service → many calendars with different rules
- Service specialists = capability - Different from calendar specialists (assignment)
- Multi-tenant by design - Every table has organization_id with RLS
- No soft delete - Services are configuration, not medical records (unlike appointments)