LP#2006971: Custom system penalties
authorMike Rylander <mrylander@gmail.com>
Wed, 14 Dec 2022 17:43:03 +0000 (12:43 -0500)
committerBill Erickson <berickxx@gmail.com>
Wed, 3 May 2023 19:38:55 +0000 (15:38 -0400)
This development creates a set of Library Settings that a staff
member with appropriate permissions can use to set a custom value
for a stock penalty according to the following workflow:

-- Create a new penalty in Standing Penalties
-- Set the desired penalty threshold in Group Penalty Thresholds
-- Use the Library Setting to associate your new penalty with a
   stock system penalty and organizational unit context.

The custom penalty will be automatically applied in place of the
stock penalty at the specified org units, when the patrons'
account meets the penalty criteria.

System penalties that can be overridden:

 * PATRON_EXCEEDS_FINES
 * PATRON_EXCEEDS_OVERDUE_COUNT
 * PATRON_EXCEEDS_CHECKOUT_COUNT
 * PATRON_EXCEEDS_LOST_COUNT
 * PATRON_EXCEEDS_LONGOVERDUE_COUNT
 * PATRON_EXCEEDS_COLLECTIONS_WARNING
 * PATRON_IN_COLLECTIONS

Signed-off-by: Mike Rylander <mrylander@gmail.com>
Signed-off-by: Elizabeth Davis <elizabeth.davis@sparkpa.org>
Signed-off-by: Bill Erickson <berickxx@gmail.com>

Open-ILS/src/sql/Pg/100.circ_matrix.sql
Open-ILS/src/sql/Pg/950.data.seed-values.sql
Open-ILS/src/sql/Pg/upgrade/XXXX.schema.custom_system_penalties.sql [new file with mode: 0644]

index 4e97715..7ef2b3e 100644 (file)
@@ -428,6 +428,7 @@ DECLARE
     circ_limit_set          config.circ_limit_set%ROWTYPE;
     hold_ratio              action.hold_stats%ROWTYPE;
     penalty_type            TEXT;
+    penalty_id              INT;
     items_out               INT;
     context_org_list        INT[];
     permit_renew            TEXT;
@@ -542,6 +543,10 @@ BEGIN
         penalty_type = '%CIRC%';
     END IF;
 
+    -- Look up any custom override for PATRON_EXCEEDS_FINES penalty
+    SELECT BTRIM(value,'"')::INT INTO penalty_id FROM actor.org_unit_ancestor_setting('circ.custom_penalty_override.PATRON_EXCEEDS_FINES', circ_ou);
+    IF NOT FOUND THEN penalty_id := 1; END IF;
+
     FOR standing_penalty IN
         SELECT  DISTINCT csp.*
           FROM  actor.usr_standing_penalty usp
@@ -554,7 +559,7 @@ BEGIN
                      OR csp.ignore_proximity < item_prox)
                 AND csp.block_list LIKE penalty_type LOOP
         -- override PATRON_EXCEEDS_FINES penalty for renewals based on org setting
-        IF renewal AND standing_penalty.name = 'PATRON_EXCEEDS_FINES' THEN
+        IF renewal AND standing_penalty.id = penalty_id THEN
             SELECT INTO permit_renew value FROM actor.org_unit_ancestor_setting('circ.permit_renew_when_exceeds_fines', circ_ou);
             IF permit_renew IS NOT NULL AND permit_renew ILIKE 'true' THEN
                 CONTINUE;
@@ -674,6 +679,7 @@ DECLARE
     max_items_out       permission.grp_penalty_threshold%ROWTYPE;
     max_lost            permission.grp_penalty_threshold%ROWTYPE;
     max_longoverdue     permission.grp_penalty_threshold%ROWTYPE;
+    penalty_id          INT;
     tmp_grp             INT;
     items_overdue       INT;
     items_out           INT;
@@ -692,12 +698,14 @@ BEGIN
 
     -- Max fines
     SELECT INTO tmp_org * FROM actor.org_unit WHERE id = context_org;
+    SELECT BTRIM(value,'"')::INT INTO penalty_id FROM actor.org_unit_ancestor_setting('circ.custom_penalty_override.PATRON_EXCEEDS_FINES', context_org);
+    IF NOT FOUND THEN penalty_id := 1; END IF;
 
     -- Fail if the user has a high fine balance
     LOOP
         tmp_grp := user_object.profile;
         LOOP
-            SELECT * INTO max_fines FROM permission.grp_penalty_threshold WHERE grp = tmp_grp AND penalty = 1 AND org_unit = tmp_org.id;
+            SELECT * INTO max_fines FROM permission.grp_penalty_threshold WHERE grp = tmp_grp AND penalty = penalty_id AND org_unit = tmp_org.id;
 
             IF max_fines.threshold IS NULL THEN
                 SELECT parent INTO tmp_grp FROM permission.grp_tree WHERE id = tmp_grp;
@@ -719,14 +727,16 @@ BEGIN
     END LOOP;
 
     IF max_fines.threshold IS NOT NULL THEN
-
+        -- The IN clause in all of the RETURN QUERY calls is used to surface now-stale non-custom penalties
+        -- so that the calling code can clear them at the boundary where custom penalties are configured.
+        -- Otherwise we would see orphaned "stock" system penalties that would never go away on their own.
         RETURN QUERY
             SELECT  *
               FROM  actor.usr_standing_penalty
               WHERE usr = match_user
                     AND org_unit = max_fines.org_unit
                     AND (stop_date IS NULL or stop_date > NOW())
-                    AND standing_penalty = 1;
+                    AND standing_penalty IN (1, penalty_id);
 
         SELECT INTO context_org_list ARRAY_AGG(id) FROM actor.org_unit_full_path( max_fines.org_unit );
 
@@ -754,20 +764,22 @@ BEGIN
         IF current_fines >= max_fines.threshold THEN
             new_sp_row.usr := match_user;
             new_sp_row.org_unit := max_fines.org_unit;
-            new_sp_row.standing_penalty := 1;
+            new_sp_row.standing_penalty := penalty_id;
             RETURN NEXT new_sp_row;
         END IF;
     END IF;
 
     -- Start over for max overdue
     SELECT INTO tmp_org * FROM actor.org_unit WHERE id = context_org;
+    SELECT BTRIM(value,'"')::INT INTO penalty_id FROM actor.org_unit_ancestor_setting('circ.custom_penalty_override.PATRON_EXCEEDS_OVERDUE_COUNT', context_org);
+    IF NOT FOUND THEN penalty_id := 2; END IF;
 
     -- Fail if the user has too many overdue items
     LOOP
         tmp_grp := user_object.profile;
         LOOP
 
-            SELECT * INTO max_overdue FROM permission.grp_penalty_threshold WHERE grp = tmp_grp AND penalty = 2 AND org_unit = tmp_org.id;
+            SELECT * INTO max_overdue FROM permission.grp_penalty_threshold WHERE grp = tmp_grp AND penalty = penalty_id AND org_unit = tmp_org.id;
 
             IF max_overdue.threshold IS NULL THEN
                 SELECT parent INTO tmp_grp FROM permission.grp_tree WHERE id = tmp_grp;
@@ -796,7 +808,7 @@ BEGIN
               WHERE usr = match_user
                     AND org_unit = max_overdue.org_unit
                     AND (stop_date IS NULL or stop_date > NOW())
-                    AND standing_penalty = 2;
+                    AND standing_penalty IN (2, penalty_id);
 
         SELECT  INTO items_overdue COUNT(*)
           FROM  action.circulation circ
@@ -809,19 +821,21 @@ BEGIN
         IF items_overdue >= max_overdue.threshold::INT THEN
             new_sp_row.usr := match_user;
             new_sp_row.org_unit := max_overdue.org_unit;
-            new_sp_row.standing_penalty := 2;
+            new_sp_row.standing_penalty := penalty_id;
             RETURN NEXT new_sp_row;
         END IF;
     END IF;
 
     -- Start over for max out
     SELECT INTO tmp_org * FROM actor.org_unit WHERE id = context_org;
