JBAS-1306 Lost/paid Payment Reciepts & Tracking
authorBill Erickson <berickxx@gmail.com>
Mon, 3 Jul 2017 21:16:42 +0000 (17:16 -0400)
committerBill Erickson <berickxx@gmail.com>
Thu, 21 Mar 2019 19:46:23 +0000 (15:46 -0400)
* refundable payment tracking
* rf payment print / email receipts (re-printable)
* rf payment staff 2ndry authorization via ldap
* rf payment staff view (for B.O.)

Signed-off-by: Bill Erickson <berickxx@gmail.com>
22 files changed:
KCLS/openils/var/templates_kcls/opac/biblio/main_payments.tt2
KCLS/sql/schema/deploy/lost-paid-receipts-data.sql [new file with mode: 0644]
KCLS/sql/schema/deploy/lost-paid-receipts.sql [new file with mode: 0644]
KCLS/sql/schema/revert/lost-paid-receipts-data.sql [new file with mode: 0644]
KCLS/sql/schema/revert/lost-paid-receipts.sql [new file with mode: 0644]
KCLS/sql/schema/sqitch.plan
KCLS/sql/schema/verify/lost-paid-receipts-data.sql [new file with mode: 0644]
KCLS/sql/schema/verify/lost-paid-receipts.sql [new file with mode: 0644]
Open-ILS/examples/fm_IDL.xml
Open-ILS/src/perlmods/lib/OpenILS/Application/Circ.pm
Open-ILS/src/perlmods/lib/OpenILS/Application/Circ/Money.pm
Open-ILS/src/perlmods/lib/OpenILS/Application/Circ/RefundablePayment.pm [new file with mode: 0644]
Open-ILS/src/perlmods/lib/OpenILS/Application/Trigger/Reactor.pm
Open-ILS/src/perlmods/lib/OpenILS/WWW/EGCatLoader/Account.pm
Open-ILS/src/templates/staff/circ/refunds/index.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/refunds/t_detail.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/refunds/t_list.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/css/style.css.tt2
Open-ILS/web/js/ui/default/staff/circ/refunds/app.js [new file with mode: 0644]
Open-ILS/xul/staff_client/chrome/content/main/constants.js
Open-ILS/xul/staff_client/server/patron/bill2.js
Open-ILS/xul/staff_client/server/patron/bill_apply_payment_form.xul [new file with mode: 0644]

index 5078382..dacc99f 100644 (file)
@@ -47,7 +47,8 @@
                         %]</td>
                         <td>[% money(payment.mp.amount) %]</td>
                         <td>
-                            [% IF payment.mp.payment_type == 'credit_card_payment' %]
+                            [% IF payment.mp.payment_type == 'credit_card_payment' 
+                                  OR payment.refundable_payment %]
                             <form action="[% ctx.opac_root %]/biblio/receipt_print" method="POST">
                                 <input type="hidden" name="payment" value="[% payment.mp.id %]" />
                                 <input type="submit" value="[% l('Print') %]" />
