DatabaseProgramming: console_2.sql

File console_2.sql, 28.9 KB (added by 231039, 10 days ago)
Line 
1-- ============================================================
2-- Trigger1: trg_schedule_next_vaccination_appointment
3--
4-- Use case: when a vaccination treatment's 'date_next' attribute
5-- is recorded (the date the pet is due for its next dose), the
6-- clinic automatically books a follow-up: a new 'appointment'
7-- and a linked 'examination' (actual scheduled slot, for date_next).
8-- ============================================================
9
10CREATE OR REPLACE FUNCTION fn_schedule_next_vaccination_appointment()
11RETURNS TRIGGER AS $$
12DECLARE
13 v_attribute_name varchar(255);
14 v_treatment_type varchar(255);
15 v_examination_id int4;
16 v_pet_id int4;
17 v_owner_id int4;
18 v_owner_phone varchar(255);
19 v_employee_id int4;
20 v_date_next date;
21 v_new_appointment_id int4;
22BEGIN
23 -- only act on the 'date_next' attribute
24 SELECT ta.name
25 INTO v_attribute_name
26 FROM treatment_attribute ta
27 WHERE ta.id = NEW.treatment_attribute_id;
28
29 IF v_attribute_name IS DISTINCT FROM 'date_next' THEN
30 RETURN NEW;
31 END IF;
32
33 -- confirm treatment is a vaccination, save its examination_id
34 SELECT tt.name, t.examination_id
35 INTO v_treatment_type, v_examination_id
36 FROM treatment t
37 JOIN treatment_type tt ON tt.id = t.treatment_type_id
38 WHERE t.id = NEW.treatment_id;
39
40 IF v_treatment_type IS DISTINCT FROM 'vaccination' THEN
41 RETURN NEW;
42 END IF;
43
44 -- value is stored as text (EAV pattern), cast to date
45 v_date_next := NEW.value::date;
46
47 -- pet/owner/employee context from the current examination -> appointment
48 SELECT a.pet_id, a.owner_id, o.phone, e.employee_id
49 INTO v_pet_id, v_owner_id, v_owner_phone, v_employee_id
50 FROM examination e
51 JOIN appointment a ON a.id = e.appointment_id
52 JOIN owner o ON o.id = a.owner_id
53 WHERE e.id = v_examination_id;
54
55 -- new appointment (request): dated today
56 INSERT INTO appointment (date_appointment, reason, phone, owner_id, pet_id)
57 VALUES (CURRENT_DATE, 'Auto-scheduled: next vaccine dose', v_owner_phone, v_owner_id, v_pet_id)
58 RETURNING id INTO v_new_appointment_id;
59
60 -- new examination (scheduled slot): dated for date_next
61 INSERT INTO examination (date_examination, status, description, appointment_id, employee_id, examination_room_id)
62 VALUES (v_date_next, 'scheduled', 'Follow-up vaccination dose', v_new_appointment_id, v_employee_id, NULL);
63
64 RETURN NEW;
65END;
66$$ LANGUAGE plpgsql;
67
68CREATE TRIGGER trg_schedule_next_vaccination_appointment
69AFTER INSERT ON treatment_attribute_value
70FOR EACH ROW
71EXECUTE FUNCTION fn_schedule_next_vaccination_appointment();
72
73SELECT id, name, data_type, treatment_type_id
74FROM treatment_attribute
75WHERE name = 'date_next';
76
77SELECT t.id, t.date_treatment, e.id AS examination_id, e.employee_id
78FROM treatment t
79JOIN examination e ON e.id = t.examination_id
80WHERE t.treatment_type_id = 2
81LIMIT 5;
82
83SELECT count(*) FROM appointment WHERE pet_id = (
84 SELECT a.pet_id FROM appointment a
85 JOIN examination e ON e.appointment_id = a.id
86 JOIN treatment t ON t.examination_id = e.id
87 WHERE t.id = 1255
88);
89
90INSERT INTO treatment_attribute_value (treatment_id, treatment_attribute_id, value)
91VALUES (1255, 12, '2026-12-01');
92
93SELECT * FROM appointment
94WHERE reason = 'Auto-scheduled: next vaccine dose'
95ORDER BY id DESC LIMIT 1;
96
97SELECT * FROM examination
98WHERE description = 'Follow-up vaccination dose'
99ORDER BY id DESC LIMIT 1;
100
101-- non-date_next attribute id, e.g. adverse_reaction (13), to test guard
102INSERT INTO treatment_attribute_value (treatment_id, treatment_attribute_id, value)
103VALUES (1568, 13, 'false');
104
105
106
107-- ============================================================
108-- Trigger2: trg_prevent_double_booking
109--
110-- Use case: before inserting or updating an examination, check
111-- if the assigned employee is already scheduled in the same
112-- examination room on the same date. If a conflict exists,
113-- the insert/update is blocked with a descriptive error.
114-- This prevents two examinations being assigned to the same
115-- employee and room at the same time.
116--
117-- Returns: raises an exception if a conflict is detected,
118-- otherwise allows the operation to proceed (RETURN NEW).
119-- ============================================================
120
121CREATE OR REPLACE FUNCTION fn_prevent_double_booking()
122RETURNS TRIGGER AS $$
123DECLARE
124 v_conflict_id int4;
125 v_employee_name text;
126 v_room_number varchar(255);
127BEGIN
128 -- only check when employee and room are both assigned
129 IF NEW.employee_id IS NULL OR NEW.examination_room_id IS NULL THEN
130 RETURN NEW;
131 END IF;
132
133 -- skip cancelled examinations
134 IF NEW.status = 'cancelled' THEN
135 RETURN NEW;
136 END IF;
137
138 -- look for any existing non-cancelled examination
139 -- on the same date, same employee, same room
140 -- excluding the current row on UPDATE (NEW.id)
141 SELECT e.id
142 INTO v_conflict_id
143 FROM examination e
144 WHERE e.employee_id = NEW.employee_id
145 AND e.examination_room_id = NEW.examination_room_id
146 AND e.date_examination = NEW.date_examination
147 AND e.status != 'cancelled'
148 AND e.id IS DISTINCT FROM NEW.id -- exclude self on UPDATE
149 LIMIT 1;
150
151 IF v_conflict_id IS NOT NULL THEN
152 -- create error message
153 SELECT emp.first_name || ' ' || emp.last_name
154 INTO v_employee_name
155 FROM employee emp
156 WHERE emp.id = NEW.employee_id;
157
158 SELECT er.room_number
159 INTO v_room_number
160 FROM examination_room er
161 WHERE er.id = NEW.examination_room_id;
162
163 RAISE EXCEPTION
164 'Double booking conflict: % is already assigned to room % on %. '
165 'Conflicting examination id: %.',
166 v_employee_name,
167 v_room_number,
168 NEW.date_examination,
169 v_conflict_id;
170 END IF;
171
172 RETURN NEW;
173END;
174$$ LANGUAGE plpgsql;
175
176CREATE TRIGGER trg_prevent_double_booking
177 BEFORE INSERT OR UPDATE
178 ON examination
179 FOR EACH ROW
180EXECUTE FUNCTION fn_prevent_double_booking();
181
182
183-- ============================================================
184-- TEST
185-- ============================================================
186
187-- existing examination
188SELECT
189 e.id,
190 e.date_examination,
191 e.employee_id,
192 e.examination_room_id,
193 e.status
194FROM examination e
195WHERE e.status != 'cancelled'
196 AND e.employee_id IS NOT NULL
197 AND e.examination_room_id IS NOT NULL
198ORDER BY e.id
199LIMIT 5;
200
201-- any appointment that has no examination yet
202SELECT a.id
203FROM appointment a
204LEFT JOIN examination e ON e.appointment_id = a.id
205WHERE e.id IS NULL;
206
207-- conflicting insert (should fail)
208INSERT INTO examination (date_examination, status, description, appointment_id, employee_id, examination_room_id)
209VALUES (
210 '2023-09-01', -- same date as existing examination
211 'scheduled',
212 'Test insert — should be blocked by trigger',
213 809019, -- a free appointment
214 5, -- same employee as existing examination
215 7 -- same room as existing examination
216);
217-- ERROR: Double booking conflict: BIANKA BARRIE is already assigned to room 7 on 2023-09-01. Conflicting examination id: 694.
218
219
220-- different employee or a different date (no conflict, should pass)
221INSERT INTO examination (date_examination, status, description, appointment_id, employee_id, examination_room_id)
222VALUES (
223 '2023-09-01', -- same date
224 'scheduled',
225 'Test insert — should SUCCEED (different employee)',
226 809019,
227 1, -- different employee, no conflict
228 7
229);
230
231
232-- ============================================================
233-- Trigger3: trg_cancel_examinations_on_pet_deactivation
234--
235-- Use case: when a pet's 'is_active' flag transitions from true
236-- to false (e.g. pet passed away, owner surrendered it, pet
237-- moved clinics), automatically cancel every future 'scheduled'
238-- examination for that pet, since it no longer makes sense to
239-- hold a room/employee slot for an inactive pet.
240--
241-- Scope: only examinations that are still 'scheduled' and whose
242-- date_examination is today or later are cancelled. Completed
243-- or already-cancelled examinations, and anything in the past,
244-- are left untouched (historical record integrity).
245-- ============================================================
246
247CREATE OR REPLACE FUNCTION fn_cancel_examinations_on_pet_deactivation()
248RETURNS TRIGGER AS $$
249DECLARE
250 v_cancelled_count int4;
251BEGIN
252 -- only act on an active -> inactive transition
253 IF NOT (OLD.is_active = true AND NEW.is_active = false) THEN
254 RETURN NEW;
255 END IF;
256
257 WITH cancelled AS (
258 UPDATE examination e
259 SET status = 'cancelled'
260 FROM appointment a
261 WHERE e.appointment_id = a.id
262 AND a.pet_id = NEW.id
263 AND e.status = 'scheduled'
264 AND e.date_examination >= CURRENT_DATE
265 RETURNING e.id
266 )
267 SELECT count(*) INTO v_cancelled_count FROM cancelled;
268
269 IF v_cancelled_count > 0 THEN
270 RAISE NOTICE 'Pet id % deactivated: % future scheduled examination(s) cancelled.',
271 NEW.id, v_cancelled_count;
272 END IF;
273
274 RETURN NEW;
275END;
276$$ LANGUAGE plpgsql;
277
278CREATE TRIGGER trg_cancel_examinations_on_pet_deactivation
279 AFTER UPDATE OF is_active ON pet
280 FOR EACH ROW
281 WHEN (OLD.is_active = true AND NEW.is_active = false)
282EXECUTE FUNCTION fn_cancel_examinations_on_pet_deactivation();
283
284
285-- ============================================================
286-- TEST
287-- ============================================================
288
289-- find a pet that currently has a future 'scheduled' examination
290SELECT
291 p.id AS pet_id,
292 p.name,
293 p.is_active,
294 e.id AS examination_id,
295 e.date_examination,
296 e.status
297FROM pet p
298JOIN appointment a ON a.pet_id = p.id
299JOIN examination e ON e.appointment_id = a.id
300WHERE p.is_active = true
301 AND e.status = 'scheduled'
302 AND e.date_examination >= CURRENT_DATE
303ORDER BY p.id
304LIMIT 5;
305
306-- deactivate that pet
307UPDATE pet
308SET is_active = false
309WHERE id = 1211;
310
311-- examination(s) should now be 'cancelled'
312SELECT id, date_examination, status
313FROM examination
314WHERE appointment_id IN (
315 SELECT id FROM appointment WHERE pet_id = 1211
316) and date_examination >= CURRENT_DATE
317ORDER BY date_examination;
318
319-- past examinations for the same pet remain untouched
320SELECT id, date_examination, status
321FROM examination
322WHERE appointment_id IN (
323 SELECT id FROM appointment WHERE pet_id = 1211
324)
325 AND date_examination < CURRENT_DATE;
326
327-- no-op check: toggling is_active from false -> true (or true -> true)
328-- does NOT fire the trigger
329UPDATE pet SET is_active = true WHERE id = 1211;
330
331
332-- ============================================================
333-- Trigger4: trg_validate_coupon_on_invoice
334--
335-- Use case: before an invoice carrying a coupon is inserted,
336-- verify the coupon is actually usable:
337-- - is_active = true
338-- - date_invoice falls within [valid_from, valid_to]
339-- - usage_count < usage_limit (still has redemptions left)
340-- - NEW.total >= coupon.min_total
341-- If any check fails, the insert is rejected. If all checks
342-- pass, usage_count is incremented in the same operation.
343--
344-- Note: NEW.total at INSERT time is the pre-discount
345-- subtotal, not the final discounted amount, this is what
346-- min_total is meant to gate. Callers (sp_generate_invoice)
347-- insert with total = subtotal, let this trigger validate and
348-- redeem the coupon, then issue a separate UPDATE afterwards
349-- to apply the discount and set the real total. That UPDATE
350-- does not re-fire this trigger (BEFORE INSERT only),
351-- validation/redemption is a one-time event tied
352-- to the insert
353-- ============================================================
354
355CREATE OR REPLACE FUNCTION fn_validate_coupon_on_invoice()
356RETURNS TRIGGER AS $$
357DECLARE
358 v_coupon coupon%ROWTYPE;
359BEGIN
360 IF NEW.coupon_id IS NULL THEN
361 RETURN NEW;
362 END IF;
363
364 SELECT *
365 INTO v_coupon
366 FROM coupon
367 WHERE id = NEW.coupon_id
368 FOR UPDATE;
369
370 IF NOT FOUND THEN
371 RAISE EXCEPTION 'Coupon id % does not exist.', NEW.coupon_id;
372 END IF;
373
374 IF v_coupon.is_active = false THEN
375 RAISE EXCEPTION 'Coupon % is not active.', v_coupon.code;
376 END IF;
377
378 IF NEW.date_invoice < v_coupon.valid_from OR NEW.date_invoice > v_coupon.valid_to THEN
379 RAISE EXCEPTION
380 'Coupon % is not valid on %. Valid window: % to %.',
381 v_coupon.code, NEW.date_invoice, v_coupon.valid_from, v_coupon.valid_to;
382 END IF;
383
384 IF v_coupon.usage_count >= v_coupon.usage_limit THEN
385 RAISE EXCEPTION
386 'Coupon % has reached its usage limit (% / %).',
387 v_coupon.code, v_coupon.usage_count, v_coupon.usage_limit;
388 END IF;
389
390 IF NEW.total < COALESCE(v_coupon.min_total, 0) THEN
391 RAISE EXCEPTION
392 'Invoice subtotal % is below coupon % minimum spend of %.',
393 NEW.total, v_coupon.code, v_coupon.min_total;
394 END IF;
395
396 UPDATE coupon
397 SET usage_count = usage_count + 1
398 WHERE id = v_coupon.id;
399
400 RETURN NEW;
401END;
402$$ LANGUAGE plpgsql;
403
404CREATE TRIGGER trg_validate_coupon_on_invoice
405 BEFORE INSERT ON invoice
406 FOR EACH ROW
407 WHEN (NEW.coupon_id IS NOT NULL)
408EXECUTE FUNCTION fn_validate_coupon_on_invoice();
409
410
411-- ============================================================
412-- Function1: fn_get_owner_total_spent
413--
414-- Returns the total amount paid by an owner across all invoices.
415-- ============================================================
416
417CREATE OR REPLACE FUNCTION fn_get_owner_total_spent(p_owner_id int4)
418 RETURNS numeric(10, 2) AS
419$$
420DECLARE
421 v_total_spend numeric(10, 2);
422BEGIN
423 SELECT COALESCE(SUM(p.amount), 0.00)
424 INTO v_total_spend
425 FROM payment p
426 JOIN invoice i
427 ON i.id = p.invoice_id
428 WHERE i.owner_id = p_owner_id;
429
430 RETURN v_total_spend;
431END;
432$$ LANGUAGE plpgsql;
433
434-- Test
435SELECT fn_get_owner_total_spent(1);
436
437
438-- ============================================================
439-- Function2: fn_is_examination_room_available
440--
441-- Checks whether an examination room is available on a given date.
442-- Returns FALSE if there is a scheduled or completed examination.
443-- ============================================================
444
445CREATE OR REPLACE FUNCTION fn_is_examination_room_available(
446 p_room_id int4,
447 p_date date
448)
449 RETURNS boolean AS
450$$
451BEGIN
452 RETURN NOT EXISTS (
453 SELECT 1
454 FROM examination e
455 WHERE e.examination_room_id = p_room_id
456 AND e.date_examination = p_date
457 AND e.status IN ('scheduled', 'completed')
458 );
459END;
460$$ LANGUAGE plpgsql;
461
462-- Test
463SELECT fn_is_examination_room_available(1, CURRENT_DATE);
464SELECT fn_is_examination_room_available(4, '2026-09-14');
465SELECT
466 er.id AS room_id,
467 CURRENT_DATE AS date
468FROM examination_room er
469WHERE fn_is_examination_room_available(er.id, CURRENT_DATE);
470
471
472
473-- ============================================================
474-- Procedure1: sp_generate_invoice
475--
476-- Collects every treatment linked (via examination -> appointment)
477-- to p_owner_id that has not yet been invoiced (no invoice_item
478-- row references it), creates one invoice, one invoice_item line
479-- per treatment, optionally applies a coupon, and sets the final
480-- discounted total.
481--
482-- Coupon handling:
483-- - p_coupon_code is resolved to a coupon_id BEFORE the insert.
484-- - If the code doesn't exist: NOT an error. A NOTICE is raised
485-- and the invoice proceeds as if no coupon was given.
486-- - If the code exists but is expired/inactive/exhausted/under
487-- min_total: trg_validate_coupon_on_invoice raises an
488-- EXCEPTION when we INSERT INTO invoice, and the whole
489-- procedure (including any invoice_item work already done)
490-- rolls back. Nothing is left half-committed.
491-- ============================================================
492
493CREATE OR REPLACE PROCEDURE sp_generate_invoice(
494 p_owner_id int4,
495 p_coupon_code varchar(255) DEFAULT NULL
496)
497LANGUAGE plpgsql
498AS $$
499DECLARE
500 v_invoice_id int4;
501 v_coupon coupon%ROWTYPE;
502 v_coupon_id int4 := NULL;
503 v_subtotal numeric(10,2);
504 v_final_total numeric(10,2);
505 v_line_count int4;
506BEGIN
507 -- resolve coupon code -> id (silent fallback on miss)
508 IF p_coupon_code IS NOT NULL THEN
509 SELECT * INTO v_coupon FROM coupon WHERE code = p_coupon_code;
510
511 IF NOT FOUND THEN
512 RAISE NOTICE 'Coupon code "%" not found — generating invoice without a coupon.',
513 p_coupon_code;
514 v_coupon_id := NULL;
515 ELSE
516 v_coupon_id := v_coupon.id;
517 END IF;
518 END IF;
519
520 -- confirm there's something to invoice, compute subtotal
521 SELECT
522 count(*),
523 SUM(
524 CASE tt.name
525 WHEN 'prescription' THEN 35.00
526 WHEN 'vaccination' THEN 30.00
527 WHEN 'consultation' THEN 40.00
528 WHEN 'operation' THEN 400.00
529 ELSE 0.00
530 END
531 )
532 INTO v_line_count, v_subtotal
533 FROM treatment t
534 JOIN treatment_type tt ON tt.id = t.treatment_type_id
535 JOIN examination e ON e.id = t.examination_id
536 JOIN appointment a ON a.id = e.appointment_id
537 WHERE a.owner_id = p_owner_id
538 AND NOT EXISTS (
539 SELECT 1 FROM invoice_item ii
540 WHERE ii.treatment_id = t.id AND ii.type = 'treatment'
541 );
542
543 IF v_line_count IS NULL OR v_line_count = 0 THEN
544 RAISE EXCEPTION 'No uninvoiced treatments found for owner id %.', p_owner_id;
545 END IF;
546
547 -- insert invoice with the SUBTOTAL (pre-discount)
548 -- trg_validate_coupon_on_invoice fires here:
549 -- - validates active / not expired / under usage_limit
550 -- - validates v_subtotal >= coupon.min_total
551 -- - increments coupon.usage_count
552 -- - RAISEs and rolls back the whole CALL if invalid
553 INSERT INTO invoice (date_invoice, total, coupon_id, owner_id)
554 VALUES (CURRENT_DATE, v_subtotal, v_coupon_id, p_owner_id)
555 RETURNING id INTO v_invoice_id;
556
557 -- one invoice_item line per uninvoiced treatment
558 -- num_item is set by trg_generate_num_item
559 INSERT INTO invoice_item (num_item, invoice_id, price, quantity, type, treatment_id)
560 SELECT
561 1,
562 v_invoice_id,
563 CASE tt.name
564 WHEN 'prescription' THEN 35.00
565 WHEN 'vaccination' THEN 30.00
566 WHEN 'consultation' THEN 40.00
567 WHEN 'operation' THEN 400.00
568 ELSE 0.00
569 END,
570 1,
571 'treatment',
572 t.id
573 FROM treatment t
574 JOIN treatment_type tt ON tt.id = t.treatment_type_id
575 JOIN examination e ON e.id = t.examination_id
576 JOIN appointment a ON a.id = e.appointment_id
577 WHERE a.owner_id = p_owner_id
578 AND NOT EXISTS (
579 SELECT 1 FROM invoice_item ii
580 WHERE ii.treatment_id = t.id AND ii.type = 'treatment'
581 );
582
583 -- apply the coupon discount and update to the final total
584 -- this UPDATE does NOT re-fire the coupon trigger (BEFORE INSERT only) — validation/redemption already done
585 IF v_coupon_id IS NOT NULL THEN
586 v_final_total := ROUND(
587 CASE v_coupon.type
588 WHEN 'fixed' THEN GREATEST(v_subtotal - v_coupon.value, 0)
589 WHEN 'percentage' THEN v_subtotal * (1 - v_coupon.value / 100)
590 END,
591 2
592 );
593
594 UPDATE invoice SET total = v_final_total WHERE id = v_invoice_id;
595 END IF;
596
597 RAISE NOTICE 'Invoice id % generated for owner id % — % line(s), subtotal %, final total %.',
598 v_invoice_id, p_owner_id, v_line_count, v_subtotal, COALESCE(v_final_total, v_subtotal);
599END;
600$$;
601
602
603-- ============================================================
604-- TEST
605-- ============================================================
606
607-- owner with uninvoiced treatments
608SELECT a.owner_id, count(*) AS uninvoiced
609FROM treatment t
610JOIN examination e ON e.id = t.examination_id
611JOIN appointment a ON a.id = e.appointment_id
612WHERE NOT EXISTS (
613 SELECT 1 FROM invoice_item ii
614 WHERE ii.treatment_id = t.id AND ii.type = 'treatment'
615)
616GROUP BY a.owner_id
617ORDER BY uninvoiced DESC
618LIMIT 5;
619
620-- no coupon
621CALL sp_generate_invoice(740, NULL);
622-- Invoice id 775850 generated for owner id 1 — 1 line(s), subtotal 30.00, final total 30.00.
623
624-- valid coupon
625SELECT code FROM coupon
626WHERE is_active = true AND usage_count < usage_limit;
627
628-- ============================================================
629-- TEST DATA — new uninvoiced treatments
630-- Creates 15 treatments for random owners.
631-- Uses existing completed examinations.
632-- ============================================================
633
634INSERT INTO treatment (
635 date_treatment,
636 notes,
637 treatment_type_id,
638 examination_id
639)
640SELECT
641 e.date_examination + (floor(random() * 3))::int AS date_treatment,
642
643 CASE tt.name
644 WHEN 'prescription' THEN
645 'TEST: Prescription created for invoice generation testing.'
646 WHEN 'vaccination' THEN
647 'TEST: Vaccination created for invoice generation testing.'
648 WHEN 'consultation' THEN
649 'TEST: Consultation created for invoice generation testing.'
650 WHEN 'operation' THEN
651 'TEST: Operation created for invoice generation testing.'
652 END AS notes,
653
654 tt.id AS treatment_type_id,
655 e.id AS examination_id
656
657FROM (
658 -- 15 random completed examinations.
659 SELECT e.id
660 FROM examination e
661 JOIN appointment a ON a.id = e.appointment_id
662 WHERE e.status = 'completed'
663 AND a.owner_id IS NOT NULL
664 ORDER BY random()
665 LIMIT 15
666) selected_exams
667
668JOIN examination e
669 ON e.id = selected_exams.id
670
671CROSS JOIN LATERAL (
672 SELECT id, name
673 FROM treatment_type
674 ORDER BY random()
675 LIMIT 1
676) tt;
677
678-- newly inserted, uninvoiced treatments:
679SELECT
680 t.id AS treatment_id,
681 a.owner_id,
682 tt.name AS treatment_type,
683 t.date_treatment,
684 e.id AS examination_id,
685 t.notes
686FROM treatment t
687JOIN treatment_type tt
688 ON tt.id = t.treatment_type_id
689JOIN examination e
690 ON e.id = t.examination_id
691JOIN appointment a
692 ON a.id = e.appointment_id
693WHERE t.notes LIKE 'TEST:%'
694ORDER BY a.owner_id, t.id;
695
696CALL sp_generate_invoice(269, 'HAA-007');
697-- Invoice id 775851 generated for owner id 209 — 1 line(s), subtotal 35.00, final total 19.70.
698
699-- bad code: should NOTICE and still succeed without a coupon
700CALL sp_generate_invoice(695, 'NOTAREALCODE');
701-- Coupon code "NOTAREALCODE" not found — generating invoice without a coupon.
702-- Invoice id 775852 generated for owner id 3687 — 1 line(s), subtotal 35.00, final total 35.00.
703
704-- owner with nothing left to invoice: should raise
705CALL sp_generate_invoice(1, NULL);
706
707
708-- ============================================================
709-- Procedure2: sp_process_payment
710--
711-- Records a payment against an invoice, supporting
712-- multiple payments over time. Each call:
713-- 1. Validates the invoice exists.
714-- 2. Computes the current remaining balance:
715-- invoice.total - SUM(existing payment.amount)
716-- 3. Rejects the call if:
717-- - p_amount <= 0
718-- - the invoice is already fully paid (balance = 0)
719-- - p_amount would overpay the invoice (amount > balance)
720-- 4. Inserts the payment row.
721-- 5. Returns (via OUT params) the new remaining balance and
722-- whether the invoice is now fully paid.
723-- ============================================================
724
725CREATE OR REPLACE PROCEDURE sp_process_payment(
726 p_invoice_id int4,
727 p_amount numeric(10,2),
728 p_method varchar(255),
729 OUT p_payment_id int4,
730 OUT p_remaining_balance numeric(10,2),
731 OUT p_fully_paid boolean
732)
733LANGUAGE plpgsql
734AS $$
735DECLARE
736 v_invoice_total numeric(10,2);
737 v_paid_so_far numeric(10,2);
738 v_balance numeric(10,2);
739BEGIN
740 -- validate the invoice exists, lock it against
741 -- payments on the same invoice
742 SELECT total
743 INTO v_invoice_total
744 FROM invoice
745 WHERE id = p_invoice_id
746 FOR UPDATE;
747
748 IF NOT FOUND THEN
749 RAISE EXCEPTION 'Invoice id % does not exist.', p_invoice_id;
750 END IF;
751
752 -- validate amount
753 IF p_amount IS NULL OR p_amount <= 0 THEN
754 RAISE EXCEPTION 'Payment amount must be greater than 0 (got %).', p_amount;
755 END IF;
756
757 -- compute remaining balance from prior payments
758 SELECT COALESCE(SUM(amount), 0)
759 INTO v_paid_so_far
760 FROM payment
761 WHERE invoice_id = p_invoice_id;
762
763 v_balance := v_invoice_total - v_paid_so_far;
764
765 IF v_balance <= 0 THEN
766 RAISE EXCEPTION 'Invoice id % is already fully paid (total %, paid %).',
767 p_invoice_id, v_invoice_total, v_paid_so_far;
768 END IF;
769
770 IF p_amount > v_balance THEN
771 RAISE EXCEPTION
772 'Payment of % exceeds remaining balance of % on invoice id % (total %, already paid %).',
773 p_amount, v_balance, p_invoice_id, v_invoice_total, v_paid_so_far;
774 END IF;
775
776 -- record the payment
777 INSERT INTO payment (date_payment, amount, method, invoice_id)
778 VALUES (CURRENT_DATE, p_amount, p_method, p_invoice_id)
779 RETURNING id INTO p_payment_id;
780
781 -- report back the new state
782 -- ------------------------------------------------------
783 p_remaining_balance := v_balance - p_amount;
784 p_fully_paid := (p_remaining_balance = 0);
785
786 IF p_fully_paid THEN
787 RAISE NOTICE 'Payment id % recorded for invoice id % — invoice is now fully paid.',
788 p_payment_id, p_invoice_id;
789 ELSE
790 RAISE NOTICE 'Payment id % recorded for invoice id % — % remaining.',
791 p_payment_id, p_invoice_id, p_remaining_balance;
792 END IF;
793END;
794$$;
795
796
797-- ============================================================
798-- TEST
799-- ============================================================
800-- Create 3 unpaid invoices
801INSERT INTO invoice (date_invoice, total, coupon_id, owner_id)
802VALUES
803 (CURRENT_DATE, 100.00, NULL, NULL),
804 (CURRENT_DATE, 150.00, NULL, NULL),
805 (CURRENT_DATE, 250.00, NULL, NULL);
806
807-- Give one of them a partial payment
808INSERT INTO payment (date_payment, amount, method, invoice_id)
809SELECT
810 CURRENT_DATE,
811 40.00,
812 'cash',
813 id
814FROM invoice
815ORDER BY id DESC
816LIMIT 1;
817
818-- invoice with a healthy total and see what's been paid so far
819SELECT i.id, i.total, COALESCE(SUM(p.amount), 0) AS paid_so_far,
820 i.total - COALESCE(SUM(p.amount), 0) AS balance
821FROM invoice i
822LEFT JOIN payment p ON p.invoice_id = i.id
823GROUP BY i.id, i.total
824HAVING i.total - COALESCE(SUM(p.amount), 0) > 0
825ORDER BY i.id
826LIMIT 5;
827-- id,total,paid_so_far,balance
828-- 775850,30.00,0,30
829
830-- partial payment: pay half the balance
831CALL sp_process_payment(
832 775849,
833 50,
834 'cash',
835 NULL, NULL, NULL -- OUT params
836);
837-- p_payment_id,p_remaining_balance,p_fully_paid
838-- 775849,15,false
839
840-- check the OUT values via a DO block (psql doesn't surface OUT
841-- params directly from CALL)
842DO $$
843DECLARE
844 v_payment_id int4;
845 v_balance numeric(10,2);
846 v_paid boolean;
847BEGIN
848 CALL sp_process_payment(775849, 50, 'debit card',
849 v_payment_id, v_balance, v_paid);
850 RAISE NOTICE 'payment_id=% balance=% fully_paid=%', v_payment_id, v_balance, v_paid;
851END $$;
852-- Payment id 775850 recorded for invoice id 775850 — invoice is now fully paid.
853-- payment_id=775850 balance=0.00 fully_paid=t
854
855-- verify total paid now matches invoice total
856SELECT i.id, i.total, SUM(p.amount) AS total_paid
857FROM invoice i
858JOIN payment p ON p.invoice_id = i.id
859WHERE i.id = 775849
860GROUP BY i.id, i.total;
861-- id,total,total_paid
862-- 775850,30.00,30
863
864-- overpayment attempt: should raise
865CALL sp_process_payment(775850, 9999999.99, 'cash', NULL, NULL, NULL);
866-- Invoice id 775850 is already fully paid (total 30.00, paid 30.00)
867
868-- already fully paid: should raise
869CALL sp_process_payment(775849, 1.00, 'cash', NULL, NULL, NULL);
870-- Invoice id 775850 is already fully paid (total 30.00, paid 30.00).
871
872-- zero / negative amount: should raise
873CALL sp_process_payment(775849, 0, 'cash', NULL, NULL, NULL);
874CALL sp_process_payment(775849, -5.00, 'cash', NULL, NULL, NULL);
875-- Payment amount must be greater than 0 (got 0).
876
877-- invalid method: should raise via existing CHECK constraint
878CALL sp_process_payment(775850, 5.00, 'bitcoin', NULL, NULL, NULL);
879-- ERROR: new row for relation "payment" violates check constraint "payment_method_check"
880
881-- nonexistent invoice: should raise
882CALL sp_process_payment(999999999, 5.00, 'cash', NULL, NULL, NULL);
883-- Invoice id 999999999 does not exist.