+    SELECT BTRIM(value,'"')::INT INTO penalty_id FROM actor.org_unit_ancestor_setting('circ.custom_penalty_override.PATRON_EXCEEDS_CHECKOUT_COUNT', context_org);
+    IF NOT FOUND THEN penalty_id := 3; END IF;
 
     -- Fail if the user has too many checked out items
     LOOP
         tmp_grp := user_object.profile;
         LOOP
-            SELECT * INTO max_items_out FROM permission.grp_penalty_threshold WHERE grp = tmp_grp AND penalty = 3 AND org_unit = tmp_org.id;
+            SELECT * INTO max_items_out FROM permission.grp_penalty_threshold WHERE grp = tmp_grp AND penalty = penalty_id AND org_unit = tmp_org.id;
 
             IF max_items_out.threshold IS NULL THEN
                 SELECT parent INTO tmp_grp FROM permission.grp_tree WHERE id = tmp_grp;
@@ -852,7 +866,7 @@ BEGIN
               WHERE usr = match_user
                     AND org_unit = max_items_out.org_unit
                     AND (stop_date IS NULL or stop_date > NOW())
-                    AND standing_penalty = 3;
+                    AND standing_penalty IN (3, penalty_id);
 
         SELECT  INTO items_out COUNT(*)
           FROM  action.circulation circ
@@ -887,20 +901,22 @@ BEGIN
            IF items_out >= max_items_out.threshold::INT THEN
             new_sp_row.usr := match_user;
             new_sp_row.org_unit := max_items_out.org_unit;
-            new_sp_row.standing_penalty := 3;
+            new_sp_row.standing_penalty := penalty_id;
             RETURN NEXT new_sp_row;
            END IF;
     END IF;
 
     -- Start over for max lost
     SELECT INTO tmp_org * FROM actor.org_unit WHERE id = context_org;
+    SELECT BTRIM(value,'"')::INT INTO penalty_id FROM actor.org_unit_ancestor_setting('circ.custom_penalty_override.PATRON_EXCEEDS_LOST_COUNT', context_org);
+    IF NOT FOUND THEN penalty_id := 5; END IF;
 
     -- Fail if the user has too many lost items
     LOOP
         tmp_grp := user_object.profile;
         LOOP
 
-            SELECT * INTO max_lost FROM permission.grp_penalty_threshold WHERE grp = tmp_grp AND penalty = 5 AND org_unit = tmp_org.id;
+            SELECT * INTO max_lost FROM permission.grp_penalty_threshold WHERE grp = tmp_grp AND penalty = penalty_id AND org_unit = tmp_org.id;
 
             IF max_lost.threshold IS NULL THEN
                 SELECT parent INTO tmp_grp FROM permission.grp_tree WHERE id = tmp_grp;
@@ -929,7 +945,7 @@ BEGIN
             WHERE usr = match_user
                 AND org_unit = max_lost.org_unit
                 AND (stop_date IS NULL or stop_date > NOW())
-                AND standing_penalty = 5;
+                AND standing_penalty IN (5, penalty_id);
 
         SELECT  INTO items_lost COUNT(*)
         FROM  action.circulation circ
@@ -942,13 +958,15 @@ BEGIN
         IF items_lost >= max_lost.threshold::INT AND 0 < max_lost.threshold::INT THEN
             new_sp_row.usr := match_user;
             new_sp_row.org_unit := max_lost.org_unit;
-            new_sp_row.standing_penalty := 5;
+            new_sp_row.standing_penalty := penalty_id;
             RETURN NEXT new_sp_row;
         END IF;
     END IF;
 
     -- Start over for max longoverdue
     SELECT INTO tmp_org * FROM actor.org_unit WHERE id = context_org;
+    SELECT BTRIM(value,'"')::INT INTO penalty_id FROM actor.org_unit_ancestor_setting('circ.custom_penalty_override.PATRON_EXCEEDS_LONGOVERDUE_COUNT', context_org);
+    IF NOT FOUND THEN penalty_id := 35; END IF;
 
     -- Fail if the user has too many longoverdue items
     LOOP
@@ -958,7 +976,7 @@ BEGIN
             SELECT * INTO max_longoverdue 
                 FROM permission.grp_penalty_threshold 
                 WHERE grp = tmp_grp AND 
-                    penalty = 35 AND 
+                    penalty = penalty_id AND 
                     org_unit = tmp_org.id;
 
             IF max_longoverdue.threshold IS NULL THEN
@@ -990,7 +1008,7 @@ BEGIN
             WHERE usr = match_user
                 AND org_unit = max_longoverdue.org_unit
                 AND (stop_date IS NULL or stop_date > NOW())
-                AND standing_penalty = 35;
+                AND standing_penalty IN (35, penalty_id);
 
         SELECT INTO items_longoverdue COUNT(*)
         FROM action.circulation circ
@@ -1005,7 +1023,7 @@ BEGIN
                 AND 0 < max_longoverdue.threshold::INT THEN
             new_sp_row.usr := match_user;
             new_sp_row.org_unit := max_longoverdue.org_unit;
-            new_sp_row.standing_penalty := 35;
+            new_sp_row.standing_penalty := penalty_id;
             RETURN NEXT new_sp_row;
         END IF;
     END IF;
@@ -1013,12 +1031,14 @@ BEGIN
 
     -- Start over for collections warning
     SELECT INTO tmp_org * FROM actor.org_unit WHERE id = context_org;
+    SELECT BTRIM(value,'"')::INT INTO penalty_id FROM actor.org_unit_ancestor_setting('circ.custom_penalty_override.PATRON_EXCEEDS_COLLECTIONS_WARNING', context_org);
+    IF NOT FOUND THEN penalty_id := 4; END IF;
 
     -- Fail if the user has a collections-level fine balance
     LOOP
         tmp_grp := user_object.profile;
         LOOP
-            SELECT * INTO max_fines FROM permission.grp_penalty_threshold WHERE grp = tmp_grp AND penalty = 4 AND org_unit = tmp_org.id;
+            SELECT * INTO max_fines FROM permission.grp_penalty_threshold WHERE grp = tmp_grp AND penalty = penalty_id AND org_unit = tmp_org.id;
 
             IF max_fines.threshold IS NULL THEN
                 SELECT parent INTO tmp_grp FROM permission.grp_tree WHERE id = tmp_grp;
@@ -1047,7 +1067,7 @@ BEGIN
               WHERE usr = match_user
                     AND org_unit = max_fines.org_unit
                     AND (stop_date IS NULL or stop_date > NOW())
-                    AND standing_penalty = 4;
+                    AND standing_penalty IN (4, penalty_id);
 
         SELECT INTO context_org_list ARRAY_AGG(id) FROM actor.org_unit_full_path( max_fines.org_unit );
 
@@ -1075,13 +1095,15 @@ BEGIN
         IF current_fines >= max_fines.threshold THEN
             new_sp_row.usr := match_user;
             new_sp_row.org_unit := max_fines.org_unit;
-            new_sp_row.standing_penalty := 4;
+            new_sp_row.standing_penalty := penalty_id;
             RETURN NEXT new_sp_row;
         END IF;
     END IF;
 
     -- Start over for in collections
     SELECT INTO tmp_org * FROM actor.org_unit WHERE id = context_org;
+    SELECT BTRIM(value,'"')::INT INTO penalty_id FROM actor.org_unit_ancestor_setting('circ.custom_penalty_override.PATRON_IN_COLLECTIONS', context_org);
+    IF NOT FOUND THEN penalty_id := 30; END IF;
 
     -- Remove the in-collections penalty if the user has paid down enough
     -- This penalty is different, because this code is not responsible for creating 
@@ -1089,7 +1111,7 @@ BEGIN
     LOOP
         tmp_grp := user_object.profile;
         LOOP