diff --git a/KCLS/sql/schema/deploy/lost-paid-receipts-data.sql b/KCLS/sql/schema/deploy/lost-paid-receipts-data.sql
new file mode 100644 (file)
index 0000000..1fdda60
--- /dev/null
@@ -0,0 +1,306 @@
+-- Deploy kcls-evergreen:lost-paid-receipts-data to pg
+-- requires: lost-paid-receipts
+
+BEGIN;
+
+DO $INSERT$
+BEGIN
+    IF evergreen.insert_on_deploy() THEN
+------------------------------------
+
+INSERT INTO config.org_unit_setting_type
+    (grp, name, datatype, label, description)
+VALUES (
+    'circ',
+    'circ.secondary_auth.ldap.server',
+    'string',
+    'Secondary Authentication LDAP Server URI',
+    'URI used by Evergreen to connect to a local LDAP server for ' ||
+    'secondary authentication.  For example: ldaps://ad.example.org:636'
+), (
+    'circ',
+    'circ.secondary_auth.ldap.domain',
+    'string',
+    'Secondary Authentication LDAP Domain',
+    'Login domain.  This is appended to the usrname during LDAP login.' ||
+    'This is likely the same as the email domain.  For example: example.org'
+), (
+    'circ',
+    'circ.secondary_auth.ldap.users_dn',
+    'string',
+    'Secondary Authentication LDAP Users DN',
+    'Example: ou=users,dc=example,dc=org'
+), (
+    'circ',
+    'circ.secondary_auth.ldap.users_filter',
+    'string',
+    'Secondary Authentication LDAP Users Query Filter',
+    'Printf-style string for the user query filter.  Example: (uid=%s)'
+), (
+    'circ',
+    'circ.secondary_auth.ldap.testmode',
+    'bool',
+    'Secondary Authentication LDAP Test Mode',
+    'When set to TRUE, all LDAP authentication requests succeed. ' ||
+    'For testing purposes only!'
+);
+
+INSERT INTO actor.org_unit_setting (org_unit, name, value) VALUES
+    (1, 'circ.secondary_auth.ldap.server', '"ldaps://ad.kcls.org:636"'),
+    (1, 'circ.secondary_auth.ldap.users_dn', '"ou=staff,dc=ad,dc=kcls,dc=org"'),
+    (1, 'circ.secondary_auth.ldap.users_filter',
+        '"(&(objectCategory=person)(objectClass=user)(userPrincipalName=%s))"'),
+    (1, 'circ.secondary_auth.ldap.domain', '"kcls.org"');
+
+INSERT INTO permission.perm_list (code, description) VALUES
+    ('ADMIN_REFUNDABLE_PAYMENT',
+        'View/modify refundable payment data for lost/paid receipts');
+
+
+-- Move all perms linked to the "Users" group down to Staff and Patrons
+-- so that Users is empty.
+
+INSERT INTO permission.grp_tree 
+    (id, name, parent, perm_interval, application_perm, description)
+VALUES 
+    (922, 'Business Office', 920, '1 year', 
+        NULL, 'KCLS Business Office Accounts');
+
+-- Give the B.O account the perms it needs to operate.
+
+INSERT INTO permission.grp_perm_map (grp, depth, grantable, perm) VALUES 
+    (922, 0, FALSE, 
+        (SELECT id FROM permission.perm_list WHERE code = 'STAFF_LOGIN')),
+    (922, 0, FALSE, 
+        (SELECT id FROM permission.perm_list WHERE code = 'REGISTER_WORKSTATION')),
+    (922, 0, FALSE, 
+        (SELECT id FROM permission.perm_list WHERE code = 'ADMIN_REFUNDABLE_PAYMENT'));
+
+-- Move all 4 "Users" perms to staff.
+UPDATE permission.grp_perm_map SET grp = 3 
+    WHERE grp = 1 AND perm IN (95, 32, 6, 5);
+
+-- Then duplicate the 2 missing ones in Patrons as well.
+-- "Patrons" already has perm 95 and don't need 32.
+INSERT INTO permission.grp_perm_map (grp, perm, depth, grantable)
+    VALUES (2, 6, 0, FALSE), (2, 5, 0, FALSE);
+
+INSERT INTO action_trigger.hook (key, core_type, description)
+    VALUES ('format.mrps.html', 'mrps', 'Refundable Payment Print Receipt');
+
+INSERT INTO action_trigger.event_definition
+    (active, owner, name, hook, validator, reactor, granularity, template)
+VALUES (TRUE, 1,
+    'Refundable Payment Print Receipt',
+    'format.mrps.html',
+    'NOOP_True',
+    'ProcessTemplate',
+    'print-on-demand',
+$TEMPLATE$
+[%- USE date ; mrps = target -%]
+[%- USE money=format('%.2f') -%]
+<div>
+  <table><tbody>
+    <tr>
+      <td>Receipt #</td>
+      <td>[% mrps.receipt_code %]</td>
+    </tr>
+    <tr>
+      <td>Patron ID</td>
+      <td>[% mrps.refundable_xact.usr %]</td>
+    </tr>
+    <tr>
+      <td>Patron Name</td>
+      <td>
+        [% mrps.refundable_xact.usr_first_name %]
+        [% mrps.refundable_xact.usr_middle_name %]
+        [% mrps.refundable_xact.usr_family_name %]
+      </td>
+    </tr>
+    <tr>
+        <td style='vertical-align:top'>Patron Address</td>
+        <td>
+            [% mrps.refundable_xact.usr_street1 %]
+            [% mrps.refundable_xact.usr_street2 %]
+            <br/>
+            [% mrps.refundable_xact.usr_city %],
+            [% mrps.refundable_xact.usr_state %]
+            [% mrps.refundable_xact.usr_post_code %]
+        </td>
+    </tr>
+    <tr>
+      <td>Copy Barcode</td>
+      <td>[% mrps.refundable_xact.copy_barcode %]</td>
+    </tr>
+    <tr>
+      <td>Copy Price</td>
+      <td>$[% money(mrps.refundable_xact.item_price) %]</td>
+    </tr>
+    <tr>
+      <td>Call Number</td>
+      <td>[% mrps.refundable_xact.call_number %]</td>
+    </tr>
+    <tr>
+      <td>Title</td>
+      <td>[% mrps.refundable_xact.title %]</td>
+    </tr>
+    <tr>
+      <td>Payment Amount</td>
+      <td>$[% money(mrps.amount) %]</td>
+    </tr>
+    <tr>
+      <td>Payment Type</td>
+      <td>
+        [% SWITCH mrps.payment_type -%]
+          [% CASE "cash_payment" %]Cash
+          [% CASE "check_payment" %]Check
+          [% CASE "credit_card_payment" %]Credit Card
+          [% CASE ; mrps.payment_type %]
+        [% END %]
+      </td>
+    </tr>
+    <tr>
+        <td>Paid At</td>
+        <td>[% mrps.payment_ou.name %]</td>
+    </tr>
+    <tr>
+      <td>Payment Accepted By</td>
+      <td>[% helpers.name_to_initials(mrps.staff_name) %]</td>
+    </tr>
+    <tr>
+      <td>Payment Date</td>
+      <td>[% date.format(helpers.format_date(mrps.payment_time), '%m/%d/%Y %l:%M %p') %]</td>
+    </tr>
+    <tr>
+      <td>Receipt Printed On</td>
+      <td>[% date.format(date.now, '%m/%d/%Y %l:%M %p') %]</td>
+    </tr>
+  </tbody></table>
+  <div>
+    <p>
+    If the items listed on this receipt are returned within 12 months of
+    the receipt date (see exclusions below), you may request a refund
+    within 12 months of the receipt date for the cost of the item minus
+    any outstanding charges on your account. The usual overdue fee will
+    still be in effect for the returned item. You may choose to pay this
+    overdue fee at the time you request a refund or have the overdue fee
+    deducted from the amount of the refund.  Patrons requesting a refund
+    must present this receipt at any KCLS location.
+    </p>
+    <p>
+    Refunds are not available for parts and pieces, overdue fines, or items
+    that do not display a specific title in My Account. For a list of
+    refundable and nonrefundable items, visit
+    <a href='http://www.kcls.org/usingthelibrary/borrowing/'>
+        http://kcls.org/faq/borrowing/#faq_1766
+    </a>
+    </p>
+    **END OF RECEIPT**
+  </div>
+</div>
+$TEMPLATE$
+);
+
+INSERT INTO action_trigger.environment (event_def, path) VALUES
+    (
+        (SELECT id FROM action_trigger.event_definition
+            WHERE hook = 'format.mrps.html'),
+        'refundable_xact'
+    ), (
+        (SELECT id FROM action_trigger.event_definition
+            WHERE hook = 'format.mrps.html'),
+        'payment_ou'
+    )
+
+;
+
+---------------------------------------------------------------
+
+INSERT INTO action_trigger.hook (key, core_type, description)
+    VALUES ('format.mrps.email', 'mrps', 'Refundable Payment Email Receipt');
+
+INSERT INTO action_trigger.event_definition
+    (active, owner, name, hook, validator, reactor, granularity, template)
+VALUES (TRUE, 1,
+    'Refundable Payment Email Receipt',
+    'format.mrps.email',
+    'NOOP_True',
+    'SendEmail',
+    'print-on-demand',
+$TEMPLATE$
+[%- USE date; 
+    USE money=format('%.2f');
+    mrps = target;
+    user = mrps.refundable_xact.usr;
+    lib = mrps.payment_ou;
+-%]
+To: [%- params.recipient_email || user.email %]
+From: [%- 
+       params.sender_email 
+    || helpers.get_org_setting(lib.id, 'org.bounced_emails') 
+    || lib.email 
+    || default_sender 
+%]
+Subject: KCLS Payment Receipt
+
+[% mrps.refundable_xact.usr_first_name %] [% mrps.refundable_xact.usr_middle_name %] [% mrps.refundable_xact.usr_family_name %]
+[% mrps.refundable_xact.usr_street1 %] [% mrps.refundable_xact.usr_street2 %]
+[% mrps.refundable_xact.usr_city %], [% mrps.refundable_xact.usr_state %] [% mrps.refundable_xact.usr_post_code %]
+
+Receipt #: [% mrps.receipt_code %]
+Patron ID: [% mrps.refundable_xact.usr.id %]
+Copy Barcode: [% mrps.refundable_xact.copy_barcode %]
+Copy Price: $[% money(mrps.refundable_xact.item_price) %]
+Call Number: [% mrps.refundable_xact.call_number %]
+Title: [% mrps.refundable_xact.title %]
+Payment Amount: $[% money(mrps.amount) %]
+Payment Type: [% SWITCH mrps.payment_type -%] 
+    [%- CASE "cash_payment" %]Cash 
+    [%- CASE "check_payment" %]Check 
+    [%- CASE "credit_card_payment" %]Credit Card 
+    [%- CASE ; mrps.payment_type -%] 
+[%- END %]
+Paid At: [% mrps.payment_ou.name %]
+Payment Accepted By: [% helpers.name_to_initials(mrps.staff_name) %]
+Payment Date: [% date.format(helpers.format_date(mrps.payment_time), '%m/%d/%Y %l:%M %p') %]
+Receipt Printed On: [% date.format(date.now, '%m/%d/%Y %l:%M %p') %]
+
+If the items listed on this receipt are returned within 12 months of
+the receipt date (see exclusions below), you may request a refund
+within 12 months of the receipt date for the cost of the item minus
+any outstanding charges on your account. The usual overdue fee will
+still be in effect for the returned item. You may choose to pay this
+overdue fee at the time you request a refund or have the overdue fee
+deducted from the amount of the refund.  Patrons requesting a refund
+must present this receipt at any KCLS location.
+
+Refunds are not available for parts and pieces, overdue fines, or items
+that do not display a specific title in My Account. For a list of
+refundable and nonrefundable items, visit
+<a href='http://www.kcls.org/usingthelibrary/borrowing/'>
+http://kcls.org/faq/borrowing/#faq_1766
+
+**END OF RECEIPT**
+
+$TEMPLATE$
+);
+
+INSERT INTO action_trigger.environment (event_def, path) VALUES
+    (
+        (SELECT id FROM action_trigger.event_definition
+            WHERE hook = 'format.mrps.email'),
+        'refundable_xact.usr'
+    ), (
+        (SELECT id FROM action_trigger.event_definition
+            WHERE hook = 'format.mrps.email'),
+        'payment_ou'
+    )
+
+;
+
+------------------------------------
+    END IF; -- insert_on_deploy
+END $INSERT$;
+
+
+COMMIT;
diff --git a/KCLS/sql/schema/deploy/lost-paid-receipts.sql b/KCLS/sql/schema/deploy/lost-paid-receipts.sql
new file mode 100644 (file)
index 0000000..96df20a
--- /dev/null
@@ -0,0 +1,143 @@
+
+BEGIN;
+
+-- Staff usr ID can be accessed from money.payment.accepting_usr.
+-- Create time comes from money.payment.payment_ts.
+
+-- money.payment.xact for circ payments always points to a circ or
+-- an aged circ.
+
+-- When a patron is purged, non-circ transaction are deleted,
+-- but any payments linked to those transactions stick around!
+
+-- Patron ID not tracked since it's accessible through the transaction
+-- and this way we don't have to NULL-ify the field if the patron is purged.
+
+CREATE TABLE money.refundable_xact (
+    id              SERIAL PRIMARY KEY,
+    xact            BIGINT, -- REFERENCES money.billable_xact (id) (parent table)
+    action_date     TIMESTAMP WITH TIME ZONE,
+    action_by       INTEGER REFERENCES actor.usr (id),
+    item_price      NUMERIC(8,2) NOT NULL DEFAULT 0,
+    refund_amount   NUMERIC(8,2),
+    rejected        BOOLEAN NOT NULL DEFAULT FALSE,
+    notes           TEXT,
+    usr_first_name  TEXT NOT NULL,
+    usr_middle_name TEXT,
+    usr_family_name TEXT NOT NULL,
+    usr_barcode     TEXT NOT NULL,
+    usr_street1     TEXT NOT NULL,
+    usr_street2     TEXT,
+    usr_city        TEXT NOT NULL,
+    usr_state       TEXT NOT NULL,
+    usr_post_code   TEXT NOT NULL
+);
+
+CREATE INDEX m_r_x_xact_idx ON money.refundable_xact (xact);
+
+CREATE TABLE money.refundable_payment (
+    id                SERIAL PRIMARY KEY,
+    refundable_xact   BIGINT NOT NULL REFERENCES money.refundable_xact (id),
+    payment           BIGINT NOT NULL, -- REFERENCES money.payment (id) (parent table)
+    payment_ou        BIGINT NOT NULL REFERENCES actor.org_unit (id),
+    final_payment     BOOLEAN NOT NULL DEFAULT TRUE,
+    receipt_number    INTEGER NOT NULL, -- trigger generated
+    refunded_via      TEXT,
+    staff_name        TEXT NOT NULL,
+    staff_email       TEXT NOT NULL,
+    CONSTRAINT valid_refunded_via CHECK 
+        (refunded_via IS NULL OR refunded_via IN ('check', 'credit_card'))
+);
+
+CREATE INDEX m_r_p_payment_idx ON money.refundable_payment (payment);
+
+CREATE OR REPLACE FUNCTION gen_refundable_payment_number ()
+    RETURNS TRIGGER AS $_$
+BEGIN
+    PERFORM TRUE FROM money.credit_card_payment WHERE id = NEW.payment;
+    IF FOUND THEN
+        -- credit card payments do not get a special receipt number.
+        NEW.receipt_number := 0;
+    ELSE
+        SELECT INTO NEW.receipt_number COALESCE(MAX(receipt_number), 0) + 1
+        FROM money.refundable_payment WHERE payment_ou = NEW.payment_ou;
+    END IF;
+
+    RETURN NEW;
+END;
+$_$ LANGUAGE PLPGSQL;
+
+CREATE TRIGGER gen_refundable_payment_number_tgr
+    BEFORE INSERT ON money.refundable_payment
+    FOR EACH ROW EXECUTE PROCEDURE gen_refundable_payment_number();
+
+CREATE OR REPLACE VIEW money.refundable_xact_summary AS
+    SELECT 
+        xact.*,
+        acp.id AS copy,
+        acp.barcode AS copy_barcode,
+        acn.label AS call_number,
+        CASE WHEN acn.id = -1 
+            THEN acp.dummy_title 
+            ELSE rsr.title 
+        END AS title,
+        circ.usr AS usr, -- may be null
+        circ.xact_start AS xact_start,
+        circ.xact_finish AS xact_finish,
+        summary.total_owed AS total_owed,
+        summary.balance_owed AS balance_owed,
+        refundable_paid.amount::NUMERIC(8,2) AS refundable_paid,
+        total_paid.amount::NUMERIC(8,2) AS total_paid,
+        total_refunded.amount::NUMERIC(8,2) AS total_refunded,
+        refundable_payment_count.count AS num_refundable_payments
+    FROM money.refundable_xact xact
+        JOIN action.all_circulation circ ON (circ.id = xact.xact)
+        JOIN asset.copy acp ON (acp.id = circ.target_copy)
+        JOIN asset.call_number acn ON (acn.id = acp.call_number)
+        JOIN reporter.materialized_simple_record rsr ON (rsr.id = acn.record)
+        JOIN money.materialized_billable_xact_summary summary 
+            ON (summary.id = xact.id)
+               JOIN (
+            SELECT pay.xact, SUM(pay.amount) amount
+            FROM money.payment pay WHERE amount > 0 GROUP BY 1
+               ) total_paid ON (total_paid.xact = xact.xact)
+               JOIN (
+                       SELECT mrp.refundable_xact, SUM(pay.amount) AS amount
+                       FROM money.refundable_payment mrp
+                       JOIN money.payment pay ON (mrp.payment = pay.id)
+                       GROUP BY 1
+               ) refundable_paid ON (refundable_paid.refundable_xact = xact.id)
+        LEFT JOIN (
+            -- refunds are negative payments, negate the amount
+            SELECT pay.xact, -SUM(pay.amount) amount
+            FROM money.cash_payment pay
+            WHERE amount < 0
+            GROUP BY 1
+        ) total_refunded ON (total_refunded.xact = xact.xact)
+               JOIN (
+                       SELECT COUNT(*) AS count, mrp.refundable_xact
+                       FROM money.refundable_payment mrp
+                       GROUP BY 2
+               ) refundable_payment_count 
+            ON (refundable_payment_count.refundable_xact = xact.id)
+;
+
+CREATE OR REPLACE VIEW money.refundable_payment_summary AS
+    SELECT 
+        mrp.*,
+        aou.shortname || 
+            LPAD(mrp.receipt_number::TEXT, 6, '0') AS receipt_code,
+        pay.payment_ts AS payment_time,
+        pay.amount AS amount,
+        pview.payment_type,
+        aws.name AS workstation
+    FROM money.refundable_payment mrp
+        JOIN money.payment pay ON (pay.id = mrp.payment)
+        JOIN actor.org_unit aou ON (aou.id = mrp.payment_ou)
+        JOIN money.payment_view pview ON (pview.id = pay.id)
+        LEFT JOIN money.cash_payment cash ON (cash.id = pay.id)
+        LEFT JOIN actor.workstation aws ON (aws.id = cash.cash_drawer)
+;
+
+COMMIT;
+
diff --git a/KCLS/sql/schema/revert/lost-paid-receipts-data.sql b/KCLS/sql/schema/revert/lost-paid-receipts-data.sql
new file mode 100644 (file)
index 0000000..e8e17b3
--- /dev/null
@@ -0,0 +1,53 @@
+-- Revert kcls-evergreen:lost-paid-receipts-data from pg
+
+BEGIN;
+
+DELETE FROM permission.grp_perm_map WHERE perm IN (
+    SELECT id FROM permission.perm_list 
+        WHERE code IN ('ADMIN_REFUNDABLE_PAYMENT')
+);
+
+DELETE FROM permission.perm_list 
+    WHERE code IN ('ADMIN_REFUNDABLE_PAYMENT');
+
+-- B.O. perms
+DELETE FROM permission.grp_perm_map WHERE grp = 922;
+
+-- Vendor, Business Office
+DELETE FROM permission.grp_tree WHERE id IN (922);
+
+-- Move the 4 global perms back to Users
+UPDATE permission.grp_perm_map SET grp = 1 
+    WHERE grp = 3 AND perm IN (95, 32, 6, 5);
+
+DELETE FROM permission.grp_perm_map WHERE grp = 2 AND perm IN (6, 5);
+
+DELETE FROM action_trigger.event WHERE event_def IN (
+    SELECT id FROM action_trigger.event_definition 
+    WHERE hook IN ('format.mrps.html', 'format.mrps.email')
+);
+
+DELETE FROM action_trigger.environment WHERE event_def IN (
+    SELECT id FROM action_trigger.event_definition 
+    WHERE hook IN ('format.mrps.html', 'format.mrps.email')
+);
+
+DELETE FROM action_trigger.event_definition WHERE id IN (
+    SELECT id FROM action_trigger.event_definition 
+    WHERE hook IN ('format.mrps.html', 'format.mrps.email')
+);
+
+DELETE FROM action_trigger.hook 
+    WHERE key IN ('format.mrps.html', 'format.mrps.email');
+
+DELETE FROM actor.org_unit_setting 
+    WHERE name ~ '^circ.secondary_auth.ldap';
+
+DELETE FROM config.org_unit_setting_type_log 
+    WHERE field_name ~ '^circ.secondary_auth.ldap';
+
+DELETE FROM config.org_unit_setting_type 
+    WHERE name ~ '^circ.secondary_auth.ldap';
+
+COMMIT;
+
diff --git a/KCLS/sql/schema/revert/lost-paid-receipts.sql b/KCLS/sql/schema/revert/lost-paid-receipts.sql
new file mode 100644 (file)
index 0000000..ccda1f3
--- /dev/null
@@ -0,0 +1,12 @@
+-- Revert kcls-evergreen:lost-paid-receipts from pg
+
+BEGIN;
+
+DROP VIEW IF EXISTS money.refundable_payment_summary;
+DROP VIEW IF EXISTS money.refundable_xact_summary;
+DROP INDEX IF EXISTS m_r_p_payment_idx;
+DROP INDEX IF EXISTS m_r_x_xact_idx;
+DROP TABLE IF EXISTS money.refundable_payment;
+DROP TABLE IF EXISTS money.refundable_xact;
+
+COMMIT;
index 265fd6c..aa87fe5 100644 (file)
@@ -73,6 +73,8 @@ ecard-data [2.10-to-2.12-upgrade] 2018-01-03T21:55:03Z Bill Erickson,,, <berick@
 remove-gender [ecard-data] 2018-06-06T14:44:36Z Bill Erickson,,, <berick@kcls-dev-local> # Remove gender field/data
 ecard-notice-validator [remove-gender] 2018-07-26T14:33:57Z Bill Erickson,,, <berick@kcls-dev-local> # eCard UMS notice validator
 aged-billings-payments [ecard-notice-validator] 2018-09-24T18:00:57Z Bill Erickson,,, <berick@kcls-dev> # Aged money/billing schema and data
