| 1 | -- schema addition: billing had no "issued" date, only payment_date (populated only once
|
|---|
| 2 | -- paid), so there was no way to measure how long a still-PENDING bill has been outstanding.
|
|---|
| 3 | -- Existing rows will backfill to NOW() at ALTER time, which is not historically accurate —
|
|---|
| 4 | -- acceptable for this project, but worth noting.
|
|---|
| 5 | ALTER TABLE billing ADD COLUMN IF NOT EXISTS created_at TIMESTAMP DEFAULT NOW();
|
|---|
| 6 |
|
|---|
| 7 | CREATE TABLE IF NOT EXISTS billing_audit_log (
|
|---|
| 8 | audit_id BIGSERIAL PRIMARY KEY,
|
|---|
| 9 | bill_id BIGINT NOT NULL REFERENCES billing(bill_id),
|
|---|
| 10 | old_amount DECIMAL(12,2),
|
|---|
| 11 | new_amount DECIMAL(12,2),
|
|---|
| 12 | old_status TEXT,
|
|---|
| 13 | new_status TEXT,
|
|---|
| 14 | change_type TEXT CHECK (change_type IN ('INSERT', 'UPDATE', 'LINE_ITEM_ADD', 'LINE_ITEM_REMOVE')),
|
|---|
| 15 | changed_at TIMESTAMP DEFAULT NOW()
|
|---|
| 16 | );
|
|---|
| 17 |
|
|---|
| 18 | -- Note: billing_procedures links to the catalog procedure_id, not to a specific
|
|---|
| 19 | -- performed_procedures row, so when the same procedure type has been performed on more
|
|---|
| 20 | -- than one patient there is no reliable way to verify a billed line item belongs to the
|
|---|
| 21 | -- same patient as the bill. That check is intentionally left out rather than implemented
|
|---|
| 22 | -- unreliably.
|
|---|
| 23 |
|
|---|
| 24 | CREATE OR REPLACE FUNCTION recalculate_billing_total(p_bill_id BIGINT, p_change_type TEXT)
|
|---|
| 25 | RETURNS VOID
|
|---|
| 26 | LANGUAGE plpgsql
|
|---|
| 27 | AS $$
|
|---|
| 28 | DECLARE
|
|---|
| 29 | v_procedure_total DECIMAL;
|
|---|
| 30 | v_lab_total DECIMAL;
|
|---|
| 31 | v_new_total DECIMAL;
|
|---|
| 32 | BEGIN
|
|---|
| 33 | SELECT COALESCE(SUM(p.cost), 0) INTO v_procedure_total
|
|---|
| 34 | FROM billing_procedures bp
|
|---|
| 35 | JOIN procedures p ON p.procedure_id = bp.procedure_id
|
|---|
| 36 | WHERE bp.bill_id = p_bill_id;
|
|---|
| 37 |
|
|---|
| 38 | SELECT COALESCE(SUM(lt.cost), 0) INTO v_lab_total
|
|---|
| 39 | FROM billing_lab_tests blt
|
|---|
| 40 | JOIN lab_tests lt ON lt.test_id = blt.test_id
|
|---|
| 41 | WHERE blt.bill_id = p_bill_id;
|
|---|
| 42 |
|
|---|
| 43 | v_new_total := v_procedure_total + v_lab_total;
|
|---|
| 44 |
|
|---|
| 45 | UPDATE billing SET total_cost = v_new_total WHERE bill_id = p_bill_id;
|
|---|
| 46 |
|
|---|
| 47 | INSERT INTO billing_audit_log (bill_id, new_amount, change_type)
|
|---|
| 48 | VALUES (p_bill_id, v_new_total, p_change_type);
|
|---|
| 49 | END;
|
|---|
| 50 | $$;
|
|---|
| 51 |
|
|---|
| 52 | -- one shared trigger function, reused for both billing_procedures and billing_lab_tests
|
|---|
| 53 | -- (both tables have a bill_id column, so NEW.bill_id / OLD.bill_id works either way)
|
|---|
| 54 | CREATE OR REPLACE FUNCTION t1_billing_line_item_changed()
|
|---|
| 55 | RETURNS TRIGGER
|
|---|
| 56 | LANGUAGE plpgsql
|
|---|
| 57 | AS $$
|
|---|
| 58 | BEGIN
|
|---|
| 59 | IF TG_OP = 'DELETE' THEN
|
|---|
| 60 | PERFORM recalculate_billing_total(OLD.bill_id, 'LINE_ITEM_REMOVE');
|
|---|
| 61 | RETURN OLD;
|
|---|
| 62 | ELSE
|
|---|
| 63 | PERFORM recalculate_billing_total(NEW.bill_id, 'LINE_ITEM_ADD');
|
|---|
| 64 | RETURN NEW;
|
|---|
| 65 | END IF;
|
|---|
| 66 | END;
|
|---|
| 67 | $$;
|
|---|
| 68 |
|
|---|
| 69 | DROP TRIGGER IF EXISTS trg_billing_procedures_update_total ON billing_procedures;
|
|---|
| 70 | CREATE TRIGGER trg_billing_procedures_update_total
|
|---|
| 71 | AFTER INSERT OR DELETE ON billing_procedures
|
|---|
| 72 | FOR EACH ROW
|
|---|
| 73 | EXECUTE FUNCTION t1_billing_line_item_changed();
|
|---|
| 74 |
|
|---|
| 75 | DROP TRIGGER IF EXISTS trg_billing_lab_tests_update_total ON billing_lab_tests;
|
|---|
| 76 | CREATE TRIGGER trg_billing_lab_tests_update_total
|
|---|
| 77 | AFTER INSERT OR DELETE ON billing_lab_tests
|
|---|
| 78 | FOR EACH ROW
|
|---|
| 79 | EXECUTE FUNCTION t1_billing_line_item_changed();
|
|---|
| 80 |
|
|---|
| 81 | CREATE OR REPLACE FUNCTION is_valid_billing_transition(p_old TEXT, p_new TEXT)
|
|---|
| 82 | RETURNS BOOLEAN
|
|---|
| 83 | LANGUAGE sql
|
|---|
| 84 | IMMUTABLE
|
|---|
| 85 | AS $$
|
|---|
| 86 | SELECT CASE
|
|---|
| 87 | WHEN p_old = p_new THEN TRUE
|
|---|
| 88 | WHEN p_old = 'PENDING' AND p_new IN ('PAID', 'CANCELLED') THEN TRUE
|
|---|
| 89 | ELSE FALSE
|
|---|
| 90 | END;
|
|---|
| 91 | $$;
|
|---|
| 92 |
|
|---|
| 93 | CREATE OR REPLACE FUNCTION t2_billing_status_transition()
|
|---|
| 94 | RETURNS TRIGGER
|
|---|
| 95 | LANGUAGE plpgsql
|
|---|
| 96 | AS $$
|
|---|
| 97 | BEGIN
|
|---|
| 98 | IF NOT is_valid_billing_transition(OLD.payment_status, NEW.payment_status) THEN
|
|---|
| 99 | RAISE EXCEPTION 'Cannot transition billing status from % to %',
|
|---|
| 100 | OLD.payment_status, NEW.payment_status;
|
|---|
| 101 | END IF;
|
|---|
| 102 |
|
|---|
| 103 | IF OLD.payment_status <> NEW.payment_status THEN
|
|---|
| 104 | INSERT INTO billing_audit_log (bill_id, old_status, new_status, change_type)
|
|---|
| 105 | VALUES (NEW.bill_id, OLD.payment_status, NEW.payment_status, 'UPDATE');
|
|---|
| 106 | END IF;
|
|---|
| 107 |
|
|---|
| 108 | RETURN NEW;
|
|---|
| 109 | END;
|
|---|
| 110 | $$;
|
|---|
| 111 |
|
|---|
| 112 | DROP TRIGGER IF EXISTS trg_billing_status_transition ON billing;
|
|---|
| 113 | CREATE TRIGGER trg_billing_status_transition
|
|---|
| 114 | BEFORE UPDATE ON billing
|
|---|
| 115 | FOR EACH ROW
|
|---|
| 116 | EXECUTE FUNCTION t2_billing_status_transition();
|
|---|
| 117 |
|
|---|
| 118 | CREATE OR REPLACE VIEW v_overdue_billings AS
|
|---|
| 119 | SELECT
|
|---|
| 120 | b.bill_id,
|
|---|
| 121 | p.patient_id, p.first_name, p.last_name,
|
|---|
| 122 | b.total_cost, b.payment_status, b.created_at,
|
|---|
| 123 | CURRENT_DATE - b.created_at::DATE AS days_outstanding,
|
|---|
| 124 | CASE
|
|---|
| 125 | WHEN CURRENT_DATE - b.created_at::DATE > 60 THEN 'CRITICAL'
|
|---|
| 126 | WHEN CURRENT_DATE - b.created_at::DATE > 30 THEN 'OVERDUE'
|
|---|
| 127 | ELSE 'PENDING'
|
|---|
| 128 | END AS urgency
|
|---|
| 129 | FROM billing b
|
|---|
| 130 | JOIN medical_records mr ON b.record_id = mr.record_id
|
|---|
| 131 | JOIN patients p ON mr.patient_id = p.patient_id
|
|---|
| 132 | WHERE b.payment_status = 'PENDING'
|
|---|
| 133 | AND CURRENT_DATE - b.created_at::DATE >= 30
|
|---|
| 134 | ORDER BY days_outstanding DESC;
|
|---|
| 135 |
|
|---|
| 136 | CREATE OR REPLACE PROCEDURE job_billing_alerts()
|
|---|
| 137 | LANGUAGE plpgsql
|
|---|
| 138 | AS $$
|
|---|
| 139 | DECLARE
|
|---|
| 140 | v_overdue_count INT;
|
|---|
| 141 | BEGIN
|
|---|
| 142 | SELECT COUNT(*) INTO v_overdue_count FROM v_overdue_billings WHERE days_outstanding > 30;
|
|---|
| 143 | RAISE NOTICE 'Found % overdue billing records requiring follow-up', v_overdue_count;
|
|---|
| 144 | END;
|
|---|
| 145 | $$; |
|---|