-- ======================================= -- 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'); CREATE TYPE campaign_type AS ENUM ('time_based', 'milestone'); CREATE TYPE milestone_type AS ENUM ('per_user_booking_count', 'global_booking_count', 'anniversary'); CREATE TYPE milestone_unit AS ENUM ('bookings', 'months', 'years'); CREATE TYPE discount_campaign_scope AS ENUM ('all_bookings', 'first_booking_only', 'new_customers_only'); CREATE TYPE discount_campaign_status AS ENUM ('draft', 'active', 'completed', 'cancelled'); -- ======================================= -- 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_user_saved_card_id() RETURNS CHAR(12) AS $$ SELECT generate_short_id('user_saved_cards'); $$ LANGUAGE sql; CREATE OR REPLACE FUNCTION generate_refund_id() RETURNS CHAR(12) AS $$ SELECT generate_short_id('refunds'); $$ LANGUAGE sql; CREATE OR REPLACE FUNCTION generate_affiliate_payout_id() RETURNS CHAR(12) AS $$ SELECT generate_short_id('affiliate_payouts'); $$ LANGUAGE sql; CREATE OR REPLACE FUNCTION generate_square_deposit_id() RETURNS CHAR(12) AS $$ SELECT generate_short_id('square_deposits'); $$ 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), -- vCard EMAIL (nullable for social-only/guest) -- Uniqueness enforced only for non-guest users via partial index 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 0, -- 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); -- Enforce unique email only for registered (non-guest) users -- Guest accounts can share emails; registered accounts cannot CREATE UNIQUE INDEX idx_users_email_registered ON users (email) WHERE account_role != 'guest'; -- ======================================= -- 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, deposit_required BOOLEAN NOT NULL DEFAULT FALSE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_by CHAR(12), idempotency_key VARCHAR(64) UNIQUE ); 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); -- ======================================= -- TIME BLOCKERS TABLE -- ======================================= -- Admin-defined time periods that are unavailable for booking -- Used for doctor appointments, extended lunch, training, etc. CREATE TABLE time_blockers ( id CHAR(12) PRIMARY KEY DEFAULT generate_short_id('time_blockers'), start_time TIMESTAMPTZ NOT NULL, duration_minutes INT NOT NULL CHECK (duration_minutes > 0), description TEXT, cron_expression TEXT, -- NULL = one-off, otherwise cron pattern for recurring created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_by CHAR(12) REFERENCES users(id) ON DELETE SET NULL ); -- Indexes for efficient overlap queries CREATE INDEX idx_time_blockers_start_time ON time_blockers(start_time); CREATE INDEX idx_time_blockers_cron ON time_blockers(cron_expression) WHERE cron_expression IS NOT NULL; -- ======================================= -- FORGIVEN NO-SHOWS TABLE -- ======================================= -- Tracks which no-shows have been forgiven (by full deposit payment) CREATE TABLE forgiven_no_shows ( id CHAR(12) PRIMARY KEY DEFAULT generate_short_id('forgiven_no_shows'), booking_id CHAR(12) NOT NULL UNIQUE REFERENCES bookings(id) ON DELETE CASCADE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX idx_forgiven_no_shows_booking_id ON forgiven_no_shows(booking_id); -- ======================================= -- 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), user_saved_card_id CHAR(12), square_payment_id TEXT, square_deposit_id CHAR(12), idempotency_key VARCHAR(64) UNIQUE, fees NUMERIC(10,2) DEFAULT 0, 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); CREATE INDEX idx_payments_booking_id_status ON payments(booking_id, status); CREATE INDEX idx_payments_created_at_status ON payments(created_at, status); -- ======================================= -- LOYALTY REDEMPTIONS TABLE -- ======================================= CREATE TABLE loyalty_redemptions ( id CHAR(12) PRIMARY KEY DEFAULT generate_short_id('loyalty_redemptions'), user_id CHAR(12) NOT NULL REFERENCES users(id) ON DELETE CASCADE, stamps_redeemed INT NOT NULL DEFAULT 10, status VARCHAR(20) NOT NULL DEFAULT 'pending', applied_to_booking_id CHAR(12) REFERENCES bookings(id) ON DELETE SET NULL, redeemed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), applied_at TIMESTAMPTZ, expires_at TIMESTAMPTZ NOT NULL DEFAULT (NOW() + INTERVAL '6 months') ); CREATE INDEX idx_loyalty_redemptions_user ON loyalty_redemptions(user_id); CREATE INDEX idx_loyalty_redemptions_status ON loyalty_redemptions(status); -- ======================================= -- DISCOUNT CAMPAIGNS TABLE -- ======================================= CREATE TABLE discount_campaigns ( id CHAR(12) PRIMARY KEY DEFAULT generate_short_id('discount_campaigns'), name VARCHAR(100) NOT NULL, description TEXT, campaign_type campaign_type NOT NULL DEFAULT 'time_based', discount_percent NUMERIC(5,2) NOT NULL, scope discount_campaign_scope, start_date TIMESTAMPTZ, end_date TIMESTAMPTZ, milestone_type milestone_type, milestone_value INT, milestone_unit milestone_unit, status discount_campaign_status NOT NULL DEFAULT 'draft', max_redemptions INT, times_redeemed INT NOT NULL DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_by CHAR(12) REFERENCES users(id) ON DELETE SET NULL, CONSTRAINT chk_dates CHECK (campaign_type = 'milestone' OR (start_date IS NOT NULL AND end_date IS NOT NULL AND end_date > start_date)), CONSTRAINT chk_discount CHECK (discount_percent > 0 AND discount_percent <= 100), CONSTRAINT chk_milestone CHECK (campaign_type = 'time_based' OR (milestone_type IS NOT NULL AND milestone_value IS NOT NULL AND (milestone_type != 'anniversary' OR milestone_unit IS NOT NULL))) ); CREATE INDEX idx_discount_campaigns_dates ON discount_campaigns(start_date, end_date) WHERE start_date IS NOT NULL; CREATE INDEX idx_discount_campaigns_status ON discount_campaigns(status); CREATE INDEX idx_discount_campaigns_type ON discount_campaigns(campaign_type); -- ======================================= -- BOOKING DISCOUNTS TABLE -- ======================================= CREATE TABLE booking_discounts ( id CHAR(12) PRIMARY KEY DEFAULT generate_short_id('booking_discounts'), booking_id CHAR(12) NOT NULL REFERENCES bookings(id) ON DELETE CASCADE, user_id CHAR(12) NOT NULL REFERENCES users(id) ON DELETE CASCADE, discount_source VARCHAR(30) NOT NULL, source_id CHAR(12), campaign_type campaign_type, milestone_type milestone_type, discount_percent NUMERIC(5,2) NOT NULL, original_total NUMERIC(10,2) NOT NULL, discount_amount NUMERIC(10,2) NOT NULL, applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX idx_booking_discounts_booking ON booking_discounts(booking_id); CREATE INDEX idx_booking_discounts_source ON booking_discounts(discount_source, source_id); CREATE INDEX idx_booking_discounts_user ON booking_discounts(user_id); CREATE INDEX idx_booking_discounts_milestone ON booking_discounts(user_id, milestone_type, source_id); -- ======================================= -- 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(); CREATE TRIGGER trigger_update_discount_campaigns_timestamp BEFORE UPDATE ON discount_campaigns 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', 'new_booking', 'edit_requested'); 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, email_enabled BOOLEAN DEFAULT false, sms_enabled BOOLEAN DEFAULT false, browser_push_enabled BOOLEAN DEFAULT false, updated_at TIMESTAMPTZ DEFAULT NOW() ); CREATE INDEX idx_user_notification_preferences_user_id ON user_notification_preferences(user_id); CREATE INDEX idx_bookings_start_time_status ON bookings(start_time, status); CREATE INDEX idx_users_created_at ON users(created_at); 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(), full_avif_url text, full_webp_url text, full_jpg_url text, full_jxl_url text, thumb_avif_url text, thumb_webp_url text, thumb_jpg_url text ); -- 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'; -- Scrub social login identities (immutable_id is PII from OAuth providers) DELETE FROM user_social_logins WHERE user_id = target_id; -- Soft-delete all saved cards and clear PCI data UPDATE user_saved_cards SET deleted_at = NOW(), retained_until = NOW(), last_4 = 'XXXX', fingerprint = NULL, exp_month = 1, exp_year = 2000 WHERE user_id = target_id; -- Expire all pending verification codes UPDATE verification_codes SET used_at = NOW() WHERE user_id = target_id AND used_at IS NULL; -- Scrub RESERVATION entries in time_blockers that reference this user UPDATE time_blockers SET description = NULL WHERE created_by = target_id AND description LIKE 'RESERVATION:user:%'; -- Scrub notes on booking_edit_requests made by this user (free-text PII) UPDATE booking_edit_requests SET notes = NULL WHERE requested_by = target_id; -- Clear notification preferences (no contractual basis after account closure) DELETE FROM user_notification_preferences WHERE user_id = target_id; END; $$ LANGUAGE plpgsql; -- Delete guest user completely (no contractual retention basis) -- WHY: Guest accounts have no ongoing contractual or legal basis for retention -- WHEN: User requests deletion or GDPR cleanup -- OUTPUT: Full removal of guest account from users table 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; -- 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, 'total_price', ( SELECT COALESCE(SUM(COALESCE(bsvc2.override_price, s2.price)), 0) FROM booking_services bsvc2 JOIN services s2 ON bsvc2.service_id = s2.id WHERE bsvc2.booking_id = b.id ), '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', ( SELECT json_build_object( 'referrer_id', ur.referrer_id, 'referrer_name', u.fn, 'referred_at', ur.referred_at ) FROM user_referrals ur JOIN users u ON u.id = ur.referrer_id WHERE ur.referred_id = target_user_id LIMIT 1 ), 'referred_users', ( SELECT COALESCE(json_agg( json_build_object( 'referred_id', ur.referred_id, 'referred_name', u.fn, 'referred_at', ur.referred_at ) 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( 'email_enabled', unp.email_enabled, 'sms_enabled', unp.sms_enabled, 'browser_push_enabled', unp.browser_push_enabled, 'updated_at', unp.updated_at ) ), '[]'::json) FROM user_notification_preferences unp WHERE unp.user_id = target_user_id ), 'saved_cards', ( SELECT COALESCE(json_agg(json_build_object( 'card_id', id, 'brand', brand, 'last_4', last_4, 'exp_month', exp_month, 'exp_year', exp_year, 'fingerprint', fingerprint, 'is_default', is_default, 'deleted_at', deleted_at, 'retained_until', retained_until, 'created_at', created_at ) ORDER BY created_at DESC), '[]'::json) FROM user_saved_cards WHERE user_id = target_user_id ), 'refunds', ( SELECT COALESCE(json_agg(json_build_object( 'refund_id', r.id, 'payment_id', r.payment_id, 'booking_id', r.booking_id, 'amount', r.amount, 'status', r.status, 'reason', r.reason, 'created_at', r.created_at ) ORDER BY r.created_at DESC), '[]'::json) FROM refunds r JOIN payments p ON r.payment_id = p.id JOIN bookings b ON p.booking_id = b.id WHERE b.user_id = target_user_id ), 'social_logins', ( SELECT COALESCE(json_agg(json_build_object( 'provider', usl.provider, 'created_at', usl.created_at ) ORDER BY usl.created_at ASC), '[]'::json) FROM user_social_logins usl WHERE usl.user_id = target_user_id ), 'loyalty_redemptions', ( SELECT COALESCE(json_agg(json_build_object( 'id', lr.id, 'stamps_redeemed', lr.stamps_redeemed, 'status', lr.status, 'booking_date', b.start_time, 'booking_services', ( SELECT COALESCE(string_agg(s.name, ', '), '') FROM booking_services bsvc JOIN services s ON bsvc.service_id = s.id WHERE bsvc.booking_id = lr.applied_to_booking_id ), 'redeemed_at', lr.redeemed_at, 'applied_at', lr.applied_at, 'expires_at', lr.expires_at ) ORDER BY lr.redeemed_at DESC), '[]'::json) FROM loyalty_redemptions lr LEFT JOIN bookings b ON lr.applied_to_booking_id = b.id WHERE lr.user_id = target_user_id ), 'booking_discounts', ( SELECT COALESCE(json_agg(json_build_object( 'id', bd.id, 'booking_id', bd.booking_id, 'campaign_name', COALESCE(dc.name, bd.discount_source), 'discount_source', bd.discount_source, 'source_id', bd.source_id, 'campaign_type', bd.campaign_type, 'milestone_type', bd.milestone_type, 'discount_percent', bd.discount_percent, 'original_total', bd.original_total, 'discount_amount', bd.discount_amount, 'applied_at', bd.applied_at ) ORDER BY bd.applied_at DESC), '[]'::json) FROM booking_discounts bd LEFT JOIN discount_campaigns dc ON bd.source_id = dc.id AND bd.discount_source = 'campaign' WHERE bd.user_id = target_user_id ), 'edit_requests', ( SELECT COALESCE(json_agg(json_build_object( 'id', ber.id, 'booking_id', ber.booking_id, 'new_start_time', ber.new_start_time, 'new_services', ber.new_services, 'notes', ber.notes, 'has_overrides', ber.has_overrides, 'updated_at', ber.updated_at ) ORDER BY ber.updated_at DESC), '[]'::json) FROM booking_edit_requests ber WHERE ber.requested_by = target_user_id ), 'affiliate_payouts', ( SELECT COALESCE(json_agg(json_build_object( 'id', ap.id, 'amount', ap.amount, 'status', ap.status, 'created_at', ap.created_at ) ORDER BY ap.created_at DESC), '[]'::json) FROM affiliate_payouts ap WHERE ap.affiliate_id = target_user_id ), 'verification_codes', ( SELECT COALESCE(json_agg(json_build_object( 'purpose', vc.purpose, 'created_at', vc.created_at, 'used_at', vc.used_at, 'expires_at', vc.expires_at ) ORDER BY vc.created_at DESC), '[]'::json) FROM verification_codes vc WHERE vc.user_id = target_user_id ), 'forgiven_no_shows', ( SELECT COALESCE(json_agg(json_build_object( 'id', fns.id, 'booking_id', fns.booking_id, 'created_at', fns.created_at ) ORDER BY fns.created_at DESC), '[]'::json) FROM forgiven_no_shows fns JOIN bookings b ON fns.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', '0.9' ) ) 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; -- ======================================= -- USER SAVED CARDS TABLE (Square Integration) -- ======================================= CREATE TABLE user_saved_cards ( id CHAR(12) PRIMARY KEY DEFAULT generate_user_saved_card_id(), user_id CHAR(12) NOT NULL REFERENCES users(id) ON DELETE CASCADE, square_card_id TEXT NOT NULL UNIQUE, brand TEXT NOT NULL, last_4 TEXT NOT NULL, exp_month INT NOT NULL, exp_year INT NOT NULL, fingerprint TEXT, is_default BOOLEAN NOT NULL DEFAULT FALSE, deleted_at TIMESTAMPTZ, deleted_by CHAR(12) REFERENCES users(id) ON DELETE SET NULL, retained_until TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX idx_user_saved_cards_user ON user_saved_cards(user_id); CREATE INDEX idx_user_saved_cards_fingerprint ON user_saved_cards(fingerprint); CREATE INDEX idx_user_saved_cards_active ON user_saved_cards(user_id, deleted_at) WHERE deleted_at IS NULL; -- ======================================= -- REFUNDS TABLE -- ======================================= CREATE TABLE refunds ( id CHAR(12) PRIMARY KEY DEFAULT generate_refund_id(), payment_id CHAR(12) NOT NULL REFERENCES payments(id) ON DELETE CASCADE, booking_id CHAR(12) NOT NULL REFERENCES bookings(id) ON DELETE CASCADE, amount NUMERIC(10,2) NOT NULL CHECK (amount > 0), square_refund_id TEXT, status payment_status NOT NULL DEFAULT 'pending', reason TEXT NOT NULL, created_by CHAR(12) REFERENCES users(id) ON DELETE SET NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX idx_refunds_payment ON refunds(payment_id); CREATE INDEX idx_refunds_booking ON refunds(booking_id); -- ======================================= -- AFFILIATE PAYOUTS TABLE -- ======================================= CREATE TABLE affiliate_payouts ( id CHAR(12) PRIMARY KEY DEFAULT generate_affiliate_payout_id(), affiliate_id CHAR(12) REFERENCES users(id) ON DELETE SET NULL, amount NUMERIC(10,2) CHECK (amount >= 0), status TEXT NOT NULL DEFAULT 'pending', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX idx_affiliate_payouts_affiliate ON affiliate_payouts(affiliate_id); -- ======================================= -- SQUARE DEPOSITS TABLE (Bank Reconciliation) -- ======================================= CREATE TABLE square_deposits ( id CHAR(12) PRIMARY KEY DEFAULT generate_square_deposit_id(), square_deposit_id TEXT, amount NUMERIC(10,2) NOT NULL, fees_deducted NUMERIC(10,2), deposited_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX idx_square_deposits_deposited ON square_deposits(deposited_at); -- ======================================= -- CARDDAV / CALDAV TABLES (used by SabreDAV sync in Go backend) -- ======================================= CREATE TABLE IF NOT EXISTS dav_principals ( id SERIAL PRIMARY KEY, uri VARCHAR(255) NOT NULL UNIQUE, email VARCHAR(255), displayname VARCHAR(255) ); CREATE TABLE IF NOT EXISTS dav_calendars ( id SERIAL PRIMARY KEY, principaluri VARCHAR(255) NOT NULL, displayname VARCHAR(255), uri VARCHAR(255) NOT NULL, description TEXT, calendarorder INT DEFAULT 0, calendarcolor VARCHAR(10), timezone TEXT, components VARCHAR(255), transparent BOOLEAN DEFAULT FALSE, UNIQUE(principaluri, uri) ); CREATE TABLE IF NOT EXISTS dav_calendarobjects ( id SERIAL PRIMARY KEY, calendardata TEXT, uri VARCHAR(255) NOT NULL, calendarid INTEGER NOT NULL REFERENCES dav_calendars(id) ON DELETE CASCADE, lastmodified INTEGER, etag VARCHAR(32), size INTEGER, componenttype VARCHAR(8), firstoccurence INTEGER, lastoccurence INTEGER, uid VARCHAR(255), UNIQUE(calendarid, uri) ); CREATE TABLE IF NOT EXISTS dav_addressbooks ( id SERIAL PRIMARY KEY, principaluri VARCHAR(255) NOT NULL, displayname VARCHAR(255), uri VARCHAR(255) NOT NULL, description TEXT, synctoken INTEGER DEFAULT 1, UNIQUE(principaluri, uri) ); CREATE TABLE IF NOT EXISTS dav_cards ( id SERIAL PRIMARY KEY, addressbookid INTEGER NOT NULL REFERENCES dav_addressbooks(id) ON DELETE CASCADE, carddata TEXT, uri VARCHAR(255) NOT NULL, lastmodified INTEGER, etag VARCHAR(32), size INTEGER, UNIQUE(addressbookid, uri) ); CREATE TABLE IF NOT EXISTS dav_addressbookchanges ( id SERIAL PRIMARY KEY, uri VARCHAR(255) NOT NULL, synctoken INTEGER NOT NULL, addressbookid INTEGER NOT NULL REFERENCES dav_addressbooks(id) ON DELETE CASCADE, operation SMALLINT NOT NULL ); CREATE TABLE IF NOT EXISTS dav_calendarchanges ( id SERIAL PRIMARY KEY, uri VARCHAR(255) NOT NULL, synctoken INTEGER NOT NULL, calendarid INTEGER NOT NULL REFERENCES dav_calendars(id) ON DELETE CASCADE, operation SMALLINT NOT NULL ); CREATE TABLE IF NOT EXISTS dav_users ( id SERIAL PRIMARY KEY, username VARCHAR(255) NOT NULL UNIQUE, digesta1 VARCHAR(32) NOT NULL ); CREATE INDEX IF NOT EXISTS idx_calendarobjects_calendarid ON dav_calendarobjects(calendarid); CREATE INDEX IF NOT EXISTS idx_cards_addressbookid ON dav_cards(addressbookid); -- Seed default principal, addressbook, and calendar for CardDAV/CalDAV sync INSERT INTO dav_principals (uri, email, displayname) SELECT 'principals/default', 'admin@example.com', 'Default User' WHERE NOT EXISTS (SELECT 1 FROM dav_principals WHERE uri = 'principals/default'); INSERT INTO dav_addressbooks (principaluri, displayname, uri, description, synctoken) SELECT 'principals/default', 'Contacts', 'default', 'Default address book', 1 WHERE NOT EXISTS (SELECT 1 FROM dav_addressbooks WHERE uri = 'default'); INSERT INTO dav_calendars (principaluri, displayname, uri, description, components, transparent) SELECT 'principals/default', 'Default Calendar', 'default', 'Default calendar', 'VEVENT,VTODO', false WHERE NOT EXISTS (SELECT 1 FROM dav_calendars WHERE uri = 'default'); -- ======================================= -- 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() */