-            SELECT * INTO max_fines FROM permission.grp_penalty_threshold WHERE grp = tmp_grp AND penalty = 30 AND org_unit = tmp_org.id;
+            SELECT * INTO max_fines FROM permission.grp_penalty_threshold WHERE grp = tmp_grp AND penalty = penalty_id AND org_unit = tmp_org.id;
 
             IF max_fines.threshold IS NULL THEN
                 SELECT parent INTO tmp_grp FROM permission.grp_tree WHERE id = tmp_grp;
@@ -1139,7 +1161,7 @@ BEGIN
         IF current_fines IS NULL OR current_fines <= max_fines.threshold THEN
             -- patron has paid down enough
 
-            SELECT INTO tmp_penalty * FROM config.standing_penalty WHERE id = 30;
+            SELECT INTO tmp_penalty * FROM config.standing_penalty WHERE id = penalty_id;
 
             IF tmp_penalty.org_depth IS NOT NULL THEN
 
@@ -1157,7 +1179,7 @@ BEGIN
                           WHERE usr = match_user
                                 AND org_unit = tmp_org.id
                                 AND (stop_date IS NULL or stop_date > NOW())
-                                AND standing_penalty = 30;
+                                AND standing_penalty IN (30, penalty_id);
 
                     IF tmp_org.parent_ou IS NULL THEN
                         EXIT;
@@ -1177,7 +1199,7 @@ BEGIN
                       WHERE usr = match_user
                             AND org_unit = max_fines.org_unit
                             AND (stop_date IS NULL or stop_date > NOW())
-                            AND standing_penalty = 30;
+                            AND standing_penalty IN (30, penalty_id);
             END IF;
     
         END IF;
@@ -1188,7 +1210,5 @@ BEGIN
 END;
 $func$ LANGUAGE plpgsql;
 
-
-
 COMMIT;
 
index 3d123ad..ec54030 100644 (file)
@@ -23397,3 +23397,70 @@ INSERT INTO config.global_flag (name, enabled, label) VALUES (
 );
 
 UPDATE config.global_flag SET value = '20' WHERE name = 'ingest.queued.max_threads';