+lost-paid-receipts [2.9-to-2.10-upgrade-reingest] 2017-07-03T20:10:59Z Bill Erickson,,, <berick@kcls-dev-local> # Lost/Paid tracking and receipts
+lost-paid-receipts-data [lost-paid-receipts] 2017-08-02T15:28:08Z Bill Erickson,,, <berick@kcls-dev-local> # Lost/Paid permissions and receipts
 stock-browse-schema [ecard-notice-validator] 2018-08-31T15:22:58Z Bill Erickson,,, <berick@kcls-dev-local> # Recover stock browse data tables, etc.
 stock-browse-headings-report [stock-browse-schema] 2018-10-04T15:56:18Z Bill Erickson,,, <berick@kcls-dev> # New heading report updates for stock browse
 stock-browse-cleanup [stock-browse-schema] 2018-10-03T18:05:49Z Bill Erickson,,, <berick@kcls-dev> # Delete old browse data
diff --git a/KCLS/sql/schema/verify/lost-paid-receipts-data.sql b/KCLS/sql/schema/verify/lost-paid-receipts-data.sql
new file mode 100644 (file)
index 0000000..038a1b2
--- /dev/null
@@ -0,0 +1,7 @@
+-- Verify kcls-evergreen:lost-paid-receipts-data on pg
+
+BEGIN;
+
+-- XXX Add verifications here.
+
+ROLLBACK;
diff --git a/KCLS/sql/schema/verify/lost-paid-receipts.sql b/KCLS/sql/schema/verify/lost-paid-receipts.sql
new file mode 100644 (file)
index 0000000..091ff6d
--- /dev/null
@@ -0,0 +1,7 @@
+-- Verify kcls-evergreen:lost-paid-receipts on pg
+
+BEGIN;
+
+-- XXX Add verifications here.
+
+ROLLBACK;
index 3551664..769afb4 100644 (file)
@@ -7253,7 +7253,7 @@ SELECT  usr,
                </links>
                <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
                        <actions>
-                               <retrieve permission="VIEW_USER_TRANSACTIONS">
+                               <retrieve permission="VIEW_USER_TRANSACTIONS ADMIN_REFUNDABLE_PAYMENT">
                                        <context link="usr" field="home_ou" />
                                </retrieve>
                        </actions>
@@ -8097,7 +8097,7 @@ SELECT  usr,
                </links>
         <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
             <actions>
-                <retrieve permission="VIEW_USER_TRANSACTIONS">
+                <retrieve permission="VIEW_USER_TRANSACTIONS ADMIN_REFUNDABLE_PAYMENT">
                     <context link="xact" jump="usr" field="home_ou"/>
                 </retrieve>
                        </actions>
@@ -8412,7 +8412,7 @@ SELECT  usr,
                </links>
         <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
             <actions>
-                <retrieve permission="VIEW_USER_TRANSACTIONS">
+                <retrieve permission="VIEW_USER_TRANSACTIONS ADMIN_REFUNDABLE_PAYMENT">
                     <context link="xact" jump="usr" field="home_ou"/>
                 </retrieve>
                        </actions>
@@ -8559,7 +8559,7 @@ SELECT  usr,
         <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
             <actions>
                 <create permission="CREATE_BILLING_TYPE" context_field="owner"/>
-                <retrieve permission="VIEW_BILLING_TYPE CREATE_BILLING_TYPE UPDATE_BILLING_TYPE DELETE_BILLING_TYPE" context_field="owner"/>
+                <retrieve permission="VIEW_BILLING_TYPE CREATE_BILLING_TYPE UPDATE_BILLING_TYPE DELETE_BILLING_TYPE ADMIN_REFUNDABLE_PAYMENT" context_field="owner"/>
                 <update permission="UPDATE_BILLING_TYPE" context_field="owner"/>
                 <delete permission="DELETE_BILLING_TYPE" context_field="owner"/>
             </actions>
@@ -12960,6 +12960,142 @@ SELECT  usr,
        </class>
 
        <!-- ********************************************************************************************************************* -->
+
+       <!-- KCLS refundable payments classes -->
+
+       <class id="mrx" controller="open-ils.cstore" 
+               oils_obj:fieldmapper="money::refundable_xact" 
+               oils_persist:tablename="money.refundable_xact" 
+               reporter:label="Refundable Transaction">
+               <fields oils_persist:primary="id"
+                       oils_persist:sequence="money.refundable_xact_id_seq">
+                       <field name="id" reporter:datatype="id" reporter:label="ID"/>
+                       <field name="xact" reporter:datatype="link" reporter:label="Transaction ID"/>
+                       <field name="action_date" reporter:datatype="timestamp" reporter:label="Action Date"/>
+                       <field name="action_by" reporter:datatype="link" reporter:label="Action By"/>
+                       <field name="item_price" reporter:datatype="money" reporter:label="Item Price"/>
+                       <field name="refund_amount" reporter:datatype="money" reporter:label="Refund Amount"/>
+                       <field name="rejected" reporter:datatype="bool" reporter:label="Rejected"/>
+                       <field name="notes" reporter:datatype="text" reporter:label="Notes"/>
+                       <field name="usr_first_name" reporter:datatype="text" suppress_controller="open-ils.reporter-store"  reporter:label="Patron First Name"/>
+                       <field name="usr_middle_name" reporter:datatype="text" suppress_controller="open-ils.reporter-store"  reporter:label="Patron Middle Name"/>
+                       <field name="usr_family_name" reporter:datatype="text" suppress_controller="open-ils.reporter-store"  reporter:label="Patron Last Name"/>
+                       <field name="usr_barcode" reporter:datatype="text" suppress_controller="open-ils.reporter-store"  reporter:label="Patron Barcode"/>
+                       <field name="usr_street1" reporter:datatype="text" suppress_controller="open-ils.reporter-store"  reporter:label="Street1"/>
+                       <field name="usr_street2" reporter:datatype="text" suppress_controller="open-ils.reporter-store"  reporter:label="Street2"/>
+                       <field name="usr_city" reporter:datatype="text" suppress_controller="open-ils.reporter-store"  reporter:label="City"/>
+                       <field name="usr_state" reporter:datatype="text" suppress_controller="open-ils.reporter-store"  reporter:label="State"/>
+                       <field name="usr_post_code" reporter:datatype="text" suppress_controller="open-ils.reporter-store"  reporter:label="Post Code"/>
+               </fields>
+       </class>
+       <class id="mrxs" controller="open-ils.cstore open-ils.pcrud" 
+               oils_obj:fieldmapper="money::refundable_xact_summary" 
+               oils_persist:tablename="money.refundable_xact_summary" 
+               reporter:label="Refundable Transaction Summary"
+               oils_persist:readonly="true">
+               <fields oils_persist:primary="id"
+                       oils_persist:sequence="money.refundable_xact_id_seq">
+                       <field name="id" reporter:datatype="id" reporter:label="ID"/>
+                       <field name="xact" reporter:datatype="link" reporter:label="Transaction ID"/>
+                       <field name="action_date" reporter:datatype="timestamp" reporter:label="Action Date"/>
+                       <field name="action_by" reporter:datatype="link" reporter:label="Action By"/>
+                       <field name="item_price" reporter:datatype="money" reporter:label="Item Price"/>
+                       <field name="refund_amount" reporter:datatype="money" reporter:label="Refund Amount"/>
+                       <field name="rejected" reporter:datatype="bool" reporter:label="Rejected"/>
+                       <field name="notes" reporter:datatype="text" reporter:label="Notes"/>
+                       <field name="usr_first_name" reporter:datatype="text" suppress_controller="open-ils.reporter-store"  reporter:label="Patron First Name"/>
+                       <field name="usr_middle_name" reporter:datatype="text" suppress_controller="open-ils.reporter-store"  reporter:label="Patron Middle Name"/>
+                       <field name="usr_family_name" reporter:datatype="text" suppress_controller="open-ils.reporter-store"  reporter:label="Patron Last Name"/>
+                       <field name="usr_barcode" reporter:datatype="text" suppress_controller="open-ils.reporter-store"  reporter:label="Patron Barcode"/>
+                       <field name="usr_street1" reporter:datatype="text" suppress_controller="open-ils.reporter-store"  reporter:label="Street1"/>
+                       <field name="usr_street2" reporter:datatype="text" suppress_controller="open-ils.reporter-store"  reporter:label="Street2"/>
+                       <field name="usr_city" reporter:datatype="text" suppress_controller="open-ils.reporter-store"  reporter:label="City"/>
+                       <field name="usr_state" reporter:datatype="text" suppress_controller="open-ils.reporter-store"  reporter:label="State"/>
+                       <field name="usr_post_code" reporter:datatype="text" suppress_controller="open-ils.reporter-store"  reporter:label="Post Code"/>
+                       <field name="usr" reporter:datatype="link" reporter:label="User ID" suppress_controller="open-ils.reporter-store"/>
+                       <field name="xact_start" reporter:datatype="timestamp" reporter:label="Transaction Start Date"/>
+                       <field name="xact_finish" reporter:datatype="timestamp" reporter:label="Transaction End Date"/>
+                       <field name="total_owed" reporter:datatype="money" reporter:label="Total Owed"/>
+                       <field name="balance_owed" reporter:datatype="money" reporter:label="Balance Owed"/>
+                       <field name="refundable_paid" reporter:datatype="money" reporter:label="Refundable Amount Paid"/>
+                       <field name="total_paid" reporter:datatype="money" reporter:label="Total Paid"/>
+                       <field name="total_refunded" reporter:datatype="money" reporter:label="Total Amount Refunded (By Staff)"/>
+                       <field name="num_refundable_payments" reporter:datatype="int" reporter:label="Refundable Payment Count"/>
+                       <field name="copy" reporter:datatype="link" reporter:label="Copy ID"/>
+                       <field name="copy_barcode" reporter:datatype="text" reporter:label="Copy Barcode"/>
+                       <field name="call_number" reporter:datatype="text" reporter:label="Call Number"/>
+                       <field name="title" reporter:datatype="text" reporter:label="Title"/>
+                       <field name="refundable_payments" reporter:datatype="link" oils_persist:virtual="true"/>
+               </fields>
+               <links>
+                       <link field="xact" reltype="has_a" key="id" map="" class="mbt"/>
+                       <link field="refundable_payments" reltype="has_many" key="refundable_xact" map="" class="mrps"/>
+                       <link field="copy" reltype="has_a" key="id" map="" class="acp"/>
+                       <link field="usr" reltype="has_a" key="id" map="" class="au"/>
+               </links>
+               <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+                       <actions>
+                               <retrieve permission="ADMIN_REFUNDABLE_PAYMENT" global_required="true"/>
+                       </actions>
+               </permacrud>
+       </class>
+       <class id="mrp" controller="open-ils.cstore"
+               oils_obj:fieldmapper="money::refundable_payment" 
+               oils_persist:tablename="money.refundable_payment" 
+               reporter:label="Refundable Payment">
+               <fields oils_persist:primary="id" 
+                       oils_persist:sequence="money.refundable_payment_id_seq">
+                       <field name="id" reporter:datatype="id" />
+                       <field name="refundable_xact" reporter:datatype="link" reporter:label="Refundable Transaction ID"/>
+                       <field name="payment" reporter:datatype="link" reporter:label="Payment ID"/>
+                       <field name="payment_ou" reporter:datatype="link" reporter:label="Payment Library"/>
+                       <field name="final_payment" reporter:datatype="bool" reporter:label="Final Payment"/>
+                       <field name="receipt_number" reporter:datatype="text" reporter:label="Receipt Code"/>
+                       <field name="refunded_via" reporter:datatype="text" reporter:label="Refunded Via"/>
+                       <field name="staff_name" reporter:datatype="text" suppress_controller="open-ils.reporter-store"  reporter:label="Staff Name"/>
+                       <field name="staff_email" reporter:datatype="text" suppress_controller="open-ils.reporter-store" reporter:label="Staff Email"/>
+               </fields>
+               <links>
+                       <link field="payment" reltype="has_a" key="id" map="" class="mp"/>
+                       <link field="payment_ou" reltype="has_a" key="id" map="" class="aou"/>
+                       <link field="refundable_xact" reltype="has_a" key="id" map="" class="mrx"/>
+                       <link field="action_by" reltype="has_a" key="id" map="" class="au"/>
+               </links>
+       </class>
+       <class id="mrps" controller="open-ils.cstore open-ils.pcrud" 
+               oils_obj:fieldmapper="money::refundable_payment_summary" 
+               oils_persist:tablename="money.refundable_payment_summary" 
+               reporter:label="Refundable Payment Summary">
+               <fields oils_persist:primary="id" 
+                       oils_persist:sequence="money.refundable_payment_id_seq">
+                       <field name="id" reporter:datatype="id" />
+                       <field name="refundable_xact" reporter:datatype="link" reporter:label="Refundable Transaction ID"/>
+                       <field name="payment" reporter:datatype="link" reporter:label="Payment ID"/>
+                       <field name="payment_ou" reporter:datatype="link" reporter:label="Payment Library"/>
+                       <field name="final_payment" reporter:datatype="bool" reporter:label="Final Payment"/>
+                       <field name="receipt_number" reporter:datatype="text" reporter:label="Receipt Number"/>
+                       <field name="refunded_via" reporter:datatype="text" reporter:label="Refunded Via"/>
+                       <field name="staff_name" reporter:datatype="text" suppress_controller="open-ils.reporter-store"  reporter:label="Staff First Name"/>
+                       <field name="staff_email" reporter:datatype="text" suppress_controller="open-ils.reporter-store" reporter:label="Staff Email"/>
+                       <field name="receipt_code" reporter:datatype="text" reporter:label="Receipt Code"/>
+                       <field name="payment_time" reporter:datatype="timestamp" reporter:label="Payment Time"/>
+                       <field name="amount" reporter:datatype="money" reporter:label="Amount Paid"/>
+                       <field name="payment_type" reporter:datatype="text" reporter:label="Payment Type"/>
+                       <field name="workstation" reporter:datatype="text" reporter:label="Payment Workstation"/>
+               </fields>
+               <links>
+                       <link field="payment" reltype="has_a" key="id" map="" class="mp"/>
+                       <link field="payment_ou" reltype="has_a" key="id" map="" class="aou"/>
+                       <link field="refundable_xact" reltype="has_a" key="id" map="" class="mrxs"/>
+                       <link field="action_by" reltype="has_a" key="id" map="" class="au"/>
+               </links>
+               <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+                       <actions>
+                               <retrieve permission="ADMIN_REFUNDABLE_PAYMENT" global_required="true"/>
+                       </actions>
+               </permacrud>
+       </class>
+
 </IDL>
 
 <!--
