-- ======================================= -- 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', 'no_deposit'); -- ======================================= -- 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_verification_code() RETURNS CHAR(12) AS $$ SELECT substr(encode(gen_random_bytes(6), 'hex'), 1, 12); $$ 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) NOT NULL, -- vCard TEL date_of_birth DATE NOT NULL, -- 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(), -- Deposit tracking: remaining deposits needed (0-3). Reduces by 1 when booking with payment completes. deposits_required INT NOT NULL DEFAULT 3, -- 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); -- ======================================= -- VERIFICATION CODES TABLE -- ======================================= CREATE TYPE verification_purpose AS ENUM ('email_verify', 'password_reset'); CREATE TABLE verification_codes ( id BIGSERIAL PRIMARY KEY, code CHAR(12) NOT NULL UNIQUE DEFAULT generate_verification_code(), user_id CHAR(12) NOT NULL REFERENCES users(id) ON DELETE CASCADE, purpose verification_purpose NOT NULL, expires_at TIMESTAMPTZ NOT NULL, used_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX idx_verification_codes_code ON verification_codes (code); CREATE INDEX idx_verification_codes_user_purpose ON verification_codes (user_id, purpose) WHERE used_at IS NULL; CREATE INDEX idx_verification_codes_expires ON verification_codes (expires_at) WHERE used_at IS NULL; -- ======================================= -- PATCH TESTS TABLE -- ======================================= CREATE TABLE patch_tests ( id CHAR(12) PRIMARY KEY DEFAULT generate_service_id(), name VARCHAR(100) NOT NULL, description TEXT, notice_duration_hours INT NOT NULL DEFAULT 24, expiry_months INT NOT NULL DEFAULT 6, service_ids CHAR(12)[] DEFAULT '{}' ); CREATE INDEX idx_patch_tests_name ON patch_tests(name); -- ======================================= -- USER PATCH TESTS TABLE -- ======================================= CREATE TABLE user_patch_tests ( id BIGSERIAL PRIMARY KEY, user_id CHAR(12) NOT NULL REFERENCES users(id) ON DELETE CASCADE, patch_test_id CHAR(12) NOT NULL REFERENCES patch_tests(id) ON DELETE CASCADE, tested_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE (user_id, patch_test_id) ); CREATE INDEX idx_user_patch_tests_user ON user_patch_tests(user_id); CREATE INDEX idx_user_patch_tests_tested_at ON user_patch_tests(tested_at); -- ======================================= -- 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, minimum_age_required INT NOT NULL DEFAULT 16, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_by CHAR(12) ); CREATE INDEX idx_services_name ON services(name); -- ======================================= -- 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) ); -- ======================================= -- BOOKING EDIT REQUESTS TABLE -- ======================================= CREATE TABLE booking_edit_requests ( id CHAR(12) PRIMARY KEY DEFAULT generate_booking_id(), booking_id CHAR(12) NOT NULL REFERENCES bookings(id) ON DELETE CASCADE, requested_by CHAR(12) NOT NULL REFERENCES users(id) ON DELETE CASCADE, new_start_time TIMESTAMPTZ, new_services CHAR(12)[] DEFAULT '{}', -- Array of service IDs to replace booking_services notes TEXT, has_overrides BOOLEAN NOT NULL DEFAULT FALSE, -- If TRUE, cannot change services, use existing overrides for duration updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT chk_at_least_one_field CHECK ( new_start_time IS NOT NULL OR array_length(new_services, 1) IS NOT NULL OR notes IS NOT NULL ) ); CREATE INDEX idx_booking_edit_requests_booking ON booking_edit_requests(booking_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 -- ======================================= -- 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', 'late_cancellation', 'no_deposit', 'deposit_paid', 'edit_request'); 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() ); -- User notification preferences for future user notification system CREATE TABLE user_notification_preferences ( id SERIAL PRIMARY KEY, user_id CHAR(12) REFERENCES users(id) ON DELETE CASCADE, notification_type VARCHAR(50) NOT NULL, email_enabled BOOLEAN DEFAULT true, sms_enabled BOOLEAN DEFAULT true, push_enabled BOOLEAN DEFAULT true, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(), UNIQUE(user_id, notification_type) ); CREATE INDEX idx_user_notification_preferences_user_id ON user_notification_preferences(user_id); 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(), url text not null, thumbnail_url text not null, tag_names text[] not null default '{}', created_at timestamptz not null default now() ); -- only for autocomplete 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_tag_names_trgm on images using gin ((tag_names::text[]) gin_trgm_ops); 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 -- NOTE: phone/date_of_birth are NOT NULL so we use placeholder values, not NULL 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, '@deleted.invalid'), phone = '+000000000000', -- NOT NULL column: use placeholder date_of_birth = '1900-01-01', -- NOT NULL column: use placeholder profile_pic_url = NULL, account_role = 'guest', loyalty_stamps = 0, referral_code = NULL, 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; -- 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, 'account_type', account_type, 'last_login_at', last_login_at, 'loyalty_stamps', loyalty_stamps, 'deposits_required', deposits_required, 'referral_code', referral_code, 'privacy_policy_and_terms_consent', privacy_policy_and_terms_consent, 'policy_consent_updated_at', policy_consent_updated_at, '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, 'notes', b.notes, 'created_by', b.created_by, 'created_at', b.created_at, 'updated_at', b.updated_at, 'services', ( SELECT COALESCE(json_agg( json_build_object( 'service_id', s.id, 'name', s.name, 'description', s.description, 'price', COALESCE(bsvc.override_price, s.price), 'duration_minutes', COALESCE(bsvc.override_duration_minutes, s.duration_minutes) ) ), '[]'::json) FROM booking_services bsvc JOIN services s ON bsvc.service_id = s.id WHERE bsvc.booking_id = b.id ) ) ORDER BY b.start_time DESC), '[]'::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, 'invoice_number', p.invoice_number, 'status', p.status, 'amount', p.amount, 'vat_amount', p.vat_amount, 'net_amount', p.net_amount, 'created_at', p.created_at, 'updated_at', p.updated_at ) ORDER BY p.created_at DESC), '[]'::json) FROM payments p JOIN bookings b ON p.booking_id = b.id WHERE b.user_id = target_user_id ), 'patch_tests', ( SELECT COALESCE(json_agg( json_build_object( 'patch_test_id', upt.patch_test_id, 'name', pt.name, 'description', pt.description, 'tested_at', upt.tested_at ) ORDER BY upt.tested_at DESC), '[]'::json) FROM user_patch_tests upt JOIN patch_tests pt ON upt.patch_test_id = pt.id WHERE upt.user_id = target_user_id ), 'referrals', ( SELECT json_build_object( 'referred_by', ( -- The user who referred this user (if any) -- First name only: referrer's name is their own personal data, -- included here only to make the SAR meaningful to the recipient. SELECT json_build_object( 'referrer_id', ur.referrer_id, 'referrer_first_name', u.n_first_name, 'referred_at', ur.referred_at, 'claimed_booking_id', ur.claimed_booking_id ) FROM user_referrals ur JOIN users u ON u.id = ur.referrer_id WHERE ur.referred_id = target_user_id LIMIT 1 ), 'referred_users', ( -- Users this person has referred -- First name only: same reasoning as above. SELECT COALESCE(json_agg( json_build_object( 'referred_id', ur.referred_id, 'referred_first_name', u.n_first_name, 'referred_at', ur.referred_at, 'claimed_booking_id', ur.claimed_booking_id ) ORDER BY ur.referred_at DESC), '[]'::json) FROM user_referrals ur JOIN users u ON u.id = ur.referred_id WHERE ur.referrer_id = target_user_id ) ) ), 'notification_preferences', ( SELECT COALESCE(json_agg( json_build_object( 'notification_type', unp.notification_type, 'email_enabled', unp.email_enabled, 'sms_enabled', unp.sms_enabled, 'push_enabled', unp.push_enabled, 'created_at', unp.created_at, 'updated_at', unp.updated_at ) ORDER BY unp.notification_type), '[]'::json) FROM user_notification_preferences unp WHERE unp.user_id = target_user_id ), 'export_metadata', json_build_object( 'exported_at', NOW(), 'exported_by', 'system', 'user_id', target_user_id, 'format_version', '1.1' ) ) 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 WHEN p.vat_amount IS NOT NULL THEN p.vat_amount 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) ELSE 0 END ), 0) AS total_vat_charged, COALESCE(SUM( CASE WHEN p.net_amount IS NOT NULL THEN p.net_amount 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) 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 -- NOTE: include_vat=false (default for micro businesses); set 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( CASE WHEN u.n_first_name = 'Deleted' THEN NULL ELSE u.fn END, 'Walk-in Customer' ) AS customer_name, CASE WHEN u.n_first_name = 'Deleted' THEN NULL ELSE u.email END AS customer_email, string_agg(s.name, ', ' ORDER BY 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 bsvc ON b.id = bsvc.booking_id LEFT JOIN services s ON bsvc.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.n_first_name, 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 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 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; SELECT COALESCE(SUM(p.vat_amount), 0), COALESCE(SUM(p.net_amount), 0) INTO total_vat, total_net FROM payments p WHERE status = 'completed' AND created_at::date >= registration_date AND vat_amount IS NOT NULL; 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 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 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 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 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 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 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 - Date and time of transaction - Description of goods/services - Amount charged (including VAT if registered) - VAT breakdown (if VAT registered): VAT number, rate, VAT amount, net amount - Receipt/invoice number - Payment method - Customer details (if not anonymised) */ -- 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(p_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; BEGIN SELECT INTO bs bs2.business_name, bs2.business_address, bs2.business_phone, bs2.business_email, bs2.vat_registration_number, bs2.is_vat_registered, bs2.currency_code FROM business_settings bs2 WHERE bs2.id = 1; RETURN QUERY SELECT bs.business_name, bs.business_address, bs.business_phone, bs.business_email, bs.vat_registration_number, bs.is_vat_registered, 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, u.id AS customer_id, CASE WHEN u.id IS NULL THEN 'Walk-in Customer' WHEN u.n_first_name = 'Deleted' THEN 'Walk-in Customer' WHEN u.account_role = 'guest' THEN 'Walk-in Customer' ELSE u.fn END AS customer_name, CASE WHEN u.id IS NULL THEN NULL WHEN u.n_first_name = 'Deleted' THEN NULL WHEN u.account_role = 'guest' THEN NULL ELSE u.email END AS customer_email, CASE WHEN u.id IS NULL THEN NULL WHEN u.n_first_name = 'Deleted' THEN NULL WHEN u.account_role = 'guest' THEN NULL ELSE u.phone END AS customer_phone, b.id AS booking_id, b.start_time AS appointment_date, b.status::text AS booking_status, COALESCE(( SELECT json_agg( json_build_object( 'service_id', s.id, 'name', s.name, 'description', s.description, 'price', COALESCE(bsvc.override_price, s.price), 'duration_minutes', COALESCE(bsvc.override_duration_minutes, s.duration_minutes) ) ORDER BY s.name ) FROM booking_services bsvc JOIN services s ON bsvc.service_id = s.id WHERE bsvc.booking_id = b.id ), '[]'::json) AS services, COALESCE(( SELECT SUM(COALESCE(bsvc.override_duration_minutes, s.duration_minutes)) FROM booking_services bsvc JOIN services s ON bsvc.service_id = s.id WHERE bsvc.booking_id = b.id ), 0)::INT AS total_duration_minutes, 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, 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 = p_payment_id; END; $$ LANGUAGE plpgsql; -- ======================================= -- FUNCTION USAGE SUMMARY -- ======================================= /* This summary organises all database functions by purpose, legal basis, and expected usage frequency — providing a clear guide for application integration. -------------------------------------------------------------------------------- 1. FINANCIAL & TAX COMPLIANCE -------------------------------------------------------------------------------- REGULAR USE (called from API endpoints): - get_sales_totals(start_date, end_date) → Dashboard metrics: total revenue, transaction count, payment method breakdown. - get_receipt_data(payment_id) → UK-compliant receipt after every completed payment. - calculate_vat(gross_amount, vat_rate) → Validate or manually compute VAT splits (only usable when VAT registered). PERIODIC USE (monthly/quarterly for accounting or HMRC): - export_sales_transactions(start_date, end_date, include_vat) → Primary export for QuickBooks / Xero. Set include_vat=true only if VAT registered. - get_monthly_business_summary(start_date, end_date) → Revenue trends, avg. transaction, and payment method adoption month by month. - get_vat_return_data(start_date, end_date) → MTD-ready VAT return summary for HMRC (only relevant when VAT registered). ONE-TIME / TRANSITIONAL: - enable_vat_registration(registration_date, vat_rate, vat_reg_number) → Run once on crossing the £90k VAT threshold; backfills historical payments. - apply_vat_to_payment(payment_id, vat_rate) → Called for every new payment after VAT registration. -------------------------------------------------------------------------------- 2. GDPR & DATA PRIVACY -------------------------------------------------------------------------------- ON-DEMAND (triggered by user request or admin action): - export_all_user_data(user_id) → Full Subject Access Request (SAR) export in JSON — GDPR Article 15. → Includes profile, bookings (with service overrides), payments, patch tests. - anonymize_user(user_id) → Right to Erasure for registered users — GDPR Article 17. → Preserves booking/payment audit trail; replaces PII with placeholders. → NOTE: phone and date_of_birth use placeholder values (NOT NULL columns). - delete_guest_user(user_id) → Complete deletion of guest accounts (no contractual retention basis). - update_data_consent(user_id, consent) → Records updated consent with timestamp for auditability. NOTE: Booking/payment data is retained under contractual necessity (GDPR Art. 6(1)(b)) even after consent withdrawal. Only optional/marketing data is consent-governed. -------------------------------------------------------------------------------- 3. 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() */