-- ======================================= -- 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', 'on_the_house'); 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', 'no_show', 'pending_release', 'deposit_lapsed'); 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'); CREATE TYPE till_item_type AS ENUM ('gift_card', 'retail_product'); -- ======================================= -- 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_till_sale_id() RETURNS CHAR(12) AS $$ SELECT generate_short_id('till_sales'); $$ 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_patch_test_id() RETURNS CHAR(12) AS $$ SELECT generate_short_id('patch_tests'); $$ LANGUAGE sql; CREATE OR REPLACE FUNCTION generate_user_patch_test_id() RETURNS CHAR(12) AS $$ SELECT generate_short_id('user_patch_tests'); $$ LANGUAGE sql; CREATE OR REPLACE FUNCTION generate_user_referrals_id() RETURNS CHAR(12) AS $$ SELECT generate_short_id('user_referrals'); $$ LANGUAGE sql; CREATE OR REPLACE FUNCTION generate_referral_discounts_id() RETURNS CHAR(12) AS $$ SELECT generate_short_id('referral_discounts'); $$ LANGUAGE sql; CREATE OR REPLACE FUNCTION generate_user_social_logins_id() RETURNS CHAR(12) AS $$ SELECT generate_short_id('user_social_logins'); $$ LANGUAGE sql; CREATE OR REPLACE FUNCTION generate_name_history_id() RETURNS CHAR(12) AS $$ SELECT generate_short_id('name_history'); $$ LANGUAGE sql; CREATE OR REPLACE FUNCTION generate_custom_services_id() RETURNS CHAR(12) AS $$ SELECT generate_short_id('custom_services'); $$ LANGUAGE sql; CREATE OR REPLACE FUNCTION generate_verification_codes_id() RETURNS CHAR(12) AS $$ SELECT generate_short_id('verification_codes'); $$ LANGUAGE sql; CREATE OR REPLACE FUNCTION generate_time_blockers_id() RETURNS CHAR(12) AS $$ SELECT generate_short_id('time_blockers'); $$ LANGUAGE sql; CREATE OR REPLACE FUNCTION generate_forgiven_no_shows_id() RETURNS CHAR(12) AS $$ SELECT generate_short_id('forgiven_no_shows'); $$ LANGUAGE sql; CREATE OR REPLACE FUNCTION generate_loyalty_redemptions_id() RETURNS CHAR(12) AS $$ SELECT generate_short_id('loyalty_redemptions'); $$ LANGUAGE sql; CREATE OR REPLACE FUNCTION generate_discount_campaigns_id() RETURNS CHAR(12) AS $$ SELECT generate_short_id('discount_campaigns'); $$ LANGUAGE sql; CREATE OR REPLACE FUNCTION generate_booking_discounts_id() RETURNS CHAR(12) AS $$ SELECT generate_short_id('booking_discounts'); $$ LANGUAGE sql; CREATE OR REPLACE FUNCTION generate_tags_id() RETURNS CHAR(12) AS $$ SELECT generate_short_id('tags'); $$ LANGUAGE sql; CREATE OR REPLACE FUNCTION generate_admin_notifications_id() RETURNS CHAR(12) AS $$ SELECT generate_short_id('admin_notifications'); $$ LANGUAGE sql; CREATE OR REPLACE FUNCTION generate_user_notification_preferences_id() RETURNS CHAR(12) AS $$ SELECT generate_short_id('user_notification_preferences'); $$ LANGUAGE sql; CREATE OR REPLACE FUNCTION generate_images_id() RETURNS CHAR(12) AS $$ SELECT generate_short_id('images'); $$ LANGUAGE sql; CREATE OR REPLACE FUNCTION generate_gift_cards_id() RETURNS CHAR(12) AS $$ SELECT generate_short_id('gift_cards'); $$ LANGUAGE sql; CREATE OR REPLACE FUNCTION generate_gift_card_transactions_id() RETURNS CHAR(12) AS $$ SELECT generate_short_id('gift_card_transactions'); $$ LANGUAGE sql; CREATE OR REPLACE FUNCTION generate_gift_card_expired_balances_id() RETURNS CHAR(12) AS $$ SELECT generate_short_id('gift_card_expired_balances'); $$ LANGUAGE sql; CREATE OR REPLACE FUNCTION generate_admin_audit_log_id() RETURNS CHAR(12) AS $$ SELECT generate_short_id('admin_audit_log'); $$ LANGUAGE sql; CREATE OR REPLACE FUNCTION generate_dispute_id() RETURNS CHAR(12) AS $$ SELECT generate_short_id('disputes'); $$ 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(), -- Account lockout fields failed_attempts INT NOT NULL DEFAULT 0, locked_until TIMESTAMPTZ, -- 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(), -- UK GDPR: consent must be freely given, specific and unambiguous — it is -- OPT-IN, never a pre-ticked default. New users start with FALSE until they -- explicitly agree in the GDPR settings UI. data_retention_consent BOOLEAN NOT NULL DEFAULT FALSE, 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, -- Two-factor authentication (2FA). Loosely faked pending email/SMS delivery -- infrastructure: two_factor_enabled is the source of truth for the -- online-card-payment gate; two_factor_method records the chosen delivery -- channel ('email' | 'sms'); pending_* hold the in-flight verification code -- (hashed) and its expiry; two_factor_last_used_at records the most recent -- successful verification (admin recovery view). Set REQUIRE_2FA=false (or -- the dev build tag) to disable all 2FA requirements for local testing. two_factor_enabled BOOLEAN NOT NULL DEFAULT FALSE, two_factor_method TEXT CHECK (two_factor_method IN ('email', 'sms') OR two_factor_method IS NULL), two_factor_pending_code_hash TEXT, two_factor_pending_code_expires TIMESTAMPTZ, two_factor_last_used_at TIMESTAMPTZ, -- staff fields notes TEXT ); CREATE TABLE user_social_logins ( id CHAR(12) PRIMARY KEY DEFAULT generate_user_social_logins_id(), 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); -- pg_trgm GIN indexes for ILIKE search on user-facing columns CREATE INDEX idx_users_fn_trgm ON users USING GIN (fn gin_trgm_ops); CREATE INDEX idx_users_email_trgm ON users USING GIN (email gin_trgm_ops); CREATE INDEX idx_users_phone_trgm ON users USING GIN (phone gin_trgm_ops); CREATE INDEX idx_users_created_at ON users(created_at); CREATE INDEX IF NOT EXISTS idx_users_n_first_name_trgm ON users USING GIN (n_first_name gin_trgm_ops); CREATE INDEX IF NOT EXISTS idx_users_n_last_name_trgm ON users USING GIN (n_last_name gin_trgm_ops); -- 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 CHAR(12) PRIMARY KEY DEFAULT generate_verification_codes_id(), 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_patch_test_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 CHAR(12) PRIMARY KEY DEFAULT generate_user_patch_test_id(), user_id CHAR(12) REFERENCES users(id) ON DELETE SET NULL, 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); CREATE INDEX IF NOT EXISTS idx_services_name_trgm ON services USING GIN (name gin_trgm_ops); -- ======================================= -- CUSTOM SERVICES TABLE (One-off / special request services) -- ======================================= CREATE TABLE custom_services ( id CHAR(12) PRIMARY KEY DEFAULT generate_custom_services_id(), name VARCHAR(100) NOT NULL, description TEXT, price NUMERIC(10,2) NOT NULL, duration_minutes INT NOT NULL, minimum_age_required INT NOT NULL DEFAULT 0, notes TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_by CHAR(12) REFERENCES users(id), usage_count INT NOT NULL DEFAULT 0, last_used_at TIMESTAMPTZ ); CREATE INDEX idx_custom_services_name ON custom_services(name); CREATE INDEX idx_custom_services_name_trgm ON custom_services USING GIN (name gin_trgm_ops); CREATE INDEX idx_custom_services_desc_trgm ON custom_services USING GIN (description gin_trgm_ops); CREATE INDEX idx_custom_services_usage ON custom_services(usage_count DESC); -- ======================================= -- 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', -- Free-text notes: treated as ONE medical/safety record. Allergy/access -- entries are special-category health data (Art 9(1), Art 4(15)). -- RETAINED at erasure (de-identified) — see RETENTION POLICY in -- anonymize_user. -- Server-side length cap mirrors the frontend CharCounter default -- (frontend/src/lib/components/ui/CharCounter.svelte: maxChars = 1,000,000), -- giving the de-identified-retention claim a technical guardrail against -- runaway free-text. notes TEXT CHECK (notes IS NULL OR char_length(notes) <= 1000000), deposit_required BOOLEAN NOT NULL DEFAULT FALSE, -- Computed fields (maintained by trigger on booking_services/booking_custom_services) total_duration_minutes INT NOT NULL DEFAULT 60, total_amount NUMERIC(10,2) NOT NULL DEFAULT 0, end_time TIMESTAMPTZ NOT NULL, -- Set by BEFORE INSERT trigger; recalculated by booking_services trigger created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_by CHAR(12), idempotency_key VARCHAR(64) UNIQUE, out_of_hours BOOLEAN NOT NULL DEFAULT FALSE ); CREATE INDEX idx_bookings_userid ON bookings(user_id); CREATE INDEX idx_bookings_userid_starttime ON bookings(user_id, start_time); CREATE INDEX idx_bookings_end_time ON bookings(end_time); CREATE INDEX idx_bookings_status_end_time ON bookings(status, end_time); CREATE INDEX IF NOT EXISTS idx_bookings_notes_trgm ON bookings USING GIN (notes gin_trgm_ops); CREATE INDEX IF NOT EXISTS idx_bookings_start_time ON bookings(start_time); -- ======================================= -- NAME HISTORY TABLE -- ======================================= -- WHY: Tracks user-initiated first/last name changes for display in booking modals -- and GDPR export. History is used to show "(formerly [old first] [old last])" -- on completed booking receipts and admin views. -- NOTE: booking_id FK references bookings, so this table is created after bookings. CREATE TABLE name_history ( id CHAR(12) PRIMARY KEY DEFAULT generate_name_history_id(), user_id CHAR(12) NOT NULL REFERENCES users(id) ON DELETE CASCADE, previous_first_name VARCHAR(50) NOT NULL, previous_last_name VARCHAR(50) NOT NULL, booking_id CHAR(12) CONSTRAINT fk_name_history_booking_id REFERENCES bookings(id) ON DELETE SET NULL, changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX idx_name_history_user_id ON name_history(user_id); CREATE INDEX idx_name_history_changed_at ON name_history(changed_at); CREATE INDEX idx_name_history_user_active ON name_history(user_id) WHERE booking_id IS NULL; -- ======================================= -- BOOKING SERVICES JUNCTION TABLE -- ======================================= CREATE TABLE booking_services ( booking_id CHAR(12) NOT NULL REFERENCES bookings(id) ON DELETE CASCADE, service_id CHAR(12) NOT NULL REFERENCES services(id) ON DELETE RESTRICT, override_price NUMERIC(10,2), override_duration_minutes INT, PRIMARY KEY (booking_id, service_id) ); CREATE INDEX idx_booking_services_service_id ON booking_services(service_id); -- ======================================= -- BOOKING CUSTOM SERVICES JUNCTION TABLE -- ======================================= CREATE TABLE booking_custom_services ( booking_id CHAR(12) NOT NULL REFERENCES bookings(id) ON DELETE CASCADE, custom_service_id CHAR(12) NOT NULL REFERENCES custom_services(id) ON DELETE RESTRICT, override_price NUMERIC(10,2), override_duration_minutes INT, PRIMARY KEY (booking_id, custom_service_id) ); CREATE INDEX idx_booking_custom_services_custom_service_id ON booking_custom_services(custom_service_id); -- ======================================= -- TRIGGER: Auto-recalculate booking duration & total -- ======================================= -- WHY: Keeps bookings.total_duration_minutes, bookings.total_amount, -- and the generated bookings.end_time in sync when services change. CREATE OR REPLACE FUNCTION recalc_booking_duration_and_total() RETURNS TRIGGER AS $$ DECLARE bid CHAR(12); BEGIN bid := COALESCE(NEW.booking_id, OLD.booking_id); UPDATE bookings b SET total_duration_minutes = sub.dur, total_amount = sub.amt, end_time = b.start_time + (sub.dur * INTERVAL '1 minute') FROM ( SELECT COALESCE((SELECT SUM(dur) FROM ( SELECT COALESCE(bs.override_duration_minutes, s.duration_minutes) AS dur FROM booking_services bs JOIN services s ON bs.service_id = s.id WHERE bs.booking_id = bid UNION ALL SELECT COALESCE(bcs.override_duration_minutes, cs.duration_minutes) FROM booking_custom_services bcs JOIN custom_services cs ON bcs.custom_service_id = cs.id WHERE bcs.booking_id = bid ) sub2), 60) AS dur, COALESCE((SELECT SUM(amt) FROM ( SELECT COALESCE(bs.override_price, s.price) AS amt FROM booking_services bs JOIN services s ON bs.service_id = s.id WHERE bs.booking_id = bid UNION ALL SELECT COALESCE(bcs.override_price, cs.price) FROM booking_custom_services bcs JOIN custom_services cs ON bcs.custom_service_id = cs.id WHERE bcs.booking_id = bid ) sub2), 0) AS amt ) sub WHERE b.id = bid; RETURN COALESCE(NEW, OLD); END; $$ LANGUAGE plpgsql; CREATE TRIGGER trg_recalc_booking_duration_total AFTER INSERT OR UPDATE OR DELETE ON booking_services FOR EACH ROW EXECUTE FUNCTION recalc_booking_duration_and_total(); CREATE TRIGGER trg_recalc_booking_duration_total_custom AFTER INSERT OR UPDATE OR DELETE ON booking_custom_services FOR EACH ROW EXECUTE FUNCTION recalc_booking_duration_and_total(); -- BEFORE INSERT trigger on bookings to set end_time from defaults -- (the AFTER trigger on booking_services overrides this when services are added) CREATE OR REPLACE FUNCTION set_booking_end_time() RETURNS TRIGGER AS $$ BEGIN NEW.end_time := NEW.start_time + (NEW.total_duration_minutes * INTERVAL '1 minute'); RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER trg_set_booking_end_time BEFORE INSERT ON bookings FOR EACH ROW EXECUTE FUNCTION set_booking_end_time(); -- ======================================= -- 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) REFERENCES users(id) ON DELETE SET NULL, new_start_time TIMESTAMPTZ, new_services CHAR(12)[] DEFAULT '{}', notes TEXT, has_overrides BOOLEAN NOT NULL DEFAULT FALSE, 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 INDEX idx_booking_edit_requests_requested_by ON booking_edit_requests(requested_by); CREATE TABLE user_referrals ( id CHAR(12) PRIMARY KEY DEFAULT generate_user_referrals_id(), referrer_id CHAR(12) REFERENCES users(id) ON DELETE SET NULL, referred_id CHAR(12) REFERENCES users(id) ON DELETE SET NULL, referred_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), claimed_booking_id CHAR(12) REFERENCES bookings(id) ON DELETE SET NULL, UNIQUE (referrer_id, referred_id) ); -- ======================================= -- REFERRAL DISCOUNTS TABLE -- ======================================= CREATE TABLE referral_discounts ( id CHAR(12) PRIMARY KEY DEFAULT generate_referral_discounts_id(), user_id CHAR(12) NOT NULL REFERENCES users(id) ON DELETE CASCADE, referral_id CHAR(12) NOT NULL REFERENCES user_referrals(id) ON DELETE CASCADE, discount_percent NUMERIC(5,2) NOT NULL DEFAULT 10.00, used BOOLEAN NOT NULL DEFAULT FALSE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), used_at TIMESTAMPTZ ); CREATE INDEX idx_referral_discounts_user_id ON referral_discounts(user_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 ); -- weekday is already indexed via PRIMARY KEY — no separate index needed 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); -- ======================================= -- SCHEDULED DEFAULT HOURS CHANGES -- ======================================= CREATE TABLE default_hours_scheduled_changes ( id SERIAL PRIMARY KEY, effective_date DATE NOT NULL, created_by CHAR(12) REFERENCES users(id), created_at TIMESTAMPTZ DEFAULT NOW(), applied_at TIMESTAMPTZ, cancelled_at TIMESTAMPTZ, hours JSONB NOT NULL ); CREATE UNIQUE INDEX idx_one_pending_change ON default_hours_scheduled_changes ((1)) WHERE applied_at IS NULL AND cancelled_at IS NULL; -- ======================================= -- 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_time_blockers_id(), 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; CREATE INDEX idx_time_blockers_created_by ON time_blockers(created_by); CREATE INDEX idx_time_blockers_created_at ON time_blockers(created_at); CREATE INDEX IF NOT EXISTS idx_time_blockers_desc_trgm ON time_blockers USING GIN (description gin_trgm_ops); -- ======================================= -- 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_forgiven_no_shows_id(), booking_id CHAR(12) NOT NULL UNIQUE REFERENCES bookings(id) ON DELETE CASCADE, forgiven_by CHAR(12), 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) 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), -- The exact source_id (card nonce or ccof: card-on-file id) sent to Square -- in the CreatePayment call, so the sweep can replay the charge with an -- IDENTICAL request body under the same idempotency key (Square returns the -- original payment on identical-body key reuse; a different body would -- trigger IDEMPOTENCY_KEY_REUSED and defeat reconciliation). square_source_id TEXT, -- Full JSON of the original CreatePayment request (source_id, idempotency -- key, amount, plus customer_id/reference_id/note/buyer_email/etc.). The -- sweep replays this verbatim so Square's idempotency dedup returns the -- original payment for a retained key. Without it, the replay body differs -- from the original (Square compares the whole request) and returns -- IDEMPOTENCY_KEY_REUSED, leaving the row pending forever. square_request_snapshot TEXT, 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), gift_card_id 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 IF NOT EXISTS idx_payments_booking_status_created ON payments(booking_id, status, created_at); CREATE INDEX idx_payments_created_at_status ON payments(created_at, status); CREATE INDEX idx_payments_payment_method ON payments(payment_method); CREATE INDEX idx_payments_payment_method_created_at ON payments(payment_method, created_at); -- ======================================= -- TERMINAL CHECKOUTS TABLE -- ======================================= -- Tracks in-flight Square Terminal checkouts per booking so a lost-response -- retry cannot create a second live checkout, and records the payment type -- the admin charged so GetCheckoutStatus records the payment with that type -- instead of hardcoding 'full'. CREATE TABLE terminal_checkouts ( checkout_id VARCHAR(64) PRIMARY KEY, booking_id CHAR(12) NOT NULL REFERENCES bookings(id) ON DELETE CASCADE, payment_type payment_type NOT NULL DEFAULT 'full', status VARCHAR(20) NOT NULL DEFAULT 'PENDING', amount NUMERIC(10,2) NOT NULL DEFAULT 0, -- Records whether the customer EXPLICITLY requested a tip (the frontend's -- tip_enabled flag). recordTerminalPaymentTx only carves a tip record from -- a charge above the remaining booking value when this is true — an -- accidental overpayment must never be relabelled gratuity (B3). tip_enabled BOOLEAN NOT NULL DEFAULT FALSE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX idx_terminal_checkouts_booking ON terminal_checkouts(booking_id, status); CREATE INDEX idx_terminal_checkouts_status ON terminal_checkouts(status); -- ======================================= -- LOYALTY REDEMPTIONS TABLE -- ======================================= CREATE TABLE loyalty_redemptions ( id CHAR(12) PRIMARY KEY DEFAULT generate_loyalty_redemptions_id(), user_id CHAR(12) REFERENCES users(id) ON DELETE SET NULL, 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_discount_campaigns_id(), 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_booking_discounts_id(), booking_id CHAR(12) NOT NULL REFERENCES bookings(id) ON DELETE CASCADE, user_id CHAR(12) REFERENCES users(id) ON DELETE SET NULL, 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); CREATE INDEX idx_booking_discounts_booking_source ON booking_discounts(booking_id, discount_source); -- ======================================= -- 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, gift_card_expiry_months INT NOT NULL DEFAULT 24, voucher_type VARCHAR(3) NOT NULL DEFAULT 'SPV', 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, gift_card_expiry_months ) 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', 24 ); -- 'critical_payment_log' surfaces unresolved money events (stale pending -- payments/till sales, refunds at the retry cap) in the admin notification -- centre — the DB-backed stand-in for the un-watched CRITICAL payment logs. 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', 'deposit_paid', 'edit_request', 'edit_requested', 'new_booking', 'deposit_not_paid_by_deadline', 'gift_card_purchased_for_friend', 'default_hours_changed', 'refund_failed', 'critical_payment_log'); CREATE TABLE admin_notifications ( id CHAR(12) PRIMARY KEY DEFAULT generate_admin_notifications_id(), reason admin_notification_reason NOT NULL, booking_id CHAR(12) REFERENCES bookings(id), user_id CHAR(12) REFERENCES users(id), acknowledged_at TIMESTAMPTZ, created_at TIMESTAMPTZ DEFAULT NOW() ); CREATE INDEX idx_admin_notifications_reason ON admin_notifications(reason); CREATE INDEX idx_admin_notifications_acknowledged_at ON admin_notifications(acknowledged_at); -- User notification preferences for future user notification system CREATE TABLE user_notification_preferences ( id CHAR(12) PRIMARY KEY DEFAULT generate_user_notification_preferences_id(), 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 table images ( id CHAR(12) PRIMARY KEY DEFAULT generate_images_id(), 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 CHAR(12) PRIMARY KEY DEFAULT generate_tags_id(), name text not null unique ); -- Indexes create index idx_images_tag_names on images using gin(tag_names); -- pg_trgm GIN index not applicable to tag_names (text[] array). -- For ILIKE search on unnested tags at scale, normalize into a separate image_tags table -- and add: CREATE INDEX idx_image_tags_name_trgm ON image_tags USING GIN (name gin_trgm_ops); create index idx_images_created_at on images(created_at desc); CREATE INDEX IF NOT EXISTS idx_images_url_trgm ON images USING GIN (url gin_trgm_ops); CREATE INDEX IF NOT EXISTS idx_images_thumbnail_url_trgm ON images USING GIN (thumbnail_url gin_trgm_ops); 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 OR idle-account batch cleanup -- OUTPUT: Converts personal data to anonymous placeholder -- NOTE: phone/date_of_birth are NOT NULL so we use placeholder values, not NULL -- NOTE: Also scrubs 2FA state (two_factor_*). Free-text notes are RETAINED -- at erasure — see RETENTION POLICY below. -- RETENTION POLICY (free-text notes — UK GDPR Art 4(1), Art 9, Recital 26): -- * Notes are a SINGLE free-text input treated as ONE medical/safety -- record — colour/preference/lateness content sits alongside -- allergy/access/disability content and cannot be split. -- * Allergy/access/disability entries are SPECIAL CATEGORY health data -- (Art 9(1), Art 4(15)). -- * At erasure the rest of the user record is fully wiped/anonymised -- (name, email, phone, DOB, saved cards, 2FA, social logins) and no -- re-identification map is maintained after anonymisation, so the -- retained notes are de-identified / effectively anonymised (Recital 26). -- * Notes are retained while they may be needed for: -- (a) safe treatment / reasonable adjustments (Equality Act 2010 -- s.20-21, s.29) if the customer returns; -- (b) legal-claims defence (Art 17(3)(e) / Art 9(2)(f)) e.g. around -- allergy mistreatment. -- * Retained notes remain access-controlled and are never exported. -- A row presented as erased keeps no re-identifiable personal data at any -- call site; retained notes are de-identified by the wiped surrounding record. 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, last_login_at = NULL, -- removes activity metadata (right to be forgotten) account_role = 'guest', loyalty_stamps = 0, referral_code = NULL, data_retention_consent = FALSE, data_consent_updated_at = NOW(), updated_at = NOW(), password_hash = NULL, two_factor_enabled = FALSE, -- live 2FA credential must not survive erasure two_factor_method = NULL, two_factor_pending_code_hash = NULL, two_factor_pending_code_expires = NULL -- notes are retained as a de-identified medical/safety record (see RETENTION POLICY below) 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, clear PCI data, and scrub Square references -- (square_card_id / square_customer_id are external-system identifiers and -- MUST be NULLed for GDPR right-to-erasure — a scrubbed card has no Square id). UPDATE user_saved_cards SET deleted_at = NOW(), retained_until = NOW() + INTERVAL '7 years', last_4 = 'XXXX', fingerprint = NULL, exp_month = 1, exp_year = 2000, square_card_id = NULL, square_customer_id = NULL WHERE user_id = target_id; -- Delete verification codes: each row holds a plaintext 12-hex auth token -- (a live credential) — GDPR Art 17 erasure must not leave it behind. DELETE FROM verification_codes WHERE user_id = target_id; -- 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:%' OR description LIKE 'RESERVATION:edit_request:%'); -- Notes on this user's bookings and booking_edit_requests are RETAINED -- as a de-identified medical/safety record — see RETENTION POLICY above. -- Edit-request rows whose ONLY content is the note survive because the -- note IS one of the chk_at_least_one_field fields — no DELETE is needed. -- Scrub previous-name history (names are PII; leaving them would let -- "formerly [name]" receipts reveal the erased identity) UPDATE name_history SET previous_first_name = 'Deleted', previous_last_name = 'User' WHERE user_id = target_id; -- Clear notification preferences (no contractual basis after account closure) DELETE FROM user_notification_preferences WHERE user_id = target_id; -- Anonymize patch test records (medical-adjacent PII — unlink user, preserve test history) UPDATE user_patch_tests SET user_id = NULL WHERE user_id = target_id; -- Anonymize referral relationships (unlink this user's side, preserve the other party's record) UPDATE user_referrals SET referrer_id = NULL WHERE referrer_id = target_id; UPDATE user_referrals SET referred_id = NULL WHERE referred_id = target_id; -- Anonymize admin notification references (not customer data — just drop the user link) UPDATE admin_notifications SET user_id = NULL WHERE user_id = target_id; -- Clear login audit trail (no ongoing legal basis after account closure) DELETE FROM login_audit WHERE user_id = target_id; -- Revoke stored refresh tokens (auth credentials of an erased identity) DELETE FROM refresh_tokens WHERE user_id = target_id; -- Scrub Square CreatePayment request snapshots (payments / till_sales): -- the stored replay JSON embeds the user's email as BuyerEmail (PII, GDPR -- Art 17 / Art 5(1)(e)). The financial rows themselves MUST survive the -- 7-year retention period, so only the snapshot is NULLed — the sweep -- rebuilds a minimal replay body when the snapshot is missing, so -- pending-row reconciliation stays money-safe. Scope: payments the user -- initiated (created_by) OR that were charged against the user's bookings -- (admin at-the-counter charges also carry the booking user's email in the -- snapshot); till_sales where the user is the customer (user_id). UPDATE payments SET square_request_snapshot = NULL WHERE created_by = target_id OR booking_id IN (SELECT id FROM bookings WHERE user_id = target_id); UPDATE till_sales SET square_request_snapshot = NULL WHERE user_id = target_id; -- NULL the cardholder-typed reason on disputes linked to this user's -- payments (free-text third-party PII — GDPR Art 17). The dispute row and -- its financial data survive; only the free-text reason is scrubbed. UPDATE disputes SET reason = NULL WHERE payment_id IN (SELECT id FROM payments WHERE created_by = target_id OR booking_id IN (SELECT id FROM bookings 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 -- SAFETY: FK constraints are SET NULL on financial tables — records survive deletion. -- Saved cards are soft-deleted with 7-year retention before unlinking. CREATE OR REPLACE FUNCTION delete_guest_user(target_id CHAR(12)) RETURNS VOID AS $$ BEGIN UPDATE user_saved_cards SET deleted_at = NOW(), retained_until = NOW() + INTERVAL '7 years', last_4 = 'XXXX', fingerprint = NULL, exp_month = 1, exp_year = 2000, -- Scrub Square references (external-system identifiers) before unlinking square_card_id = NULL, square_customer_id = NULL, user_id = NULL WHERE user_id = target_id; -- Clear login audit trail before deleting the user row DELETE FROM login_audit WHERE user_id = target_id; -- Revoke stored refresh tokens (auth credentials of an erased identity) DELETE FROM refresh_tokens WHERE user_id = target_id; -- Scrub Square CreatePayment request snapshots (payments / till_sales): -- the stored replay JSON embeds the guest's email as BuyerEmail (PII). -- The financial rows survive the 7-year retention period — only the -- snapshot is NULLed (the sweep rebuilds a minimal body when it is -- missing, keeping reconciliation money-safe). Scope mirrors -- anonymize_user(): payments the guest initiated (created_by) or charged -- against the guest's bookings, and till_sales where the guest is the -- customer (user_id). UPDATE payments SET square_request_snapshot = NULL WHERE created_by = target_id OR booking_id IN (SELECT id FROM bookings WHERE user_id = target_id); UPDATE till_sales SET square_request_snapshot = NULL WHERE user_id = target_id; -- Notes (bookings, booking_edit_requests, till_sales, -- gift_card_transactions) are RETAINED as a de-identified medical/safety -- record — see RETENTION POLICY above. The guest user row is fully -- deleted below, so the retained notes cannot be traced back to the -- individual (Recital 26). Edit-request rows whose ONLY content is the -- note survive (chk_at_least_one_field — the note IS one of the fields). -- Unlink no-action FK references to users(id) that would otherwise make -- the DELETE below fail (each column has REFERENCES users(id) with no -- ON DELETE clause). The financial/till rows are preserved for the 7-year -- retention period; only the user link is cleared. UPDATE gift_card_transactions SET user_id = NULL WHERE user_id = target_id; UPDATE gift_cards SET redeemed_by = NULL WHERE redeemed_by = target_id; UPDATE gift_cards SET created_by = NULL WHERE created_by = target_id; UPDATE admin_notifications SET user_id = NULL WHERE user_id = target_id; UPDATE admin_audit_log SET target_user_id = NULL WHERE target_user_id = target_id; 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, 'failed_attempts', failed_attempts, 'locked_until', locked_until ) 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(price_val), 0) FROM ( SELECT COALESCE(bsvc2.override_price, s2.price) AS price_val FROM booking_services bsvc2 JOIN services s2 ON bsvc2.service_id = s2.id WHERE bsvc2.booking_id = b.id UNION ALL SELECT COALESCE(bcs2.override_price, cs2.price) FROM booking_custom_services bcs2 JOIN custom_services cs2 ON bcs2.custom_service_id = cs2.id WHERE bcs2.booking_id = b.id ) price_sub ), 'services', ( SELECT COALESCE(json_agg(service_obj), '[]'::json) FROM ( SELECT 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) ) AS service_obj FROM booking_services bsvc JOIN services s ON bsvc.service_id = s.id WHERE bsvc.booking_id = b.id UNION ALL SELECT json_build_object( 'service_id', cs.id, 'name', cs.name, 'description', cs.description, 'price', COALESCE(bcs.override_price, cs.price), 'duration_minutes', COALESCE(bcs.override_duration_minutes, cs.duration_minutes) ) FROM booking_custom_services bcs JOIN custom_services cs ON bcs.custom_service_id = cs.id WHERE bcs.booking_id = b.id ) services_sub ) ) 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 LEFT JOIN bookings b ON p.booking_id = b.id LEFT JOIN gift_cards gc ON p.gift_card_id = gc.id WHERE b.user_id = target_user_id OR gc.created_by = 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 ) ) ), 'referral_discounts', ( SELECT COALESCE(json_agg(json_build_object( 'id', rd.id, 'discount_percent', rd.discount_percent, 'used', rd.used, 'created_at', rd.created_at, 'used_at', rd.used_at ) ORDER BY rd.created_at DESC), '[]'::json) FROM referral_discounts rd WHERE rd.user_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 LEFT JOIN bookings b ON p.booking_id = b.id LEFT JOIN gift_cards gc ON p.gift_card_id = gc.id WHERE b.user_id = target_user_id OR gc.created_by = 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(name, ', '), '') FROM ( SELECT s.name FROM booking_services bsvc JOIN services s ON bsvc.service_id = s.id WHERE bsvc.booking_id = lr.applied_to_booking_id UNION ALL SELECT cs.name FROM booking_custom_services bcs JOIN custom_services cs ON bcs.custom_service_id = cs.id WHERE bcs.booking_id = lr.applied_to_booking_id ) svc_names ), '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 ), '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 ), 'gift_card_balance', ( SELECT COALESCE( (SELECT json_build_object( 'balance', COALESCE(ugb.balance, 0), 'updated_at', ugb.updated_at ) FROM user_giftcard_balances ugb WHERE ugb.user_id = target_user_id), json_build_object('balance', 0, 'updated_at', NULL) ) ), 'gift_card_transactions', ( SELECT COALESCE(json_agg(json_build_object( 'id', gct.id, 'transaction_type', gct.transaction_type, 'amount', gct.amount, 'reference_type', gct.reference_type, 'reference_id', gct.reference_id, 'notes', gct.notes, 'created_at', gct.created_at ) ORDER BY gct.created_at DESC), '[]'::json) FROM gift_card_transactions gct WHERE gct.user_id = target_user_id ), 'gift_cards', ( SELECT COALESCE(json_agg(json_build_object( 'id', gc.id, 'total_funds_added', gc.total_funds_added, 'amount_remaining', gc.amount_remaining, 'expiry_date', gc.expiry_date, 'redeemed_at', gc.redeemed_at, 'created_at', gc.created_at ) ORDER BY gc.created_at DESC), '[]'::json) FROM gift_cards gc WHERE gc.redeemed_by = target_user_id ), 'admin_audit_log', ( SELECT COALESCE(json_agg(json_build_object( 'action_type', aal.action_type, 'details', aal.details, 'created_at', aal.created_at ) ORDER BY aal.created_at DESC), '[]'::json) FROM admin_audit_log aal WHERE aal.target_user_id = export_all_user_data.target_user_id ), 'login_audit', ( SELECT COALESCE(json_agg(json_build_object( 'attempt_type', la.attempt_type, 'ip_address', la.ip_address::text, 'success', la.success, 'created_at', la.created_at ) ORDER BY la.created_at DESC), '[]'::json) FROM login_audit la WHERE la.user_id = target_user_id ), 'refresh_tokens', ( SELECT COALESCE(json_agg(json_build_object( 'role', rt.role, 'revoked', rt.revoked, 'created_at', rt.created_at, 'expires_at', rt.expires_at ) ORDER BY rt.created_at DESC), '[]'::json) FROM refresh_tokens rt WHERE rt.user_id = target_user_id ), 'name_history', ( SELECT COALESCE(json_agg(json_build_object( 'previous_first_name', nh.previous_first_name, 'previous_last_name', nh.previous_last_name, 'booking_id', nh.booking_id, 'changed_at', nh.changed_at ) ORDER BY nh.changed_at DESC), '[]'::json) FROM name_history nh WHERE nh.user_id = target_user_id ), 'disputes', ( SELECT COALESCE(json_agg(json_build_object( 'dispute_id', d.id, 'payment_id', d.payment_id, 'status', d.status, 'amount', d.amount, 'reason', d.reason, 'created_at', d.created_at, 'updated_at', d.updated_at ) ORDER BY d.created_at DESC), '[]'::json) FROM disputes d JOIN payments p ON d.payment_id = p.id LEFT JOIN bookings b ON p.booking_id = b.id LEFT JOIN gift_cards gc ON p.gift_card_id = gc.id WHERE b.user_id = target_user_id OR gc.created_by = 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(total_sales), 0) AS total_sales, COALESCE(SUM(total_vat_charged), 0) AS total_vat_charged, COALESCE(SUM(net_sales), 0) AS net_sales, COALESCE(SUM(transaction_count), 0)::BIGINT AS transaction_count FROM ( SELECT 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(*)::BIGINT 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') UNION ALL SELECT COALESCE(SUM(ts.total_amount), 0) AS total_sales, COALESCE(SUM(COALESCE(ts.vat_amount, 0)), 0) AS total_vat_charged, COALESCE(SUM(COALESCE(ts.net_amount, ts.total_amount)), 0) AS net_sales, COUNT(*)::BIGINT AS transaction_count FROM till_sales ts WHERE ts.status = 'completed' AND ts.payment_method != 'on_the_house' AND ts.created_at::date BETWEEN start_date AND end_date ) sub; 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(COALESCE(s.name, cs.name), ', ' ORDER BY COALESCE(s.name, cs.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 COALESCE(p.net_amount, ROUND(p.amount / (1 + COALESCE(p.vat_rate, (SELECT default_vat_rate FROM business_settings)) / 100), 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 COALESCE(p.vat_amount, ROUND(p.amount - (p.amount / (1 + COALESCE(p.vat_rate, (SELECT default_vat_rate FROM business_settings)) / 100)), 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 LEFT JOIN booking_custom_services bcs ON b.id = bcs.booking_id LEFT JOIN custom_services cs ON bcs.custom_service_id = cs.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, till_sales_updated INT, total_vat_calculated NUMERIC(12,2), total_net_calculated NUMERIC(12,2), registration_effective_date DATE, settings_updated BOOLEAN ) AS $$ DECLARE pay_updated_count INT; till_updated_count INT; total_vat NUMERIC(12,2); total_net NUMERIC(12,2); settings_ok BOOLEAN := FALSE; BEGIN -- Retrospectively apply VAT to completed payments 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 pay_updated_count = ROW_COUNT; -- Retrospectively apply VAT to completed till sales (gift card purchases etc.) UPDATE till_sales SET vat_rate = enable_vat_registration.vat_rate, vat_amount = ROUND(total_amount - (total_amount / (1 + enable_vat_registration.vat_rate / 100)), 2), net_amount = ROUND(total_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 till_updated_count = ROW_COUNT; -- Aggregate totals 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; -- Also include till_sales in the aggregate totals SELECT total_vat + COALESCE(SUM(ts.vat_amount), 0), total_net + COALESCE(SUM(ts.net_amount), 0) INTO total_vat, total_net FROM till_sales ts WHERE status = 'completed' AND created_at::date >= registration_date AND vat_amount IS NOT NULL; -- Update business_settings record 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 pay_updated_count, till_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; CREATE OR REPLACE FUNCTION apply_vat_to_till_sale( sale_id CHAR(12), vat_rate NUMERIC(5,2) DEFAULT NULL ) 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 till_sales SET vat_rate = effective_rate, vat_amount = ROUND(total_amount - (total_amount / (1 + effective_rate / 100)), 2), net_amount = ROUND(total_amount / (1 + effective_rate / 100), 2), is_vat_applicable = TRUE, updated_at = NOW() WHERE id = sale_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(service_obj) FROM ( SELECT 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) ) AS service_obj FROM booking_services bsvc JOIN services s ON bsvc.service_id = s.id WHERE bsvc.booking_id = b.id UNION ALL SELECT json_build_object( 'service_id', cs.id, 'name', cs.name, 'description', cs.description, 'price', COALESCE(bcs.override_price, cs.price), 'duration_minutes', COALESCE(bcs.override_duration_minutes, cs.duration_minutes) ) FROM booking_custom_services bcs JOIN custom_services cs ON bcs.custom_service_id = cs.id WHERE bcs.booking_id = b.id ) svc_sub ), '[]'::json) AS services, COALESCE(( SELECT SUM(dur) FROM ( SELECT COALESCE(bsvc.override_duration_minutes, s.duration_minutes) AS dur FROM booking_services bsvc JOIN services s ON bsvc.service_id = s.id WHERE bsvc.booking_id = b.id UNION ALL SELECT COALESCE(bcs.override_duration_minutes, cs.duration_minutes) FROM booking_custom_services bcs JOIN custom_services cs ON bcs.custom_service_id = cs.id WHERE bcs.booking_id = b.id ) dur_sub ), 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) REFERENCES users(id) ON DELETE SET NULL, -- NULL once a card is scrubbed by anonymization (anonymize_user, -- delete_guest_user, AnonymizeStaleGuestAccounts). A scrubbed card has no -- Square id — the reference must be removable for GDPR right-to-erasure. square_card_id TEXT, -- Square customer profile id (P14): populated lazily the first time the -- user SAVES a card, then reused for every subsequent card save. NULL for -- rows created before provisioning was introduced. One-off (non-save) -- payments never mint a Square customer, so this stays NULL for them. square_customer_id TEXT, 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(), -- Uniqueness is per-user: the same physical card saved by two users yields -- two independent rows, so user B saving a card already on user A's account -- can never mutate A's row (or revive A's deleted card). A response-lost -- retry re-tokenizing the same card for the SAME user upserts via this key. UNIQUE (user_id, square_card_id) ); 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 is NULL for non-booking payments (e.g. gift-card purchase -- refunds); the payment row still links the refund via payment_id. booking_id CHAR(12) 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', refund_attempts INT NOT NULL DEFAULT 0, origin VARCHAR(16) NOT NULL DEFAULT 'manual', reason TEXT NOT NULL, idempotency_key VARCHAR(64) UNIQUE, 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); -- ======================================= -- TILL SALES TABLE -- Point-of-sale transactions not linked to a booking (gift card purchases, retail products, etc.) -- Extensible for future at-the-till products via till_item_type + item_id -- ======================================= CREATE TABLE till_sales ( id CHAR(12) PRIMARY KEY DEFAULT generate_till_sale_id(), item_type till_item_type NOT NULL, item_id CHAR(12), -- FK to gift_cards, future product tables description TEXT NOT NULL DEFAULT '', quantity INT NOT NULL DEFAULT 1, unit_price NUMERIC(10,2) NOT NULL, total_amount NUMERIC(10,2) NOT NULL, payment_method payment_method NOT NULL, status payment_status NOT NULL DEFAULT 'pending', user_id CHAR(12) REFERENCES users(id) ON DELETE SET NULL, -- customer (nullable for walk-ins) user_saved_card_id CHAR(12) REFERENCES user_saved_cards(id) ON DELETE SET NULL, square_payment_id TEXT, square_checkout_id TEXT, -- Mirrors payments.square_source_id: the exact source_id sent in the -- CreatePayment call, enabling identical-body replay by the sweep. square_source_id TEXT, -- Full JSON of the original CreatePayment request for identical-body replay -- by the sweep (see payments.square_request_snapshot). square_request_snapshot TEXT, idempotency_key VARCHAR(64) UNIQUE, notes TEXT, created_by CHAR(12) NOT NULL REFERENCES users(id) ON DELETE SET NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- VAT columns (NULL until VAT registered — SPV treatment for gift cards) is_vat_applicable BOOLEAN NOT NULL DEFAULT FALSE, vat_rate NUMERIC(5,2), vat_amount NUMERIC(10,2), net_amount NUMERIC(10,2) ); CREATE INDEX idx_till_sales_created_at ON till_sales(created_at); CREATE INDEX idx_till_sales_item_type ON till_sales(item_type); CREATE INDEX idx_till_sales_user ON till_sales(user_id) WHERE user_id IS NOT NULL; CREATE INDEX idx_till_sales_square_checkout ON till_sales(square_checkout_id) WHERE square_checkout_id IS NOT NULL; -- ======================================= -- FINANCIAL AGGREGATES TABLE -- Monthly aggregated financial statistics (no PII) -- ======================================= -- FINANCIAL AGGREGATES TABLE -- ======================================= -- Monthly aggregates replacing granular transaction records after retention expires. -- -- Retention: MAX(p.created_at + 7 years, user_anonymized_at + 1 year) -- - 7 years: HMRC requirement for corporation tax records (HMRC CH14600). -- Companies Act 2006 s.388 requires 3 years for private companies, but tax -- law extends this to 6 years. We use 7 years as a conservative buffer. -- - 1 year after anonymization: Allows time to handle disputes/complaints -- after account deletion. Scottish prescriptive period is 5 years, but -- 1 year post-anonymization is sufficient for most operational needs. -- -- Populated by CleanupExpiredFinancialRecords when granular records expire. -- This table is retained indefinitely (no deletion) as it contains no PII. -- ======================================= CREATE TABLE financial_aggregates ( month DATE PRIMARY KEY, total_payments NUMERIC(12,2) NOT NULL DEFAULT 0, total_refunds NUMERIC(12,2) NOT NULL DEFAULT 0, total_square_fees NUMERIC(12,2) NOT NULL DEFAULT 0, total_cash NUMERIC(12,2) NOT NULL DEFAULT 0, total_online NUMERIC(12,2) NOT NULL DEFAULT 0, total_in_person NUMERIC(12,2) NOT NULL DEFAULT 0, total_discounts NUMERIC(12,2) NOT NULL DEFAULT 0, total_giftcard NUMERIC(12,2) NOT NULL DEFAULT 0, total_tips NUMERIC(12,2) NOT NULL DEFAULT 0, total_deposits NUMERIC(12,2) NOT NULL DEFAULT 0, total_balances NUMERIC(12,2) NOT NULL DEFAULT 0, total_partials NUMERIC(12,2) NOT NULL DEFAULT 0, total_vat_amount NUMERIC(12,2) NOT NULL DEFAULT 0, total_net_amount NUMERIC(12,2) NOT NULL DEFAULT 0, booking_count INT NOT NULL DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); -- ======================================= -- 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); -- ======================================= -- GIFT CARDS TABLE -- ======================================= -- Gift cards are Single-Purpose Vouchers (SPVs) under UK VAT law. -- VAT is charged at point of sale, NOT at redemption. -- -- Expiry: rolling from last usage (industry standard per CMA guidance), -- default 24 months. The window is configurable via -- business_settings.gift_card_expiry_months (admin settings) — the SINGLE -- source of truth read by the expiry job and every expiry_date write. -- UK Consumer Rights Act 2015 requires expiry terms to be "fair and transparent". -- 24 months matches premium retailers (John Lewis, M&S, Sainsbury's) and is -- widely considered reasonable by the CMA. Under 12 months risks being challenged -- as an unfair contract term. -- -- Once redeemed to an account, the value becomes account balance (no expiry on -- the balance itself, but the account can be deleted after idle time per GDPR). -- ======================================= CREATE TABLE gift_cards ( id CHAR(12) PRIMARY KEY DEFAULT generate_gift_cards_id(), total_funds_added NUMERIC(12,2) NOT NULL DEFAULT 0, amount_remaining NUMERIC(12,2) NOT NULL DEFAULT 0, created_by CHAR(12) REFERENCES users(id), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), redeemed_at TIMESTAMPTZ, redeemed_by CHAR(12) REFERENCES users(id), is_inventory BOOLEAN NOT NULL DEFAULT FALSE, expiry_date TIMESTAMPTZ, -- Rolling expiry: 24 months from last usage (redeem, topup, balance check) last_used_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- Voucher type at time of purchase ('SPV' or 'MPV'). Read at redemption to -- determine whether VAT was already paid at sale (SPV) or must be applied -- now (MPV). NULL if the card was created before this column was added. voucher_type_at_purchase VARCHAR(3) ); CREATE INDEX idx_gift_cards_last_used_at ON gift_cards(last_used_at) WHERE last_used_at IS NOT NULL AND redeemed_by IS NULL AND amount_remaining > 0; -- ======================================= -- USER GIFTCARD BALANCES TABLE -- ======================================= -- Pooled balance from redeemed gift cards. Once redeemed, the value is no longer -- tied to a specific gift card — it becomes account credit. -- -- Account balances do NOT expire directly. However, the account holding the balance -- can be deleted after idle time per GDPR storage limitation (Article 5(1)(e)): -- - No money: 2 years idle (legitimate interest in customer relationship weakens) -- - With money: 5 years idle (Scottish prescriptive period for contract claims, -- Prescription and Limitation (Scotland) Act 1973 s.6) -- -- When an account with balance is deleted, the balance moves to gift_card_expired_balances -- for recovery claims (retained indefinitely, no PII). -- ======================================= CREATE TABLE user_giftcard_balances ( user_id CHAR(12) PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, balance NUMERIC(12,2) NOT NULL DEFAULT 0, updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); -- ======================================= -- GIFT CARD TRANSACTIONS TABLE (Audit Log) -- ======================================= CREATE TABLE gift_card_transactions ( id CHAR(12) PRIMARY KEY DEFAULT generate_gift_card_transactions_id(), gift_card_id CHAR(12) NOT NULL REFERENCES gift_cards(id), transaction_type VARCHAR(20) NOT NULL, amount NUMERIC(10,2) NOT NULL, reference_type VARCHAR(20), reference_id CHAR(12), user_id CHAR(12) REFERENCES users(id), notes TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX idx_gift_card_transactions_card ON gift_card_transactions(gift_card_id); CREATE INDEX idx_gift_card_transactions_type ON gift_card_transactions(transaction_type); -- ======================================= -- GIFT CARD EXPIRED BALANCES TABLE (Recovery Mechanism) -- ======================================= -- Dormant balances from deleted accounts. Retained indefinitely to allow recovery -- claims via account ID. -- -- Legal basis: UK consumer protection law treats gift card balances as the customer's -- money held by the business. When an account is deleted (GDPR compliance), the balance -- becomes "dormant" but remains claimable. This transforms "forfeiture" into "dormancy", -- reducing legal risk from 40-50% to 10-20% per CMA unfair terms analysis. -- -- Analogous to Dormant Bank and Building Society Accounts Act 2008 (15-year dormancy -- before transfer to reclaim fund). No claim deadline imposed — consumer can recover -- at any time with valid account ID. -- -- No PII stored: only account ID (nullable — unredeemed gift cards have no -- account) + balance amount. Account-anonymized claims are verified with the -- account ID; unredeemed-card expiries carry NULL (no account to reference). -- ======================================= CREATE TABLE gift_card_expired_balances ( id CHAR(12) PRIMARY KEY DEFAULT generate_gift_card_expired_balances_id(), -- NULL for unredeemed gift-card expiries (no user account to reference); -- account-based expiries always store the account ID. account_id CHAR(12), original_balance NUMERIC(10,2) NOT NULL, expired_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), claimed_at TIMESTAMPTZ, claimed_by_admin CHAR(12) REFERENCES users(id), notes TEXT ); CREATE INDEX idx_gift_card_expired_balances_account ON gift_card_expired_balances(account_id); CREATE INDEX idx_gift_card_expired_balances_unclaimed ON gift_card_expired_balances(claimed_at) WHERE claimed_at IS NULL; CREATE INDEX idx_gift_card_transactions_created_at ON gift_card_transactions(created_at); -- ======================================= -- ADMIN AUDIT LOG TABLE -- ======================================= -- Records admin actions that access or modify user financial data. -- Legal basis: GDPR Article 30 (records of processing) and financial audit requirements. -- ======================================= CREATE TABLE admin_audit_log ( id CHAR(12) PRIMARY KEY DEFAULT generate_admin_audit_log_id(), admin_id CHAR(12) NOT NULL REFERENCES users(id), action_type VARCHAR(30) NOT NULL, target_user_id CHAR(12) REFERENCES users(id), target_gift_card_id CHAR(12) REFERENCES gift_cards(id), details JSONB, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX idx_admin_audit_log_admin ON admin_audit_log(admin_id); CREATE INDEX idx_admin_audit_log_target_user ON admin_audit_log(target_user_id); CREATE INDEX idx_admin_audit_log_created_at ON admin_audit_log(created_at); -- ======================================= -- SQUARE WEBHOOK EVENTS TABLE (Webhook Dedup) -- ======================================= -- Records every Square webhook event_id the handler has accepted. The webhook -- handler inserts with ON CONFLICT (event_id) DO NOTHING and treats a 0-row -- insert as a duplicate, giving restart-safe, at-most-once delivery with no -- eviction limit (unlike the in-memory fast-path cache). CREATE TABLE IF NOT EXISTS square_webhook_events ( event_id TEXT PRIMARY KEY, received_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); -- ======================================= -- DISPUTES TABLE -- Chargeback/dispute tracking surfaced from Square webhooks (dispute.created, -- dispute.state.updated). Each row is one Square dispute against a local -- payment, with the terminal resolution (open → won/lost) recorded so the -- admin can see chargebacks in the DB-backed 'critical_payment_log' -- notification centre instead of un-watched CRITICAL log lines. -- ======================================= CREATE TABLE IF NOT EXISTS disputes ( id CHAR(12) PRIMARY KEY DEFAULT generate_dispute_id(), square_dispute_id VARCHAR(64) UNIQUE NOT NULL, payment_id CHAR(12) NOT NULL REFERENCES payments(id), status VARCHAR(32) NOT NULL DEFAULT 'open', -- open, won, lost amount NUMERIC(10,2) NOT NULL, reason VARCHAR(192), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX idx_disputes_payment ON disputes(payment_id); CREATE INDEX idx_disputes_status ON disputes(status); -- ======================================= -- 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'); -- Account lockout audit log CREATE TABLE IF NOT EXISTS login_audit ( id BIGSERIAL PRIMARY KEY, user_id CHAR(12) REFERENCES users(id) ON DELETE SET NULL, ip_address INET NOT NULL, attempt_type TEXT NOT NULL, -- 'login' | 'register' | 'refresh' success BOOLEAN NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_login_audit_user_id ON login_audit (user_id); CREATE INDEX IF NOT EXISTS idx_login_audit_ip ON login_audit (ip_address); CREATE INDEX IF NOT EXISTS idx_login_audit_created_at ON login_audit (created_at); -- ======================================= -- 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. - apply_vat_to_till_sale(sale_id, vat_rate) → Called for every new till sale after VAT registration (gift card SPV treatment). -------------------------------------------------------------------------------- 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() */ -- ============================================================================ -- Auth tables -- ============================================================================ -- Revoked JTIs for JWT token revocation CREATE TABLE IF NOT EXISTS revoked_jtis ( jti TEXT PRIMARY KEY, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), expires_at TIMESTAMPTZ NOT NULL ); CREATE INDEX IF NOT EXISTS idx_revoked_jtis_expires_at ON revoked_jtis (expires_at); -- Refresh tokens with rotation tracking CREATE TABLE IF NOT EXISTS refresh_tokens ( id BIGSERIAL PRIMARY KEY, user_id CHAR(12) NOT NULL REFERENCES users(id) ON DELETE CASCADE, token_hash TEXT NOT NULL UNIQUE, role TEXT NOT NULL, revoked BOOLEAN NOT NULL DEFAULT FALSE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), expires_at TIMESTAMPTZ NOT NULL ); CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user_id ON refresh_tokens (user_id); CREATE INDEX IF NOT EXISTS idx_refresh_tokens_expires_at ON refresh_tokens (expires_at);