index c7feb25..a966d74 100644 (file)
@@ -14,6 +14,7 @@ use OpenILS::Application::Circ::Money;
 use OpenILS::Application::Circ::NonCat;
 use OpenILS::Application::Circ::CopyLocations;
 use OpenILS::Application::Circ::CircCommon;
+use OpenILS::Application::Circ::RefundablePayment;
 
 use DateTime;
 use DateTime::Format::ISO8601;
index 2a50f05..38169bc 100644 (file)
@@ -35,6 +35,7 @@ use OpenILS::Const qw/:const/;
 use OpenILS::Utils::DateTime qw/:datetime/;
 use DateTime::Format::ISO8601;
 my $parser = DateTime::Format::ISO8601->new;
+use OpenILS::Application::Circ::RefundablePayment;
 
 sub get_processor_settings {
     my $e = shift;
@@ -192,6 +193,13 @@ __PACKAGE__->register_method(
                         [trans_id, amt], 
                         [...]
                     ], 
+                    refundable_args : {
+                        secondary_auth_key : $key,
+                        transactions : [
+                            {xact : id},
+                            ...
+                        ]
+                    }
                 }/, type => 'hash'
             },
             {
@@ -265,11 +273,18 @@ sub make_payments {
     my $drawer = $e->requestor->wsid;
     my $note = $payments->{note};
     my $cc_args = $payments->{cc_args};
+    my $refundable_args = $payments->{refundable_args} || {};
     my $check_number = $payments->{check_number};
     my $total_paid = 0;
     my $this_ou = $e->requestor->ws_ou || $e->requestor->home_ou;
     my %orgs;
 
+    return OpenILS::Event->new('BAD_PARAMS', note => 
+        'Secondary auth key required for refundable payment tracking')
+        if (
+            $refundable_args->{transactions} 
+            && !$refundable_args->{secondary_auth_key}
+        );
 
     # unless/until determined by payment processor API
     my ($approval_code, $cc_processor, $cc_order_number) = (undef,undef,undef, undef);
@@ -543,6 +558,14 @@ sub make_payments {
         }
 
         push(@payment_ids, $payment->id);
+
+        if ($refundable_args->{transactions}) {
+            # If this is one of the refundable payments, add the payment
+            # ID to the set of per-payment options provided by the caller.
+            my ($ref_data) = grep 
+                {$_->{xact} == $transid} @{$refundable_args->{transactions}};
+            $ref_data->{payment_id} = $payment->id if $ref_data;
+        }
     }
 
     my $evt = _update_patron_credit($e, $patron, $credit);
@@ -564,6 +587,24 @@ sub make_payments {
         }
     }
 
+    my $refundable_payments = [];
+    if ($refundable_args->{transactions}) {
+        my $authkey = $refundable_args->{secondary_auth_key};
+
+        for my $pay_args (@{$refundable_args->{transactions}}) {
+            my $pay_id = $pay_args->{payment_id};
+            my $evt = OpenILS::Application::Circ::RefundablePayment
+                ->create_refundable_payment(
+                    $e, $authkey, $pay_id, $pay_args, $refundable_payments);
+
+            if ($evt) {
+                $logger->error(
+                    "Error tracking refundable payment data for payment $pay_id");
+                return $e->die_event;
+            }
+        }
+    }
+
     # update the user to create a new last_xact_id
     $e->update_actor_user($patron) or return $e->die_event;
     $patron = $e->retrieve_actor_user($patron) or return $e->die_event;
