I am building an app for dental practices - whilst they get a lot of their revenue from NHS, they must still charge patients for treatment that is not covered as well as private treatment - what are the essential elements they need to consider when creating a price list for their practice?
When building an app that handles fee configuration for UK dental practices, your software needs to support a "mixed practice" business model. Unlike the rigid, fixed three-band system of the NHS, setting private prices requires a meticulous, multi-variable approach. [1, 2, 3, 4]
To make your app indispensable, the pricing engine or configuration dashboard should help practice managers account for these five essential elements:
The foundation of private pricing is knowing exactly how much it costs to run a specific surgery (treatment room) per hour. Your app should allow them to input fixed and variable operational costs to calculate an hourly burn rate. [5]
Private dentistry relies heavily on "fee per item" or "time-based billing". A key element your app must accommodate is assigning a standard duration to every private code. [3, 5, 6, 9]
Unlike standard retail items, private dental treatments often have expensive variable inputs that must be added directly to the base chair-time cost. [3, 10]
Most dental practices rely on associate dentists who operate as self-employed contractors. [13, 14]
In the UK, the General Dental Council (GDC) strictly regulates how fees are displayed to prevent misleading pricing. Your pricing module should inherently keep them compliant: [15]
To help a dentist structure a price, use this universal logical formula:
If you are developing this feature right now, let me know:
[2] https://nhsdentistcost.com
[3] https://salisburydentist.co.uk
[6] https://urgentcaredental.co.uk
[9] https://dentalaccountancy.co.uk
[10] https://www.westburydentalcare.com
[11] https://findcarecompare.com
[13] https://kudosaccounting.co.uk
[14] https://reai.uk
[16] https://www.gov.uk
[17] https://www.livingstonesaccountants.co.uk
[18] https://www.practiceplan.co.uk
The app does have personal web app (pwa) wherein patients will be able to view their bills and make payments - I haven't yet considered monthly membership plan tracking like Denplan - is this insurance?
No, Denplan is not insurance in the traditional sense. Legally and structurally, it is a dental capitation plan (often called a maintenance or membership plan). [1, 2]
Understanding the distinction is vital for a developer, as it changes the way your Progressive Web App (PWA) logic must handle invoicing, zero-dollar balances, and automated adjustments.
(Note: Denplan packages do include a tiny, baked-in insurance element strictly for out-of-hours "worldwide emergency cover", but the clinical treatments at the home practice are entirely capitation-based). [2]
If a practice uses Denplan, your PWA's billing module cannot simply display a blanket "Private Price List" to every patient. You need to support Plan-Specific Adjustments. [2]
Practices generally use two main types of tiers, and your software must accommodate them:
| Plan Type | What it Covers | How your PWA must handle the bill |
|---|---|---|
| 1. Essentials / Maintenance | Covers routine check-ups, hygiene visits, and X-rays. Restorative work (fillings, etc.) is extra, but usually discounted. | • Routine appointments auto-resolve to £0.00. • Fillings or extractions automatically apply a custom discount rule (e.g., 10% to 20% off standard private rates). |
| 2. Full Care / Capitation | Patient pays a higher monthly fee based on their oral health. Covers all routine and restorative work (fillings, crowns, root canals). | • Almost all clinical time auto-resolves to £0.00. • CRUCIAL: Laboratory fees for crowns/bridges are not covered. The PWA bill must charge the patient only the lab cost, while wiping out the clinical time fee. |
Because tracking plans is a massive pain point for practices, introducing a membership engine gives your software a major competitive edge:
Would you like to explore how to structure the database schema relationships between patient profiles, custom price lists, and plan-based discount matrices?
[1] https://www.dentistcost.co.uk
[2] https://www.dentistcost.co.uk
[3] https://www.dentalplanfinder.co.uk
[4] https://www.dentalplanfinder.co.uk
Yes we need to explore how to structure the database schema relationships between patient profiles, custom price lists, and plan-based discount matrices
To handle the complexity of NHS bands, standard private fees, and plan-based discounts (like Denplan, Practice Plan, or an in-house plan), your database schema needs to separate clinical treatment items from financial prices and discount rules.
The structure below uses a normalized, relational approach (ideal for PostgreSQL or MySQL) to give you total flexibility.
patientsStores individual patient profiles and links them to a specific membership tier if applicable.
CREATE TABLE patients (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
first_name VARCHAR(100),
last_name VARCHAR(100),
membership_plan_id UUID REFERENCES membership_plans(id), -- NULL if standard private/NHS
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
treatment_itemsThe clinical catalog of what can be done to a tooth. This table tracks clinical parameters, not prices.
CREATE TABLE treatment_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code VARCHAR(20) UNIQUE, -- e.g., "EXAM01", "COMP02", "CRWN05"
name VARCHAR(255), -- e.g., "Routine Examination", "Porcelain Crown"
category VARCHAR(50), -- e.g., 'Diagnostic', 'Restorative', 'Cosmetic'
average_duration_mins INT, -- Essential for chair-time scheduling
is_laboratory_required BOOLEAN DEFAULT FALSE -- Crucial for Capitation logic
);
standard_pricesHolds the default, baseline private prices. Keeping this separate from treatment_items allows for historical price updates without destroying historical invoice tracking.
CREATE TABLE standard_prices (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
treatment_item_id UUID REFERENCES treatment_items(id),
base_fee DECIMAL(10, 2), -- The standard private fee
is_nhs_covered BOOLEAN DEFAULT FALSE, -- Flips pricing to NHS set bands instead
effective_from DATE NOT NULL,
effective_to DATE
);
membership_plansDefines the types of plans a practice offers.
CREATE TABLE membership_plans (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(100), -- e.g., "Denplan Essentials", "Practice Plan Care Tier B"
provider VARCHAR(50), -- e.g., 'Denplan', 'In-House'
monthly_fee DECIMAL(10, 2)
);
plan_discount_rulesThis is the "Discount Matrix" table. Instead of manually editing prices for every plan, this matrix evaluates treatments by specific items or broad categories to determine what the patient pays.
CREATE TABLE plan_discount_rules (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
membership_plan_id UUID REFERENCES membership_plans(id),
-- Target scoping (Either apply to a specific item or an entire category)
treatment_item_id UUID REFERENCES treatment_items(id), -- NULL if category-wide
treatment_category VARCHAR(50), -- NULL if item-specific
-- Pricing Action Flags
is_fully_covered BOOLEAN DEFAULT FALSE, -- e.g., Routine Exam = 100% free (£0)
discount_percentage INT DEFAULT 0, -- e.g., 20% off white fillings
charge_lab_fee_only BOOLEAN DEFAULT FALSE, -- Used for Denplan Care crowns
UNIQUE (membership_plan_id, treatment_item_id),
UNIQUE (membership_plan_id, treatment_category)
);
patient_billing_itemsWhen a dentist adds a treatment to a patient's chart, this ledger table calculates and logs the financial breakdown in real time. Your PWA reads from this table to display the bill.
CREATE TABLE patient_billing_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
patient_id UUID REFERENCES patients(id),
treatment_item_id UUID REFERENCES treatment_items(id),
appointment_date DATE NOT NULL,
-- Snapshot data (Must be frozen at the moment of billing)
standard_fee_snapshot DECIMAL(10, 2) NOT NULL,
discount_applied_snapshot DECIMAL(10, 2) DEFAULT 0.00,
lab_fee_passed_through DECIMAL(10, 2) DEFAULT 0.00,
-- Final amount the patient sees in the PWA
amount_due_by_patient DECIMAL(10, 2) NOT NULL,
payment_status VARCHAR(20) DEFAULT 'Unpaid' -- 'Unpaid', 'Partially Paid', 'Paid'
);
When a treatment item (e.g., a Porcelain Crown, code CRWN05, base price £600.00, requiring a lab fee of £150.00) is assigned to a patient, your backend query should run this pricing pipeline:
Check Patient Status: Does the patient have a membership_plan_id?
plan_discount_rules for that plan and item.Evaluate Rules:
is_fully_covered = TRUE Amount Due = £0.00.discount_percentage = 20 Amount Due = £480.00 (£600 - 20%).charge_lab_fee_only = TRUE (Typical Denplan Care crown) The app zeroes out the clinical time and sets the Amount Due = £150.00 (passing only the raw lab cost to the patient).To help us narrow this database design down to your exact needs, let me know:
yes it must support NHS pricing band calculations (Band 1, 2, 3 flat fees) within this same schema
To blend rigid, flat-rate NHS dental bands with a dynamic, private fee-per-item structure in the same schema, your database must account for how the NHS explicitly clusters treatments.
In the UK (England and Wales), the NHS charges patients one of four fixed financial bands regardless of how many individual treatments are completed within that course of treatment.
Here is how to update your database architecture to support this hybrid model cleanly.
We will introduce a new lookup table for the fixed NHS Band Prices and add an nhs_band designation directly to your clinical catalog.
nhs_band_prices (New Table)This table stores the statutory flat-rate prices for NHS dental charges. Because the UK government changes these rates almost every year, tracking the date ranges (effective_from / effective_to) is mandatory.
CREATE TABLE nhs_band_prices (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
band_name VARCHAR(20) NOT NULL, -- 'Band 1', 'Band 2', 'Band 3', 'Band 4' (Urgent)
flat_fee DECIMAL(10, 2) NOT NULL, -- e.g., £26.80, £73.50, £319.10 (historic rates)
effective_from DATE NOT NULL,
effective_to DATE,
UNIQUE (band_name, effective_from)
);
treatment_items (Updated Column)We add an nhs_band column to link clinical codes to their respective statutory NHS bands.
CREATE TABLE treatment_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code VARCHAR(20) UNIQUE, -- e.g., "EXAM01" (Exam), "CRWN05" (Crown)
name VARCHAR(255) NOT NULL,
category VARCHAR(50),
-- NHS Configuration
nhs_band VARCHAR(20) CHECK (nhs_band IN ('Band 1', 'Band 2', 'Band 3', 'Band 4', 'Exempt')),
is_laboratory_required BOOLEAN DEFAULT FALSE
);
patients (Updated Column)NHS billing requires knowing if a patient is exempt from charges (e.g., pregnant women, those on specific low-income benefits, children under 18).
CREATE TABLE patients (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
first_name VARCHAR(100),
last_name VARCHAR(100),
membership_plan_id UUID REFERENCES membership_plans(id), -- Null if NHS or pure Private
-- NHS Exemption Tracking
is_nhs_charge_exempt BOOLEAN DEFAULT FALSE,
nhs_exemption_reason VARCHAR(100) -- e.g., 'Income Support', 'Pregnancy'
);
Your billing engine must handle a critical rule: The Cap Rule.
If an NHS patient undergoes three separate fillings (all Band 2 items) and an extraction (also a Band 2 item) during a single "Course of Treatment," they are only charged one single Band 2 flat fee, not four separate charges.
To build this safely, you will need a parent courses_of_treatment table to group multiple billed items together.
CREATE TABLE courses_of_treatment (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
patient_id UUID REFERENCES patients(id),
billing_type VARCHAR(10) CHECK (billing_type IN ('NHS', 'Private')),
status VARCHAR(20) DEFAULT 'Open', -- 'Open', 'Completed'
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
When rendering a patient's bill inside your PWA, your backend application layer uses this hierarchy to determine the price:
To visualize how your PWA billing engine will calculate amounts using this unified schema:
| Scenario | Clinical Items in Course | System Billing Type | Calculated PWA Balance Due | Why? |
|---|---|---|---|---|
| NHS Patient | Check-up (Band 1) | NHS | £26.80 (Example rate) | Flat Band 1 rate. |
| NHS Patient | Check-up (Band 1) + 2x Fillings (Band 2) | NHS | £73.50 (Example rate) | Band 2 is the highest tier. Single flat fee applies. |
| NHS Patient | Check-up (Band 1) + Crown (Band 3) | NHS | £319.10 (Example rate) | Band 3 is the highest tier. Single flat fee applies. |
| Exempt Patient | Check-up (Band 1) + Crown (Band 3) | NHS | £0.00 | Patient's is_nhs_charge_exempt flag is TRUE. |
| Private Patient | Check-up + Crown | Private | £675.00 | Calculated on an explicit item-by-item basis. |
Now that the logic spans across Course groupings, let me know:
yes please show a sample of a PostgreSQL function that dynamically computes the total course balance by identifying the highest NHS band added to a file
To dynamically calculate the balance of an NHS course of treatment, your PostgreSQL function must inspect all the items attached to that course, find the single most expensive (highest) NHS band, and verify whether the patient is exempt from charges.
Before creating the function, let's establish the schema connection table that links the parent Course of Treatment to the individual Treatment Items:
CREATE TABLE course_treatment_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
course_id UUID REFERENCES courses_of_treatment(id) ON DELETE CASCADE,
treatment_item_id UUID REFERENCES treatment_items(id),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
This function takes a course_id, evaluates the patient's exemption status, dynamically scans for the highest NHS band added to that file, looks up the current active statutory flat fee, and returns the final balance due.
CREATE OR REPLACE FUNCTION calculate_nhs_course_balance(p_course_id UUID)
RETURNS DECIMAL(10, 2) AS $$
DECLARE
v_patient_exempt BOOLEAN;
v_highest_band VARCHAR(20);
v_final_fee DECIMAL(10, 2) := 0.00;
v_course_type VARCHAR(10);
BEGIN
-- 1. Verify this is actually an NHS course of treatment
SELECT billing_type, patient_id
INTO v_course_type
FROM courses_of_treatment
WHERE id = p_course_id;
IF v_course_type != 'NHS' THEN
RAISE EXCEPTION 'Function explicitly handles NHS billing. Course % is flagged as Private.', p_course_id;
END IF;
-- 2. Check if the patient linked to this course is exempt from NHS fees
SELECT COALESCE(is_nhs_charge_exempt, FALSE)
INTO v_patient_exempt
FROM patients p
JOIN courses_of_treatment c ON c.patient_id = p.id
WHERE c.id = p_course_id;
-- If the patient is exempt, the final fee is guaranteed to be zero
IF v_patient_exempt THEN
RETURN 0.00;
END IF;
-- 3. Determine the HIGHEST NHS Band present in this specific course.
-- Order logic handles ranking: Band 3 > Band 2 > Band 1 > Urgent (Band 4)
SELECT ti.nhs_band
INTO v_highest_band
FROM course_treatment_items cti
JOIN treatment_items ti ON cti.treatment_item_id = ti.id
WHERE cti.course_id = p_course_id
AND ti.nhs_band IN ('Band 1', 'Band 2', 'Band 3', 'Band 4')
ORDER BY
CASE ti.nhs_band
WHEN 'Band 3' THEN 4
WHEN 'Band 2' THEN 3
WHEN 'Band 1' THEN 2
WHEN 'Band 4' THEN 1 -- Urgent care
ELSE 0
END DESC
LIMIT 1;
-- If there are no billable NHS items added to the course yet, return zero
IF v_highest_band IS NULL THEN
RETURN 0.00;
END IF;
-- 4. Look up the flat statutory fee for that specific band
-- Filters dynamically for the price active right now (CURRENT_DATE)
SELECT flat_fee
INTO v_final_fee
FROM nhs_band_prices
WHERE band_name = v_highest_band
AND effective_from <= CURRENT_DATE
AND (effective_to IS NULL OR effective_to >= CURRENT_DATE)
ORDER BY effective_from DESC
LIMIT 1;
-- Return the evaluated flat fee
RETURN COALESCE(v_final_fee, 0.00);
END;
$$ LANGUAGE plpgsql;
When your web app hits an endpoint like /api/courses/:id/balance, you don't need to run a complex calculation chain on your backend server. You can execute a quick, clean database call that invokes your new function:
SELECT
c.id AS course_id,
c.status AS course_status,
p.first_name || ' ' || p.last_name AS patient_name,
calculate_nhs_course_balance(c.id) AS total_balance_due
FROM courses_of_treatment c
JOIN patients p ON c.patient_id = p.id
WHERE c.id = 'YOUR-COURSE-UUID-HERE';
To make sure your API layer is robust, let me know:
where do we get information from for NHS bands and how much they cover for treatment?
To build an accurate pricing engine, you should source your data directly from official UK government bodies. The specific organizations that manage, update, and publish NHS dental bands differ slightly depending on the region.
The core authoritative sources for this information include:
If your software is going to be sold to dental practices across the whole of the UK, do not hardcode England's bands. The home nations set entirely separate dental contract pricing:
App Architecture Tip: Ensure your nhs_band_prices schema supports a region column (e.g., 'England', 'Wales') so the calculation logic targets the clinic’s physical location.
According to the NHSBSA Knowledge Base, clinical treatments must map to the following structures: [1]
| Band Name | Current Fee (England) | Main Treatments Covered | Developer Mapping Logic |
|---|---|---|---|
| Band 1 | £27.90 | Clinical exams, diagnostics, X-rays, emergency pain management advice, and basic cleanings if clinically necessary. | Diagnostic & Preventative codes map here. |
| Band 2 | £76.60 | Everything in Band 1, plus fillings (amalgam or composite depending on tooth position), root-canal therapies, and tooth extractions. | Restorative & Surgical codes map here. |
| Band 3 | £332.10 | Everything in Bands 1 and 2, plus complex treatments requiring a physical laboratory component: crowns, bridges, dentures, and dental appliances. | Any code where is_laboratory_required = TRUE. |
| Urgent | £27.90 | Urgent, unscheduled appointments meant strictly to resolve acute pain (e.g., temporary dressings, immediate palliative care). | Distinct triage clinic codes. |
Because these fees alter near the end of the financial year, your app shouldn't make users change prices manually.
Instead, create a background seed job in your deployment pipeline. You can monitor the NHSBSA Dental News page or set an alert for the statutory instruments published on legislation.gov.uk regarding "The National Health Service (Dental Charges) Regulations". When the new rates are published, you simply insert a new row into your nhs_band_prices table with the future effective_from date set to April 1st.
Would you like help designing a JSON structure or configuration file format to manage these regional tax, band, and clinical rules dynamically inside your codebase?
[3] https://www.spadental.co.uk
[7] https://www.dentistcost.co.uk
When a patient in England starts an NHS course of dental treatment before 1 April and the course completes after the annual charge uplift takes effect, which patient charge applies — the rate in force when the course started, or when it finished? Answer for the NHSBSA dental charging rules and cite the source.
According to the official NHS Business Services Authority (NHSBSA) charging and processing guidelines, the patient must be charged the rate in force on the date the course of treatment started (the acceptance date). [1]
Even if the treatment finishes, the claim is completed, or the invoice is processed after the annual April 1st uplift takes effect, the price is strictly locked to the initial date the dentist examined the patient and opened the FP17 course of treatment form. [1]
The regulatory framework dictating this behavior is legally bound by the National Health Service (Dental Charges) Regulations. The NHSBSA applies the following transition rules: [1]
Under these principal regulations, a patient's liability for a dental charge is established at the time they are accepted for a Course of Treatment (CoT). Therefore, your app's code logic should pull the effective_from rate matching the course's created_at or acceptance_date stamp rather than the completion date. [1, 2]
To implement this correctly in your backend billing calculations, you must ensure that your pricing queries do not dynamically pull the active date at completion.
Instead, modify the snapshot lookup to target the historical date when the course officially began:
-- Always map the fee back to the date the course of treatment opened
SELECT flat_fee
FROM nhs_band_prices
WHERE band_name = :highest_band_in_course
AND effective_from <= :course_start_date -- Use start date, NOT CURRENT_DATE
AND (effective_to IS NULL OR effective_to >= :course_start_date)
ORDER BY effective_from DESC
LIMIT 1;
If you want to review how this transitional pricing handles claims submitted late via electronic EDI files to the NHSBSA portal, or how to handle amendments made to open courses, let me know!