diff --git a/init-scripts/init-script.sql b/init-scripts/init-script.sql index aa2fc6a..544224d 100644 --- a/init-scripts/init-script.sql +++ b/init-scripts/init-script.sql @@ -174,7 +174,6 @@ CREATE TABLE user_patch_tests ( user_id CHAR(12) NOT NULL REFERENCES users(id) ON DELETE CASCADE, patch_test_id CHAR(12) NOT NULL REFERENCES patch_tests(id) ON DELETE CASCADE, tested_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - notes TEXT, UNIQUE (user_id, patch_test_id) ); @@ -453,41 +452,30 @@ GDPR COMPLIANCE NOTES: -- 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, '@example.com'), - phone = NULL, - profile_pic_url = NULL, - date_of_birth = NULL, - account_role = 'guest', - loyalty_stamps = 0, + 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 + updated_at = NOW(), + password_hash = NULL WHERE id = target_id AND account_role != 'guest'; END; $$ LANGUAGE plpgsql; --- Fully delete guest user --- WHY: Guest accounts have no ongoing business relationship --- WHEN: Cleaning up temporary/incomplete accounts --- OUTPUT: Complete removal from database -CREATE OR REPLACE FUNCTION delete_guest_user(target_id CHAR(12)) -RETURNS VOID AS $$ -BEGIN - DELETE FROM users - WHERE id = target_id - AND account_role = 'guest'; -END; -$$ LANGUAGE plpgsql; - -- Update consent -- WHY: GDPR requires tracking consent changes -- WHEN: User updates privacy preferences @@ -496,9 +484,9 @@ CREATE OR REPLACE FUNCTION update_data_consent(target_id CHAR(12), consent BOOLE RETURNS VOID AS $$ BEGIN UPDATE users - SET data_retention_consent = consent, + SET data_retention_consent = consent, data_consent_updated_at = NOW(), - updated_at = NOW() + updated_at = NOW() WHERE id = target_id; END; $$ LANGUAGE plpgsql; @@ -516,75 +504,88 @@ BEGIN SELECT json_build_object( 'user_profile', ( SELECT json_build_object( - 'id', id, - 'first_name', n_first_name, - 'last_name', n_last_name, - 'full_name', fn, - 'email', email, - 'phone', phone, - 'date_of_birth', date_of_birth, - 'profile_pic_url', profile_pic_url, - 'account_role', account_role, - 'loyalty_stamps', loyalty_stamps, - 'data_retention_consent', data_retention_consent, + 'id', id, + 'first_name', n_first_name, + 'last_name', n_last_name, + 'full_name', fn, + 'email', email, + 'phone', phone, + 'date_of_birth', date_of_birth, + 'profile_pic_url', profile_pic_url, + 'account_role', account_role, + 'loyalty_stamps', loyalty_stamps, + 'referral_code', referral_code, + 'data_retention_consent', data_retention_consent, 'data_consent_updated_at', data_consent_updated_at, - 'created_at', created_at, - 'updated_at', updated_at + 'created_at', created_at, + 'updated_at', updated_at ) FROM users WHERE id = target_user_id ), 'bookings', ( SELECT COALESCE(json_agg( json_build_object( - 'booking_id', b.id, - 'start_time', b.start_time, - 'status', b.status, - 'created_at', b.created_at, - 'updated_at', b.updated_at, - 'created_by', b.created_by, - 'updated_by', b.updated_by, + 'booking_id', b.id, + 'start_time', b.start_time, + 'status', b.status, + 'notes', b.notes, + 'created_at', b.created_at, + 'updated_at', b.updated_at, 'services', ( SELECT COALESCE(json_agg( json_build_object( - 'service_id', s.id, - 'name', s.name, - 'description', s.description, - 'price', s.price, - 'duration_minutes', s.duration_minutes + 'service_id', s.id, + 'name', s.name, + 'description', s.description, + 'price', COALESCE(bsvc.override_price, s.price), + 'duration_minutes', COALESCE(bsvc.override_duration_minutes, s.duration_minutes) ) ), '[]'::json) - FROM booking_services bs - JOIN services s ON bs.service_id = s.id - WHERE bs.booking_id = b.id + FROM booking_services bsvc + JOIN services s ON bsvc.service_id = s.id + WHERE bsvc.booking_id = b.id ) ) - ), '[]'::json) + 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_id', p.id, + 'booking_id', p.booking_id, + 'payment_type', p.payment_type, 'payment_method', p.payment_method, - 'vendor_code', p.vendor_code, - 'status', p.status, - 'amount', p.amount, - 'created_at', p.created_at, - 'updated_at', p.updated_at, - 'created_by', p.created_by, - 'updated_by', p.updated_by + '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 ) - ), '[]'::json) + 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 + ), 'export_metadata', json_build_object( - 'exported_at', NOW(), - 'exported_by', 'system', - 'user_id', target_user_id, + 'exported_at', NOW(), + 'exported_by', 'system', + 'user_id', target_user_id, 'format_version', '1.0' ) ) INTO result; @@ -612,51 +613,46 @@ TAX COMPLIANCE NOTES: -- USE: Export to QuickBooks/Xero for MTD submission to HMRC CREATE OR REPLACE FUNCTION get_vat_return_data( start_date DATE, - end_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 + 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, + start_date AS period_start, + end_date AS period_end, + COALESCE(SUM(p.amount), 0) AS total_sales, COALESCE(SUM( CASE - -- If VAT is explicitly stored, use it WHEN p.vat_amount IS NOT NULL THEN p.vat_amount - -- If business is VAT registered but no VAT breakdown, calculate it WHEN (SELECT is_vat_registered FROM business_settings WHERE id = 1) THEN - ROUND(p.amount - (p.amount / (1 + COALESCE(p.vat_rate, (SELECT default_vat_rate FROM business_settings WHERE id = 1)) / 100)), 2) - -- Business not VAT registered = no VAT charged + 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, + ), 0) AS total_vat_charged, COALESCE(SUM( CASE - -- If net amount is explicitly stored, use it WHEN p.net_amount IS NOT NULL THEN p.net_amount - -- If business is VAT registered but no net amount, calculate it WHEN (SELECT is_vat_registered FROM business_settings WHERE id = 1) THEN ROUND(p.amount / (1 + COALESCE(p.vat_rate, - (SELECT default_vat_rate FROM business_settings WHERE id = 1))/100), 2) - -- Business not VAT registered = gross amount is net amount + (SELECT default_vat_rate FROM business_settings WHERE id = 1)) / 100), 2) ELSE p.amount END - ), 0) as net_sales, - COUNT(*) as transaction_count + ), 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'); + AND p.created_at::date BETWEEN start_date AND end_date + AND p.payment_type IN ('full', 'partial', 'balance'); END; $$ LANGUAGE plpgsql; @@ -665,66 +661,70 @@ $$ LANGUAGE plpgsql; -- WHEN: Monthly/quarterly for accounting software import -- OUTPUT: CSV-compatible transaction list with customer, service, payment details -- USE: Import directly into QuickBooks, Xero, or other accounting software --- PARAMETERS: include_vat=false (default for micro businesses), include_vat=true only when VAT registered +-- 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, + 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, + 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) + 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.created_at::date AS transaction_date, p.invoice_number::text AS invoice_number, - COALESCE(u.fn, 'Walk-in Customer') as customer_name, - u.email as customer_email, - string_agg(s.name, ', ') as service_description, - p.payment_method::text as payment_method, - p.amount as gross_amount, + COALESCE( + CASE WHEN u.n_first_name = 'Deleted' THEN NULL ELSE u.fn END, + 'Walk-in Customer' + ) AS customer_name, + CASE WHEN u.n_first_name = 'Deleted' THEN NULL ELSE u.email END AS customer_email, + string_agg(s.name, ', ' ORDER BY s.name) AS service_description, + p.payment_method::text AS payment_method, + p.amount AS gross_amount, CASE WHEN include_vat AND p.net_amount IS NOT NULL THEN p.net_amount WHEN include_vat THEN ROUND(p.amount / 1.20, 2) ELSE p.amount - END as net_amount, + 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, + 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 + END AS vat_rate, + b.id AS booking_id, + p.id AS payment_id FROM payments p JOIN bookings b ON p.booking_id = b.id LEFT JOIN users u ON b.user_id = u.id - LEFT JOIN booking_services bs ON b.id = bs.booking_id - LEFT JOIN services s ON bs.service_id = s.id + LEFT JOIN booking_services bsvc ON b.id = bsvc.booking_id + LEFT JOIN services s ON bsvc.service_id = s.id WHERE p.status = 'completed' - AND p.created_at::date BETWEEN start_date AND end_date - AND p.payment_type IN ('full', 'partial', 'balance') - GROUP BY p.id, b.id, u.fn, u.email, p.created_at, p.amount, p.net_amount, p.vat_amount, p.vat_rate, p.payment_method, p.invoice_number + 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 @@ -732,32 +732,32 @@ $$ LANGUAGE plpgsql; -- 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 + 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) + 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 + 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') + 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; @@ -770,28 +770,28 @@ $$ LANGUAGE plpgsql; -- USE: Homepage dashboard, "How much did I make this week?" queries CREATE OR REPLACE FUNCTION get_sales_totals( start_date DATE, - end_date DATE + end_date DATE ) RETURNS TABLE ( - total_sales NUMERIC(12,2), + total_sales NUMERIC(12,2), total_transactions BIGINT, - cash_total NUMERIC(12,2), - card_total NUMERIC(12,2), - online_total NUMERIC(12,2) + 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 + 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'); + AND p.created_at::date BETWEEN start_date AND end_date + AND p.payment_type IN ('full', 'partial', 'balance'); END; $$ LANGUAGE plpgsql; @@ -799,69 +799,57 @@ $$ LANGUAGE plpgsql; -- UTILITY FUNCTIONS -- ======================================= --- VAT Registration Transition Function --- WHY: When crossing £90k threshold, must register for VAT and backfill existing data --- WHEN: One-time use when VAT registration becomes mandatory --- OUTPUT: Updates all historical payments with VAT breakdown + sets new defaults --- USE: Call once when we register for VAT - transforms business from non-VAT to VAT --- ======================================= --- UTILITY FUNCTIONS --- ======================================= - -- VAT Registration Transition Function -- WHY: When crossing £90k threshold, must register for VAT and backfill existing data -- WHEN: One-time use when VAT registration becomes mandatory -- OUTPUT: Updates all historical payments with VAT breakdown + sets new defaults -- USE: Call once when we register for VAT - transforms business from non-VAT to VAT CREATE OR REPLACE FUNCTION enable_vat_registration( - registration_date DATE DEFAULT CURRENT_DATE, - vat_rate NUMERIC(5,2) DEFAULT 20.00, - vat_reg_number TEXT DEFAULT NULL + registration_date DATE DEFAULT CURRENT_DATE, + vat_rate NUMERIC(5,2) DEFAULT 20.00, + vat_reg_number TEXT DEFAULT NULL ) RETURNS TABLE ( - payments_updated INT, - total_vat_calculated NUMERIC(12,2), - total_net_calculated NUMERIC(12,2), + payments_updated INT, + total_vat_calculated NUMERIC(12,2), + total_net_calculated NUMERIC(12,2), registration_effective_date DATE, - settings_updated BOOLEAN + settings_updated BOOLEAN ) AS $$ DECLARE updated_count INT; - total_vat NUMERIC(12,2); - total_net NUMERIC(12,2); - settings_ok BOOLEAN := FALSE; + total_vat NUMERIC(12,2); + total_net NUMERIC(12,2); + settings_ok BOOLEAN := FALSE; BEGIN - -- Update all existing completed payments with VAT breakdown UPDATE payments SET - vat_rate = enable_vat_registration.vat_rate, - vat_amount = ROUND(amount - (amount / (1 + enable_vat_registration.vat_rate/100)), 2), - net_amount = ROUND(amount / (1 + enable_vat_registration.vat_rate/100), 2), + 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() + updated_at = NOW() WHERE status = 'completed' - AND created_at::date >= registration_date - AND vat_amount IS NULL; + AND created_at::date >= registration_date + AND vat_amount IS NULL; GET DIAGNOSTICS updated_count = ROW_COUNT; - -- Calculate totals SELECT - COALESCE(SUM(vat_amount), 0), - COALESCE(SUM(net_amount), 0) + COALESCE(SUM(p.vat_amount), 0), + COALESCE(SUM(p.net_amount), 0) INTO total_vat, total_net - FROM payments + FROM payments p WHERE status = 'completed' - AND created_at::date >= registration_date - AND vat_amount IS NOT NULL; + AND created_at::date >= registration_date + AND vat_amount IS NOT NULL; - -- Update business_settings to reflect VAT registration UPDATE business_settings SET - is_vat_registered = TRUE, + 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() + default_vat_rate = enable_vat_registration.vat_rate, + updated_at = NOW() WHERE id = 1; IF FOUND THEN @@ -877,63 +865,51 @@ BEGIN END; $$ LANGUAGE plpgsql; - -- Update payment with VAT (for new payments after VAT registration) -- WHY: After VAT registration, all new payments need VAT calculation --- WHEN: Called automatically when processing new payments (if VAT registered) +-- 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 --- ======================================= --- UPDATE PAYMENT WITH VAT --- ======================================= --- WHY: After VAT registration, all new payments need VAT calculation --- WHEN: Called automatically when processing new payments (if VAT registered) --- OUTPUT: Updates individual payment with VAT breakdown --- USE: Call from Go or backend when processing new payments CREATE OR REPLACE FUNCTION apply_vat_to_payment( payment_id CHAR(12), - vat_rate NUMERIC(5,2) DEFAULT NULL -- NULL = use default from business_settings + vat_rate NUMERIC(5,2) DEFAULT NULL -- NULL = use default from business_settings ) RETURNS VOID AS $$ DECLARE effective_rate NUMERIC(5,2); BEGIN - -- Determine effective VAT rate IF vat_rate IS NULL THEN SELECT default_vat_rate INTO effective_rate FROM business_settings WHERE id = 1 AND is_vat_registered = TRUE; IF NOT FOUND OR effective_rate IS NULL THEN - RAISE EXCEPTION 'no vat rate provided and business is not registered for vat'; + RAISE EXCEPTION 'No VAT rate provided and business is not registered for VAT'; END IF; ELSE effective_rate := vat_rate; END IF; - -- Update payment with VAT amounts UPDATE payments SET - vat_rate = effective_rate, - vat_amount = ROUND(amount - (amount / (1 + effective_rate/100)), 2), - net_amount = ROUND(amount / (1 + effective_rate/100), 2), + 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() + updated_at = NOW() WHERE id = payment_id - AND vat_amount IS NULL; + AND vat_amount IS NULL; END; $$ LANGUAGE plpgsql; --- ======================================= --- SIMPLE VAT CALCULATION HELPER --- ======================================= +-- 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 + vat_rate NUMERIC(5,2) DEFAULT NULL -- NULL = use default from business_settings ) RETURNS TABLE( net NUMERIC(10,2), @@ -942,49 +918,39 @@ RETURNS TABLE( DECLARE effective_rate NUMERIC(5,2); BEGIN - -- Determine effective VAT rate IF vat_rate IS NULL THEN SELECT default_vat_rate INTO effective_rate FROM business_settings WHERE id = 1 AND is_vat_registered = TRUE; IF NOT FOUND OR effective_rate IS NULL THEN - RAISE EXCEPTION 'no vat rate provided and business is not registered for vat'; + RAISE EXCEPTION 'No VAT rate provided and business is not registered for VAT'; END IF; ELSE effective_rate := vat_rate; END IF; - -- Return net and VAT RETURN QUERY SELECT - ROUND(gross_amount / (1 + effective_rate/100), 2) AS net, - gross_amount - ROUND(gross_amount / (1 + effective_rate/100), 2) AS vat; + ROUND(gross_amount / (1 + effective_rate / 100), 2) AS net, + gross_amount - ROUND(gross_amount / (1 + effective_rate / 100), 2) AS vat; END; $$ LANGUAGE plpgsql; - -- ======================================= -- UK RECEIPT COMPLIANCE FUNCTION -- ======================================= /* UK RECEIPT REQUIREMENTS: -- Business name and address (static - provided by application) +- 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 breakdown (if VAT registered): VAT number, rate, VAT amount, net amount - Receipt/invoice number - Payment method -- Customer details (if requested) - -VAT RECEIPT REQUIREMENTS (if VAT registered): -- VAT registration number (static - provided by application) -- VAT rate applied -- VAT amount -- Net amount (excluding VAT) -- Total amount (including VAT) +- Customer details (if not anonymised) */ -- Complete Receipt Data for UK Compliance @@ -995,64 +961,62 @@ VAT RECEIPT REQUIREMENTS (if VAT registered): 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, + business_name TEXT, + business_address TEXT, + business_phone TEXT, + business_email TEXT, vat_registration_number TEXT, - is_vat_registered BOOLEAN, + is_vat_registered BOOLEAN, -- Payment Information - payment_id CHAR(12), - invoice_number TEXT, - transaction_date TIMESTAMPTZ, - payment_method TEXT, - payment_status TEXT, + 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, + 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, + booking_id CHAR(12), + appointment_date TIMESTAMPTZ, + booking_status TEXT, -- Service Details - services JSON, - total_duration_minutes INT, + 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, + 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 + receipt_generated_at TIMESTAMPTZ, + currency_code TEXT ) AS $$ DECLARE - bs RECORD; -- To hold business settings + bs RECORD; BEGIN - -- Fetch current business settings (assume single row, id=1) SELECT INTO bs - business_name, - business_address, - business_phone, - business_email, - vat_registration_number, - is_vat_registered, - currency_code - FROM business_settings - WHERE id = 1; + 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 - -- Business Info bs.business_name, bs.business_address, bs.business_phone, @@ -1060,68 +1024,67 @@ BEGIN bs.vat_registration_number, bs.is_vat_registered, - -- Payment Info - p.id as payment_id, - COALESCE('INV-' || p.invoice_number::text, 'INV-' || p.id) as invoice_number, - p.created_at as transaction_date, - p.payment_method::text as payment_method, - p.status::text as payment_status, + p.id AS payment_id, + COALESCE('INV-' || p.invoice_number::text, 'INV-' || p.id) AS invoice_number, + p.created_at AS transaction_date, + p.payment_method::text AS payment_method, + p.status::text AS payment_status, - -- Customer Info - u.id as customer_id, + u.id AS customer_id, CASE - WHEN u.account_role = 'guest' OR u.fn = 'Deleted User' THEN 'Walk-in Customer' + 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, + END AS customer_name, CASE - WHEN u.account_role = 'guest' OR u.fn = 'Deleted User' THEN NULL + 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, + END AS customer_email, CASE - WHEN u.account_role = 'guest' OR u.fn = 'Deleted User' THEN NULL + 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, + END AS customer_phone, - -- Booking Info - b.id as booking_id, - b.start_time as appointment_date, - b.status::text as booking_status, + b.id AS booking_id, + b.start_time AS appointment_date, + b.status::text AS booking_status, - -- Service Details COALESCE(( SELECT json_agg( json_build_object( - 'service_id', s.id, - 'name', s.name, - 'description', s.description, - 'price', s.price, - 'duration_minutes', s.duration_minutes + 'service_id', s.id, + 'name', s.name, + 'description', s.description, + 'price', COALESCE(bsvc.override_price, s.price), + 'duration_minutes', COALESCE(bsvc.override_duration_minutes, s.duration_minutes) ) ORDER BY s.name ) - FROM booking_services bs_inner - JOIN services s ON bs_inner.service_id = s.id - WHERE bs_inner.booking_id = b.id - ), '[]'::json) as services, + FROM booking_services bsvc + JOIN services s ON bsvc.service_id = s.id + WHERE bsvc.booking_id = b.id + ), '[]'::json) AS services, - -- Total service duration COALESCE(( - SELECT SUM(s.duration_minutes) - FROM booking_services bs_inner - JOIN services s ON bs_inner.service_id = s.id - WHERE bs_inner.booking_id = b.id - ), 0) as total_duration_minutes, + SELECT SUM(COALESCE(bsvc.override_duration_minutes, s.duration_minutes)) + FROM booking_services bsvc + JOIN services s ON bsvc.service_id = s.id + WHERE bsvc.booking_id = b.id + ), 0)::INT AS total_duration_minutes, - -- Financial Info - p.amount as gross_amount, - COALESCE(p.net_amount, p.amount) as net_amount, - COALESCE(p.vat_amount, 0.00) as vat_amount, - p.vat_rate as vat_rate, - p.is_vat_applicable as is_vat_applicable, + p.amount AS gross_amount, + COALESCE(p.net_amount, p.amount) AS net_amount, + COALESCE(p.vat_amount, 0.00) AS vat_amount, + p.vat_rate AS vat_rate, + p.is_vat_applicable AS is_vat_applicable, - -- Receipt Metadata - NOW() as receipt_generated_at, - bs.currency_code as currency_code + NOW() AS receipt_generated_at, + bs.currency_code AS currency_code FROM payments p JOIN bookings b ON p.booking_id = b.id @@ -1134,73 +1097,62 @@ $$ LANGUAGE plpgsql; -- FUNCTION USAGE SUMMARY -- ======================================= /* -This summary organizes all database functions by their primary purpose, legal basis, and expected usage frequency— -providing a clear guide for integration into our application, compliance workflows, and business operations. +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 or UI): +REGULAR USE (called from API endpoints): - get_sales_totals(start_date, end_date) - → Quick dashboard metrics: total revenue, transaction count, and payment method breakdown. + → Dashboard metrics: total revenue, transaction count, payment method breakdown. - get_receipt_data(payment_id) - → Generate a UK-compliant receipt after every completed payment. + → UK-compliant receipt after every completed payment. - calculate_vat(gross_amount, vat_rate) - → Utility for validating or manually computing VAT splits. + → Validate or manually compute VAT splits (only usable when VAT registered). -PERIODIC USE (monthly/quarterly for accounting or tax filing): +PERIODIC USE (monthly/quarterly for accounting or HMRC): - export_sales_transactions(start_date, end_date, include_vat) - → Primary export for accounting software (QuickBooks, Xero). Set include_vat = true only if VAT-registered. + → Primary export for QuickBooks / Xero. Set include_vat=true only if VAT registered. - get_monthly_business_summary(start_date, end_date) - → Analyze trends: bookings, revenue, avg. transaction size, and payment method adoption. + → 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 if VAT-registered). + → MTD-ready VAT return summary for HMRC (only relevant when VAT registered). -ONE-TIME OR TRANSITIONAL USE: +ONE-TIME / TRANSITIONAL: - enable_vat_registration(registration_date, vat_rate, vat_reg_number) - → Run once when crossing the £90k VAT threshold to backfill historical payments. + → Run once on crossing the £90k VAT threshold; backfills historical payments. - apply_vat_to_payment(payment_id, vat_rate) - → Called automatically for every new payment after VAT registration. + → Called for every new payment after VAT registration. -------------------------------------------------------------------------------- 2. GDPR & DATA PRIVACY -------------------------------------------------------------------------------- -ON-DEMAND USE (triggered by user request or admin action): +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). + → 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 audit trail. + → 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 basis). + → Complete deletion of guest accounts (no contractual retention basis). - update_data_consent(user_id, consent) - → Records updated consent preference with timestamp for auditability. + → Records updated consent with timestamp for auditability. -NOTE: Booking data is retained under contractual necessity (GDPR Art. 6(1)(b)) even if consent is withdrawn. -Only optional data (e.g., marketing) is governed by consent. +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. BUSINESS OPERATIONS & LOYALTY +3. INTEGRATION QUICK REFERENCE -------------------------------------------------------------------------------- - -- Referral Program: Use user_referrals table + referral_code in users (handled in app logic). -- Patch Test Tracking: user_service_patch_tests enforces allergen safety (enforced in app based on services.patch_test_duration_hours). - --------------------------------------------------------------------------------- -4. UTILITY & MAINTENANCE --------------------------------------------------------------------------------- - -- generate_*_id() functions: Internal use only (DEFAULT in table definitions). -- Timestamp triggers (e.g., on business_settings): Automatic—no manual call needed. - --------------------------------------------------------------------------------- -INTEGRATION QUICK REFERENCE --------------------------------------------------------------------------------- -• User deletes account → anonymize_user() or delete_guest_user() +• 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() +• 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() */