update sql functions

This commit is contained in:
2026-02-25 00:39:51 +00:00
parent 2c94a4d9c3
commit 4fc84e6d39
+299 -347
View File
@@ -174,7 +174,6 @@ CREATE TABLE user_patch_tests (
user_id CHAR(12) NOT NULL REFERENCES users(id) ON DELETE CASCADE, 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, patch_test_id CHAR(12) NOT NULL REFERENCES patch_tests(id) ON DELETE CASCADE,
tested_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), tested_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
notes TEXT,
UNIQUE (user_id, patch_test_id) UNIQUE (user_id, patch_test_id)
); );
@@ -453,41 +452,30 @@ GDPR COMPLIANCE NOTES:
-- WHY: GDPR Article 17 - users can request data deletion -- WHY: GDPR Article 17 - users can request data deletion
-- WHEN: User requests account deletion -- WHEN: User requests account deletion
-- OUTPUT: Converts personal data to anonymous placeholder -- 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)) CREATE OR REPLACE FUNCTION anonymize_user(target_id CHAR(12))
RETURNS VOID AS $$ RETURNS VOID AS $$
BEGIN BEGIN
UPDATE users UPDATE users
SET SET
n_first_name = 'Deleted', n_first_name = 'Deleted',
n_last_name = 'User', n_last_name = 'User',
email = CONCAT('deleted+', target_id, '@example.com'), email = CONCAT('deleted+', target_id, '@deleted.invalid'),
phone = NULL, phone = '+000000000000', -- NOT NULL column: use placeholder
profile_pic_url = NULL, date_of_birth = '1900-01-01', -- NOT NULL column: use placeholder
date_of_birth = NULL, profile_pic_url = NULL,
account_role = 'guest', account_role = 'guest',
loyalty_stamps = 0, loyalty_stamps = 0,
referral_code = NULL,
data_retention_consent = FALSE, data_retention_consent = FALSE,
data_consent_updated_at = NOW(), data_consent_updated_at = NOW(),
updated_at = NOW(), updated_at = NOW(),
password_hash = NULL password_hash = NULL
WHERE id = target_id WHERE id = target_id
AND account_role != 'guest'; AND account_role != 'guest';
END; END;
$$ LANGUAGE plpgsql; $$ 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 -- Update consent
-- WHY: GDPR requires tracking consent changes -- WHY: GDPR requires tracking consent changes
-- WHEN: User updates privacy preferences -- 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 $$ RETURNS VOID AS $$
BEGIN BEGIN
UPDATE users UPDATE users
SET data_retention_consent = consent, SET data_retention_consent = consent,
data_consent_updated_at = NOW(), data_consent_updated_at = NOW(),
updated_at = NOW() updated_at = NOW()
WHERE id = target_id; WHERE id = target_id;
END; END;
$$ LANGUAGE plpgsql; $$ LANGUAGE plpgsql;
@@ -516,75 +504,88 @@ BEGIN
SELECT json_build_object( SELECT json_build_object(
'user_profile', ( 'user_profile', (
SELECT json_build_object( SELECT json_build_object(
'id', id, 'id', id,
'first_name', n_first_name, 'first_name', n_first_name,
'last_name', n_last_name, 'last_name', n_last_name,
'full_name', fn, 'full_name', fn,
'email', email, 'email', email,
'phone', phone, 'phone', phone,
'date_of_birth', date_of_birth, 'date_of_birth', date_of_birth,
'profile_pic_url', profile_pic_url, 'profile_pic_url', profile_pic_url,
'account_role', account_role, 'account_role', account_role,
'loyalty_stamps', loyalty_stamps, 'loyalty_stamps', loyalty_stamps,
'data_retention_consent', data_retention_consent, 'referral_code', referral_code,
'data_retention_consent', data_retention_consent,
'data_consent_updated_at', data_consent_updated_at, 'data_consent_updated_at', data_consent_updated_at,
'created_at', created_at, 'created_at', created_at,
'updated_at', updated_at 'updated_at', updated_at
) )
FROM users WHERE id = target_user_id FROM users WHERE id = target_user_id
), ),
'bookings', ( 'bookings', (
SELECT COALESCE(json_agg( SELECT COALESCE(json_agg(
json_build_object( json_build_object(
'booking_id', b.id, 'booking_id', b.id,
'start_time', b.start_time, 'start_time', b.start_time,
'status', b.status, 'status', b.status,
'created_at', b.created_at, 'notes', b.notes,
'updated_at', b.updated_at, 'created_at', b.created_at,
'created_by', b.created_by, 'updated_at', b.updated_at,
'updated_by', b.updated_by,
'services', ( 'services', (
SELECT COALESCE(json_agg( SELECT COALESCE(json_agg(
json_build_object( json_build_object(
'service_id', s.id, 'service_id', s.id,
'name', s.name, 'name', s.name,
'description', s.description, 'description', s.description,
'price', s.price, 'price', COALESCE(bsvc.override_price, s.price),
'duration_minutes', s.duration_minutes 'duration_minutes', COALESCE(bsvc.override_duration_minutes, s.duration_minutes)
) )
), '[]'::json) ), '[]'::json)
FROM booking_services bs FROM booking_services bsvc
JOIN services s ON bs.service_id = s.id JOIN services s ON bsvc.service_id = s.id
WHERE bs.booking_id = b.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 FROM bookings b WHERE b.user_id = target_user_id
), ),
'payments', ( 'payments', (
SELECT COALESCE(json_agg( SELECT COALESCE(json_agg(
json_build_object( json_build_object(
'payment_id', p.id, 'payment_id', p.id,
'booking_id', p.booking_id, 'booking_id', p.booking_id,
'payment_type', p.payment_type, 'payment_type', p.payment_type,
'payment_method', p.payment_method, 'payment_method', p.payment_method,
'vendor_code', p.vendor_code, 'invoice_number', p.invoice_number,
'status', p.status, 'status', p.status,
'amount', p.amount, 'amount', p.amount,
'created_at', p.created_at, 'vat_amount', p.vat_amount,
'updated_at', p.updated_at, 'net_amount', p.net_amount,
'created_by', p.created_by, 'created_at', p.created_at,
'updated_by', p.updated_by 'updated_at', p.updated_at
) )
), '[]'::json) ORDER BY p.created_at DESC), '[]'::json)
FROM payments p FROM payments p
JOIN bookings b ON p.booking_id = b.id JOIN bookings b ON p.booking_id = b.id
WHERE b.user_id = target_user_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( 'export_metadata', json_build_object(
'exported_at', NOW(), 'exported_at', NOW(),
'exported_by', 'system', 'exported_by', 'system',
'user_id', target_user_id, 'user_id', target_user_id,
'format_version', '1.0' 'format_version', '1.0'
) )
) INTO result; ) INTO result;
@@ -612,51 +613,46 @@ TAX COMPLIANCE NOTES:
-- USE: Export to QuickBooks/Xero for MTD submission to HMRC -- USE: Export to QuickBooks/Xero for MTD submission to HMRC
CREATE OR REPLACE FUNCTION get_vat_return_data( CREATE OR REPLACE FUNCTION get_vat_return_data(
start_date DATE, start_date DATE,
end_date DATE end_date DATE
) )
RETURNS TABLE ( RETURNS TABLE (
period_start DATE, period_start DATE,
period_end DATE, period_end DATE,
total_sales NUMERIC(12,2), total_sales NUMERIC(12,2),
total_vat_charged NUMERIC(12,2), total_vat_charged NUMERIC(12,2),
net_sales NUMERIC(12,2), net_sales NUMERIC(12,2),
transaction_count BIGINT transaction_count BIGINT
) AS $$ ) AS $$
BEGIN BEGIN
RETURN QUERY RETURN QUERY
SELECT SELECT
start_date as period_start, start_date AS period_start,
end_date as period_end, end_date AS period_end,
COALESCE(SUM(p.amount), 0) as total_sales, COALESCE(SUM(p.amount), 0) AS total_sales,
COALESCE(SUM( COALESCE(SUM(
CASE CASE
-- If VAT is explicitly stored, use it
WHEN p.vat_amount IS NOT NULL THEN p.vat_amount 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 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) ROUND(p.amount - (p.amount / (1 + COALESCE(p.vat_rate,
-- Business not VAT registered = no VAT charged (SELECT default_vat_rate FROM business_settings WHERE id = 1)) / 100)), 2)
ELSE 0 ELSE 0
END END
), 0) as total_vat_charged, ), 0) AS total_vat_charged,
COALESCE(SUM( COALESCE(SUM(
CASE CASE
-- If net amount is explicitly stored, use it
WHEN p.net_amount IS NOT NULL THEN p.net_amount 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 WHEN (SELECT is_vat_registered FROM business_settings WHERE id = 1) THEN
ROUND(p.amount / (1 + COALESCE(p.vat_rate, ROUND(p.amount / (1 + COALESCE(p.vat_rate,
(SELECT default_vat_rate FROM business_settings WHERE id = 1))/100), 2) (SELECT default_vat_rate FROM business_settings WHERE id = 1)) / 100), 2)
-- Business not VAT registered = gross amount is net amount
ELSE p.amount ELSE p.amount
END END
), 0) as net_sales, ), 0) AS net_sales,
COUNT(*) as transaction_count COUNT(*) AS transaction_count
FROM payments p FROM payments p
JOIN bookings b ON p.booking_id = b.id JOIN bookings b ON p.booking_id = b.id
WHERE p.status = 'completed' WHERE p.status = 'completed'
AND p.created_at::date BETWEEN start_date AND end_date AND p.created_at::date BETWEEN start_date AND end_date
AND p.payment_type IN ('full', 'partial', 'balance'); AND p.payment_type IN ('full', 'partial', 'balance');
END; END;
$$ LANGUAGE plpgsql; $$ LANGUAGE plpgsql;
@@ -665,66 +661,70 @@ $$ LANGUAGE plpgsql;
-- WHEN: Monthly/quarterly for accounting software import -- WHEN: Monthly/quarterly for accounting software import
-- OUTPUT: CSV-compatible transaction list with customer, service, payment details -- OUTPUT: CSV-compatible transaction list with customer, service, payment details
-- USE: Import directly into QuickBooks, Xero, or other accounting software -- 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( CREATE OR REPLACE FUNCTION export_sales_transactions(
start_date DATE, start_date DATE,
end_date DATE, end_date DATE,
include_vat BOOLEAN DEFAULT FALSE include_vat BOOLEAN DEFAULT FALSE
) )
RETURNS TABLE ( RETURNS TABLE (
transaction_date DATE, transaction_date DATE,
invoice_number TEXT, invoice_number TEXT,
customer_name TEXT, customer_name TEXT,
customer_email TEXT, customer_email TEXT,
service_description TEXT, service_description TEXT,
payment_method TEXT, payment_method TEXT,
gross_amount NUMERIC(10,2), gross_amount NUMERIC(10,2),
net_amount NUMERIC(10,2), net_amount NUMERIC(10,2),
vat_amount NUMERIC(10,2), vat_amount NUMERIC(10,2),
vat_rate NUMERIC(5,2), vat_rate NUMERIC(5,2),
booking_id CHAR(12), booking_id CHAR(12),
payment_id CHAR(12) payment_id CHAR(12)
) AS $$ ) AS $$
BEGIN BEGIN
RETURN QUERY RETURN QUERY
SELECT SELECT
p.created_at::date as transaction_date, p.created_at::date AS transaction_date,
p.invoice_number::text AS invoice_number, p.invoice_number::text AS invoice_number,
COALESCE(u.fn, 'Walk-in Customer') as customer_name, COALESCE(
u.email as customer_email, CASE WHEN u.n_first_name = 'Deleted' THEN NULL ELSE u.fn END,
string_agg(s.name, ', ') as service_description, 'Walk-in Customer'
p.payment_method::text as payment_method, ) AS customer_name,
p.amount as gross_amount, 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 CASE
WHEN include_vat AND p.net_amount IS NOT NULL THEN p.net_amount WHEN include_vat AND p.net_amount IS NOT NULL THEN p.net_amount
WHEN include_vat THEN ROUND(p.amount / 1.20, 2) WHEN include_vat THEN ROUND(p.amount / 1.20, 2)
ELSE p.amount ELSE p.amount
END as net_amount, END AS net_amount,
CASE CASE
WHEN include_vat AND p.vat_amount IS NOT NULL THEN p.vat_amount 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) WHEN include_vat THEN ROUND(p.amount - (p.amount / 1.20), 2)
ELSE 0 ELSE 0
END as vat_amount, END AS vat_amount,
CASE CASE
WHEN include_vat THEN COALESCE(p.vat_rate, 20.00) WHEN include_vat THEN COALESCE(p.vat_rate, 20.00)
ELSE NULL ELSE NULL
END as vat_rate, END AS vat_rate,
b.id as booking_id, b.id AS booking_id,
p.id as payment_id p.id AS payment_id
FROM payments p FROM payments p
JOIN bookings b ON p.booking_id = b.id JOIN bookings b ON p.booking_id = b.id
LEFT JOIN users u ON b.user_id = u.id LEFT JOIN users u ON b.user_id = u.id
LEFT JOIN booking_services bs ON b.id = bs.booking_id LEFT JOIN booking_services bsvc ON b.id = bsvc.booking_id
LEFT JOIN services s ON bs.service_id = s.id LEFT JOIN services s ON bsvc.service_id = s.id
WHERE p.status = 'completed' WHERE p.status = 'completed'
AND p.created_at::date BETWEEN start_date AND end_date AND p.created_at::date BETWEEN start_date AND end_date
AND p.payment_type IN ('full', 'partial', 'balance') 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 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; ORDER BY p.created_at;
END; END;
$$ LANGUAGE plpgsql; $$ LANGUAGE plpgsql;
-- Monthly Business Summary -- Monthly Business Summary
-- WHY: Track business performance trends and payment method preferences -- WHY: Track business performance trends and payment method preferences
-- WHEN: Monthly review of business performance -- WHEN: Monthly review of business performance
@@ -732,32 +732,32 @@ $$ LANGUAGE plpgsql;
-- USE: Personal business insights - "How am I doing vs last month?" -- USE: Personal business insights - "How am I doing vs last month?"
CREATE OR REPLACE FUNCTION get_monthly_business_summary( CREATE OR REPLACE FUNCTION get_monthly_business_summary(
start_date DATE, start_date DATE,
end_date DATE end_date DATE
) )
RETURNS TABLE ( RETURNS TABLE (
month_year TEXT, month_year TEXT,
total_bookings BIGINT, total_bookings BIGINT,
total_revenue NUMERIC(12,2), total_revenue NUMERIC(12,2),
cash_payments NUMERIC(12,2), cash_payments NUMERIC(12,2),
card_payments NUMERIC(12,2), card_payments NUMERIC(12,2),
online_payments NUMERIC(12,2), online_payments NUMERIC(12,2),
avg_transaction NUMERIC(10,2) avg_transaction NUMERIC(10,2)
) AS $$ ) AS $$
BEGIN BEGIN
RETURN QUERY RETURN QUERY
SELECT SELECT
TO_CHAR(p.created_at, 'YYYY-MM') as month_year, TO_CHAR(p.created_at, 'YYYY-MM') AS month_year,
COUNT(DISTINCT b.id) as total_bookings, COUNT(DISTINCT b.id) AS total_bookings,
SUM(p.amount) as total_revenue, 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 = '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 = '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, 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 ROUND(AVG(p.amount), 2) AS avg_transaction
FROM payments p FROM payments p
JOIN bookings b ON p.booking_id = b.id JOIN bookings b ON p.booking_id = b.id
WHERE p.status = 'completed' WHERE p.status = 'completed'
AND p.created_at::date BETWEEN start_date AND end_date AND p.created_at::date BETWEEN start_date AND end_date
AND p.payment_type IN ('full', 'partial', 'balance') AND p.payment_type IN ('full', 'partial', 'balance')
GROUP BY TO_CHAR(p.created_at, 'YYYY-MM') GROUP BY TO_CHAR(p.created_at, 'YYYY-MM')
ORDER BY month_year; ORDER BY month_year;
END; END;
@@ -770,28 +770,28 @@ $$ LANGUAGE plpgsql;
-- USE: Homepage dashboard, "How much did I make this week?" queries -- USE: Homepage dashboard, "How much did I make this week?" queries
CREATE OR REPLACE FUNCTION get_sales_totals( CREATE OR REPLACE FUNCTION get_sales_totals(
start_date DATE, start_date DATE,
end_date DATE end_date DATE
) )
RETURNS TABLE ( RETURNS TABLE (
total_sales NUMERIC(12,2), total_sales NUMERIC(12,2),
total_transactions BIGINT, total_transactions BIGINT,
cash_total NUMERIC(12,2), cash_total NUMERIC(12,2),
card_total NUMERIC(12,2), card_total NUMERIC(12,2),
online_total NUMERIC(12,2) online_total NUMERIC(12,2)
) AS $$ ) AS $$
BEGIN BEGIN
RETURN QUERY RETURN QUERY
SELECT SELECT
COALESCE(SUM(p.amount), 0) as total_sales, COALESCE(SUM(p.amount), 0) AS total_sales,
COUNT(*) as total_transactions, 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 = '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 = '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(CASE WHEN p.payment_method = 'online_square' THEN p.amount ELSE 0 END), 0) AS online_total
FROM payments p FROM payments p
JOIN bookings b ON p.booking_id = b.id JOIN bookings b ON p.booking_id = b.id
WHERE p.status = 'completed' WHERE p.status = 'completed'
AND p.created_at::date BETWEEN start_date AND end_date AND p.created_at::date BETWEEN start_date AND end_date
AND p.payment_type IN ('full', 'partial', 'balance'); AND p.payment_type IN ('full', 'partial', 'balance');
END; END;
$$ LANGUAGE plpgsql; $$ LANGUAGE plpgsql;
@@ -799,69 +799,57 @@ $$ LANGUAGE plpgsql;
-- UTILITY FUNCTIONS -- 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 -- VAT Registration Transition Function
-- WHY: When crossing £90k threshold, must register for VAT and backfill existing data -- WHY: When crossing £90k threshold, must register for VAT and backfill existing data
-- WHEN: One-time use when VAT registration becomes mandatory -- WHEN: One-time use when VAT registration becomes mandatory
-- OUTPUT: Updates all historical payments with VAT breakdown + sets new defaults -- 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 -- USE: Call once when we register for VAT - transforms business from non-VAT to VAT
CREATE OR REPLACE FUNCTION enable_vat_registration( CREATE OR REPLACE FUNCTION enable_vat_registration(
registration_date DATE DEFAULT CURRENT_DATE, registration_date DATE DEFAULT CURRENT_DATE,
vat_rate NUMERIC(5,2) DEFAULT 20.00, vat_rate NUMERIC(5,2) DEFAULT 20.00,
vat_reg_number TEXT DEFAULT NULL vat_reg_number TEXT DEFAULT NULL
) )
RETURNS TABLE ( RETURNS TABLE (
payments_updated INT, payments_updated INT,
total_vat_calculated NUMERIC(12,2), total_vat_calculated NUMERIC(12,2),
total_net_calculated NUMERIC(12,2), total_net_calculated NUMERIC(12,2),
registration_effective_date DATE, registration_effective_date DATE,
settings_updated BOOLEAN settings_updated BOOLEAN
) AS $$ ) AS $$
DECLARE DECLARE
updated_count INT; updated_count INT;
total_vat NUMERIC(12,2); total_vat NUMERIC(12,2);
total_net NUMERIC(12,2); total_net NUMERIC(12,2);
settings_ok BOOLEAN := FALSE; settings_ok BOOLEAN := FALSE;
BEGIN BEGIN
-- Update all existing completed payments with VAT breakdown
UPDATE payments UPDATE payments
SET SET
vat_rate = enable_vat_registration.vat_rate, vat_rate = enable_vat_registration.vat_rate,
vat_amount = ROUND(amount - (amount / (1 + enable_vat_registration.vat_rate/100)), 2), 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), net_amount = ROUND(amount / (1 + enable_vat_registration.vat_rate / 100), 2),
is_vat_applicable = TRUE, is_vat_applicable = TRUE,
updated_at = NOW() updated_at = NOW()
WHERE status = 'completed' WHERE status = 'completed'
AND created_at::date >= registration_date AND created_at::date >= registration_date
AND vat_amount IS NULL; AND vat_amount IS NULL;
GET DIAGNOSTICS updated_count = ROW_COUNT; GET DIAGNOSTICS updated_count = ROW_COUNT;
-- Calculate totals
SELECT SELECT
COALESCE(SUM(vat_amount), 0), COALESCE(SUM(p.vat_amount), 0),
COALESCE(SUM(net_amount), 0) COALESCE(SUM(p.net_amount), 0)
INTO total_vat, total_net INTO total_vat, total_net
FROM payments FROM payments p
WHERE status = 'completed' WHERE status = 'completed'
AND created_at::date >= registration_date AND created_at::date >= registration_date
AND vat_amount IS NOT NULL; AND vat_amount IS NOT NULL;
-- Update business_settings to reflect VAT registration
UPDATE business_settings UPDATE business_settings
SET SET
is_vat_registered = TRUE, is_vat_registered = TRUE,
vat_registration_number = COALESCE(vat_reg_number, vat_registration_number), vat_registration_number = COALESCE(vat_reg_number, vat_registration_number),
default_vat_rate = enable_vat_registration.vat_rate, default_vat_rate = enable_vat_registration.vat_rate,
updated_at = NOW() updated_at = NOW()
WHERE id = 1; WHERE id = 1;
IF FOUND THEN IF FOUND THEN
@@ -877,63 +865,51 @@ BEGIN
END; END;
$$ LANGUAGE plpgsql; $$ LANGUAGE plpgsql;
-- Update payment with VAT (for new payments after VAT registration) -- Update payment with VAT (for new payments after VAT registration)
-- WHY: After VAT registration, all new payments need VAT calculation -- 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 -- OUTPUT: Updates individual payment with VAT breakdown
-- USE: Call from Go when processing new payments after VAT registration -- 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( CREATE OR REPLACE FUNCTION apply_vat_to_payment(
payment_id CHAR(12), 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 $$ RETURNS VOID AS $$
DECLARE DECLARE
effective_rate NUMERIC(5,2); effective_rate NUMERIC(5,2);
BEGIN BEGIN
-- Determine effective VAT rate
IF vat_rate IS NULL THEN IF vat_rate IS NULL THEN
SELECT default_vat_rate INTO effective_rate SELECT default_vat_rate INTO effective_rate
FROM business_settings FROM business_settings
WHERE id = 1 AND is_vat_registered = TRUE; WHERE id = 1 AND is_vat_registered = TRUE;
IF NOT FOUND OR effective_rate IS NULL THEN 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; END IF;
ELSE ELSE
effective_rate := vat_rate; effective_rate := vat_rate;
END IF; END IF;
-- Update payment with VAT amounts
UPDATE payments UPDATE payments
SET SET
vat_rate = effective_rate, vat_rate = effective_rate,
vat_amount = ROUND(amount - (amount / (1 + effective_rate/100)), 2), vat_amount = ROUND(amount - (amount / (1 + effective_rate / 100)), 2),
net_amount = ROUND(amount / (1 + effective_rate/100), 2), net_amount = ROUND(amount / (1 + effective_rate / 100), 2),
is_vat_applicable = TRUE, is_vat_applicable = TRUE,
updated_at = NOW() updated_at = NOW()
WHERE id = payment_id WHERE id = payment_id
AND vat_amount IS NULL; AND vat_amount IS NULL;
END; END;
$$ LANGUAGE plpgsql; $$ LANGUAGE plpgsql;
-- ======================================= -- Simple VAT Calculation Helper
-- SIMPLE VAT CALCULATION HELPER
-- =======================================
-- WHY: Manual VAT calculations or validation -- WHY: Manual VAT calculations or validation
-- WHEN: Checking VAT calculations or manual entry corrections -- WHEN: Checking VAT calculations or manual entry corrections
-- OUTPUT: net amount and vat amount from gross amount -- OUTPUT: net amount and vat amount from gross amount
-- USE: Manual calculations, validation, or corrections -- USE: Manual calculations, validation, or corrections
CREATE OR REPLACE FUNCTION calculate_vat( CREATE OR REPLACE FUNCTION calculate_vat(
gross_amount NUMERIC(10,2), 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( RETURNS TABLE(
net NUMERIC(10,2), net NUMERIC(10,2),
@@ -942,49 +918,39 @@ RETURNS TABLE(
DECLARE DECLARE
effective_rate NUMERIC(5,2); effective_rate NUMERIC(5,2);
BEGIN BEGIN
-- Determine effective VAT rate
IF vat_rate IS NULL THEN IF vat_rate IS NULL THEN
SELECT default_vat_rate INTO effective_rate SELECT default_vat_rate INTO effective_rate
FROM business_settings FROM business_settings
WHERE id = 1 AND is_vat_registered = TRUE; WHERE id = 1 AND is_vat_registered = TRUE;
IF NOT FOUND OR effective_rate IS NULL THEN 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; END IF;
ELSE ELSE
effective_rate := vat_rate; effective_rate := vat_rate;
END IF; END IF;
-- Return net and VAT
RETURN QUERY RETURN QUERY
SELECT SELECT
ROUND(gross_amount / (1 + effective_rate/100), 2) AS net, ROUND(gross_amount / (1 + effective_rate / 100), 2) AS net,
gross_amount - ROUND(gross_amount / (1 + effective_rate/100), 2) AS vat; gross_amount - ROUND(gross_amount / (1 + effective_rate / 100), 2) AS vat;
END; END;
$$ LANGUAGE plpgsql; $$ LANGUAGE plpgsql;
-- ======================================= -- =======================================
-- UK RECEIPT COMPLIANCE FUNCTION -- UK RECEIPT COMPLIANCE FUNCTION
-- ======================================= -- =======================================
/* /*
UK RECEIPT REQUIREMENTS: UK RECEIPT REQUIREMENTS:
- Business name and address (static - provided by application) - Business name and address
- Date and time of transaction - Date and time of transaction
- Description of goods/services - Description of goods/services
- Amount charged (including VAT if registered) - 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 - Receipt/invoice number
- Payment method - Payment method
- Customer details (if requested) - Customer details (if not anonymised)
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)
*/ */
-- Complete Receipt Data for UK Compliance -- 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)) CREATE OR REPLACE FUNCTION get_receipt_data(p_payment_id CHAR(12))
RETURNS TABLE ( RETURNS TABLE (
-- Business Information -- Business Information
business_name TEXT, business_name TEXT,
business_address TEXT, business_address TEXT,
business_phone TEXT, business_phone TEXT,
business_email TEXT, business_email TEXT,
vat_registration_number TEXT, vat_registration_number TEXT,
is_vat_registered BOOLEAN, is_vat_registered BOOLEAN,
-- Payment Information -- Payment Information
payment_id CHAR(12), payment_id CHAR(12),
invoice_number TEXT, invoice_number TEXT,
transaction_date TIMESTAMPTZ, transaction_date TIMESTAMPTZ,
payment_method TEXT, payment_method TEXT,
payment_status TEXT, payment_status TEXT,
-- Customer Information -- Customer Information
customer_id CHAR(12), customer_id CHAR(12),
customer_name TEXT, customer_name TEXT,
customer_email TEXT, customer_email TEXT,
customer_phone TEXT, customer_phone TEXT,
-- Booking Information -- Booking Information
booking_id CHAR(12), booking_id CHAR(12),
appointment_date TIMESTAMPTZ, appointment_date TIMESTAMPTZ,
booking_status TEXT, booking_status TEXT,
-- Service Details -- Service Details
services JSON, services JSON,
total_duration_minutes INT, total_duration_minutes INT,
-- Financial Information -- Financial Information
gross_amount NUMERIC(10,2), gross_amount NUMERIC(10,2),
net_amount NUMERIC(10,2), net_amount NUMERIC(10,2),
vat_amount NUMERIC(10,2), vat_amount NUMERIC(10,2),
vat_rate NUMERIC(5,2), vat_rate NUMERIC(5,2),
is_vat_applicable BOOLEAN, is_vat_applicable BOOLEAN,
-- Receipt Metadata -- Receipt Metadata
receipt_generated_at TIMESTAMPTZ, receipt_generated_at TIMESTAMPTZ,
currency_code TEXT currency_code TEXT
) AS $$ ) AS $$
DECLARE DECLARE
bs RECORD; -- To hold business settings bs RECORD;
BEGIN BEGIN
-- Fetch current business settings (assume single row, id=1)
SELECT INTO bs SELECT INTO bs
business_name, bs2.business_name,
business_address, bs2.business_address,
business_phone, bs2.business_phone,
business_email, bs2.business_email,
vat_registration_number, bs2.vat_registration_number,
is_vat_registered, bs2.is_vat_registered,
currency_code bs2.currency_code
FROM business_settings FROM business_settings bs2
WHERE id = 1; WHERE bs2.id = 1;
RETURN QUERY RETURN QUERY
SELECT SELECT
-- Business Info
bs.business_name, bs.business_name,
bs.business_address, bs.business_address,
bs.business_phone, bs.business_phone,
@@ -1060,68 +1024,67 @@ BEGIN
bs.vat_registration_number, bs.vat_registration_number,
bs.is_vat_registered, bs.is_vat_registered,
-- Payment Info p.id AS payment_id,
p.id as payment_id, COALESCE('INV-' || p.invoice_number::text, 'INV-' || p.id) AS invoice_number,
COALESCE('INV-' || p.invoice_number::text, 'INV-' || p.id) as invoice_number, p.created_at AS transaction_date,
p.created_at as transaction_date, p.payment_method::text AS payment_method,
p.payment_method::text as payment_method, p.status::text AS payment_status,
p.status::text as payment_status,
-- Customer Info u.id AS customer_id,
u.id as customer_id,
CASE 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 ELSE u.fn
END as customer_name, END AS customer_name,
CASE 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 ELSE u.email
END as customer_email, END AS customer_email,
CASE 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 ELSE u.phone
END as customer_phone, END AS customer_phone,
-- Booking Info b.id AS booking_id,
b.id as booking_id, b.start_time AS appointment_date,
b.start_time as appointment_date, b.status::text AS booking_status,
b.status::text as booking_status,
-- Service Details
COALESCE(( COALESCE((
SELECT json_agg( SELECT json_agg(
json_build_object( json_build_object(
'service_id', s.id, 'service_id', s.id,
'name', s.name, 'name', s.name,
'description', s.description, 'description', s.description,
'price', s.price, 'price', COALESCE(bsvc.override_price, s.price),
'duration_minutes', s.duration_minutes 'duration_minutes', COALESCE(bsvc.override_duration_minutes, s.duration_minutes)
) )
ORDER BY s.name ORDER BY s.name
) )
FROM booking_services bs_inner FROM booking_services bsvc
JOIN services s ON bs_inner.service_id = s.id JOIN services s ON bsvc.service_id = s.id
WHERE bs_inner.booking_id = b.id WHERE bsvc.booking_id = b.id
), '[]'::json) as services, ), '[]'::json) AS services,
-- Total service duration
COALESCE(( COALESCE((
SELECT SUM(s.duration_minutes) SELECT SUM(COALESCE(bsvc.override_duration_minutes, s.duration_minutes))
FROM booking_services bs_inner FROM booking_services bsvc
JOIN services s ON bs_inner.service_id = s.id JOIN services s ON bsvc.service_id = s.id
WHERE bs_inner.booking_id = b.id WHERE bsvc.booking_id = b.id
), 0) as total_duration_minutes, ), 0)::INT AS total_duration_minutes,
-- Financial Info p.amount AS gross_amount,
p.amount as gross_amount, COALESCE(p.net_amount, p.amount) AS net_amount,
COALESCE(p.net_amount, p.amount) as net_amount, COALESCE(p.vat_amount, 0.00) AS vat_amount,
COALESCE(p.vat_amount, 0.00) as vat_amount, p.vat_rate AS vat_rate,
p.vat_rate as vat_rate, p.is_vat_applicable AS is_vat_applicable,
p.is_vat_applicable as is_vat_applicable,
-- Receipt Metadata NOW() AS receipt_generated_at,
NOW() as receipt_generated_at, bs.currency_code AS currency_code
bs.currency_code as currency_code
FROM payments p FROM payments p
JOIN bookings b ON p.booking_id = b.id JOIN bookings b ON p.booking_id = b.id
@@ -1134,73 +1097,62 @@ $$ LANGUAGE plpgsql;
-- FUNCTION USAGE SUMMARY -- FUNCTION USAGE SUMMARY
-- ======================================= -- =======================================
/* /*
This summary organizes all database functions by their primary purpose, legal basis, and expected usage frequency— This summary organises all database functions by purpose, legal basis, and expected
providing a clear guide for integration into our application, compliance workflows, and business operations. usage frequency — providing a clear guide for application integration.
-------------------------------------------------------------------------------- --------------------------------------------------------------------------------
1. FINANCIAL & TAX COMPLIANCE 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) - 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) - 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) - 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) - 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) - 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) - 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) - 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) - 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 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) - 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) - 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) - 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) - 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. NOTE: Booking/payment data is retained under contractual necessity (GDPR Art. 6(1)(b))
Only optional data (e.g., marketing) is governed by consent. even after consent withdrawal. Only optional/marketing data is consent-governed.
-------------------------------------------------------------------------------- --------------------------------------------------------------------------------
3. BUSINESS OPERATIONS & LOYALTY 3. INTEGRATION QUICK REFERENCE
-------------------------------------------------------------------------------- --------------------------------------------------------------------------------
• User deletes account → anonymize_user() or delete_guest_user()
- 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()
• "What data do you have?" → export_all_user_data() • "What data do you have?" → export_all_user_data()
• New payment → apply_vat_to_payment() (if VAT reg) + get_receipt_data() • New payment → apply_vat_to_payment() (if VAT reg) + get_receipt_data()
• Monthly close → export_sales_transactions(..., include_vat := [true/false]) • Monthly close → export_sales_transactions(..., include_vat := [true/false])
• VAT registration day → enable_vat_registration() once, then apply_vat_to_payment() • VAT registration day → enable_vat_registration() once, then apply_vat_to_payment()
• Dashboard load → get_sales_totals() + get_monthly_business_summary() • Dashboard load → get_sales_totals() + get_monthly_business_summary()
*/ */