+
+INSERT INTO config.org_unit_setting_type (name, label, grp, description, datatype, fm_class) VALUES
+(   'circ.custom_penalty_override.PATRON_EXCEEDS_FINES',
+    oils_i18n_gettext('circ.custom_penalty_override.PATRON_EXCEEDS_FINES',
+        'Custom PATRON_EXCEEDS_FINES penalty',
+        'coust', 'label'),
+    'circ',
+    oils_i18n_gettext('circ.custom_penalty_override.PATRON_EXCEEDS_FINES',
+        'Specifies a non-default standing penalty to apply to patrons that exceed the max-fine threshold for their group.',
+        'coust', 'description'),
+    'link', 'csp'),
+(   'circ.custom_penalty_override.PATRON_EXCEEDS_OVERDUE_COUNT',
+    oils_i18n_gettext('circ.custom_penalty_override.PATRON_EXCEEDS_OVERDUE_COUNT',
+        'Custom PATRON_EXCEEDS_OVERDUE_COUNT penalty',
+        'coust', 'label'),
+    'circ',
+    oils_i18n_gettext('circ.custom_penalty_override.PATRON_EXCEEDS_OVERDUE_COUNT',
+        'Specifies a non-default standing penalty to apply to patrons that exceed the overdue count threshold for their group.',
+        'coust', 'description'),
+    'link', 'csp'),
+(   'circ.custom_penalty_override.PATRON_EXCEEDS_CHECKOUT_COUNT',
+    oils_i18n_gettext('circ.custom_penalty_override.PATRON_EXCEEDS_CHECKOUT_COUNT',
+        'Custom PATRON_EXCEEDS_CHECKOUT_COUNT penalty',
+        'coust', 'label'),
+    'circ',
+    oils_i18n_gettext('circ.custom_penalty_override.PATRON_EXCEEDS_CHECKOUT_COUNT',
+        'Specifies a non-default standing penalty to apply to patrons that exceed the checkout count threshold for their group.',
+        'coust', 'description'),
+    'link', 'csp'),
+(   'circ.custom_penalty_override.PATRON_EXCEEDS_COLLECTIONS_WARNING',
+    oils_i18n_gettext('circ.custom_penalty_override.PATRON_EXCEEDS_COLLECTIONS_WARNING',
+        'Custom PATRON_EXCEEDS_COLLECTIONS_WARNING penalty',
+        'coust', 'label'),
+    'circ',
+    oils_i18n_gettext('circ.custom_penalty_override.PATRON_EXCEEDS_COLLECTIONS_WARNING',
+        'Specifies a non-default standing penalty to apply to patrons that exceed the collections fine warning threshold for their group.',
+        'coust', 'description'),
+    'link', 'csp'),
+(   'circ.custom_penalty_override.PATRON_EXCEEDS_LOST_COUNT',
+    oils_i18n_gettext('circ.custom_penalty_override.PATRON_EXCEEDS_LOST_COUNT',
+        'Custom PATRON_EXCEEDS_LOST_COUNT penalty',
+        'coust', 'label'),
+    'circ',
+    oils_i18n_gettext('circ.custom_penalty_override.PATRON_EXCEEDS_LOST_COUNT',
+        'Specifies a non-default standing penalty to apply to patrons that exceed the lost item count threshold for their group.',
+        'coust', 'description'),
+    'link', 'csp'),
+(   'circ.custom_penalty_override.PATRON_EXCEEDS_LONGOVERDUE_COUNT',
+    oils_i18n_gettext('circ.custom_penalty_override.PATRON_EXCEEDS_LONGOVERDUE_COUNT',
+        'Custom PATRON_EXCEEDS_LONGOVERDUE_COUNT penalty',
+        'coust', 'label'),
+    'circ',
+    oils_i18n_gettext('circ.custom_penalty_override.PATRON_EXCEEDS_LONGOVERDUE_COUNT',
+        'Specifies a non-default standing penalty to apply to patrons that exceed the long-overdue item count threshold for their group.',
+        'coust', 'description'),
+    'link', 'csp'),
+(   'circ.custom_penalty_override.PATRON_IN_COLLECTIONS',
+    oils_i18n_gettext('circ.custom_penalty_override.PATRON_IN_COLLECTIONS',
+        'Custom PATRON_IN_COLLECTIONS penalty',
+        'coust', 'label'),
+    'circ',
+    oils_i18n_gettext('circ.custom_penalty_override.PATRON_IN_COLLECTIONS',
+        'Specifies a non-default standing penalty that may have been applied to patrons that have been placed into collections and that should be automatically removed if they have paid down their balance below the threshold for their group. Use of this feature will likely require configuration and coordination with an external collection agency.',
+        'coust', 'description'),
+    'link', 'csp')
+;
+
diff --git a/Open-ILS/src/sql/Pg/upgrade/XXXX.schema.custom_system_penalties.sql b/Open-ILS/src/sql/Pg/upgrade/XXXX.schema.custom_system_penalties.sql
new file mode 100644 (file)
index 0000000..4f60581
--- /dev/null
@@ -0,0 +1,859 @@
+BEGIN;
+
+SELECT evergreen.upgrade_deps_block_check('XXXX', :eg_version);
+
+INSERT INTO config.org_unit_setting_type (name, label, grp, description, datatype, fm_class) VALUES
+(   'circ.custom_penalty_override.PATRON_EXCEEDS_FINES',
+    oils_i18n_gettext('circ.custom_penalty_override.PATRON_EXCEEDS_FINES',
+        'Custom PATRON_EXCEEDS_FINES penalty',
+        'coust', 'label'),
+    'circ',
+    oils_i18n_gettext('circ.custom_penalty_override.PATRON_EXCEEDS_FINES',
+        'Specifies a non-default standing penalty to apply to patrons that exceed the max-fine threshold for their group.',
+        'coust', 'description'),
+    'link', 'csp'),
+(   'circ.custom_penalty_override.PATRON_EXCEEDS_OVERDUE_COUNT',
+    oils_i18n_gettext('circ.custom_penalty_override.PATRON_EXCEEDS_OVERDUE_COUNT',
+        'Custom PATRON_EXCEEDS_OVERDUE_COUNT penalty',
+        'coust', 'label'),
+    'circ',
+    oils_i18n_gettext('circ.custom_penalty_override.PATRON_EXCEEDS_OVERDUE_COUNT',
+        'Specifies a non-default standing penalty to apply to patrons that exceed the overdue count threshold for their group.',
+        'coust', 'description'),
+    'link', 'csp'),
+(   'circ.custom_penalty_override.PATRON_EXCEEDS_CHECKOUT_COUNT',
+    oils_i18n_gettext('circ.custom_penalty_override.PATRON_EXCEEDS_CHECKOUT_COUNT',
+        'Custom PATRON_EXCEEDS_CHECKOUT_COUNT penalty',
+        'coust', 'label'),
+    'circ',
+    oils_i18n_gettext('circ.custom_penalty_override.PATRON_EXCEEDS_CHECKOUT_COUNT',
+        'Specifies a non-default standing penalty to apply to patrons that exceed the checkout count threshold for their group.',
+        'coust', 'description'),
+    'link', 'csp'),
+(   'circ.custom_penalty_override.PATRON_EXCEEDS_COLLECTIONS_WARNING',
+    oils_i18n_gettext('circ.custom_penalty_override.PATRON_EXCEEDS_COLLECTIONS_WARNING',
+        'Custom PATRON_EXCEEDS_COLLECTIONS_WARNING penalty',
+        'coust', 'label'),
+    'circ',
+    oils_i18n_gettext('circ.custom_penalty_override.PATRON_EXCEEDS_COLLECTIONS_WARNING',
+        'Specifies a non-default standing penalty to apply to patrons that exceed the collections fine warning threshold for their group.',
+        'coust', 'description'),
+    'link', 'csp'),
+(   'circ.custom_penalty_override.PATRON_EXCEEDS_LOST_COUNT',
+    oils_i18n_gettext('circ.custom_penalty_override.PATRON_EXCEEDS_LOST_COUNT',
+        'Custom PATRON_EXCEEDS_LOST_COUNT penalty',
+        'coust', 'label'),
+    'circ',
+    oils_i18n_gettext('circ.custom_penalty_override.PATRON_EXCEEDS_LOST_COUNT',
+        'Specifies a non-default standing penalty to apply to patrons that exceed the lost item count threshold for their group.',
+        'coust', 'description'),
+    'link', 'csp'),
+(   'circ.custom_penalty_override.PATRON_EXCEEDS_LONGOVERDUE_COUNT',
+    oils_i18n_gettext('circ.custom_penalty_override.PATRON_EXCEEDS_LONGOVERDUE_COUNT',
+        'Custom PATRON_EXCEEDS_LONGOVERDUE_COUNT penalty',
+        'coust', 'label'),
+    'circ',
+    oils_i18n_gettext('circ.custom_penalty_override.PATRON_EXCEEDS_LONGOVERDUE_COUNT',
+        'Specifies a non-default standing penalty to apply to patrons that exceed the long-overdue item count threshold for their group.',
+        'coust', 'description'),
+    'link', 'csp'),
+(   'circ.custom_penalty_override.PATRON_IN_COLLECTIONS',
+    oils_i18n_gettext('circ.custom_penalty_override.PATRON_IN_COLLECTIONS',
+        'Custom PATRON_IN_COLLECTIONS penalty',
+        'coust', 'label'),
+    'circ',
+    oils_i18n_gettext('circ.custom_penalty_override.PATRON_IN_COLLECTIONS',
+        'Specifies a non-default standing penalty that may have been applied to patrons that have been placed into collections and that should be automatically removed if they have paid down their balance below the threshold for their group. Use of this feature will likely require configuration and coordination with an external collection agency.',
+        'coust', 'description'),
+    'link', 'csp')
+;
+
+CREATE OR REPLACE FUNCTION actor.calculate_system_penalties( match_user INT, context_org INT ) RETURNS SETOF actor.usr_standing_penalty AS $func$
+DECLARE
+    user_object         actor.usr%ROWTYPE;
+    new_sp_row          actor.usr_standing_penalty%ROWTYPE;
+    existing_sp_row     actor.usr_standing_penalty%ROWTYPE;
+    collections_fines   permission.grp_penalty_threshold%ROWTYPE;
+    max_fines           permission.grp_penalty_threshold%ROWTYPE;
+    max_overdue         permission.grp_penalty_threshold%ROWTYPE;
+    max_items_out       permission.grp_penalty_threshold%ROWTYPE;
+    max_lost            permission.grp_penalty_threshold%ROWTYPE;
+    max_longoverdue     permission.grp_penalty_threshold%ROWTYPE;
+    penalty_id          INT;
+    tmp_grp             INT;
+    items_overdue       INT;
+    items_out           INT;
+    items_lost          INT;
+    items_longoverdue   INT;
+    context_org_list    INT[];
+    current_fines        NUMERIC(8,2) := 0.0;
+    tmp_fines            NUMERIC(8,2);
+    tmp_groc            RECORD;
+    tmp_circ            RECORD;
+    tmp_org             actor.org_unit%ROWTYPE;
+    tmp_penalty         config.standing_penalty%ROWTYPE;
+    tmp_depth           INTEGER;
+BEGIN
+    SELECT INTO user_object * FROM actor.usr WHERE id = match_user;
+
+    -- Max fines
+    SELECT INTO tmp_org * FROM actor.org_unit WHERE id = context_org;
+    SELECT BTRIM(value,'"')::INT INTO penalty_id FROM actor.org_unit_ancestor_setting('circ.custom_penalty_override.PATRON_EXCEEDS_FINES', context_org);
+    IF NOT FOUND THEN penalty_id := 1; END IF;
+
+    -- Fail if the user has a high fine balance
+    LOOP
+        tmp_grp := user_object.profile;
+        LOOP
+            SELECT * INTO max_fines FROM permission.grp_penalty_threshold WHERE grp = tmp_grp AND penalty = penalty_id AND org_unit = tmp_org.id;
+
+            IF max_fines.threshold IS NULL THEN
+                SELECT parent INTO tmp_grp FROM permission.grp_tree WHERE id = tmp_grp;
+            ELSE
+                EXIT;
+            END IF;
+
+            IF tmp_grp IS NULL THEN
+                EXIT;
+            END IF;
+        END LOOP;
+
+        IF max_fines.threshold IS NOT NULL OR tmp_org.parent_ou IS NULL THEN
+            EXIT;
+        END IF;
+
+        SELECT * INTO tmp_org FROM actor.org_unit WHERE id = tmp_org.parent_ou;
+
+    END LOOP;
+
+    IF max_fines.threshold IS NOT NULL THEN
+        -- The IN clause in all of the RETURN QUERY calls is used to surface now-stale non-custom penalties
+        -- so that the calling code can clear them at the boundary where custom penalties are configured.
+        -- Otherwise we would see orphaned "stock" system penalties that would never go away on their own.
+        RETURN QUERY
+            SELECT  *
+              FROM  actor.usr_standing_penalty
+              WHERE usr = match_user
+                    AND org_unit = max_fines.org_unit
+                    AND (stop_date IS NULL or stop_date > NOW())
+                    AND standing_penalty IN (1, penalty_id);
+
+        SELECT INTO context_org_list ARRAY_AGG(id) FROM actor.org_unit_full_path( max_fines.org_unit );
+
+        SELECT  SUM(f.balance_owed) INTO current_fines
+          FROM  money.materialized_billable_xact_summary f
+                JOIN (
+                    SELECT  r.id
+                      FROM  booking.reservation r
+                      WHERE r.usr = match_user
+                            AND r.pickup_lib IN (SELECT * FROM unnest(context_org_list))
+                            AND xact_finish IS NULL
+                                UNION ALL
+                    SELECT  g.id
+                      FROM  money.grocery g
+                      WHERE g.usr = match_user
+                            AND g.billing_location IN (SELECT * FROM unnest(context_org_list))
+                            AND xact_finish IS NULL
+                                UNION ALL
+                    SELECT  circ.id
+                      FROM  action.circulation circ
+                      WHERE circ.usr = match_user
+                            AND circ.circ_lib IN (SELECT * FROM unnest(context_org_list))
+                            AND xact_finish IS NULL ) l USING (id);
+
+        IF current_fines >= max_fines.threshold THEN
+            new_sp_row.usr := match_user;
+            new_sp_row.org_unit := max_fines.org_unit;
+            new_sp_row.standing_penalty := penalty_id;
+            RETURN NEXT new_sp_row;
+        END IF;
+    END IF;
+
+    -- Start over for max overdue
+    SELECT INTO tmp_org * FROM actor.org_unit WHERE id = context_org;
+    SELECT BTRIM(value,'"')::INT INTO penalty_id FROM actor.org_unit_ancestor_setting('circ.custom_penalty_override.PATRON_EXCEEDS_OVERDUE_COUNT', context_org);
+    IF NOT FOUND THEN penalty_id := 2; END IF;
+
+    -- Fail if the user has too many overdue items
+    LOOP
+        tmp_grp := user_object.profile;
+        LOOP
+
+            SELECT * INTO max_overdue FROM permission.grp_penalty_threshold WHERE grp = tmp_grp AND penalty = penalty_id AND org_unit = tmp_org.id;
+
+            IF max_overdue.threshold IS NULL THEN
+                SELECT parent INTO tmp_grp FROM permission.grp_tree WHERE id = tmp_grp;
+            ELSE
+                EXIT;
+            END IF;
+
+            IF tmp_grp IS NULL THEN
+                EXIT;
+            END IF;
+        END LOOP;
+
+        IF max_overdue.threshold IS NOT NULL OR tmp_org.parent_ou IS NULL THEN
+            EXIT;
+        END IF;
+
+        SELECT INTO tmp_org * FROM actor.org_unit WHERE id = tmp_org.parent_ou;
+
+    END LOOP;
+
+    IF max_overdue.threshold IS NOT NULL THEN
+
+        RETURN QUERY
+            SELECT  *
+              FROM  actor.usr_standing_penalty
+              WHERE usr = match_user
+                    AND org_unit = max_overdue.org_unit
+                    AND (stop_date IS NULL or stop_date > NOW())
+                    AND standing_penalty IN (2, penalty_id);
+
+        SELECT  INTO items_overdue COUNT(*)
+          FROM  action.circulation circ
+                JOIN  actor.org_unit_full_path( max_overdue.org_unit ) fp ON (circ.circ_lib = fp.id)
+          WHERE circ.usr = match_user
+            AND circ.checkin_time IS NULL
+            AND circ.due_date < NOW()
+            AND (circ.stop_fines = 'MAXFINES' OR circ.stop_fines IS NULL);
+
+        IF items_overdue >= max_overdue.threshold::INT THEN
+            new_sp_row.usr := match_user;
+            new_sp_row.org_unit := max_overdue.org_unit;
+            new_sp_row.standing_penalty := penalty_id;
+            RETURN NEXT new_sp_row;
+        END IF;
+    END IF;
+
+    -- Start over for max out
+    SELECT INTO tmp_org * FROM actor.org_unit WHERE id = context_org;
+    SELECT BTRIM(value,'"')::INT INTO penalty_id FROM actor.org_unit_ancestor_setting('circ.custom_penalty_override.PATRON_EXCEEDS_CHECKOUT_COUNT', context_org);
+    IF NOT FOUND THEN penalty_id := 3; END IF;
+
+    -- Fail if the user has too many checked out items
+    LOOP
+        tmp_grp := user_object.profile;
+        LOOP
+            SELECT * INTO max_items_out FROM permission.grp_penalty_threshold WHERE grp = tmp_grp AND penalty = penalty_id AND org_unit = tmp_org.id;
+
+            IF max_items_out.threshold IS NULL THEN
+                SELECT parent INTO tmp_grp FROM permission.grp_tree WHERE id = tmp_grp;
+            ELSE
+                EXIT;
+            END IF;
+
+            IF tmp_grp IS NULL THEN
+                EXIT;
+            END IF;
+        END LOOP;
+
+        IF max_items_out.threshold IS NOT NULL OR tmp_org.parent_ou IS NULL THEN
+            EXIT;
+        END IF;
+
+        SELECT INTO tmp_org * FROM actor.org_unit WHERE id = tmp_org.parent_ou;
+
+    END LOOP;
+
+
+    -- Fail if the user has too many items checked out
+    IF max_items_out.threshold IS NOT NULL THEN
+
+        RETURN QUERY
+            SELECT  *
+              FROM  actor.usr_standing_penalty
+              WHERE usr = match_user
+                    AND org_unit = max_items_out.org_unit
+                    AND (stop_date IS NULL or stop_date > NOW())
+                    AND standing_penalty IN (3, penalty_id);
+
+        SELECT  INTO items_out COUNT(*)
+          FROM  action.circulation circ
+                JOIN  actor.org_unit_full_path( max_items_out.org_unit ) fp ON (circ.circ_lib = fp.id)
+          WHERE circ.usr = match_user
+                AND circ.checkin_time IS NULL
+                AND (circ.stop_fines IN (
+                    SELECT 'MAXFINES'::TEXT
+                    UNION ALL
+                    SELECT 'LONGOVERDUE'::TEXT
+                    UNION ALL
+                    SELECT 'LOST'::TEXT
+                    WHERE 'true' ILIKE
+                    (
+                        SELECT CASE
+                            WHEN (SELECT value FROM actor.org_unit_ancestor_setting('circ.tally_lost', circ.circ_lib)) ILIKE 'true' THEN 'true'
+                            ELSE 'false'
+                        END
+                    )
+                    UNION ALL
+                    SELECT 'CLAIMSRETURNED'::TEXT
+                    WHERE 'false' ILIKE
+                    (
+                        SELECT CASE
+                            WHEN (SELECT value FROM actor.org_unit_ancestor_setting('circ.do_not_tally_claims_returned', circ.circ_lib)) ILIKE 'true' THEN 'true'
+                            ELSE 'false'
+                        END
+                    )
+                    ) OR circ.stop_fines IS NULL)
+                AND xact_finish IS NULL;
+
+           IF items_out >= max_items_out.threshold::INT THEN
+            new_sp_row.usr := match_user;
+            new_sp_row.org_unit := max_items_out.org_unit;
+            new_sp_row.standing_penalty := penalty_id;
+            RETURN NEXT new_sp_row;
+           END IF;
+    END IF;
+
+    -- Start over for max lost
+    SELECT INTO tmp_org * FROM actor.org_unit WHERE id = context_org;
+    SELECT BTRIM(value,'"')::INT INTO penalty_id FROM actor.org_unit_ancestor_setting('circ.custom_penalty_override.PATRON_EXCEEDS_LOST_COUNT', context_org);
+    IF NOT FOUND THEN penalty_id := 5; END IF;
+
+    -- Fail if the user has too many lost items
+    LOOP
+        tmp_grp := user_object.profile;
+        LOOP
+
+            SELECT * INTO max_lost FROM permission.grp_penalty_threshold WHERE grp = tmp_grp AND penalty = penalty_id AND org_unit = tmp_org.id;
+
+            IF max_lost.threshold IS NULL THEN
+                SELECT parent INTO tmp_grp FROM permission.grp_tree WHERE id = tmp_grp;
+            ELSE
+                EXIT;
+            END IF;
+
+            IF tmp_grp IS NULL THEN
+                EXIT;
+            END IF;
+        END LOOP;
+
+        IF max_lost.threshold IS NOT NULL OR tmp_org.parent_ou IS NULL THEN
+            EXIT;
+        END IF;
+
+        SELECT INTO tmp_org * FROM actor.org_unit WHERE id = tmp_org.parent_ou;
+
+    END LOOP;
+
+    IF max_lost.threshold IS NOT NULL THEN
+
+        RETURN QUERY
+            SELECT  *
+            FROM  actor.usr_standing_penalty
+            WHERE usr = match_user
+                AND org_unit = max_lost.org_unit
+                AND (stop_date IS NULL or stop_date > NOW())
+                AND standing_penalty IN (5, penalty_id);
+
+        SELECT  INTO items_lost COUNT(*)
+        FROM  action.circulation circ
+            JOIN  actor.org_unit_full_path( max_lost.org_unit ) fp ON (circ.circ_lib = fp.id)
+        WHERE circ.usr = match_user
+            AND circ.checkin_time IS NULL
+            AND (circ.stop_fines = 'LOST')
+            AND xact_finish IS NULL;
+
+        IF items_lost >= max_lost.threshold::INT AND 0 < max_lost.threshold::INT THEN
+            new_sp_row.usr := match_user;
+            new_sp_row.org_unit := max_lost.org_unit;
+            new_sp_row.standing_penalty := penalty_id;
+            RETURN NEXT new_sp_row;
+        END IF;
+    END IF;
+
+    -- Start over for max longoverdue
+    SELECT INTO tmp_org * FROM actor.org_unit WHERE id = context_org;
+    SELECT BTRIM(value,'"')::INT INTO penalty_id FROM actor.org_unit_ancestor_setting('circ.custom_penalty_override.PATRON_EXCEEDS_LONGOVERDUE_COUNT', context_org);
+    IF NOT FOUND THEN penalty_id := 35; END IF;
+
+    -- Fail if the user has too many longoverdue items
+    LOOP
+        tmp_grp := user_object.profile;
+        LOOP
+
+            SELECT * INTO max_longoverdue 
+                FROM permission.grp_penalty_threshold 
+                WHERE grp = tmp_grp AND 
+                    penalty = penalty_id AND 
+                    org_unit = tmp_org.id;
+
+            IF max_longoverdue.threshold IS NULL THEN
+                SELECT parent INTO tmp_grp 
+                    FROM permission.grp_tree WHERE id = tmp_grp;
+            ELSE
+                EXIT;
+            END IF;
+
+            IF tmp_grp IS NULL THEN
+                EXIT;
+            END IF;
+        END LOOP;
+
+        IF max_longoverdue.threshold IS NOT NULL 
+                OR tmp_org.parent_ou IS NULL THEN
+            EXIT;
+        END IF;
+
+        SELECT INTO tmp_org * FROM actor.org_unit WHERE id = tmp_org.parent_ou;
+
+    END LOOP;
+
+    IF max_longoverdue.threshold IS NOT NULL THEN
+
+        RETURN QUERY
+            SELECT  *
+            FROM  actor.usr_standing_penalty
+            WHERE usr = match_user
+                AND org_unit = max_longoverdue.org_unit
+                AND (stop_date IS NULL or stop_date > NOW())
+                AND standing_penalty IN (35, penalty_id);
+
+        SELECT INTO items_longoverdue COUNT(*)
+        FROM action.circulation circ
+            JOIN actor.org_unit_full_path( max_longoverdue.org_unit ) fp 
+                ON (circ.circ_lib = fp.id)
+        WHERE circ.usr = match_user
+            AND circ.checkin_time IS NULL
+            AND (circ.stop_fines = 'LONGOVERDUE')
+            AND xact_finish IS NULL;
+
+        IF items_longoverdue >= max_longoverdue.threshold::INT 
+                AND 0 < max_longoverdue.threshold::INT THEN
+            new_sp_row.usr := match_user;
+            new_sp_row.org_unit := max_longoverdue.org_unit;
+            new_sp_row.standing_penalty := penalty_id;
+            RETURN NEXT new_sp_row;
+        END IF;
+    END IF;
+
+
+    -- Start over for collections warning
+    SELECT INTO tmp_org * FROM actor.org_unit WHERE id = context_org;
+    SELECT BTRIM(value,'"')::INT INTO penalty_id FROM actor.org_unit_ancestor_setting('circ.custom_penalty_override.PATRON_EXCEEDS_COLLECTIONS_WARNING', context_org);
+    IF NOT FOUND THEN penalty_id := 4; END IF;
+
+    -- Fail if the user has a collections-level fine balance
+    LOOP
+        tmp_grp := user_object.profile;
+        LOOP
+            SELECT * INTO max_fines FROM permission.grp_penalty_threshold WHERE grp = tmp_grp AND penalty = penalty_id AND org_unit = tmp_org.id;
+
+            IF max_fines.threshold IS NULL THEN
+                SELECT parent INTO tmp_grp FROM permission.grp_tree WHERE id = tmp_grp;
+            ELSE
+                EXIT;
+            END IF;
+
+            IF tmp_grp IS NULL THEN
+                EXIT;
+            END IF;
+        END LOOP;
+
+        IF max_fines.threshold IS NOT NULL OR tmp_org.parent_ou IS NULL THEN
+            EXIT;
+        END IF;
+
+        SELECT * INTO tmp_org FROM actor.org_unit WHERE id = tmp_org.parent_ou;
+
+    END LOOP;
+
+    IF max_fines.threshold IS NOT NULL THEN
+
+        RETURN QUERY
+            SELECT  *
+              FROM  actor.usr_standing_penalty
+              WHERE usr = match_user
+                    AND org_unit = max_fines.org_unit
+                    AND (stop_date IS NULL or stop_date > NOW())
+                    AND standing_penalty IN (4, penalty_id);
+
+        SELECT INTO context_org_list ARRAY_AGG(id) FROM actor.org_unit_full_path( max_fines.org_unit );
+
+        SELECT  SUM(f.balance_owed) INTO current_fines
+          FROM  money.materialized_billable_xact_summary f
+                JOIN (
+                    SELECT  r.id
+                      FROM  booking.reservation r
+                      WHERE r.usr = match_user
+                            AND r.pickup_lib IN (SELECT * FROM unnest(context_org_list))
+                            AND r.xact_finish IS NULL
+                                UNION ALL
+                    SELECT  g.id
+                      FROM  money.grocery g
+                      WHERE g.usr = match_user
+                            AND g.billing_location IN (SELECT * FROM unnest(context_org_list))
+                            AND g.xact_finish IS NULL
+                                UNION ALL
+                    SELECT  circ.id
+                      FROM  action.circulation circ
+                      WHERE circ.usr = match_user
+                            AND circ.circ_lib IN (SELECT * FROM unnest(context_org_list))
+                            AND circ.xact_finish IS NULL ) l USING (id);
+
+        IF current_fines >= max_fines.threshold THEN
+            new_sp_row.usr := match_user;
+            new_sp_row.org_unit := max_fines.org_unit;
+            new_sp_row.standing_penalty := penalty_id;
+            RETURN NEXT new_sp_row;
+        END IF;
+    END IF;
+
+    -- Start over for in collections
+    SELECT INTO tmp_org * FROM actor.org_unit WHERE id = context_org;
+    SELECT BTRIM(value,'"')::INT INTO penalty_id FROM actor.org_unit_ancestor_setting('circ.custom_penalty_override.PATRON_IN_COLLECTIONS', context_org);
+    IF NOT FOUND THEN penalty_id := 30; END IF;
+
+    -- Remove the in-collections penalty if the user has paid down enough
+    -- This penalty is different, because this code is not responsible for creating 
+    -- new in-collections penalties, only for removing them
+    LOOP
+        tmp_grp := user_object.profile;
+        LOOP
+            SELECT * INTO max_fines FROM permission.grp_penalty_threshold WHERE grp = tmp_grp AND penalty = penalty_id AND org_unit = tmp_org.id;
+
+            IF max_fines.threshold IS NULL THEN
+                SELECT parent INTO tmp_grp FROM permission.grp_tree WHERE id = tmp_grp;
+            ELSE
+                EXIT;
+            END IF;
+
+            IF tmp_grp IS NULL THEN
+                EXIT;
+            END IF;
+        END LOOP;
+
+        IF max_fines.threshold IS NOT NULL OR tmp_org.parent_ou IS NULL THEN
+            EXIT;
+        END IF;
+
+        SELECT * INTO tmp_org FROM actor.org_unit WHERE id = tmp_org.parent_ou;
+
+    END LOOP;
+
+    IF max_fines.threshold IS NOT NULL THEN
+
+        SELECT INTO context_org_list ARRAY_AGG(id) FROM actor.org_unit_full_path( max_fines.org_unit );
+
+        -- first, see if the user had paid down to the threshold
+        SELECT  SUM(f.balance_owed) INTO current_fines
+          FROM  money.materialized_billable_xact_summary f
+                JOIN (
+                    SELECT  r.id
+                      FROM  booking.reservation r
+                      WHERE r.usr = match_user
+                            AND r.pickup_lib IN (SELECT * FROM unnest(context_org_list))
+                            AND r.xact_finish IS NULL
+                                UNION ALL
+                    SELECT  g.id
+                      FROM  money.grocery g
+                      WHERE g.usr = match_user
+                            AND g.billing_location IN (SELECT * FROM unnest(context_org_list))
+                            AND g.xact_finish IS NULL
+                                UNION ALL
+                    SELECT  circ.id
+                      FROM  action.circulation circ
+                      WHERE circ.usr = match_user
+                            AND circ.circ_lib IN (SELECT * FROM unnest(context_org_list))
+                            AND circ.xact_finish IS NULL ) l USING (id);
+
+        IF current_fines IS NULL OR current_fines <= max_fines.threshold THEN
+            -- patron has paid down enough
+
+            SELECT INTO tmp_penalty * FROM config.standing_penalty WHERE id = penalty_id;
+
+            IF tmp_penalty.org_depth IS NOT NULL THEN
+
+                -- since this code is not responsible for applying the penalty, it can't 
+                -- guarantee the current context org will match the org at which the penalty 
+                --- was applied.  search up the org tree until we hit the configured penalty depth
+                SELECT INTO tmp_org * FROM actor.org_unit WHERE id = context_org;
+                SELECT INTO tmp_depth depth FROM actor.org_unit_type WHERE id = tmp_org.ou_type;
+
+                WHILE tmp_depth >= tmp_penalty.org_depth LOOP
+
+                    RETURN QUERY
+                        SELECT  *
+                          FROM  actor.usr_standing_penalty
+                          WHERE usr = match_user
+                                AND org_unit = tmp_org.id
+                                AND (stop_date IS NULL or stop_date > NOW())
+                                AND standing_penalty IN (30, penalty_id);
+
+                    IF tmp_org.parent_ou IS NULL THEN
+                        EXIT;
+                    END IF;
+
+                    SELECT * INTO tmp_org FROM actor.org_unit WHERE id = tmp_org.parent_ou;
+                    SELECT INTO tmp_depth depth FROM actor.org_unit_type WHERE id = tmp_org.ou_type;
+                END LOOP;
+
+            ELSE
+
+                -- no penalty depth is defined, look for exact matches
+
+                RETURN QUERY
+                    SELECT  *
+                      FROM  actor.usr_standing_penalty
+                      WHERE usr = match_user
+                            AND org_unit = max_fines.org_unit
+                            AND (stop_date IS NULL or stop_date > NOW())
+                            AND standing_penalty IN (30, penalty_id);
+            END IF;
+    
+        END IF;
+
+    END IF;
+
+    RETURN;
+END;
+$func$ LANGUAGE plpgsql;
+
+CREATE OR REPLACE FUNCTION action.item_user_circ_test( circ_ou INT, match_item BIGINT, match_user INT, renewal BOOL ) RETURNS SETOF action.circ_matrix_test_result AS $func$
+DECLARE
+    user_object             actor.usr%ROWTYPE;
+    standing_penalty        config.standing_penalty%ROWTYPE;
+    item_object             asset.copy%ROWTYPE;
+    item_status_object      config.copy_status%ROWTYPE;
+    item_location_object    asset.copy_location%ROWTYPE;
+    result                  action.circ_matrix_test_result;
+    circ_test               action.found_circ_matrix_matchpoint;
+    circ_matchpoint         config.circ_matrix_matchpoint%ROWTYPE;
+    circ_limit_set          config.circ_limit_set%ROWTYPE;
+    hold_ratio              action.hold_stats%ROWTYPE;
+    penalty_type            TEXT;
+    penalty_id              INT;
+    items_out               INT;
+    context_org_list        INT[];
+    permit_renew            TEXT;
+    done                    BOOL := FALSE;
+    item_prox               INT;
+    home_prox               INT;
+BEGIN
+    -- Assume success unless we hit a failure condition
+    result.success := TRUE;
+
+    -- Need user info to look up matchpoints
+    SELECT INTO user_object * FROM actor.usr WHERE id = match_user AND NOT deleted;
+
+    -- (Insta)Fail if we couldn't find the user
+    IF user_object.id IS NULL THEN
+        result.fail_part := 'no_user';
+        result.success := FALSE;
+        done := TRUE;
+        RETURN NEXT result;
+        RETURN;
+    END IF;
+
+    -- Need item info to look up matchpoints
+    SELECT INTO item_object * FROM asset.copy WHERE id = match_item AND NOT deleted;
+
+    -- (Insta)Fail if we couldn't find the item
+    IF item_object.id IS NULL THEN
+        result.fail_part := 'no_item';
+        result.success := FALSE;
+        done := TRUE;
+        RETURN NEXT result;
+        RETURN;
+    END IF;
+
+    SELECT INTO circ_test * FROM action.find_circ_matrix_matchpoint(circ_ou, item_object, user_object, renewal);
+
+    circ_matchpoint             := circ_test.matchpoint;
+    result.matchpoint           := circ_matchpoint.id;
+    result.circulate            := circ_matchpoint.circulate;
+    result.duration_rule        := circ_matchpoint.duration_rule;
+    result.recurring_fine_rule  := circ_matchpoint.recurring_fine_rule;
+    result.max_fine_rule        := circ_matchpoint.max_fine_rule;
+    result.hard_due_date        := circ_matchpoint.hard_due_date;
+    result.renewals             := circ_matchpoint.renewals;
+    result.grace_period         := circ_matchpoint.grace_period;
+    result.buildrows            := circ_test.buildrows;
+
+    -- (Insta)Fail if we couldn't find a matchpoint
+    IF circ_test.success = false THEN
+        result.fail_part := 'no_matchpoint';
+        result.success := FALSE;
+        done := TRUE;
+        RETURN NEXT result;
+        RETURN;
+    END IF;
+
+    -- All failures before this point are non-recoverable
+    -- Below this point are possibly overridable failures
+
+    -- Fail if the user is barred
+    IF user_object.barred IS TRUE THEN
+        result.fail_part := 'actor.usr.barred';
+        result.success := FALSE;
+        done := TRUE;
+        RETURN NEXT result;
+    END IF;
+
+    -- Fail if the item can't circulate
+    IF item_object.circulate IS FALSE THEN
+        result.fail_part := 'asset.copy.circulate';
+        result.success := FALSE;
+        done := TRUE;
+        RETURN NEXT result;
+    END IF;
+
+    -- Fail if the item isn't in a circulateable status on a non-renewal
+    IF NOT renewal AND item_object.status <> 8 AND item_object.status NOT IN (
+        (SELECT id FROM config.copy_status WHERE is_available) ) THEN
+        result.fail_part := 'asset.copy.status';
+        result.success := FALSE;
+        done := TRUE;
+        RETURN NEXT result;
+    -- Alternately, fail if the item isn't checked out on a renewal
+    ELSIF renewal AND item_object.status <> 1 THEN
+        result.fail_part := 'asset.copy.status';
+        result.success := FALSE;
+        done := TRUE;
+        RETURN NEXT result;
+    END IF;
+
+    -- Fail if the item can't circulate because of the shelving location
+    SELECT INTO item_location_object * FROM asset.copy_location WHERE id = item_object.location;
+    IF item_location_object.circulate IS FALSE THEN
+        result.fail_part := 'asset.copy_location.circulate';
+        result.success := FALSE;
+        done := TRUE;
+        RETURN NEXT result;
+    END IF;
+
+    -- Use Circ OU for penalties and such
+    SELECT INTO context_org_list ARRAY_AGG(id) FROM actor.org_unit_full_path( circ_ou );
+
+    -- Proximity of user's home_ou to circ_ou to see if penalties should be ignored.
+    SELECT INTO home_prox prox FROM actor.org_unit_proximity WHERE from_org = user_object.home_ou AND to_org = circ_ou;
+
+    -- Proximity of user's home_ou to item circ_lib to see if penalties should be ignored.
+    SELECT INTO item_prox prox FROM actor.org_unit_proximity WHERE from_org = user_object.home_ou AND to_org = item_object.circ_lib;
+
+    IF renewal THEN
+        penalty_type = '%RENEW%';
+    ELSE
+        penalty_type = '%CIRC%';
+    END IF;
+
+    -- Look up any custom override for PATRON_EXCEEDS_FINES penalty
+    SELECT BTRIM(value,'"')::INT INTO penalty_id FROM actor.org_unit_ancestor_setting('circ.custom_penalty_override.PATRON_EXCEEDS_FINES', circ_ou);
+    IF NOT FOUND THEN penalty_id := 1; END IF;
+
+    FOR standing_penalty IN
+        SELECT  DISTINCT csp.*
+          FROM  actor.usr_standing_penalty usp
+                JOIN config.standing_penalty csp ON (csp.id = usp.standing_penalty)
+          WHERE usr = match_user
+                AND usp.org_unit IN ( SELECT * FROM unnest(context_org_list) )
+                AND (usp.stop_date IS NULL or usp.stop_date > NOW())
+                AND (csp.ignore_proximity IS NULL
+                     OR csp.ignore_proximity < home_prox
+                     OR csp.ignore_proximity < item_prox)
+                AND csp.block_list LIKE penalty_type LOOP
+        -- override PATRON_EXCEEDS_FINES penalty for renewals based on org setting
+        IF renewal AND standing_penalty.id = penalty_id THEN
+            SELECT INTO permit_renew value FROM actor.org_unit_ancestor_setting('circ.permit_renew_when_exceeds_fines', circ_ou);
+            IF permit_renew IS NOT NULL AND permit_renew ILIKE 'true' THEN
+                CONTINUE;
+            END IF;
+        END IF;
+
+        result.fail_part := standing_penalty.name;
+        result.success := FALSE;
+        done := TRUE;
+        RETURN NEXT result;
+    END LOOP;
+
+    -- Fail if the test is set to hard non-circulating
+    IF circ_matchpoint.circulate IS FALSE THEN
+        result.fail_part := 'config.circ_matrix_test.circulate';
+        result.success := FALSE;
+        done := TRUE;
+        RETURN NEXT result;
+    END IF;
+
+    -- Fail if the total copy-hold ratio is too low
+    IF circ_matchpoint.total_copy_hold_ratio IS NOT NULL THEN
+        SELECT INTO hold_ratio * FROM action.copy_related_hold_stats(match_item);
+        IF hold_ratio.total_copy_ratio IS NOT NULL AND hold_ratio.total_copy_ratio < circ_matchpoint.total_copy_hold_ratio THEN
+            result.fail_part := 'config.circ_matrix_test.total_copy_hold_ratio';
+            result.success := FALSE;
+            done := TRUE;
+            RETURN NEXT result;
+        END IF;
+    END IF;
+
+    -- Fail if the available copy-hold ratio is too low
+    IF circ_matchpoint.available_copy_hold_ratio IS NOT NULL THEN
+        IF hold_ratio.hold_count IS NULL THEN
+            SELECT INTO hold_ratio * FROM action.copy_related_hold_stats(match_item);
+        END IF;
+        IF hold_ratio.available_copy_ratio IS NOT NULL AND hold_ratio.available_copy_ratio < circ_matchpoint.available_copy_hold_ratio THEN
+            result.fail_part := 'config.circ_matrix_test.available_copy_hold_ratio';
+            result.success := FALSE;
+            done := TRUE;
+            RETURN NEXT result;
+        END IF;
+    END IF;
+
+    -- Fail if the user has too many items out by defined limit sets
+    FOR circ_limit_set IN SELECT ccls.* FROM config.circ_limit_set ccls
+      JOIN config.circ_matrix_limit_set_map ccmlsm ON ccmlsm.limit_set = ccls.id
+      WHERE ccmlsm.active AND ( ccmlsm.matchpoint = circ_matchpoint.id OR
+        ( ccmlsm.matchpoint IN (SELECT * FROM unnest(result.buildrows)) AND ccmlsm.fallthrough )
+        ) LOOP
+            IF circ_limit_set.items_out > 0 AND NOT renewal THEN
+                SELECT INTO context_org_list ARRAY_AGG(aou.id)
+                  FROM actor.org_unit_full_path( circ_ou ) aou
+                    JOIN actor.org_unit_type aout ON aou.ou_type = aout.id
+                  WHERE aout.depth >= circ_limit_set.depth;
+                IF circ_limit_set.global THEN
+                    WITH RECURSIVE descendant_depth AS (
+                        SELECT  ou.id,
+                            ou.parent_ou
+                        FROM  actor.org_unit ou
+                        WHERE ou.id IN (SELECT * FROM unnest(context_org_list))
+                            UNION
+                        SELECT  ou.id,
+                            ou.parent_ou
+                        FROM  actor.org_unit ou
+                            JOIN descendant_depth ot ON (ot.id = ou.parent_ou)
+                    ) SELECT INTO context_org_list ARRAY_AGG(ou.id) FROM actor.org_unit ou JOIN descendant_depth USING (id);
+                END IF;
+                SELECT INTO items_out COUNT(DISTINCT circ.id)
+                  FROM action.circulation circ
+                    JOIN asset.copy copy ON (copy.id = circ.target_copy)
+                    LEFT JOIN action.circulation_limit_group_map aclgm ON (circ.id = aclgm.circ)
+                  WHERE circ.usr = match_user
+                    AND circ.circ_lib IN (SELECT * FROM unnest(context_org_list))
+                    AND circ.checkin_time IS NULL
+                    AND circ.xact_finish IS NULL
+                    AND (circ.stop_fines IN ('MAXFINES','LONGOVERDUE') OR circ.stop_fines IS NULL)
+                    AND (copy.circ_modifier IN (SELECT circ_mod FROM config.circ_limit_set_circ_mod_map WHERE limit_set = circ_limit_set.id)
+                        OR copy.location IN (SELECT copy_loc FROM config.circ_limit_set_copy_loc_map WHERE limit_set = circ_limit_set.id)
+                        OR aclgm.limit_group IN (SELECT limit_group FROM config.circ_limit_set_group_map WHERE limit_set = circ_limit_set.id)
+                    );
+                IF items_out >= circ_limit_set.items_out THEN
+                    result.fail_part := 'config.circ_matrix_circ_mod_test';
+                    result.success := FALSE;
+                    done := TRUE;
+                    RETURN NEXT result;
+                END IF;
+            END IF;
+            SELECT INTO result.limit_groups result.limit_groups || ARRAY_AGG(limit_group) FROM config.circ_limit_set_group_map WHERE limit_set = circ_limit_set.id AND NOT check_only;
+    END LOOP;
+
+    -- If we passed everything, return the successful matchpoint
+    IF NOT done THEN
+        RETURN NEXT result;
+    END IF;
+
+    RETURN;
+END;
+$func$ LANGUAGE plpgsql;
+
+COMMIT;
+