Files
Crussell/init-scripts/init-script.sql
T

1146 lines
41 KiB
PL/PgSQL

-- =======================================
-- NAIL SALON DATABASE SCHEMA
-- Complete init.sql with GDPR & Tax Compliance
-- =======================================
-- Enable pgcrypto for generating random IDs
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE EXTENSION IF NOT EXISTS pg_trgm;
-- =======================================
-- ENUMS
-- =======================================
CREATE TYPE account_role AS ENUM ('unverified_email', 'verified_email', 'admin', 'guest', 'affiliate');
CREATE TYPE account_type AS ENUM ('email', 'google', 'microsoft', 'facebook', 'guest');
CREATE TYPE payment_type AS ENUM ('deposit', 'full', 'tip', 'balance', 'partial');
CREATE TYPE payment_method AS ENUM ('online_square', 'in_person_card', 'cash', 'giftcard', 'discount');
CREATE TYPE payment_status AS ENUM ('pending', 'completed', 'failed', 'refunded');
CREATE TYPE booking_status AS ENUM ('pending', 'confirmed', 'in_progress', 'completed', 'client_cancelled', 'we_cancelled', 're-schedule', 'no_show');
-- =======================================
-- SHORT ID GENERATION
-- =======================================
CREATE OR REPLACE FUNCTION generate_short_id(table_name TEXT)
RETURNS CHAR(12) AS $$
DECLARE
new_id CHAR(12);
collision_count INT := 0;
max_attempts INT := 100;
BEGIN
LOOP
new_id := substr(encode(gen_random_bytes(6), 'hex'), 1, 12);
EXECUTE format('SELECT 1 FROM %I WHERE id = $1 LIMIT 1', table_name) USING new_id;
IF NOT FOUND THEN
RETURN new_id;
END IF;
collision_count := collision_count + 1;
IF collision_count >= max_attempts THEN
RAISE EXCEPTION 'Unable to generate unique ID after % attempts for table %', max_attempts, table_name;
END IF;
END LOOP;
END;
$$ LANGUAGE plpgsql;
CREATE OR REPLACE FUNCTION generate_user_id() RETURNS CHAR(12) AS $$ SELECT generate_short_id('users'); $$ LANGUAGE sql;
CREATE OR REPLACE FUNCTION generate_service_id() RETURNS CHAR(12) AS $$ SELECT generate_short_id('services'); $$ LANGUAGE sql;
CREATE OR REPLACE FUNCTION generate_booking_id() RETURNS CHAR(12) AS $$ SELECT generate_short_id('bookings'); $$ LANGUAGE sql;
CREATE OR REPLACE FUNCTION generate_payment_id() RETURNS CHAR(12) AS $$ SELECT generate_short_id('payments'); $$ LANGUAGE sql;
CREATE OR REPLACE FUNCTION generate_referral_code()
RETURNS CHAR(12) AS $$
DECLARE
new_code CHAR(12);
collision_count INT := 0;
max_attempts INT := 100;
BEGIN
LOOP
new_code := substr(encode(gen_random_bytes(6), 'hex'), 1, 12);
-- Check against referral_code column in users table (not id column)
PERFORM 1 FROM users WHERE referral_code = new_code LIMIT 1;
IF NOT FOUND THEN
RETURN new_code;
END IF;
collision_count := collision_count + 1;
IF collision_count >= max_attempts THEN
RAISE EXCEPTION 'Unable to generate unique referral code after % attempts', max_attempts;
END IF;
END LOOP;
END;
$$ LANGUAGE plpgsql;
-- =======================================
-- USERS TABLE
-- =======================================
CREATE TABLE users (
id CHAR(12) PRIMARY KEY DEFAULT generate_user_id(),
-- Identity fields
n_first_name VARCHAR(50) NOT NULL, -- vCard N.given
n_last_name VARCHAR(50) NOT NULL, -- vCard N.family
fn VARCHAR(120) GENERATED ALWAYS AS (n_first_name || ' ' || n_last_name) STORED, -- vCard FN
email VARCHAR(255) UNIQUE, -- vCard EMAIL (nullable for social-only/guest)
phone VARCHAR(20), -- vCard TEL
date_of_birth DATE, -- vCard BDAY
profile_pic_url TEXT, -- vCard PHOTO
-- Account fields
account_role account_role NOT NULL DEFAULT 'unverified_email', -- initial signup role
account_type account_type NOT NULL DEFAULT 'email', -- initial signup type (info only)
-- Security fields
password_hash TEXT, -- NULL for pure social logins
last_login_at TIMESTAMPTZ DEFAULT NOW(),
-- Loyalty fields
loyalty_stamps INT NOT NULL DEFAULT 0,
referral_code CHAR(12) UNIQUE DEFAULT generate_referral_code(),
-- GDPR fields
privacy_policy_and_terms_consent BOOLEAN NOT NULL DEFAULT TRUE,
policy_consent_updated_at TIMESTAMPTZ DEFAULT NOW(),
data_retention_consent BOOLEAN NOT NULL DEFAULT TRUE,
data_consent_updated_at TIMESTAMPTZ DEFAULT NOW(),
-- Audit fields
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- staff fields
notes TEXT
);
CREATE TABLE user_social_logins (
id BIGSERIAL PRIMARY KEY,
user_id CHAR(12) NOT NULL REFERENCES users(id) ON DELETE CASCADE,
provider account_type NOT NULL CHECK (provider IN ('google', 'microsoft', 'facebook')),
immutable_id TEXT NOT NULL, -- the stable provider user ID
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (provider, immutable_id), -- prevents duplicate identities
UNIQUE (user_id, provider) -- one account per provider per user
);
CREATE INDEX idx_users_email_lower ON users (LOWER(email));
CREATE INDEX idx_users_account_role ON users (account_role);
-- =======================================
-- SERVICES TABLE
-- =======================================
CREATE TABLE services (
id CHAR(12) PRIMARY KEY DEFAULT generate_service_id(),
name VARCHAR(100) NOT NULL,
description TEXT,
price NUMERIC(10,2) NOT NULL,
duration_minutes INT NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
patch_test_duration_hours INT NOT NULL DEFAULT 0,
minimum_age_required INT NOT NULL DEFAULT 16,
requires_manual_pricing BOOLEAN NOT NULL DEFAULT FALSE,
requires_manual_duration BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by CHAR(12)
);
CREATE INDEX idx_services_name ON services(name);
CREATE TABLE user_service_patch_tests (
id BIGSERIAL PRIMARY KEY,
user_id CHAR(12) NOT NULL REFERENCES users(id) ON DELETE CASCADE,
service_id CHAR(12) NOT NULL REFERENCES services(id) ON DELETE CASCADE,
last_time TIMESTAMPTZ NOT NULL
);
CREATE INDEX idx_service_patch_tests_userid ON user_service_patch_tests(user_id);
-- =======================================
-- BOOKINGS TABLE
-- =======================================
CREATE TABLE bookings (
id CHAR(12) PRIMARY KEY DEFAULT generate_booking_id(),
user_id CHAR(12) REFERENCES users(id) ON DELETE SET NULL,
start_time TIMESTAMPTZ NOT NULL,
status booking_status NOT NULL DEFAULT 'pending',
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by CHAR(12)
);
CREATE INDEX idx_bookings_userid ON bookings(user_id);
CREATE INDEX idx_bookings_starttime ON bookings(start_time);
CREATE INDEX idx_bookings_status ON bookings(status);
CREATE INDEX idx_bookings_userid_starttime ON bookings(user_id, start_time);
-- =======================================
-- BOOKING SERVICES JUNCTION TABLE
-- =======================================
CREATE TABLE booking_services (
booking_id CHAR(12) NOT NULL REFERENCES bookings(id) ON DELETE CASCADE,
service_id CHAR(12) NOT NULL REFERENCES services(id) ON DELETE RESTRICT,
override_price NUMERIC(10,2),
override_duration_minutes INT,
PRIMARY KEY (booking_id, service_id)
);
CREATE TABLE user_referrals (
referrer_id CHAR(12) NOT NULL REFERENCES users(id) ON DELETE CASCADE,
referred_id CHAR(12) NOT NULL REFERENCES users(id) ON DELETE CASCADE,
referred_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
claimed_booking_id CHAR(12) REFERENCES bookings(id) ON DELETE SET NULL,
PRIMARY KEY (referrer_id, referred_id)
);
-- =======================================
-- DEFAULT WORKING HOURS TABLE
-- =======================================
CREATE TABLE working_hours (
weekday SMALLINT PRIMARY KEY, -- 0 = Monday, 6 = Sunday
start_time TIME NOT NULL, -- e.g. 09:00
end_time TIME NOT NULL, -- e.g. 17:00
is_open BOOLEAN NOT NULL DEFAULT TRUE
);
CREATE INDEX idx_working_hours_weekday ON working_hours(weekday);
INSERT INTO working_hours VALUES (0, '00:00:00', '00:00:00', FALSE);
INSERT INTO working_hours VALUES (1, '09:00:00', '17:00:00', TRUE);
INSERT INTO working_hours VALUES (2, '09:00:00', '17:00:00', TRUE);
INSERT INTO working_hours VALUES (3, '12:00:00', '20:00:00', TRUE);
INSERT INTO working_hours VALUES (4, '09:00:00', '17:00:00', TRUE);
INSERT INTO working_hours VALUES (5, '09:00:00', '17:00:00', TRUE);
INSERT INTO working_hours VALUES (6, '00:00:00', '00:00:00', FALSE);
-- =======================================
-- EXCEPTIONAL WORKING HOURS
-- =======================================
-- Exceptional working hours groups (template)
CREATE TABLE exceptional_working_hours_groups (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL, -- e.g. "Christmas Schedule"
description TEXT NOT NULL -- e.g. "Extended hours for holiday period"
);
-- Exceptional working hours (7 entries per group, one per weekday)
CREATE TABLE exceptional_working_hours (
id SERIAL PRIMARY KEY,
group_id INT NOT NULL REFERENCES exceptional_working_hours_groups(id) ON DELETE CASCADE,
weekday SMALLINT NOT NULL, -- 0 = Monday ... 6 = Sunday
start_time TIME NOT NULL,
end_time TIME NOT NULL,
is_open BOOLEAN NOT NULL DEFAULT TRUE
);
CREATE UNIQUE INDEX idx_group_weekday ON exceptional_working_hours(group_id, weekday);
-- Exceptional group applications (assign a group to a specific week)
CREATE TABLE exceptional_group_applications (
id SERIAL PRIMARY KEY,
group_id INT NOT NULL REFERENCES exceptional_working_hours_groups(id) ON DELETE CASCADE,
week_start DATE NOT NULL -- Monday of the week this group applies to
);
CREATE UNIQUE INDEX idx_group_application_week ON exceptional_group_applications(week_start);
-- =======================================
-- PAYMENTS TABLE
-- =======================================
CREATE SEQUENCE invoice_number_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
CREATE TABLE payments (
id CHAR(12) PRIMARY KEY DEFAULT generate_payment_id(),
booking_id CHAR(12) NOT NULL REFERENCES bookings(id) ON DELETE RESTRICT,
payment_type payment_type NOT NULL,
payment_method payment_method NOT NULL,
vendor_code TEXT,
invoice_number INT UNIQUE DEFAULT nextval('invoice_number_seq'),
status payment_status NOT NULL DEFAULT 'pending',
amount NUMERIC(10,2) NOT NULL,
-- VAT fields (NULL until VAT registered - likely won't need but good to be ready for)
is_vat_applicable BOOLEAN NOT NULL DEFAULT FALSE,
vat_rate NUMERIC(5,2),
vat_amount NUMERIC(10,2),
net_amount NUMERIC(10,2),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by CHAR(12)
);
CREATE INDEX idx_payments_bookingid ON payments(booking_id);
CREATE INDEX idx_payments_status ON payments(status);
CREATE INDEX idx_payments_createdat ON payments(created_at);
-- =======================================
-- BUSINESS SETTINGS TABLE (FOR COMPLIANCE)
-- Stores legal business info for receipts, VAT status, currency, etc.
-- =======================================
CREATE TABLE business_settings (
id INT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
business_name VARCHAR(255) NOT NULL,
business_address TEXT NOT NULL,
business_phone VARCHAR(20),
business_email VARCHAR(255),
vat_registration_number VARCHAR(20),
is_vat_registered BOOLEAN NOT NULL DEFAULT FALSE,
default_vat_rate NUMERIC(5,2) NOT NULL DEFAULT 20.00,
currency_code CHAR(3) NOT NULL DEFAULT 'GBP',
website_url TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Add trigger to auto-update timestamp
CREATE OR REPLACE FUNCTION update_business_settings_timestamp()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trigger_update_business_settings_timestamp
BEFORE UPDATE ON business_settings
FOR EACH ROW
EXECUTE FUNCTION update_business_settings_timestamp();
-- Insert default row
INSERT INTO business_settings (
business_name,
business_address,
business_phone,
business_email,
vat_registration_number,
is_vat_registered,
default_vat_rate,
currency_code,
website_url
) VALUES (
'Crussell Nail Art Studio',
'Address',
'+44 131 123 4567',
'crussellnails@gmail.com',
NULL, -- Set this when we register for VAT
FALSE, -- Set to TRUE when we register for VAT
20.00,
'GBP',
'https://www.website.co.uk'
);
CREATE TYPE admin_notification_reason AS ENUM ('pending_booking', 'cancelled_booking', 'rescheduled_booking', '1_week_no_pay', '1_month_no_pay', 'affiliate_claim');
CREATE TABLE admin_notifications (
id SERIAL PRIMARY KEY,
reason admin_notification_reason NOT NULL,
booking_id CHAR(12) REFERENCES bookings(id),
user_id CHAR(12) REFERENCES users(id),
acknowledged_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_payments_booking_id_status ON payments(booking_id, status);
CREATE INDEX idx_bookings_start_time_status ON bookings(start_time, status);
CREATE INDEX idx_users_created_at ON users(created_at);
CREATE INDEX idx_payments_created_at_status ON payments(created_at, status);
create table images (
id uuid primary key default gen_random_uuid(),
r2_url text not null,
tag_names text[] not null default '{}', -- for searching and filtering
created_at timestamptz not null default now()
);
-- only for autocomlpete text
create table tags (
id serial primary key,
name text not null unique
);
-- Indexes
create index idx_images_tag_names on images using gin(tag_names);
create index idx_images_created_at on images(created_at desc);
create index idx_tags_name_trgm on tags using gin (name gin_trgm_ops);
-- =======================================
-- GDPR COMPLIANCE FUNCTIONS
-- =======================================
/*
GDPR COMPLIANCE NOTES:
- Legal basis for booking data: Contract performance (Art 6(1)(b) GDPR)
- Legal basis for optional data (marketing): Consent (Art 6(1)(a) GDPR)
- Data retention: While consent remains and account is active
- Right to be forgotten: anonymize_user() for registered users
- Subject access rights: export_all_user_data() provides complete export
*/
-- Anonymize registered user (Right to be Forgotten)
-- WHY: GDPR Article 17 - users can request data deletion
-- WHEN: User requests account deletion
-- OUTPUT: Converts personal data to anonymous placeholder
CREATE OR REPLACE FUNCTION anonymize_user(target_id CHAR(12))
RETURNS VOID AS $$
BEGIN
UPDATE users
SET
n_first_name = 'Deleted',
n_last_name = 'User',
email = CONCAT('deleted+', target_id, '@example.com'),
phone = NULL,
profile_pic_url = NULL,
date_of_birth = NULL,
account_role = 'guest',
loyalty_stamps = 0,
data_retention_consent = FALSE,
data_consent_updated_at = NOW(),
updated_at = NOW(),
password_hash = NULL
WHERE id = target_id
AND account_role != 'guest';
END;
$$ LANGUAGE plpgsql;
-- Fully delete guest user
-- WHY: Guest accounts have no ongoing business relationship
-- WHEN: Cleaning up temporary/incomplete accounts
-- OUTPUT: Complete removal from database
CREATE OR REPLACE FUNCTION delete_guest_user(target_id CHAR(12))
RETURNS VOID AS $$
BEGIN
DELETE FROM users
WHERE id = target_id
AND account_role = 'guest';
END;
$$ LANGUAGE plpgsql;
-- Update consent
-- WHY: GDPR requires tracking consent changes
-- WHEN: User updates privacy preferences
-- OUTPUT: Updates consent flags and timestamps
CREATE OR REPLACE FUNCTION update_data_consent(target_id CHAR(12), consent BOOLEAN)
RETURNS VOID AS $$
BEGIN
UPDATE users
SET data_retention_consent = consent,
data_consent_updated_at = NOW(),
updated_at = NOW()
WHERE id = target_id;
END;
$$ LANGUAGE plpgsql;
-- Complete Subject Access Request Export
-- WHY: GDPR Article 15 - users have right to access their data
-- WHEN: Customer requests "what data do you have on me?"
-- OUTPUT: Complete JSON export of all user data
-- USE: Call from Go API endpoint, return to user via email/download
CREATE OR REPLACE FUNCTION export_all_user_data(target_user_id CHAR(12))
RETURNS JSON AS $$
DECLARE
result JSON;
BEGIN
SELECT json_build_object(
'user_profile', (
SELECT json_build_object(
'id', id,
'first_name', n_first_name,
'last_name', n_last_name,
'full_name', fn,
'email', email,
'phone', phone,
'date_of_birth', date_of_birth,
'profile_pic_url', profile_pic_url,
'account_role', account_role,
'loyalty_stamps', loyalty_stamps,
'data_retention_consent', data_retention_consent,
'data_consent_updated_at', data_consent_updated_at,
'created_at', created_at,
'updated_at', updated_at
)
FROM users WHERE id = target_user_id
),
'bookings', (
SELECT COALESCE(json_agg(
json_build_object(
'booking_id', b.id,
'start_time', b.start_time,
'status', b.status,
'created_at', b.created_at,
'updated_at', b.updated_at,
'created_by', b.created_by,
'updated_by', b.updated_by,
'services', (
SELECT COALESCE(json_agg(
json_build_object(
'service_id', s.id,
'name', s.name,
'description', s.description,
'price', s.price,
'duration_minutes', s.duration_minutes
)
), '[]'::json)
FROM booking_services bs
JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = b.id
)
)
), '[]'::json)
FROM bookings b WHERE b.user_id = target_user_id
),
'payments', (
SELECT COALESCE(json_agg(
json_build_object(
'payment_id', p.id,
'booking_id', p.booking_id,
'payment_type', p.payment_type,
'payment_method', p.payment_method,
'vendor_code', p.vendor_code,
'status', p.status,
'amount', p.amount,
'created_at', p.created_at,
'updated_at', p.updated_at,
'created_by', p.created_by,
'updated_by', p.updated_by
)
), '[]'::json)
FROM payments p
JOIN bookings b ON p.booking_id = b.id
WHERE b.user_id = target_user_id
),
'export_metadata', json_build_object(
'exported_at', NOW(),
'exported_by', 'system',
'user_id', target_user_id,
'format_version', '1.0'
)
) INTO result;
RETURN result;
END;
$$ LANGUAGE plpgsql;
-- =======================================
-- TAX COMPLIANCE FUNCTIONS
-- =======================================
/*
TAX COMPLIANCE NOTES:
- Below £90k turnover: No VAT registration required
- Above £90k turnover: VAT registration mandatory, Making Tax Digital required
- All businesses: Income tax records required (simplified from April 2026/2027)
- VAT rate: 20% standard rate applies to nail services
*/
-- VAT Return Data (for Making Tax Digital submission)
-- WHY: VAT registered businesses must submit quarterly VAT returns via MTD software
-- WHEN: Every quarter if VAT registered (£90k+ annual turnover)
-- OUTPUT: Summary totals for VAT return - total sales, VAT charged, net sales
-- USE: Export to QuickBooks/Xero for MTD submission to HMRC
CREATE OR REPLACE FUNCTION get_vat_return_data(
start_date DATE,
end_date DATE
)
RETURNS TABLE (
period_start DATE,
period_end DATE,
total_sales NUMERIC(12,2),
total_vat_charged NUMERIC(12,2),
net_sales NUMERIC(12,2),
transaction_count BIGINT
) AS $$
BEGIN
RETURN QUERY
SELECT
start_date as period_start,
end_date as period_end,
COALESCE(SUM(p.amount), 0) as total_sales,
COALESCE(SUM(
CASE
-- If VAT is explicitly stored, use it
WHEN p.vat_amount IS NOT NULL THEN p.vat_amount
-- If business is VAT registered but no VAT breakdown, calculate it
WHEN (SELECT is_vat_registered FROM business_settings WHERE id = 1) THEN
ROUND(p.amount - (p.amount / (1 + COALESCE(p.vat_rate,
(SELECT default_vat_rate FROM business_settings WHERE id = 1))/100), 2)
-- Business not VAT registered = no VAT charged
ELSE 0
END
), 0) as total_vat_charged,
COALESCE(SUM(
CASE
-- If net amount is explicitly stored, use it
WHEN p.net_amount IS NOT NULL THEN p.net_amount
-- If business is VAT registered but no net amount, calculate it
WHEN (SELECT is_vat_registered FROM business_settings WHERE id = 1) THEN
ROUND(p.amount / (1 + COALESCE(p.vat_rate,
(SELECT default_vat_rate FROM business_settings WHERE id = 1))/100), 2)
-- Business not VAT registered = gross amount is net amount
ELSE p.amount
END
), 0) as net_sales,
COUNT(*) as transaction_count
FROM payments p
JOIN bookings b ON p.booking_id = b.id
WHERE p.status = 'completed'
AND p.created_at::date BETWEEN start_date AND end_date
AND p.payment_type IN ('full', 'partial', 'balance');
END;
$$ LANGUAGE plpgsql;
-- Detailed Transaction Export (Primary tax function)
-- WHY: All businesses need transaction records for tax returns and accounting
-- WHEN: Monthly/quarterly for accounting software import
-- OUTPUT: CSV-compatible transaction list with customer, service, payment details
-- USE: Import directly into QuickBooks, Xero, or other accounting software
-- PARAMETERS: include_vat=false (default for micro businesses), include_vat=true only when VAT registered
CREATE OR REPLACE FUNCTION export_sales_transactions(
start_date DATE,
end_date DATE,
include_vat BOOLEAN DEFAULT FALSE
)
RETURNS TABLE (
transaction_date DATE,
invoice_number TEXT,
customer_name TEXT,
customer_email TEXT,
service_description TEXT,
payment_method TEXT,
gross_amount NUMERIC(10,2),
net_amount NUMERIC(10,2),
vat_amount NUMERIC(10,2),
vat_rate NUMERIC(5,2),
booking_id CHAR(12),
payment_id CHAR(12)
) AS $$
BEGIN
RETURN QUERY
SELECT
p.created_at::date as transaction_date,
p.invoice_number::text AS invoice_number,
COALESCE(u.fn, 'Walk-in Customer') as customer_name,
u.email as customer_email,
string_agg(s.name, ', ') as service_description,
p.payment_method::text as payment_method,
p.amount as gross_amount,
CASE
WHEN include_vat AND p.net_amount IS NOT NULL THEN p.net_amount
WHEN include_vat THEN ROUND(p.amount / 1.20, 2)
ELSE p.amount
END as net_amount,
CASE
WHEN include_vat AND p.vat_amount IS NOT NULL THEN p.vat_amount
WHEN include_vat THEN ROUND(p.amount - (p.amount / 1.20), 2)
ELSE 0
END as vat_amount,
CASE
WHEN include_vat THEN COALESCE(p.vat_rate, 20.00)
ELSE NULL
END as vat_rate,
b.id as booking_id,
p.id as payment_id
FROM payments p
JOIN bookings b ON p.booking_id = b.id
LEFT JOIN users u ON b.user_id = u.id
LEFT JOIN booking_services bs ON b.id = bs.booking_id
LEFT JOIN services s ON bs.service_id = s.id
WHERE p.status = 'completed'
AND p.created_at::date BETWEEN start_date AND end_date
AND p.payment_type IN ('full', 'partial', 'balance')
GROUP BY p.id, b.id, u.fn, u.email, p.created_at, p.amount, p.net_amount, p.vat_amount, p.vat_rate, p.payment_method, p.invoice_number
ORDER BY p.created_at;
END;
$$ LANGUAGE plpgsql;
-- Monthly Business Summary
-- WHY: Track business performance trends and payment method preferences
-- WHEN: Monthly review of business performance
-- OUTPUT: Month-by-month breakdown of revenue, bookings, payment methods
-- USE: Personal business insights - "How am I doing vs last month?"
CREATE OR REPLACE FUNCTION get_monthly_business_summary(
start_date DATE,
end_date DATE
)
RETURNS TABLE (
month_year TEXT,
total_bookings BIGINT,
total_revenue NUMERIC(12,2),
cash_payments NUMERIC(12,2),
card_payments NUMERIC(12,2),
online_payments NUMERIC(12,2),
avg_transaction NUMERIC(10,2)
) AS $$
BEGIN
RETURN QUERY
SELECT
TO_CHAR(p.created_at, 'YYYY-MM') as month_year,
COUNT(DISTINCT b.id) as total_bookings,
SUM(p.amount) as total_revenue,
SUM(CASE WHEN p.payment_method = 'cash' THEN p.amount ELSE 0 END) as cash_payments,
SUM(CASE WHEN p.payment_method = 'in_person_card' THEN p.amount ELSE 0 END) as card_payments,
SUM(CASE WHEN p.payment_method = 'online_square' THEN p.amount ELSE 0 END) as online_payments,
ROUND(AVG(p.amount), 2) as avg_transaction
FROM payments p
JOIN bookings b ON p.booking_id = b.id
WHERE p.status = 'completed'
AND p.created_at::date BETWEEN start_date AND end_date
AND p.payment_type IN ('full', 'partial', 'balance')
GROUP BY TO_CHAR(p.created_at, 'YYYY-MM')
ORDER BY month_year;
END;
$$ LANGUAGE plpgsql;
-- Simple Sales Totals (Quick business snapshot)
-- WHY: Quick overview of current performance for dashboard/API
-- WHEN: Dashboard loading, daily/weekly check-ins
-- OUTPUT: Total sales, transaction count, payment method breakdown
-- USE: Homepage dashboard, "How much did I make this week?" queries
CREATE OR REPLACE FUNCTION get_sales_totals(
start_date DATE,
end_date DATE
)
RETURNS TABLE (
total_sales NUMERIC(12,2),
total_transactions BIGINT,
cash_total NUMERIC(12,2),
card_total NUMERIC(12,2),
online_total NUMERIC(12,2)
) AS $$
BEGIN
RETURN QUERY
SELECT
COALESCE(SUM(p.amount), 0) as total_sales,
COUNT(*) as total_transactions,
COALESCE(SUM(CASE WHEN p.payment_method = 'cash' THEN p.amount ELSE 0 END), 0) as cash_total,
COALESCE(SUM(CASE WHEN p.payment_method = 'in_person_card' THEN p.amount ELSE 0 END), 0) as card_total,
COALESCE(SUM(CASE WHEN p.payment_method = 'online_square' THEN p.amount ELSE 0 END), 0) as online_total
FROM payments p
JOIN bookings b ON p.booking_id = b.id
WHERE p.status = 'completed'
AND p.created_at::date BETWEEN start_date AND end_date
AND p.payment_type IN ('full', 'partial', 'balance');
END;
$$ LANGUAGE plpgsql;
-- =======================================
-- UTILITY FUNCTIONS
-- =======================================
-- VAT Registration Transition Function
-- WHY: When crossing £90k threshold, must register for VAT and backfill existing data
-- WHEN: One-time use when VAT registration becomes mandatory
-- OUTPUT: Updates all historical payments with VAT breakdown + sets new defaults
-- USE: Call once when we register for VAT - transforms business from non-VAT to VAT
-- =======================================
-- UTILITY FUNCTIONS
-- =======================================
-- VAT Registration Transition Function
-- WHY: When crossing £90k threshold, must register for VAT and backfill existing data
-- WHEN: One-time use when VAT registration becomes mandatory
-- OUTPUT: Updates all historical payments with VAT breakdown + sets new defaults
-- USE: Call once when we register for VAT - transforms business from non-VAT to VAT
CREATE OR REPLACE FUNCTION enable_vat_registration(
registration_date DATE DEFAULT CURRENT_DATE,
vat_rate NUMERIC(5,2) DEFAULT 20.00,
vat_reg_number TEXT DEFAULT NULL
)
RETURNS TABLE (
payments_updated INT,
total_vat_calculated NUMERIC(12,2),
total_net_calculated NUMERIC(12,2),
registration_effective_date DATE,
settings_updated BOOLEAN
) AS $$
DECLARE
updated_count INT;
total_vat NUMERIC(12,2);
total_net NUMERIC(12,2);
settings_ok BOOLEAN := FALSE;
BEGIN
-- Update all existing completed payments with VAT breakdown
UPDATE payments
SET
vat_rate = enable_vat_registration.vat_rate,
vat_amount = ROUND(amount - (amount / (1 + enable_vat_registration.vat_rate/100)), 2),
net_amount = ROUND(amount / (1 + enable_vat_registration.vat_rate/100), 2),
is_vat_applicable = TRUE,
updated_at = NOW()
WHERE status = 'completed'
AND created_at::date >= registration_date
AND vat_amount IS NULL;
GET DIAGNOSTICS updated_count = ROW_COUNT;
-- Calculate totals
SELECT
COALESCE(SUM(vat_amount), 0),
COALESCE(SUM(net_amount), 0)
INTO total_vat, total_net
FROM payments
WHERE status = 'completed'
AND created_at::date >= registration_date
AND vat_amount IS NOT NULL;
-- Update business_settings to reflect VAT registration
UPDATE business_settings
SET
is_vat_registered = TRUE,
vat_registration_number = COALESCE(vat_reg_number, vat_registration_number),
default_vat_rate = enable_vat_registration.vat_rate,
updated_at = NOW()
WHERE id = 1;
IF FOUND THEN
settings_ok := TRUE;
END IF;
RETURN QUERY SELECT
updated_count,
total_vat,
total_net,
registration_date,
settings_ok;
END;
$$ LANGUAGE plpgsql;
-- Update payment with VAT (for new payments after VAT registration)
-- WHY: After VAT registration, all new payments need VAT calculation
-- WHEN: Called automatically when processing new payments (if VAT registered)
-- OUTPUT: Updates individual payment with VAT breakdown
-- USE: Call from Go when processing new payments after VAT registration
-- =======================================
-- UPDATE PAYMENT WITH VAT
-- =======================================
-- WHY: After VAT registration, all new payments need VAT calculation
-- WHEN: Called automatically when processing new payments (if VAT registered)
-- OUTPUT: Updates individual payment with VAT breakdown
-- USE: Call from Go or backend when processing new payments
CREATE OR REPLACE FUNCTION apply_vat_to_payment(
payment_id CHAR(12),
vat_rate NUMERIC(5,2) DEFAULT NULL -- NULL = use default from business_settings
)
RETURNS VOID AS $$
DECLARE
effective_rate NUMERIC(5,2);
BEGIN
-- Determine effective VAT rate
IF vat_rate IS NULL THEN
SELECT default_vat_rate INTO effective_rate
FROM business_settings
WHERE id = 1 AND is_vat_registered = TRUE;
IF NOT FOUND OR effective_rate IS NULL THEN
RAISE EXCEPTION 'no vat rate provided and business is not registered for vat';
END IF;
ELSE
effective_rate := vat_rate;
END IF;
-- Update payment with VAT amounts
UPDATE payments
SET
vat_rate = effective_rate,
vat_amount = ROUND(amount - (amount / (1 + effective_rate/100)), 2),
net_amount = ROUND(amount / (1 + effective_rate/100), 2),
is_vat_applicable = TRUE,
updated_at = NOW()
WHERE id = payment_id
AND vat_amount IS NULL;
END;
$$ LANGUAGE plpgsql;
-- =======================================
-- SIMPLE VAT CALCULATION HELPER
-- =======================================
-- WHY: Manual VAT calculations or validation
-- WHEN: Checking VAT calculations or manual entry corrections
-- OUTPUT: net amount and vat amount from gross amount
-- USE: Manual calculations, validation, or corrections
CREATE OR REPLACE FUNCTION calculate_vat(
gross_amount NUMERIC(10,2),
vat_rate NUMERIC(5,2) DEFAULT NULL -- NULL = use default from business_settings
)
RETURNS TABLE(
net NUMERIC(10,2),
vat NUMERIC(10,2)
) AS $$
DECLARE
effective_rate NUMERIC(5,2);
BEGIN
-- Determine effective VAT rate
IF vat_rate IS NULL THEN
SELECT default_vat_rate INTO effective_rate
FROM business_settings
WHERE id = 1 AND is_vat_registered = TRUE;
IF NOT FOUND OR effective_rate IS NULL THEN
RAISE EXCEPTION 'no vat rate provided and business is not registered for vat';
END IF;
ELSE
effective_rate := vat_rate;
END IF;
-- Return net and VAT
RETURN QUERY
SELECT
ROUND(gross_amount / (1 + effective_rate/100), 2) AS net,
gross_amount - ROUND(gross_amount / (1 + effective_rate/100), 2) AS vat;
END;
$$ LANGUAGE plpgsql;
-- =======================================
-- UK RECEIPT COMPLIANCE FUNCTION
-- =======================================
/*
UK RECEIPT REQUIREMENTS:
- Business name and address (static - provided by application)
- Date and time of transaction
- Description of goods/services
- Amount charged (including VAT if registered)
- VAT breakdown (if VAT registered)
- Receipt/invoice number
- Payment method
- Customer details (if requested)
VAT RECEIPT REQUIREMENTS (if VAT registered):
- VAT registration number (static - provided by application)
- VAT rate applied
- VAT amount
- Net amount (excluding VAT)
- Total amount (including VAT)
*/
-- Complete Receipt Data for UK Compliance
-- WHY: Generate legally compliant receipts for customers
-- WHEN: After each completed payment/booking
-- OUTPUT: All data needed for receipt printing/email
-- USE: Call from Go API to generate customer receipts
CREATE OR REPLACE FUNCTION get_receipt_data(payment_id CHAR(12))
RETURNS TABLE (
-- Business Information
business_name TEXT,
business_address TEXT,
business_phone TEXT,
business_email TEXT,
vat_registration_number TEXT,
is_vat_registered BOOLEAN,
-- Payment Information
payment_id CHAR(12),
invoice_number TEXT,
transaction_date TIMESTAMPTZ,
payment_method TEXT,
payment_status TEXT,
-- Customer Information
customer_id CHAR(12),
customer_name TEXT,
customer_email TEXT,
customer_phone TEXT,
-- Booking Information
booking_id CHAR(12),
appointment_date TIMESTAMPTZ,
booking_status TEXT,
-- Service Details
services JSON,
total_duration_minutes INT,
-- Financial Information
gross_amount NUMERIC(10,2),
net_amount NUMERIC(10,2),
vat_amount NUMERIC(10,2),
vat_rate NUMERIC(5,2),
is_vat_applicable BOOLEAN,
-- Receipt Metadata
receipt_generated_at TIMESTAMPTZ,
currency_code TEXT
) AS $$
DECLARE
bs RECORD; -- To hold business settings
BEGIN
-- Fetch current business settings (assume single row, id=1)
SELECT INTO bs
business_name,
business_address,
business_phone,
business_email,
vat_registration_number,
is_vat_registered,
currency_code
FROM business_settings
WHERE id = 1;
RETURN QUERY
SELECT
-- Business Info
bs.business_name,
bs.business_address,
bs.business_phone,
bs.business_email,
bs.vat_registration_number,
bs.is_vat_registered,
-- Payment Info
p.id as payment_id,
COALESCE('INV-' || p.invoice_number::text, 'INV-' || p.id) as invoice_number,
p.created_at as transaction_date,
p.payment_method::text as payment_method,
p.status::text as payment_status,
-- Customer Info
u.id as customer_id,
CASE
WHEN u.account_role = 'guest' OR u.fn = 'Deleted User' THEN 'Walk-in Customer'
ELSE u.fn
END as customer_name,
CASE
WHEN u.account_role = 'guest' OR u.fn = 'Deleted User' THEN NULL
ELSE u.email
END as customer_email,
CASE
WHEN u.account_role = 'guest' OR u.fn = 'Deleted User' THEN NULL
ELSE u.phone
END as customer_phone,
-- Booking Info
b.id as booking_id,
b.start_time as appointment_date,
b.status::text as booking_status,
-- Service Details
COALESCE((
SELECT json_agg(
json_build_object(
'service_id', s.id,
'name', s.name,
'description', s.description,
'price', s.price,
'duration_minutes', s.duration_minutes
)
ORDER BY s.name
)
FROM booking_services bs_inner
JOIN services s ON bs_inner.service_id = s.id
WHERE bs_inner.booking_id = b.id
), '[]'::json) as services,
-- Total service duration
COALESCE((
SELECT SUM(s.duration_minutes)
FROM booking_services bs_inner
JOIN services s ON bs_inner.service_id = s.id
WHERE bs_inner.booking_id = b.id
), 0) as total_duration_minutes,
-- Financial Info
p.amount as gross_amount,
COALESCE(p.net_amount, p.amount) as net_amount,
COALESCE(p.vat_amount, 0.00) as vat_amount,
p.vat_rate as vat_rate,
p.is_vat_applicable as is_vat_applicable,
-- Receipt Metadata
NOW() as receipt_generated_at,
bs.currency_code as currency_code
FROM payments p
JOIN bookings b ON p.booking_id = b.id
LEFT JOIN users u ON b.user_id = u.id
WHERE p.id = payment_id;
END;
$$ LANGUAGE plpgsql;
-- =======================================
-- FUNCTION USAGE SUMMARY
-- =======================================
/*
This summary organizes all database functions by their primary purpose, legal basis, and expected usage frequency—
providing a clear guide for integration into our application, compliance workflows, and business operations.
--------------------------------------------------------------------------------
1. FINANCIAL & TAX COMPLIANCE
--------------------------------------------------------------------------------
REGULAR USE (called from API endpoints or UI):
- get_sales_totals(start_date, end_date)
→ Quick dashboard metrics: total revenue, transaction count, and payment method breakdown.
- get_receipt_data(payment_id)
→ Generate a UK-compliant receipt after every completed payment.
- calculate_vat(gross_amount, vat_rate)
→ Utility for validating or manually computing VAT splits.
PERIODIC USE (monthly/quarterly for accounting or tax filing):
- export_sales_transactions(start_date, end_date, include_vat)
→ Primary export for accounting software (QuickBooks, Xero). Set include_vat = true only if VAT-registered.
- get_monthly_business_summary(start_date, end_date)
→ Analyze trends: bookings, revenue, avg. transaction size, and payment method adoption.
- get_vat_return_data(start_date, end_date)
→ MTD-ready VAT return summary for HMRC (only if VAT-registered).
ONE-TIME OR TRANSITIONAL USE:
- enable_vat_registration(registration_date, vat_rate, vat_reg_number)
→ Run once when crossing the £90k VAT threshold to backfill historical payments.
- apply_vat_to_payment(payment_id, vat_rate)
→ Called automatically for every new payment after VAT registration.
--------------------------------------------------------------------------------
2. GDPR & DATA PRIVACY
--------------------------------------------------------------------------------
ON-DEMAND USE (triggered by user request or admin action):
- export_all_user_data(user_id)
→ Full Subject Access Request (SAR) export in JSON (GDPR Article 15).
- anonymize_user(user_id)
→ Right to Erasure for registered users (GDPR Article 17); preserves audit trail.
- delete_guest_user(user_id)
→ Complete deletion of guest accounts (no contractual basis).
- update_data_consent(user_id, consent)
→ Records updated consent preference with timestamp for auditability.
NOTE: Booking data is retained under contractual necessity (GDPR Art. 6(1)(b)) even if consent is withdrawn.
Only optional data (e.g., marketing) is governed by consent.
--------------------------------------------------------------------------------
3. BUSINESS OPERATIONS & LOYALTY
--------------------------------------------------------------------------------
- Referral Program: Use user_referrals table + referral_code in users (handled in app logic).
- Patch Test Tracking: user_service_patch_tests enforces allergen safety (enforced in app based on services.patch_test_duration_hours).
--------------------------------------------------------------------------------
4. UTILITY & MAINTENANCE
--------------------------------------------------------------------------------
- generate_*_id() functions: Internal use only (DEFAULT in table definitions).
- Timestamp triggers (e.g., on business_settings): Automatic—no manual call needed.
--------------------------------------------------------------------------------
INTEGRATION QUICK REFERENCE
--------------------------------------------------------------------------------
• User deletes account → anonymize_user() or delete_guest_user()
• "What data do you have?" → export_all_user_data()
• New payment → apply_vat_to_payment() (if VAT reg) + get_receipt_data()
• Monthly close → export_sales_transactions(..., include_vat := [true/false])
• VAT registration day → enable_vat_registration() once, then apply_vat_to_payment()
• Dashboard load → get_sales_totals() + get_monthly_business_summary()
*/