@@ -575,7 +616,11 @@ sub make_payments {
         if $user_id == $e->requestor->id;
 
     $U->log_user_activity($user_id, '', 'payment');
-    return {last_xact_id => $patron->last_xact_id, payments => \@payment_ids};
+    return {
+        last_xact_id => $patron->last_xact_id, 
+        payments => \@payment_ids,
+        refundable_payments => $refundable_payments
+    };
 }
 
 sub _recording_failure {
diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/Circ/RefundablePayment.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/Circ/RefundablePayment.pm
new file mode 100644 (file)
index 0000000..7a00b28
--- /dev/null
@@ -0,0 +1,426 @@
+# ---------------------------------------------------------------
+# Copyright (C) 2017 King County Library System
+# Bill Erickson <berickxx@gmail.com>
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+# ---------------------------------------------------------------
+#
+# KCLS JBAS-1306 Lost+Paid Refundable Payments Tracking
+#
+# ---------------------------------------------------------------
+package OpenILS::Application::Circ::RefundablePayment;
+use strict; use warnings;
+use base qw/OpenILS::Application/;
+use OpenSRF::Utils::Cache;
+use OpenSRF::Utils::Logger qw/:logger/;
+use OpenILS::Application::AppUtils;
+use OpenILS::Event;
+use OpenILS::Utils::CStoreEditor qw/:funcs/;
+use Digest::MD5 qw(md5_hex);
+use Net::LDAP;
+use Net::LDAP::Constant qw[LDAP_INVALID_CREDENTIALS];
+my $U = "OpenILS::Application::AppUtils";
+
+my $ldap_timeout = 30; # server connection timeout
+my $ldap_key_timeout = 30; # 2ndary auth token timeout
+my $ldap_key_prefix = 'ldap_auth_';
+
+# XXX
+# XXX Add this API to the log_protect section in opensrf_core.xml
+# XXX
+
+__PACKAGE__->register_method(
+    method    => 'authenticate_ldap',
+    api_name  => 'open-ils.circ.staff.secondary_auth.ldap',
+    signature => {
+        desc   => q/Verifies secondary credentials via LDAP/,
+        params => [
+            {desc => 'Authentication token', type => 'string'},
+            {desc => 'Username.  No domain info', type => 'string'},
+            {desc => 'Password', type => 'string'}
+        ],
+        return => {desc => 
+            'Temporary authentication key on success, Event on error'}
+    }
+);
+
+sub authenticate_ldap {
+    my ($self, $client, $auth, $username, $password) = @_;
+
+    return OpenILS::Event->new('BAD_PARAMS') 
+        unless $auth && $username && $password;
+
+    # Secondary auth checks require an authenticated staff account.
+    my $e = new_editor(authtoken => $auth); # no xact!
+    return $e->event unless $e->checkauth;
+    return $e->event unless $e->allowed('STAFF_LOGIN');
+
+    if ($username !~ /@/) {
+        # A bare username requires a domain.  
+
+        my $ldap_domain = $U->ou_ancestor_setting_value(
+            $e->requestor->ws_ou, 'circ.secondary_auth.ldap.domain', $e);
+
+        return OpenILS::Event->new('LDAP_CONNECTION_ERROR') 
+            unless $ldap_domain;
+
+        $username = "$username\@$ldap_domain";
+    }
+
+    $logger->info("LDAP auth request called for $username");
+
+    my $testmode = $U->ou_ancestor_setting_value(
+        $e->requestor->ws_ou, 'circ.secondary_auth.ldap.testmode', $e);
+
+    my $ldap_resp;
+    if ($testmode) {
+        $logger->info("LDAP auth skipping check in testmode");
+        $ldap_resp = {
+            staff_name =>'Test Mode Name',
+            staff_email => $username
+        };
+
+    } else {
+        $ldap_resp = check_ldap_auth($e, $username, $password);
+        return $ldap_resp->{evt} if $ldap_resp->{evt};
+    }
+
+    my $key = md5_hex($$.rand().time);
+
+    OpenSRF::Utils::Cache->new('global')->put_cache(
+        $ldap_key_prefix.$key, {
+            username => $username,
+            staff_name => $ldap_resp->{staff_name},
+            staff_email => $ldap_resp->{staff_email}
+        }, 
+        $ldap_key_timeout
+    );
+
+    return $key;
+}
+
+sub check_ldap_auth {
+    my ($e, $username, $password) = @_;
+
+    my $ldap_server = $U->ou_ancestor_setting_value(
+        $e->requestor->ws_ou, 'circ.secondary_auth.ldap.server', $e);
+
+    return {evt => OpenILS::Event->new('LDAP_CONNECTION_ERROR')} unless $ldap_server;
+
+    my $connection = Net::LDAP->new($ldap_server, timeout => $ldap_timeout);
+
+    if (!$connection) {
+        $logger->error("Cannot connect to LDAP server $ldap_server : $!");
+        return {evt => OpenILS::Event->new('LDAP_CONNECTION_ERROR')};
+    }
+
+    my $message = $connection->bind($username, password => $password);
+
+    if ($message->is_error) {
+
+        if ($message->code == LDAP_INVALID_CREDENTIALS) {
+            $logger->info("LDAP auth failed for $username");
+            return {evt => OpenILS::Event->new('LDAP_AUTH_FAILED')};
+        }
+
+        # Something else went wrong...
+        my $error = $message->error;
+        $logger->error("LDAP auth for $username returned an error: $error");
+        return {evt => OpenILS::Event->new('LDAP_CONNECTION_ERROR')};
+    }
+
+    # Bind succeeded.  Now lookup user info.
+
+    my $users_dn = $U->ou_ancestor_setting_value(
+        $e->requestor->ws_ou, 'circ.secondary_auth.ldap.users_dn', $e);
+
+    # Defaults to ActiveDirectory-style attributes
+    my $users_filter = $U->ou_ancestor_setting_value(
+        $e->requestor->ws_ou, 'circ.secondary_auth.ldap.users_filter', $e) ||
+        '(&(objectCategory=person)(objectClass=user)(userPrincipalName=%s))';
+
+    $users_filter = sprintf($users_filter, $username);
+
+    my $search = $connection->search(
+        base => $users_dn, scope => 'sub', filter => $users_filter);
+
+    my $resp = {};
+    if ($search->count == 0) {
+        $logger->error("LDAP name lookup returned 0 results.");
+    } else {
+        my $entry = $search->entry(0);
+        $resp->{staff_name} = $entry->get_value('cn');
+        $resp->{staff_email} = $entry->get_value('mail');
+    }
+
+    unless ($resp->{staff_name} && $resp->{staff_email}) {
+        $logger->error("LDAP name lookup failed. ".
+            "QUERY: DN=$users_dn ; FILTER=$users_filter");
+        return {evt => OpenILS::Event->new('LDAP_CONNECTION_ERROR')};
+    }
+
+    $connection->unbind;
+    $logger->info("LDAP auth succeeded for $username");
+
+    return $resp;
+}
+
+
+# Called from the payment create API.
+# Caller is responsible for commits and rollbacks
+# Newly created refundable_payment.id's are pushed into $respond_payments
+# so the caller can see what we did.
+# Returns undef on success, Event on error.
+sub create_refundable_payment {
+    my ($class, $e, $secondary_auth_key, $payment_id, $options, $respond_payments) = @_;
+    $options ||= {};
+
+    my $ldap_auth = OpenSRF::Utils::Cache->new('global')
+        ->get_cache($ldap_key_prefix.$secondary_auth_key);
+
+    unless ($ldap_auth && 
+        $ldap_auth->{staff_name} && 
+        $ldap_auth->{staff_email}) {
+        $logger->error("Refundable payment attempted with ".
+            "invalid secondary auth key: $secondary_auth_key");
+        return OpenILS::Event->new('LDAP_AUTH_FAILED');
+    }
+
+    my $payment = $e->retrieve_money_payment([
+        $payment_id, {
+            flesh => 2, 
+            flesh_fields => {
+                mp => ['xact'],
+                mbt => ['summary']
+            }
+        }
+    ]) or return $e->die_event;
+
+    # refundable payments may only be applied to circulations.
+    my $circ = $e->retrieve_action_circulation([
+        $payment->xact->id, {
+            flesh => 2,
+            flesh_fields => {
+                circ => ['usr', 'target_copy'],
+                au => [qw/card billing_address mailing_address/]
+            }
+        }
+    ]) or return $e->die_event;
+
+    my $usr = $circ->usr;
+    my $addr = $usr->mailing_address || $usr->billing_address;
+
+    my $mrx = $e->search_money_refundable_xact({xact => $payment->xact->id})->[0];
+
+    if (!$mrx) {
+        # Create the refundable transaction before linking the payment.
+
+        $mrx = Fieldmapper::money::refundable_xact->new;
+        $mrx->xact($payment->xact->id);
+        $mrx->usr_first_name($usr->first_given_name);
+        $mrx->usr_middle_name($usr->second_given_name);
+        $mrx->usr_family_name($usr->family_name);
+        $mrx->usr_barcode($usr->card->barcode);
+        $mrx->usr_street1($addr->street1);
+        $mrx->usr_street2($addr->street2);
+        $mrx->usr_city($addr->city);
+        $mrx->usr_state($addr->state);
+        $mrx->usr_post_code($addr->post_code);
+        $mrx->item_price($U->get_copy_price($e, $circ->target_copy));
+
+        $e->create_money_refundable_xact($mrx) or return $e->die_event;
+    }
+
+    my $accepting_usr;
+    my $ctx_org_id;
+
+    my $desk_payment = $e->retrieve_money_desk_payment($payment->id);
+
+    # credit card payments are "desk payments" but online 
+    # card payments will not have a cash_drawer value.
+    if ($desk_payment && $desk_payment->cash_drawer) {
+        $ctx_org_id = 
+            $e->retrieve_actor_workstation($desk_payment->cash_drawer)->owning_lib;
+        $accepting_usr = $e->retrieve_actor_user($desk_payment->accepting_usr);
+
+    } else {
+        $ctx_org_id = $circ->circ_lib;
+        $accepting_usr = $usr;
+    }
+
+    # Perm check not needed since this is called from payment create API.
+    # return $e->die_event unless $e->allowed('CREATE_PAYMENT', $ctx_org_id);
+
+    my $mrp = Fieldmapper::money::refundable_payment->new;
+    $mrp->refundable_xact($mrx->id);
+    $mrp->payment($payment->id);
+    $mrp->payment_ou($ctx_org_id);
+
+    $mrp->final_payment('f') if $payment->xact->summary->balance_owed != 0;
+
+    $mrp->staff_name($ldap_auth->{staff_name});
+    $mrp->staff_email($ldap_auth->{staff_email});
+
+    $e->create_money_refundable_payment($mrp) or return $e->die_event;
+
+    push(@$respond_payments, $mrp->id) if $respond_payments;
+
+    return undef;
+}
+
+__PACKAGE__->register_method(
+    method    => 'update_refundable_xact',
+    api_name  => 'open-ils.circ.refundable_xact.update',
+    signature => {
+        desc   => q/Modify a money.refundable_xact'/,
+        params => [
+            {desc => 'Authentication token', type => 'string'},
+            {desc => 'Transaction ID', type => 'number'},
+            {desc => 'Arguments', type => 'hash'}
+        ],
+        return => {desc => '1 on success, 0 on no-op, Event on error'}
+    }
+);
+
+sub update_refundable_xact {
+    my ($self, $client, $auth, $mrx_id, $args) = @_;
+    return 0 unless ref $args;
+
+    my $e = new_editor(authtoken => $auth, xact => 1);
+    return $e->die_event unless $e->checkauth;
+
+    my $mrx = $e->retrieve_money_refundable_xact($mrx_id)
+        or return $e->die_event;
+
+    for my $f (qw/refund_amount notes/) {
+        $mrx->$f($args->{$f}) if defined $args->{$f};
+    }
+
+    if ($args->{refund}) {
+        $mrx->rejected('f');
+        $mrx->action_date('now');
+        $mrx->action_by($e->requestor->id);
+
+    } elsif ($args->{reject}) {
+        $mrx->rejected('t');
+        $mrx->clear_refund_amount;
+        $mrx->action_date('now');
+        $mrx->action_by($e->requestor->id);
+
+    } elsif ($args->{clear_action}) {
+        $mrx->clear_action_date;
+        $mrx->clear_action_by;
+        $mrx->rejected('f');
+    }
+
+    $e->update_money_refundable_xact($mrx) or return $e->die_event;
+
+    if ($args->{update_payments}) {
+        for my $pay (@{$args->{update_payments}}) {
+            my $mrp = $e->retrieve_money_refundable_payment($pay->{id})
+                or return $e->die_event;
+
+            $mrp->refunded_via($pay->{refunded_via}) 
+                if defined $pay->{refunded_via};
+
+            $e->update_money_refundable_payment($mrp)
+                or return $e->die_event;
+        }
+    }
+
+    $e->commit;
+    return 1;
+}
+
+__PACKAGE__->register_method(
+    method    => 'generate_refundable_payment_receipt',
+    api_name  => 'open-ils.circ.refundable_payment.receipt.html',
+    signature => {
+        desc   => q/Generate a printable HTML refundable payment receipt/,
+        params => [
+            {desc => 'Authentication token', type => 'string'},
+            {desc => 'Refundable Payment ID', type => 'number'}
+        ],
+        return => {
+            desc => 'A/T event with fleshed outputs on success, event on error'
+        }
+    }
+);
+
+__PACKAGE__->register_method(
+    method    => 'generate_refundable_payment_receipt',
+    api_name  => 'open-ils.circ.refundable_payment.receipt.html',
+    signature => {
+        desc   => q/Generate a printable HTML refundable payment receipt/,
+        params => [
+            {desc => 'Authentication token', type => 'string'},
+            {desc => 'Refundable Payment ID', type => 'number'}
+        ],
+        return => {
+            desc => 'A/T event with fleshed outputs on success, event on error'
+        }
+    }
+);
+
+__PACKAGE__->register_method(
+    method    => 'generate_refundable_payment_receipt',
+    api_name  => 'open-ils.circ.refundable_payment.receipt.email',
+    signature => {
+        desc   => q/Generate an email refundable payment receipt/,
+        params => [
+            {desc => 'Authentication token', type => 'string'},
+            {desc => 'Refundable Payment ID', type => 'number'}
+        ],
+        return => {
+            desc => 'Undef on success, event on error'
+        }
+    }
+);
+
+sub generate_refundable_payment_receipt {
+    my ($self, $client, $auth, $mrp_id) = @_;
+
+    my $e = new_editor(authtoken => $auth);
+    return $e->die_event unless $e->checkauth;
+
+    my $mrps= $e->retrieve_money_refundable_payment_summary([
+        $mrp_id, {flesh => 1, flesh_fields => {mrps => ['refundable_xact']}}
+    ]) or return $e->event;
+
+    # ->usr may be undef when the transaction in question has been purged.
+    # Patrons do not need to print receipts for purged transactions.
+    if ($mrps->refundable_xact->usr && 
+        $mrps->refundable_xact->usr == $e->requestor->id) {
+
+        # Patrons are allowed to print receipts for their own payments.
+        # Nothing to verify here.
+
+    } else {
+        return $e->event unless 
+            $e->allowed('CREATE_PAYMENT', $mrps->payment_ou);
+    }
+
+    if ($self->api_name =~ /html/) {
+
+        return $U->fire_object_event(
+            undef, 'format.mrps.html', $mrps, $mrps->payment_ou);
+
+    } else {
+
+        $U->create_events_for_hook(
+            'format.mrps.email', $mrps, $mrps->payment_ou, undef, undef, 1);
+        return undef;
+    }
+}
+
+1;
+
+
index bd9bf4c..b397ae9 100644 (file)
@@ -431,6 +431,17 @@ $_TT_helpers = {
         return $U->create_uuid_string;
     },
 
+    # JBAS-1306
+    # Lazy initials generator.  Just chops name by spaces, uses the first
+    # letter of each part, separated by a '.'
+    name_to_initials => sub {
+        my $name = shift;
+        return '' unless $name;
+        my @parts = split(/ /, $name);
+        my $initials = join('.', map { substr($_, 0, 1) } @parts) . '.';
+        return $initials;
+    },
+
     # escapes quotes in csv string values
     escape_csv => sub {
         my $string = shift;
index 291f48e..b3d405a 100644 (file)
@@ -2134,6 +2134,13 @@ sub load_myopac_payments {
         'open-ils.actor.user.payments.retrieve.atomic',
         $e->authtoken, $e->requestor->id, $args);
 
+    for my $pay (@{$self->ctx->{payments}}) {
+        $pay->{refundable_payment} = 
+            $e->search_money_refundable_payment(
+                {payment => $pay->{mp}->id})->[0];
+    }
+
+
     return Apache2::Const::OK;
 }
 
@@ -2311,29 +2318,72 @@ sub load_myopac_pay {
 sub load_myopac_receipt_print {
     my $self = shift;
 
-    $self->ctx->{printable_receipt} = $U->simplereq(
-    "open-ils.circ", "open-ils.circ.money.payment_receipt.print",
-    $self->editor->authtoken, [$self->cgi->param("payment")]
-    );
+    my $pay_id = $self->cgi->param("payment");
+    my $receipt = '';
+
+    # KCLS JBAS-1306
+    # Refundable payments use a different receipt
+    my $rf_pay = 
+        $self->editor->search_money_refundable_payment(
+            {payment => $pay_id})->[0];
+
+    if ($rf_pay) { # Refundable receipt
+
+        $receipt = $U->simplereq(
+            "open-ils.circ", 
+            "open-ils.circ.refundable_payment.receipt.html",
+            $self->editor->authtoken, $rf_pay->id
+        );
+
+    } else { # Non-refunable receipt
+
+        $receipt = $U->simplereq(
+            "open-ils.circ", 
+            "open-ils.circ.money.payment_receipt.print",
+            $self->editor->authtoken, [$pay_id]
+        );
+    }
 
+    $self->ctx->{printable_receipt} = $receipt;
     return Apache2::Const::OK;
 }
 
 sub load_myopac_receipt_email {
     my $self = shift;
 
-    # The following ML method doesn't actually check whether the user in
-    # question has an email address, so we do.
-    if ($self->ctx->{user}->email) {
-        $self->ctx->{email_receipt_result} = $U->simplereq(
-        "open-ils.circ", "open-ils.circ.money.payment_receipt.email",
-        $self->editor->authtoken, [$self->cgi->param("payment")]
-        );
-    } else {
+    if (!$self->ctx->{user}->email) {
         $self->ctx->{email_receipt_result} =
             new OpenILS::Event("PATRON_NO_EMAIL_ADDRESS");
+        return Apache2::Const::OK;
+    }
+
+    my $pay_id = $self->cgi->param("payment");
+    my $result;
+
+    # KCLS JBAS-1306
+    # Refundable payments use a different receipt
+    my $rf_pay = 
+        $self->editor->search_money_refundable_payment(
+            {payment => $pay_id})->[0];
+
+
+    if ($rf_pay) { # Refundable receipt
+
+        $result = $U->simplereq(
+            "open-ils.circ", 
+            "open-ils.circ.refundable_payment.receipt.email",
+            $self->editor->authtoken, $rf_pay->id
+        );
+
+    } else {
+        $result = $U->simplereq(
+            "open-ils.circ", 
+            "open-ils.circ.money.payment_receipt.email",
+            $self->editor->authtoken, [$pay_id]
+        );
     }
 
+    $self->ctx->{email_receipt_result} = $result;
     return Apache2::Const::OK;
 }
 
diff --git a/Open-ILS/src/templates/staff/circ/refunds/index.tt2 b/Open-ILS/src/templates/staff/circ/refunds/index.tt2
new file mode 100644 (file)
index 0000000..60300a2
--- /dev/null
@@ -0,0 +1,40 @@
+[%
+  WRAPPER "staff/base.tt2";
+  ctx.page_title = l("Refundable Payment Transactions"); 
+  ctx.page_app = "egRefundApp";
+%]
+
+[% BLOCK APP_JS %]
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/grid.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/ui.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/circ/refunds/app.js"></script>
+
+<style>
+  .money-input {
+    width: 5em;
+    height: 75%;
+    text-align: right;
+  }
+  .detail-header {
+    border-bottom: 1px dashed #BEBEBE;
+  }
+  .selected-state { text-decoration: underline }
+  .invalid-amount { color:red }
+  #all-billings-list {
+    border-right: 1px solid #888;
+  }
+  .full-details-container:nth-child(odd) {
+    background-color: rgb(248, 248, 248)
+  }
+</style>
+[% END %]
+
+<div class="container-fluid" style="text-align:center">
+  <div class="alert alert-info alert-less-pad strong-text-2">
+    <span>[% l('Refundable Transactions') %]</span>
+  </div>
+</div>
+
+<div ng-view></div>
+
+[% END %]
diff --git a/Open-ILS/src/templates/staff/circ/refunds/t_detail.tt2 b/Open-ILS/src/templates/staff/circ/refunds/t_detail.tt2
new file mode 100644 (file)
index 0000000..2759c13
--- /dev/null
@@ -0,0 +1,248 @@
+<!-- TODO
+  * editing refunded xacts / modifying notes
+   ** Apply & Cancel buttons
+  * test CC payments / refunded_via
+  * rejecting
+-->
+
+<div class="row">
+  <div class="col-md-12">
+    <span>
+      <button class="btn btn-success" ng-click="return_to_list()">
+        <span class="glyphicon glyphicon-chevron-left"></span>
+        Return To List
+      </button>
+    </span>
+    <span class='pad-left-more'>
+      <button class="btn btn-info" ng-disabled="invalid_refund_amount()" 
+        ng-click="apply_updates()">
+        <span class="glyphicon glyphicon-pencil"></span>
+        Apply Updates
+      </button>
+    </span>
+    <span class='pad-left-more'>Mark As:</span>
+    <span class='pad-left'>
+      <input type='radio' ng-model="xact_state" value="pending"/>
+      <span class="pad-left-min"
+        ng-class="{'selected-state':xact_state == 'pending'}">Pending</span>
+    </span>
+    <span class='pad-left'>
+      <input type='radio' ng-model="xact_state" 
+        ng-disabled="mrxs.action_date() && xact_state == 'rejected'" value="refunded"/>
+      <span class="pad-left-min"
+        ng-class="{'selected-state':xact_state == 'refunded'}">Refunded</span>
+    </span>
+    <span class='pad-left'>
+      <input type='radio' ng-model="xact_state" value="rejected"/>
+      <span class="pad-left-min" 
+        ng-class="{'selected-state':xact_state == 'rejected'}">Rejected</span>
+    </span>
+  </div>
+</div>
+
+<div class="row">
+  <div class="col-md-12">
+    <h3 class="detail-header">Transaction Summary</h3>
+  </div>
+</div>
+
+<div class="row">
+  <div class="col-md-6">
+    <div class="row">
+      <div class="col-md-6">Transaction ID</div>
+      <div class="col-md-6">{{mrxs.xact().id()}}</div>
+    </div>
+    <div class="row">
+      <div class="col-md-6">Amount Paid</div>
+      <div class="col-md-6">{{mrxs.refundable_paid() | currency}}</div>
+    </div>
+    <div class="row">
+      <div class="col-md-6">B.O. Refund Amount</div>
+      <div class="col-md-6 strong-text" ng-if="mrxs.rejected() == 't'">
+        Rejected
+      </div>
+      <div class="col-md-6 strong-text" 
+        ng-if="!editing && mrxs.rejected() != 't'">
+        {{mrxs.refund_amount() | currency}}
+      </div>
+      <div class="col-md-6" ng-if="editing">
+        <input type="text" class="money-input" 
+          ng-class="{'invalid-amount':invalid_refund_amount()}"
+          select-me="select_refund_amt"
+          ng-model="mrxs.refund_amount"
+          ng-model-options="{getterSetter: true}"
+        />
+      </div>
+    </div>
+    <div class="row" ng-if="!editing">
+      <div class="col-md-6">B.O. Action Date</div>
+      <div class="col-md-6">{{mrxs.action_date() | date:'short'}}</div>
+    </div>
+    <div class="row">
+      <div class="col-md-6">Copy Barcode</div>
+      <div class="col-md-6">{{mrxs.copy_barcode()}}</div>
+    </div>
+    <div class="row">
+      <div class="col-md-6">Title</div>
+      <div class="col-md-6">{{mrxs.title()}}</div>
+    </div>
+    <div class="row">
+      <div class="col-md-6">Patron ID</div>
+      <div class="col-md-6">{{mrxs.usr()}}</div>
+    </div>
+    <div class="row">
+      <div class="col-md-6">Patron Name</div>
+      <div class="col-md-6">
+        {{mrxs.usr_first_name()}} 
+        {{mrxs.usr_middle_name()}} 
+        {{mrxs.usr_family_name()}} 
+      </div>
+    </div>
+    <div class="row">
+      <div class="col-md-6">Patron Address</div>
+      <div class="col-md-6">
+        {{mrxs.usr_street1()}} {{mrxs.usr_street2()}} 
+        {{mrxs.usr_city()}}, {{mrxs.usr_state()}} {{mrxs.usr_post_code()}}
+      </div>
+    </div>
+
+  </div>
+  <div class="col-md-5">
+    <textarea class="form-control" rows="7" placeholder="Notes..."
+      ng-model="mrxs.notes" ng-model-options="{getterSetter: true}">
+    </textarea>
+  </div>
+</div>
+
+<div class="row">
+  <div class="col-md-12">
+    <h3 class="detail-header">Payments</div>
+  </div>
+</div>
+
+<div ng-repeat="mrps in mrxs.refundable_payments()">
+  <div class="row">
+    <div class="col-md-3">Receipt Code</div>
+    <div class="col-md-3">{{mrps.receipt_code()}}</div>
+  </div>
+  <div class="row">
+    <div class="col-md-3">Amount</div>
+    <div class="col-md-3">{{mrps.amount() | currency}}</div>
+  </div>
+  <div class="row">
+    <div class="col-md-3">Payment Date</div>
+    <div class="col-md-3">{{mrps.payment_time() | date:'short'}}</div>
+  </div>
+  <div class="row">
+    <div class="col-md-3">Payment Type</div>
+    <div class="col-md-3">
+      <span ng-switch on="mrps.payment_type()">
+        <span ng-switch-when="cash_payment">Cash</span>
+        <span ng-switch-when="check_payment">Check</span>
+        <span ng-switch-when="credit_card_payment">Credit Card</span>
+        <span ng-switch-default>{{mrps.payment_type()}}</span>
+      </span>
+    </div>
+  </div>
+  <div class="row">
+    <div class="col-md-3">Final Payment</div>
+    <div class="col-md-3">
+      <span class="label label-success" 
+        ng-if="mrps.final_payment() == 't'">Yes</span>
+      <span ng-if="mrps.final_payment() != 't'">No</span>
+    </div>
+  </div>
+  <div class="row" ng-if="mrps.payment_type() == 'credit_card_payment'">
+    <div class="col-md-3">Refunded Via</div>
+    <div class="col-md-6">
+      <span>
+        <input type='radio' ng-model="mrps.refunded_via" 
+          ng-model-options="{getterSetter: true}" value="check"/>
+        <span class="pad-left-min">Check</span>
+      </span>
+      <span class='pad-left'>
+        <input type='radio' ng-model="mrps.refunded_via" 
+          ng-model-options="{getterSetter: true}" value="credit_card"/>
+        <span class="pad-left-min">Credit Card</span>
+      </span>
+    </div>
+  </div>
+  <div class="row">
+    <div class="col-md-3">Staff Name</div>
+    <div class="col-md-3">{{mrps.staff_name()}}</div>
+  </div>
+  <div class="row">
+    <div class="col-md-3">Staff Email</div>
+    <div class="col-md-3">{{mrps.staff_email()}}</div>
+  </div>
+  <div class="row">
+   <div class="col-md-6"><hr/></div>
+  </div>
+</div>
+
+<div class="row">
+  <div class="col-md-12">
+    <h3 class="detail-header">All Transaction Billings and Payments</div>
+  </div>
+</div>
+<div class="row">
+  <div class="col-md-7" id="all-billings-list">
+    <h4>All Billings</h4>
+    <div class="row" style="font-weight:bold">
+      <div class="col-md-2">Date</div>
+      <div class="col-md-1">ID#</div>
+      <div class="col-md-2">Amount</div>
+      <div class="col-md-3">Type</div>
+      <div class="col-md-2">Voided</div>
+      <div class="col-md-2">Void Date</div>
+    </div>
+    <div class="full-details-container" ng-repeat="bill in mrxs.xact().billings()">
+      <div class="row">
+        <div class="col-md-2">{{bill.billing_ts() | date:'shortDate'}}</div>
+        <div class="col-md-1">{{bill.id()}}</div>
+        <div class="col-md-2">{{bill.amount() | currency}}</div>
+        <div class="col-md-3">{{bill.btype().name()}}</div>
+        <div class="col-md-2">
+          <div ng-if="bill.voided() == 't'">
+            <span class="text-danger">Yes</span> 
+          </div>
+        </div>
+        <div class="col-md-2">
+          {{bill.void_time() | date:'shortDate'}}
+        </div>
+      </div>
+    </div>
+  </div>
+  <div class="col-md-5">
+    <h4>All Payments</h4>
+    <div class="row" style="font-weight:bold">
+      <div class="col-md-3">Date</div>
+      <div class="col-md-2">ID#</div>
+      <div class="col-md-2">Amount</div>
+      <div class="col-md-3">Type</div>
+      <div class="col-md-2">Voided</div>
+    </div>
+    <div class="full-details-container" ng-repeat="pay in mrxs.xact().payments()">
+      <div class="row">
+        <div class="col-md-3">{{pay.payment_ts() | date:'shortDate'}}</div>
+        <div class="col-md-2">{{pay.id()}}</div>
+        <div class="col-md-2">{{pay.amount() | currency}}</div>
+        <div class="col-md-3">
+          <span ng-switch on="pay.payment_type()">
+            <span ng-switch-when="cash_payment">Cash</span>
+            <span ng-switch-when="check_payment">Check</span>
+            <span ng-switch-when="credit_card_payment">Credit Card</span>
+            <span ng-switch-default>{{pay.payment_type()}}</span>
+          </span>
+        </div>
+        <div class="col-md-2">
+          <div ng-if="pay.voided() == 't'">
+            <span class="text-danger">Yes</span> 
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</div>
+
+
diff --git a/Open-ILS/src/templates/staff/circ/refunds/t_list.tt2 b/Open-ILS/src/templates/staff/circ/refunds/t_list.tt2
new file mode 100644 (file)
index 0000000..00c77c9
--- /dev/null
@@ -0,0 +1,85 @@
+<div class="row pad-vert">
+  <div class="col-lg-4">
+    <div class="input-group">
+      <div class="input-group-btn" uib-dropdown>
+        <button type="button" class="btn btn-default" uib-dropdown-toggle>
+          <span ng-switch on="ctx.search_param">
+            <span ng-switch-when="receipt_code">Receipt Code</span>
+            <span ng-switch-when="usr_name">Patron Last, First</span>
+            <span ng-switch-when="usr_barcode">Patron Barcode</span>
+            <span ng-switch-when="usr">Patron ID</span>
+            <span ng-switch-default>Search By</span>
+          </span>
+          <span class="caret"></span>
+        </button>
+        <ul uib-dropdown-menu>
+          <li><a href ng-click="ctx.search_param='receipt_code'">Receipt Code</a></li>
+          <li><a href ng-click="ctx.search_param='usr_name'">Patron Last, First</a></li>
+          <li><a href ng-click="ctx.search_param='usr_barcode'">Patron Barcode</a></li>
+          <li><a href ng-click="ctx.search_param='usr'">Patron ID</a></li>
+        </ul>
+      </div><!-- /btn-group -->
+      <input type="text" class="form-control" ng-model="ctx.search_query" 
+        placeholder="Search for..."/>
+    </div><!-- /input-group -->
+  </div>
+  <div class="col-md-1">
+    <button ng-click="ctx.perform_search()" class="btn btn-default">Search</button>
+  </div>
+  <div class="col-md-6">
+    <!-- bootstrap checkboxes don't want to sit next to each other 
+        unless they're in a form wrapper, etc.  Just use spans. -->
+    <div style='margin-top:10px'>
+      <span class='pad-right'>
+        <input type='checkbox' ng-model="ctx.limit_to_refundable" 
+          ng-change='ctx.perform_search()'
+          ng-disabled="ctx.search_param=='receipt_code'"/>
+        <span class='pad-all-min'>Limit to Refund Pending</span>
+      </span>
+      <span>
+        <input type='checkbox' ng-model="ctx.limit_to_1year"
+          ng-change='ctx.perform_search()'
+          ng-disabled="ctx.search_param=='receipt_code'"/>
+        <span class='pad-all-min'>Limit to Last 12 Months</span>
+      </span>
+    </div>
+  </div>
+</div>
+
+<div class="row">
+  <div class="col-md-12">
+
+    <eg-grid
+      idl-class="mrxs" 
+      items-provider="gridDataProvider"
+      grid-controls="gridControls"
+      persist-key="circ.refunds.list">
+
+      <eg-grid-field path="id" required hidden></eg-grid-field>
+      <eg-grid-field path="xact" required hidden></eg-grid-field>
+      <eg-grid-field name="last_payment" label="Last Payment" nonsortable>
+        {{item._last_payment.receipt_code()}} ({{item.num_refundable_payments()}})
+      </eg-grid-field>
+      <eg-grid-field name="last_payment" label="Last Payment Date" nonsortable>
+        {{item._last_payment.payment_time() | date:'short' }}
+      </eg-grid-field>
+      <eg-grid-field path="refundable_paid" label="Amount Paid"></eg-grid-field>
+      <eg-grid-field path="usr" label="Patron ID"></eg-grid-field>
+      <eg-grid-field path="copy_barcode"></eg-grid-field>
+      <eg-grid-field path="usr_first_name" hidden></eg-grid-field>
+      <eg-grid-field path="usr_middle_name" hidden></eg-grid-field>
+      <eg-grid-field path="usr_family_name"></eg-grid-field>
+      <eg-grid-field path="usr_street1" flex="3" label="Patron Street Addr."></eg-grid-field>
+      <eg-grid-field path="refund_amount" label="B.O. Refund Amount"></eg-grid-field>
+      <eg-grid-field path="action_date" label="B.O. Action Date"></eg-grid-field>
+      <eg-grid-field path="title" hidden></eg-grid-field>
+      <eg-grid-field path="usr_street2" hidden></eg-grid-field>
+      <eg-grid-field path="usr_city" hidden></eg-grid-field>
+      <eg-grid-field path="usr_state" hidden></eg-grid-field>
+      <eg-grid-field path="usr_post_code" hidden></eg-grid-field>
+      <eg-grid-field path="rejected" hidden></eg-grid-field>
+      <eg-grid-field path="notes" hidden></eg-grid-field>
+      <eg-grid-field path="num_refundable_payments" label="Payments" hidden required></eg-grid-field>
+  </div>
+</div>
+
index 4647ab3..351292e 100644 (file)
@@ -127,7 +127,11 @@ table.list tr.selected td { /* deprecated? */
 
 .pad-horiz {padding : 0px 10px 0px 10px; }
 .pad-vert {padding : 20px 0px 10px 0px;}
+.pad-vert-min {padding : 5px 0px 5px 0px;}
+.pad-vert-min-2 {padding : 2px 0px 2px 0px;}
 .pad-left {padding-left: 10px;}
+.pad-left-more {padding-left: 20px;}
+.pad-left-min {padding-left: 5px;}
 .pad-right {padding-right: 10px;}
 .pad-right-min {padding-right: 5px;}
 .pad-all-min {padding : 5px; }
diff --git a/Open-ILS/web/js/ui/default/staff/circ/refunds/app.js b/Open-ILS/web/js/ui/default/staff/circ/refunds/app.js
new file mode 100644 (file)
index 0000000..dec7616
--- /dev/null
@@ -0,0 +1,285 @@
+angular.module('egRefundApp', 
+    ['ngRoute', 'ui.bootstrap', 'egCoreMod', 'egUiMod', 'egGridMod'])
+
+.config(function($routeProvider, $locationProvider, $compileProvider) {
+    $locationProvider.html5Mode(true);
+    $compileProvider.aHrefSanitizationWhitelist(/^\s*(https?|blob):/); // grid export
+
+    var resolver = {delay : 
+        ['egStartup', function(egStartup) {return egStartup.go()}]}
+
+    $routeProvider.when('/circ/refunds/list', {
+        templateUrl: './circ/refunds/t_list',
+        controller: 'RefundListCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.when('/circ/refunds/view/:id', {
+        templateUrl: './circ/refunds/t_detail',
+        controller: 'RefundDetailCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.otherwise({redirectTo : '/circ/refunds/list'});
+})
+
+.factory('mrxSvc',
+       ['$q','egCore',
+function($q , egCore) {
+
+    var service = {
+
+        // store filters in the service so they persist across
+        // list and details pages.
+        filters : {
+            limit_to_refundable : true,
+            limit_to_1year : true
+        }, 
+
+        // sort payments newest to oldest and also set the 
+        // newest as the _last_payment attribute on the mrxs.
+        sort_payments : function(mrxs) {
+            var payments = mrxs.refundable_payments().sort(
+                function(a, b) {
+                    return a.payment_time() > b.payment_time() ? -1 : 1
+                }
+            );
+            mrxs.refundable_payments(payments);
+            mrxs._last_payment = payments[0];
+        }
+    };
+
+    return service;
+}])
+
+
+/**
+ */
+.controller('RefundListCtrl',
+       ['$scope','$q','$window','$location','egCore','egGridDataProvider','mrxSvc',
+function($scope , $q , $window , $location , egCore , egGridDataProvider , mrxSvc) {
+
+    $scope.ctx = {
+        limit_to_refundable : mrxSvc.filters.limit_to_refundable,
+        limit_to_1year : mrxSvc.filters.limit_to_1year
+    };
+
+    $scope.gridControls = {
+        activateItem : function(item) {
+            // dbl-click goes to transaction detail view
+            $location.path('/circ/refunds/view/' + item.id());
+        }
+    };
+
+    var provider 
+        = $scope.gridDataProvider
+        = egGridDataProvider.instance({});
+
+    $scope.ctx.perform_search = function() {
+        // copy UI filters back into service
+        mrxSvc.filters.limit_to_refundable = $scope.ctx.limit_to_refundable;
+        mrxSvc.filters.limit_to_1year = $scope.ctx.limit_to_1year;
+
+        provider.refresh();
+    }
+
+    function compile_query() {
+        var query = {};
+
+        if ($scope.ctx.limit_to_1year) {
+            var last_year = new Date();
+            last_year.setFullYear(last_year.getFullYear() - 1);
+            query['-or'] = [
+                {xact_finish : {'>=' : last_year.toISOString()}},
+                {xact_finish : null}
+            ]
+        }
+
+        if ($scope.ctx.limit_to_refundable) {
+            query.action_date = null;
+        }
+
+        if (!($scope.ctx.search_param && $scope.ctx.search_query)) {
+            query.id = {'<>' : null};
+
+        } else if ($scope.ctx.search_param == 'usr_name') {
+
+            var parts = $scope.ctx.search_query.split(/,/);
+            query.usr_family_name = {'~*' : parts[0].trim()};
+            if (parts[1]) query.usr_first_name = {'~*' : parts[1].trim()};
+           
+        } else if ($scope.ctx.search_param == 'receipt_code') {
+            return receipt_code_query();
+
+        } else {
+            query[$scope.ctx.search_param] = $scope.ctx.search_query;
+        }
+
+        return $q.when(query);
+    }
+
+    function receipt_code_query() {
+        // Querying the receipt code means query payments instead 
+        // of transactions.
+
+        return egCore.pcrud.search('mrps', 
+            {receipt_code : $scope.ctx.search_query}
+        ).then(function(payment) {
+            if (payment) return {id : payment.refundable_xact()};
+
+            // if no payments found, return a no-op query.
+            return {id : null};
+        });
+    }
+
+    function compile_sort() {
+        // turn the grid sort data into a pcrud sort string.
+        var order_by = '';
+        angular.forEach(provider.sort, function(col) {
+            if (order_by) order_by += ',';
+            if (angular.isObject(col)) {
+                var name = Object.keys(col)[0];
+                order_by += name + ' ' + col[name];
+            } else {
+                order_by += col;
+            }
+        });
+
+        return order_by ? {mrxs : order_by} : {};
+    }
+
+    provider.get = function(offset, count) {
+
+        var deferred = $q.defer();
+
+        compile_query().then(function(query) {
+
+            return egCore.pcrud.search('mrxs', query,
+                {   limit : count, 
+                    offset : offset,
+                    flesh : 1,
+                    flesh_fields : {mrxs : ['refundable_payments']},
+                    order_by : compile_sort()
+                }
+            ).then(
+                deferred.resolve,
+                deferred.reject,
+                function(mrxs) {
+                    mrxSvc.sort_payments(mrxs);
+                    deferred.notify(mrxs);
+                }
+            );
+        });
+
+        return deferred.promise;
+    }
+
+}])
+
+.controller('RefundDetailCtrl',
+       ['$scope','$q','$routeParams','$window','$location','egCore','mrxSvc',
+function($scope , $q , $routeParams , $window , $location , egCore , mrxSvc) {
+
+    // TODO: make this call authoritative once we're running on 
+    // newer pcrud.js code that supports it.  (IIRC 2.12 or later)
+
+    function load_data() {
+        egCore.pcrud.retrieve('mrxs', $routeParams.id, {
+            flesh : 4, 
+            flesh_fields : {
+                mrxs : ['refundable_payments', 'xact'],
+                mbt : ['payments', 'billings'],
+                mb : ['btype']
+            },
+        }).then(function(mrxs) {
+            if (!mrxs) return;
+            if (!mrxs.refund_amount()) mrxs.refund_amount('0.00');
+
+            if (mrxs.action_date()) {
+                $scope.editing = false;
+                if (mrxs.rejected() == 't') {
+                    $scope.xact_state = 'rejected';
+                } else {
+                    $scope.xact_state = 'refunded';
+                }
+            } else {
+                $scope.editing = true;
+                $scope.xact_state = 'pending';
+            }
+
+            mrxs.xact().billings(
+                mrxs.xact().billings().sort(function(a, b) {
+                    return a.billing_ts() > b.billing_ts() ? -1 : 1
+                })
+            );
+
+            mrxs.xact().payments(
+                mrxs.xact().payments().sort(function(a, b) {
+                    return a.payment_ts() > b.payment_ts() ? -1 : 1
+                })
+            );
+
+            $scope.mrxs = mrxs;
+            mrxSvc.sort_payments(mrxs);
+            $scope.select_refund_amt = true;
+        });
+    }
+
+    $scope.return_to_list = function() {
+         // navigate to the detail view on double-click
+        $location.path('/circ/refunds/list');
+    }
+
+    $scope.invalid_refund_amount = function() {
+        if (!$scope.mrxs) return false;
+
+        var amt = Number($scope.mrxs.refund_amount());
+
+        if (amt < 0 || amt > Number($scope.mrxs.refundable_paid())) 
+            return true;
+
+        // $0.00 is not a valid amount when marking as refunded, 
+        // but OK otherwise.
+        if ($scope.xact_state == 'refunded') return amt == 0;
+
+        return false;
+    }
+
+    $scope.apply_updates = function() {
+        var args = {
+            notes : $scope.mrxs.notes(),
+            refund_amount : $scope.mrxs.refund_amount()
+        };
+
+        args.clear_action = $scope.xact_state == 'pending';
+        args.refund = $scope.xact_state == 'refunded';
+        args.reject = $scope.xact_state == 'rejected';
+
+        args.update_payments = [];
+        angular.forEach($scope.mrxs.refundable_payments(),
+            function(pay) {
+                if (pay.refunded_via()) {
+                    args.update_payments.push(
+                        {id : pay.id(), refunded_via : pay.refunded_via()});
+                }
+            }
+        );
+
+        egCore.net.request(
+            'open-ils.circ',
+            'open-ils.circ.refundable_xact.update',
+            egCore.auth.token(), $scope.mrxs.id(), args
+        ).then(function(result) {
+            var evt = egCore.evt.parse(result);
+            if (evt) {
+                alert(evt);
+            } else {
+                load_data(); // refresh
+            }
+        });
+    }
+
+    load_data();
+}])
+
+
index 9cb0936..5d2ea50 100644 (file)
@@ -468,6 +468,7 @@ var urls = {
     'XUL_PATRON_BILL_DETAILS' : 'oils://remote/xul/server/patron/bill_details.xul',
     'XUL_PATRON_BILL_HISTORY' : 'oils://remote/xul/server/patron/bill_history.xul',
     'XUL_PATRON_BILL_WIZARD' : 'oils://remote/xul/server/patron/bill_wizard.xul',
+    'XUL_PATRON_BILL_APPLY_PAYMENT_FORM' : 'oils://remote/xul/server/patron/bill_apply_payment_form.xul',
     'XUL_PATRON_DISPLAY' : 'oils://remote/xul/server/patron/display.xul',
     'XUL_PATRON_HORIZ_DISPLAY' : 'oils://remote/xul/server/patron/display_horiz.xul',
     'XUL_PATRON_EDIT' : 'oils://remote/eg/actor/user/register',
index ea7e128..ab65647 100644 (file)
@@ -241,6 +241,128 @@ function event_listeners() {
     }
 }
 
+/**
+ * Launch secondary authentication dialog.
+ * "Skip" option bypasses the secondary authentication step.
+ * "Cancel" option prevents the payment from proceeding.
+ * "Submit" option requests a secondary authentication token from the   
+ * server, which is later used by the payment create API.               
+ * If a login failure occurs, the dialog is reopened for additional 
+ * authentication attempts.
+ *
+ * Returns null on Cancel
+ * Returns unaltered payment_blob if no applicable (LOST) payments are 
+ * being processed OR if the dialog is manually skipped.
+ * Returns payment args (with 2ndry auth data) on successful Submit.
+ */
+function handle_lost_payment_dialog(payment_blob) {
+
+    var lostpaid_xacts = [];
+    var row_ids = g.bill_list.dump_retrieve_ids();
+
+    // Find LOST transactions in the pending payments.
+    payment_blob.payments.forEach(function(xact) {
+        // xact[0] == transaction_id, xact[1] == payment amount
+        row_ids.forEach(function(row_id) {
+            var row_params = g.row_map[row_id];
+            var circ = row_params.row.my.circ;
+            if (!circ) return;
+            if (circ.id() == xact[0]) {
+                if (circ.stop_fines() == 'LOST') {
+                    lostpaid_xacts.push(xact[0]);
+                }
+            }
+        });
+    });
+
+    // No lost+paid transcactions to process.
+    if (lostpaid_xacts.length == 0) return payment_blob;
+
+    // If we have any LOST payments, do the secondary auth dance.
+
+    JSAN.use('util.window');
+    while (true) {
+
+        var win = new util.window();
+        var dialog = win.open(
+            urls.XUL_PATRON_BILL_APPLY_PAYMENT_FORM,
+            'applypayment',
+            'chrome,resizable,modal'
+        );
+
+        if (dialog.skip_lost_payment) {
+            return payment_blob;
+        } else if (!dialog.process_lost_payment) {
+            // Payment canceled from dialog
+            return null;
+        }
+
+        // Attempt secondary authentication
+
+        var resp = g.network.request(
+            'open-ils.circ',
+            'open-ils.circ.staff.secondary_auth.ldap',
+            [ses(), dialog.username, dialog.password]
+        );
+
+        if (resp && typeof resp == 'string') {
+            // Secondary auth succeeded.  
+            // Embed the refundable args data in the in-progress payment blob. 
+            payment_blob.refundable_args = {
+                secondary_auth_key : resp,
+                transactions : lostpaid_xacts.map(
+                    function(x) {
+                        return {xact : x};
+                    }
+                )
+            };
+
+            return payment_blob;
+        }
+
+        if (resp && resp.textcode) {
+            // If the secondary auth fails for any reason, re-open the
+            // dialog.  User may Cancel at any time to break out
+            // of the loop.
+
+            if (resp.textcode == 'LDAP_AUTH_FAILED') {
+                alert('Secondary Authentication Failed');
+                $('apply_payment_password').value = '';
+
+            } else {
+                g.error.standard_unexpected_error_alert(
+                    'Secondary Authentication Error', resp);
+            }
+
+        } else { // unknown failure
+            g.error.standard_unexpected_error_alert(
+                'Secondary Authentication Error');
+        }
+    }
+}
+
+function apply_payment_skip_form() {
+    this.skip_lost_payment = true;
+    this.close();
+}
+
+function apply_payment_cancel() {
+    this.process_lost_payment = false;
+    this.close();
+}
+
+function apply_payment_submit_form() {
+    this.process_lost_payment = true;
+
+    this.username = $('apply_payment_username').value;
+    this.password = $('apply_payment_password').value;
+
+    // leave the dialog open if required fields are empty.
+    if (!this.username || !this.password) return;
+
+    this.close();
+}
+
 function $(id) { return document.getElementById(id); }
 
 function default_focus() {
@@ -920,6 +1042,7 @@ function verify_amount() {
 
 
 function apply_payment() {
+
     try {
         var payment_blob = {};
         JSAN.use('util.window');
@@ -982,6 +1105,10 @@ function apply_payment() {
             alert($("patronStrings").getString('staff.patron.bills.apply_payment.nothing_applied'));
             return;
         }
+
+        payment_blob = handle_lost_payment_dialog(payment_blob);
+        if (payment_blob === null) return; // payment canceled via dialog
+
         if ( pay( payment_blob ) ) {
 
             $('payment').value = ''; $('payment').select(); $('payment').focus();
@@ -1105,6 +1232,11 @@ function pay(payment_blob) {
             alert('Error logging payment in bill2.js: ' + E);
         }
 
+        if (robj && robj.refundable_payments 
+                && robj.refundable_payments.length) {
+                print_refundable_payments_receipt(robj.refundable_payments);
+        }
+
         if (typeof robj.ilsevent != 'undefined') {
             switch(robj.textcode) {
                 case 'SUCCESS' : return true; break;
@@ -1129,6 +1261,29 @@ function pay(payment_blob) {
     }
 }
 
+function print_refundable_payments_receipt(mrp_ids) {
+    for (var i = 0; i < mrp_ids.length; i++) {
+        mrp_id = mrp_ids[0];
+        var receipt = g.network.request(
+            'open-ils.circ', 
+            'open-ils.circ.refundable_payment.receipt.html', 
+            [ses(), mrp_id]
+        );
+
+        if (!receipt || !receipt.template_output()) {
+            return alert(
+                'Error creating refundable payment receipt for payment ' + mrp_id);
+        }
+
+        var html = receipt.template_output().data();
+        JSAN.use('util.print'); var print = new util.print('mail');
+        print.simple(html , {
+            no_prompt: false, /*TODO*/
+            content_type: 'text/html'
+        });
+    }
+}
+
 function refresh(params) {
     try {
         if (g.safe_for_refresh) {
diff --git a/Open-ILS/xul/staff_client/server/patron/bill_apply_payment_form.xul b/Open-ILS/xul/staff_client/server/patron/bill_apply_payment_form.xul
new file mode 100644 (file)
index 0000000..3636514
--- /dev/null
@@ -0,0 +1,88 @@
+<?xml version="1.0"?>
+<!-- Application: Evergreen Staff Client -->
+<!-- Screen: Patron Display -->
+
+<!-- ///////////////////////////////////////////////////////////////////////////////////////////////////////////// -->
+<!-- STYLESHEETS -->
+<?xml-stylesheet href="chrome://global/skin/" type="text/css"?>
+<?xml-stylesheet href="/xul/server/skin/global.css" type="text/css"?>
+<?xml-stylesheet href="/xul/server/skin/circ.css" type="text/css"?>
+<?xml-stylesheet href="/xul/server/skin/patron_display.css" type="text/css"?>
+
+<!-- ///////////////////////////////////////////////////////////////////////////////////////////////////////////// -->
+<!-- LOCALIZATION -->
+<!DOCTYPE window PUBLIC "" ""[
+    <!--#include virtual="/opac/locale/${locale}/lang.dtd"-->
+]>
+
+<!-- ///////////////////////////////////////////////////////////////////////////////////////////////////////////// -->
+<window width="550" height="175" oils_persist="width height sizemode"
+    onload="my_init()"
+    xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
+
+    <!-- ///////////////////////////////////////////////////////////////////////////////////////////////////////////// -->
+    <!-- BEHAVIOR -->
+<?xul-overlay href="/xul/server/OpenILS/util_overlay.xul"?>
+<?xul-overlay href="/xul/server/patron/bill_summary_overlay.xul"?>
+    <script type="text/javascript">var myPackageDir = 'open_ils_staff_client'; var IAMXUL = true; var g = {};</script>
+    <scripts id="openils_util_scripts"/>
+
+    <script type="text/javascript" src="/xul/server/main/JSAN.js"/>
+    <script type="text/javascript" src="/xul/server/patron/bill2.js"/>
+    <script>
+        function my_init() {
+            // Focus the username input on page load
+            setTimeout(
+                function() {
+                    document.getElementById('apply_payment_username').focus();
+                }
+            );
+        }
+
+        function check_enter(event) {
+            if (event.keyCode != 13) return;
+            if (!document.getElementById('apply_payment_username').value) return;
+            if (!document.getElementById('apply_payment_password').value) return;
+
+            this.apply_payment_submit_form();
+        }
+
+    </script>
+
+    <messagecatalog id="patronStrings" src="/xul/server/locale/<!--#echo var='locale'-->/patron.properties"/>
+
+    <vbox flex="1" class="my_overflow">
+
+        <groupbox id="bill_payment_form" flex="1" >
+            <caption label="Please enter credentials for tracking payments for LOST items."/>
+            <hbox>
+                <vbox>
+                    <label control="apply_payment_username" value="Username"/>
+                    <textbox id="apply_payment_username" 
+                        onkeypress="check_enter(event)" maxwidth="150"/>
+                </vbox>
+                <vbox>
+                    <label control="apply_payment_password" value="Password"/>
+                    <textbox id="apply_payment_password" type="password" 
+                        onkeypress="check_enter(event)" maxwidth="150"/>
+                </vbox>
+            </hbox>
+        </groupbox>
+        <hbox>
+            <button label="Submit" accesskey="S"
+                id='apply_lost_payment_submit' 
+                oncommand="apply_payment_submit_form()"/>
+            <spacer flex="1"/>
+            <button 
+                hidden="true"
+                label="Skip Refund Tracking" accesskey="K"
+                id='apply_lost_payment_skip'
+                oncommand="apply_payment_skip_form()"/>
+            <button 
+                label="Cancel Payment" accesskey="C"
+                id='apply_lost_payment_cancel'
+                oncommand="apply_payment_cancel()"/>
+        </hbox>
+    </vbox>
+
+</window>