Retrospectively apply VAT to till sales in enable_vat_registration function. Add voucher_type_at_purchase column to gift_cards for SPV/MPV per-card tracking. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2400 lines
97 KiB
PL/PgSQL
2400 lines
97 KiB
PL/PgSQL
-- =======================================
|
|
-- NAIL SALON DATABASE SCHEMA
|
|
-- Complete init.sql with GDPR & Tax Compliance
|
|
-- =======================================
|
|
|
|
-- Enable pgcrypto for generating random IDs
|
|
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
|
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
|
|
|
-- =======================================
|
|
-- ENUMS
|
|
-- =======================================
|
|
|
|
CREATE TYPE account_role AS ENUM ('unverified_email', 'verified_email', 'admin', 'guest', 'affiliate');
|
|
CREATE TYPE account_type AS ENUM ('email', 'google', 'microsoft', 'facebook', 'guest');
|
|
CREATE TYPE payment_type AS ENUM ('deposit', 'full', 'tip', 'balance', 'partial');
|
|
CREATE TYPE payment_method AS ENUM ('online_square', 'in_person_card', 'cash', 'giftcard', 'discount', '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_square_deposit_id() RETURNS CHAR(12) AS $$
|
|
SELECT generate_short_id('square_deposits');
|
|
$$ 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_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(),
|
|
data_retention_consent BOOLEAN NOT NULL DEFAULT TRUE,
|
|
data_consent_updated_at TIMESTAMPTZ DEFAULT NOW(),
|
|
-- Audit fields
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
-- Deposit tracking: remaining deposits needed (0-3). Reduces by 1 when booking with payment completes.
|
|
deposits_required INT NOT NULL DEFAULT 0,
|
|
-- staff fields
|
|
notes TEXT
|
|
);
|
|
|
|
CREATE TABLE user_social_logins (
|
|
id 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';
|
|
|
|
-- =======================================
|
|
-- 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.
|
|
|
|
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),
|
|
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;
|
|
|
|
-- =======================================
|
|
-- 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',
|
|
notes TEXT,
|
|
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);
|
|
|
|
ALTER TABLE name_history ADD CONSTRAINT fk_name_history_booking_id
|
|
FOREIGN KEY (booking_id) REFERENCES bookings(id) ON DELETE SET 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);
|
|
|
|
-- =======================================
|
|
-- 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 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),
|
|
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);
|
|
|
|
-- =======================================
|
|
-- 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 12,
|
|
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
|
|
) VALUES (
|
|
'Crussell Nail Art Studio',
|
|
'Address',
|
|
'+44 131 123 4567',
|
|
'crussellnails@gmail.com',
|
|
NULL, -- Set this when we register for VAT
|
|
FALSE, -- Set to TRUE when we register for VAT
|
|
20.00,
|
|
'GBP',
|
|
'https://www.website.co.uk'
|
|
);
|
|
|
|
CREATE TYPE admin_notification_reason AS ENUM ('pending_booking', 'cancelled_booking', 'rescheduled_booking', '1_week_no_pay', '1_month_no_pay', 'affiliate_claim', 'late_cancellation', 'deposit_paid', 'edit_request', 'edit_requested', 'new_booking', 'deposit_not_paid_by_deadline');
|
|
|
|
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
|
|
-- OUTPUT: Converts personal data to anonymous placeholder
|
|
-- NOTE: phone/date_of_birth are NOT NULL so we use placeholder values, not NULL
|
|
CREATE OR REPLACE FUNCTION anonymize_user(target_id CHAR(12))
|
|
RETURNS VOID AS $$
|
|
BEGIN
|
|
UPDATE users
|
|
SET
|
|
n_first_name = 'Deleted',
|
|
n_last_name = 'User',
|
|
email = CONCAT('deleted+', target_id, '@deleted.invalid'),
|
|
phone = '+000000000000', -- NOT NULL column: use placeholder
|
|
date_of_birth = '1900-01-01', -- NOT NULL column: use placeholder
|
|
profile_pic_url = NULL,
|
|
account_role = 'guest',
|
|
loyalty_stamps = 0,
|
|
referral_code = NULL,
|
|
data_retention_consent = FALSE,
|
|
data_consent_updated_at = NOW(),
|
|
updated_at = NOW(),
|
|
password_hash = NULL
|
|
WHERE id = target_id
|
|
AND account_role != 'guest';
|
|
|
|
-- Scrub social login identities (immutable_id is PII from OAuth providers)
|
|
DELETE FROM user_social_logins WHERE user_id = target_id;
|
|
|
|
-- Soft-delete all saved cards and clear PCI data
|
|
UPDATE user_saved_cards
|
|
SET deleted_at = NOW(),
|
|
retained_until = NOW() + INTERVAL '7 years',
|
|
last_4 = 'XXXX',
|
|
fingerprint = NULL,
|
|
exp_month = 1,
|
|
exp_year = 2000
|
|
WHERE user_id = target_id;
|
|
|
|
-- Expire all pending verification codes
|
|
UPDATE verification_codes
|
|
SET used_at = NOW()
|
|
WHERE user_id = target_id
|
|
AND used_at IS NULL;
|
|
|
|
-- Scrub RESERVATION entries in time_blockers that reference this user
|
|
UPDATE time_blockers
|
|
SET description = NULL
|
|
WHERE created_by = target_id
|
|
AND description LIKE 'RESERVATION:user:%';
|
|
|
|
-- Scrub notes on booking_edit_requests made by this user (free-text PII)
|
|
UPDATE booking_edit_requests
|
|
SET notes = NULL
|
|
WHERE requested_by = target_id;
|
|
|
|
-- Clear notification preferences (no contractual basis after account closure)
|
|
DELETE FROM user_notification_preferences WHERE user_id = target_id;
|
|
|
|
-- 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;
|
|
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,
|
|
user_id = NULL
|
|
WHERE 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
|
|
JOIN bookings b ON p.booking_id = b.id
|
|
WHERE b.user_id = target_user_id
|
|
),
|
|
'patch_tests', (
|
|
SELECT COALESCE(json_agg(
|
|
json_build_object(
|
|
'patch_test_id', upt.patch_test_id,
|
|
'name', pt.name,
|
|
'description', pt.description,
|
|
'tested_at', upt.tested_at
|
|
)
|
|
ORDER BY upt.tested_at DESC), '[]'::json)
|
|
FROM user_patch_tests upt
|
|
JOIN patch_tests pt ON upt.patch_test_id = pt.id
|
|
WHERE upt.user_id = target_user_id
|
|
),
|
|
'referrals', (
|
|
SELECT json_build_object(
|
|
'referred_by', (
|
|
SELECT json_build_object(
|
|
'referrer_id', ur.referrer_id,
|
|
'referrer_name', u.fn,
|
|
'referred_at', ur.referred_at
|
|
)
|
|
FROM user_referrals ur
|
|
JOIN users u ON u.id = ur.referrer_id
|
|
WHERE ur.referred_id = target_user_id
|
|
LIMIT 1
|
|
),
|
|
'referred_users', (
|
|
SELECT COALESCE(json_agg(
|
|
json_build_object(
|
|
'referred_id', ur.referred_id,
|
|
'referred_name', u.fn,
|
|
'referred_at', ur.referred_at
|
|
)
|
|
ORDER BY ur.referred_at DESC), '[]'::json)
|
|
FROM user_referrals ur
|
|
JOIN users u ON u.id = ur.referred_id
|
|
WHERE ur.referrer_id = target_user_id
|
|
)
|
|
)
|
|
),
|
|
'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
|
|
JOIN bookings b ON p.booking_id = b.id
|
|
WHERE b.user_id = target_user_id
|
|
),
|
|
'social_logins', (
|
|
SELECT COALESCE(json_agg(json_build_object(
|
|
'provider', usl.provider,
|
|
'created_at', usl.created_at
|
|
) ORDER BY usl.created_at ASC), '[]'::json)
|
|
FROM user_social_logins usl WHERE usl.user_id = target_user_id
|
|
),
|
|
'loyalty_redemptions', (
|
|
SELECT COALESCE(json_agg(json_build_object(
|
|
'id', lr.id,
|
|
'stamps_redeemed', lr.stamps_redeemed,
|
|
'status', lr.status,
|
|
'booking_date', b.start_time,
|
|
'booking_services', (
|
|
SELECT COALESCE(string_agg(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
|
|
),
|
|
'export_metadata', json_build_object(
|
|
'exported_at', NOW(),
|
|
'exported_by', 'system',
|
|
'user_id', target_user_id,
|
|
'format_version', '1.0'
|
|
)
|
|
) INTO result;
|
|
|
|
RETURN result;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
-- =======================================
|
|
-- TAX COMPLIANCE FUNCTIONS
|
|
-- =======================================
|
|
|
|
/*
|
|
TAX COMPLIANCE NOTES:
|
|
- Below £90k turnover: No VAT registration required
|
|
- Above £90k turnover: VAT registration mandatory, Making Tax Digital required
|
|
- All businesses: Income tax records required (simplified from April 2026/2027)
|
|
- VAT rate: 20% standard rate applies to nail services
|
|
*/
|
|
|
|
-- VAT Return Data (for Making Tax Digital submission)
|
|
-- WHY: VAT registered businesses must submit quarterly VAT returns via MTD software
|
|
-- WHEN: Every quarter if VAT registered (£90k+ annual turnover)
|
|
-- OUTPUT: Summary totals for VAT return - total sales, VAT charged, net sales
|
|
-- USE: Export to QuickBooks/Xero for MTD submission to HMRC
|
|
CREATE OR REPLACE FUNCTION get_vat_return_data(
|
|
start_date DATE,
|
|
end_date DATE
|
|
)
|
|
RETURNS TABLE (
|
|
period_start DATE,
|
|
period_end DATE,
|
|
total_sales NUMERIC(12,2),
|
|
total_vat_charged NUMERIC(12,2),
|
|
net_sales NUMERIC(12,2),
|
|
transaction_count BIGINT
|
|
) AS $$
|
|
BEGIN
|
|
RETURN QUERY
|
|
SELECT
|
|
start_date AS period_start,
|
|
end_date AS period_end,
|
|
COALESCE(SUM(p.amount), 0) AS total_sales,
|
|
COALESCE(SUM(
|
|
CASE
|
|
WHEN p.vat_amount IS NOT NULL THEN p.vat_amount
|
|
WHEN (SELECT is_vat_registered FROM business_settings WHERE id = 1) THEN
|
|
ROUND(p.amount - (p.amount / (1 + COALESCE(p.vat_rate,
|
|
(SELECT default_vat_rate FROM business_settings WHERE id = 1)) / 100)), 2)
|
|
ELSE 0
|
|
END
|
|
), 0) AS total_vat_charged,
|
|
COALESCE(SUM(
|
|
CASE
|
|
WHEN p.net_amount IS NOT NULL THEN p.net_amount
|
|
WHEN (SELECT is_vat_registered FROM business_settings WHERE id = 1) THEN
|
|
ROUND(p.amount / (1 + COALESCE(p.vat_rate,
|
|
(SELECT default_vat_rate FROM business_settings WHERE id = 1)) / 100), 2)
|
|
ELSE p.amount
|
|
END
|
|
), 0) AS net_sales,
|
|
COUNT(*) AS transaction_count
|
|
FROM payments p
|
|
JOIN bookings b ON p.booking_id = b.id
|
|
WHERE p.status = 'completed'
|
|
AND p.created_at::date BETWEEN start_date AND end_date
|
|
AND p.payment_type IN ('full', 'partial', 'balance');
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
-- Detailed Transaction Export (Primary tax function)
|
|
-- WHY: All businesses need transaction records for tax returns and accounting
|
|
-- WHEN: Monthly/quarterly for accounting software import
|
|
-- OUTPUT: CSV-compatible transaction list with customer, service, payment details
|
|
-- USE: Import directly into QuickBooks, Xero, or other accounting software
|
|
-- NOTE: include_vat=false (default for micro businesses); set true only when VAT registered
|
|
CREATE OR REPLACE FUNCTION export_sales_transactions(
|
|
start_date DATE,
|
|
end_date DATE,
|
|
include_vat BOOLEAN DEFAULT FALSE
|
|
)
|
|
RETURNS TABLE (
|
|
transaction_date DATE,
|
|
invoice_number TEXT,
|
|
customer_name TEXT,
|
|
customer_email TEXT,
|
|
service_description TEXT,
|
|
payment_method TEXT,
|
|
gross_amount NUMERIC(10,2),
|
|
net_amount NUMERIC(10,2),
|
|
vat_amount NUMERIC(10,2),
|
|
vat_rate NUMERIC(5,2),
|
|
booking_id CHAR(12),
|
|
payment_id CHAR(12)
|
|
) AS $$
|
|
BEGIN
|
|
RETURN QUERY
|
|
SELECT
|
|
p.created_at::date AS transaction_date,
|
|
p.invoice_number::text AS invoice_number,
|
|
COALESCE(
|
|
CASE WHEN u.n_first_name = 'Deleted' THEN NULL ELSE u.fn END,
|
|
'Walk-in Customer'
|
|
) AS customer_name,
|
|
CASE WHEN u.n_first_name = 'Deleted' THEN NULL ELSE u.email END AS customer_email,
|
|
string_agg(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 ROUND(p.amount / 1.20, 2)
|
|
ELSE p.amount
|
|
END AS net_amount,
|
|
CASE
|
|
WHEN include_vat AND p.vat_amount IS NOT NULL THEN p.vat_amount
|
|
WHEN include_vat THEN ROUND(p.amount - (p.amount / 1.20), 2)
|
|
ELSE 0
|
|
END AS vat_amount,
|
|
CASE
|
|
WHEN include_vat THEN COALESCE(p.vat_rate, 20.00)
|
|
ELSE NULL
|
|
END AS vat_rate,
|
|
b.id AS booking_id,
|
|
p.id AS payment_id
|
|
FROM payments p
|
|
JOIN bookings b ON p.booking_id = b.id
|
|
LEFT JOIN users u ON b.user_id = u.id
|
|
LEFT JOIN booking_services bsvc ON b.id = bsvc.booking_id
|
|
LEFT JOIN services s ON bsvc.service_id = s.id
|
|
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,
|
|
square_card_id TEXT NOT NULL UNIQUE,
|
|
brand TEXT NOT NULL,
|
|
last_4 TEXT NOT NULL,
|
|
exp_month INT NOT NULL,
|
|
exp_year INT NOT NULL,
|
|
fingerprint TEXT,
|
|
is_default BOOLEAN NOT NULL DEFAULT FALSE,
|
|
deleted_at TIMESTAMPTZ,
|
|
deleted_by CHAR(12) REFERENCES users(id) ON DELETE SET NULL,
|
|
retained_until TIMESTAMPTZ,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
);
|
|
|
|
CREATE INDEX idx_user_saved_cards_user ON user_saved_cards(user_id);
|
|
CREATE INDEX idx_user_saved_cards_fingerprint ON user_saved_cards(fingerprint);
|
|
CREATE INDEX idx_user_saved_cards_active ON user_saved_cards(user_id, deleted_at) WHERE deleted_at IS NULL;
|
|
|
|
-- =======================================
|
|
-- REFUNDS TABLE
|
|
-- =======================================
|
|
|
|
CREATE TABLE refunds (
|
|
id CHAR(12) PRIMARY KEY DEFAULT generate_refund_id(),
|
|
payment_id CHAR(12) NOT NULL REFERENCES payments(id) ON DELETE CASCADE,
|
|
booking_id CHAR(12) NOT NULL REFERENCES bookings(id) ON DELETE CASCADE,
|
|
amount NUMERIC(10,2) NOT NULL CHECK (amount > 0),
|
|
square_refund_id TEXT,
|
|
status payment_status NOT NULL DEFAULT 'pending',
|
|
reason TEXT NOT NULL,
|
|
created_by CHAR(12) REFERENCES users(id) ON DELETE SET NULL,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
);
|
|
|
|
CREATE INDEX idx_refunds_payment ON refunds(payment_id);
|
|
CREATE INDEX idx_refunds_booking ON refunds(booking_id);
|
|
|
|
-- =======================================
|
|
-- 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,
|
|
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,
|
|
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: 24 months rolling from last usage (industry standard per CMA guidance).
|
|
-- 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 + balance amount. After account anonymization,
|
|
-- we cannot verify claims without the account ID (by design — GDPR compliance).
|
|
-- =======================================
|
|
|
|
CREATE TABLE gift_card_expired_balances (
|
|
id CHAR(12) PRIMARY KEY DEFAULT generate_gift_card_expired_balances_id(),
|
|
account_id CHAR(12) NOT NULL,
|
|
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 DEPOSITS TABLE (Bank Reconciliation)
|
|
-- =======================================
|
|
|
|
CREATE TABLE square_deposits (
|
|
id CHAR(12) PRIMARY KEY DEFAULT generate_square_deposit_id(),
|
|
square_deposit_id TEXT,
|
|
amount NUMERIC(10,2) NOT NULL,
|
|
fees_deducted NUMERIC(10,2),
|
|
deposited_at TIMESTAMPTZ,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
);
|
|
|
|
CREATE INDEX idx_square_deposits_deposited ON square_deposits(deposited_at);
|
|
|
|
-- =======================================
|
|
-- CARDDAV / CALDAV TABLES (used by SabreDAV sync in Go backend)
|
|
-- =======================================
|
|
|
|
CREATE TABLE IF NOT EXISTS dav_principals (
|
|
id SERIAL PRIMARY KEY,
|
|
uri VARCHAR(255) NOT NULL UNIQUE,
|
|
email VARCHAR(255),
|
|
displayname VARCHAR(255)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS dav_calendars (
|
|
id SERIAL PRIMARY KEY,
|
|
principaluri VARCHAR(255) NOT NULL,
|
|
displayname VARCHAR(255),
|
|
uri VARCHAR(255) NOT NULL,
|
|
description TEXT,
|
|
calendarorder INT DEFAULT 0,
|
|
calendarcolor VARCHAR(10),
|
|
timezone TEXT,
|
|
components VARCHAR(255),
|
|
transparent BOOLEAN DEFAULT FALSE,
|
|
UNIQUE(principaluri, uri)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS dav_calendarobjects (
|
|
id SERIAL PRIMARY KEY,
|
|
calendardata TEXT,
|
|
uri VARCHAR(255) NOT NULL,
|
|
calendarid INTEGER NOT NULL REFERENCES dav_calendars(id) ON DELETE CASCADE,
|
|
lastmodified INTEGER,
|
|
etag VARCHAR(32),
|
|
size INTEGER,
|
|
componenttype VARCHAR(8),
|
|
firstoccurence INTEGER,
|
|
lastoccurence INTEGER,
|
|
uid VARCHAR(255),
|
|
UNIQUE(calendarid, uri)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS dav_addressbooks (
|
|
id SERIAL PRIMARY KEY,
|
|
principaluri VARCHAR(255) NOT NULL,
|
|
displayname VARCHAR(255),
|
|
uri VARCHAR(255) NOT NULL,
|
|
description TEXT,
|
|
synctoken INTEGER DEFAULT 1,
|
|
UNIQUE(principaluri, uri)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS dav_cards (
|
|
id SERIAL PRIMARY KEY,
|
|
addressbookid INTEGER NOT NULL REFERENCES dav_addressbooks(id) ON DELETE CASCADE,
|
|
carddata TEXT,
|
|
uri VARCHAR(255) NOT NULL,
|
|
lastmodified INTEGER,
|
|
etag VARCHAR(32),
|
|
size INTEGER,
|
|
UNIQUE(addressbookid, uri)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS dav_addressbookchanges (
|
|
id SERIAL PRIMARY KEY,
|
|
uri VARCHAR(255) NOT NULL,
|
|
synctoken INTEGER NOT NULL,
|
|
addressbookid INTEGER NOT NULL REFERENCES dav_addressbooks(id) ON DELETE CASCADE,
|
|
operation SMALLINT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS dav_calendarchanges (
|
|
id SERIAL PRIMARY KEY,
|
|
uri VARCHAR(255) NOT NULL,
|
|
synctoken INTEGER NOT NULL,
|
|
calendarid INTEGER NOT NULL REFERENCES dav_calendars(id) ON DELETE CASCADE,
|
|
operation SMALLINT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS dav_users (
|
|
id SERIAL PRIMARY KEY,
|
|
username VARCHAR(255) NOT NULL UNIQUE,
|
|
digesta1 VARCHAR(32) NOT NULL
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_calendarobjects_calendarid ON dav_calendarobjects(calendarid);
|
|
CREATE INDEX IF NOT EXISTS idx_cards_addressbookid ON dav_cards(addressbookid);
|
|
|
|
-- Seed default principal, addressbook, and calendar for CardDAV/CalDAV sync
|
|
INSERT INTO dav_principals (uri, email, displayname)
|
|
SELECT 'principals/default', 'admin@example.com', 'Default User'
|
|
WHERE NOT EXISTS (SELECT 1 FROM dav_principals WHERE uri = 'principals/default');
|
|
|
|
INSERT INTO dav_addressbooks (principaluri, displayname, uri, description, synctoken)
|
|
SELECT 'principals/default', 'Contacts', 'default', 'Default address book', 1
|
|
WHERE NOT EXISTS (SELECT 1 FROM dav_addressbooks WHERE uri = 'default');
|
|
|
|
INSERT INTO dav_calendars (principaluri, displayname, uri, description, components, transparent)
|
|
SELECT 'principals/default', 'Default Calendar', 'default', 'Default calendar', 'VEVENT,VTODO', false
|
|
WHERE NOT EXISTS (SELECT 1 FROM dav_calendars WHERE uri = 'default');
|
|
|
|
-- 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);
|
|
|
|
-- Migration: add voucher_type_at_purchase to gift_cards (for SPV/MPV per-card tracking)
|
|
ALTER TABLE gift_cards ADD COLUMN IF NOT EXISTS voucher_type_at_purchase VARCHAR(3);
|