JBAS-1494 PayFlow Hosted Pages for CC payments
authorBill Erickson <berickxx@gmail.com>
Thu, 14 Jul 2016 20:16:38 +0000 (16:16 -0400)
committerBill Erickson <berickxx@gmail.com>
Thu, 21 Mar 2019 19:46:23 +0000 (15:46 -0400)
https://developer.paypal.com/docs/classic/payflow/gs_ppa_hosted_pages/

* Library settings to configure and activate.
* New TPAC templates and WWW perl for processing payments.

Signed-off-by: Bill Erickson <berickxx@gmail.com>
18 files changed:
KCLS/openils/var/templates_kcls/opac/biblio/main_fines.tt2
KCLS/openils/var/templates_kcls/opac/parts/myopac/payment_xacts.tt2 [new file with mode: 0644]
KCLS/openils/var/templates_kcls/opac/payflow/errors.tt2 [new file with mode: 0644]
KCLS/openils/var/templates_kcls/opac/payflow/footer.tt2 [new file with mode: 0644]
KCLS/openils/var/templates_kcls/opac/payflow/form1.tt2 [new file with mode: 0644]
KCLS/openils/var/templates_kcls/opac/payflow/form2.tt2 [new file with mode: 0644]
KCLS/openils/var/templates_kcls/opac/payflow/pay_form.tt2 [new file with mode: 0644]
KCLS/openils/var/templates_kcls/opac/payflow/pay_receipt.tt2 [new file with mode: 0644]
KCLS/openils/var/templates_kcls/opac/payflow/pay_response.tt2 [new file with mode: 0644]
KCLS/sql/schema/deploy/payflow-hosted-org-settings.sql [new file with mode: 0644]
KCLS/sql/schema/revert/payflow-hosted-org-settings.sql [new file with mode: 0644]
KCLS/sql/schema/sqitch.plan
KCLS/sql/schema/verify/payflow-hosted-org-settings.sql [new file with mode: 0644]
Open-ILS/src/perlmods/lib/OpenILS/Application/Circ/CircCommon.pm
Open-ILS/src/perlmods/lib/OpenILS/Application/Circ/Money.pm
Open-ILS/src/perlmods/lib/OpenILS/WWW/EGCatLoader.pm
Open-ILS/src/perlmods/lib/OpenILS/WWW/EGCatLoader/Account.pm
Open-ILS/src/perlmods/lib/OpenILS/WWW/EGCatLoader/PayflowHosted.pm [new file with mode: 0644]

index c273bb0..3bad65b 100644 (file)
        </div>
 </div>
 
-<form action="[% ctx.opac_root %]/biblio/main_payment_form" method="GET" style="background:#fff">
+[%
+  pay_form_url = ctx.opac_root _ '/biblio/main_payment_form';
+  IF ctx.using_payflow OR CGI.param('use_payflow');
+    pay_form_url = ctx.opac_root _ '/payflow/pay_form';
+  END;
+%]
+<form action="[% pay_form_url %]" method="GET" style="background:#fff">
     [% IF ctx.fines.circulation.size > 0 %]
     <div id='myopac_circ_trans_div'>
         <table width='100%' class='data_grid'>
diff --git a/KCLS/openils/var/templates_kcls/opac/parts/myopac/payment_xacts.tt2 b/KCLS/openils/var/templates_kcls/opac/parts/myopac/payment_xacts.tt2
new file mode 100644 (file)
index 0000000..ae7ee82
--- /dev/null
@@ -0,0 +1,46 @@
+<div>
+  <br/>
+  <p>[% l('Selected fines you are paying for:') %]</p>
+
+  <table cellpadding="0" cellspacing="0" border="0" class="myopac_payments_table">
+    <thead>
+      <tr><th>[% l('Name') %]</th><th>[% l('Amount') %]</th></tr>
+    </thead>
+
+    <tbody>
+    [%
+    FOR f IN ctx.fines.circulation;
+      NEXT IF CGI.param('xact').size &&
+        !CGI.param('xact').grep(f.xact.id).size;
+      attrs = {marc_xml => f.marc_xml};
+      IF f.marc_xml;
+        PROCESS get_marc_attrs args=attrs;
+      ELSIF f.xact.reservation;
+        attrs.title = f.xact.reservation.target_resource_type.name;
+      END %]
+      <tr>
+        <td>[% attrs.title | html %]</td>
+        <td class="text-right">[% money(f.xact.balance_owed) %]</td>
+      </tr>
+    [%
+    END;
+    FOR f IN ctx.fines.grocery;
+      NEXT IF CGI.param('xact_misc').size &&
+        !CGI.param('xact_misc').grep(f.xact.id).size %]
+      <tr>
+        <td>[% f.xact.last_billing_type | html %]</td>
+        <td class="text-right">[% money(f.xact.balance_owed) %]</td>
+      </tr>
+    [% END %]
+    </tbody>
+  </table>
+
+  <br/>
+
+  <div>
+    [% l('Total amount to pay:') %]
+    <strong>[% money(ctx.fines.balance_owed) %]</strong>
+  </div>
+
+</div>
+
diff --git a/KCLS/openils/var/templates_kcls/opac/payflow/errors.tt2 b/KCLS/openils/var/templates_kcls/opac/payflow/errors.tt2
new file mode 100644 (file)
index 0000000..15dc23e
--- /dev/null
@@ -0,0 +1,41 @@
+<div class="payment-error">
+[% 
+
+# Map PayFlow POST response codes to patron messages.
+# https://developer.paypal.com/docs/classic/payflow/integration-guide/#result-values-and-respmsg-text
+
+SWITCH ctx.payflow_hosted_ctx.RESULT;
+
+  CASE '12';  # Declined.
+    l('Declined. Please verify your card details.');
+
+  CASE '23'; # Invalid CC number (e..g mis-typed)
+    l('Declined. Please make sure you entered your credit card number correctly.');
+
+  CASE '25';  # Transaction type not mapped to this host
+    l('KCLS does not accept AMEX or Discover Card at this time. Please use your Visa or MasterCard.');
+
+  CASE '114'; # CVV2 or CID Mismatch 
+    l('Declined. Please make sure you entered the three digit code on the back of your card correctly.');
+
+  CASE '125';
+    handled = 0;
+    l('Declined.  ');
+
+    IF ctx.payflow_hosted_ctx.PROCCVV2 == 'N';
+      l('Please make sure you entered the three digit code on the back of your card correctly.  ');
+      handled = 1;
+    END;
+
+    IF NOT handled
+      OR ctx.payflow_hosted_ctx.AVSADDR == 'N'
+      OR ctx.payflow_hosted_ctx.AVSZIP == 'N';
+      l('Please make sure your billing information matches what the bank has on file for your credit card.');
+    END;
+
+  CASE DEFAULT;
+    l('An unkown error occurred attempting credit card payment.');
+END;
+
+%]
+</div>
diff --git a/KCLS/openils/var/templates_kcls/opac/payflow/footer.tt2 b/KCLS/openils/var/templates_kcls/opac/payflow/footer.tt2
new file mode 100644 (file)
index 0000000..199765c
--- /dev/null
@@ -0,0 +1,9 @@
+<div id="footer">
+    <div class="float-left"</div>
+    <a href="http://www.kcls.org/ask/" 
+      style="font-size: 14px; color: white; font-family: Arial, Helvetica, sans-serif; font-weight: 600;" >
+      [% l('Ask KCLS') %]
+    </a>
+    <div class="common-no-pad"></div>
+</div>
+
diff --git a/KCLS/openils/var/templates_kcls/opac/payflow/form1.tt2 b/KCLS/openils/var/templates_kcls/opac/payflow/form1.tt2
new file mode 100644 (file)
index 0000000..93906cb
--- /dev/null
@@ -0,0 +1,105 @@
+
+<div id='cc-form-warning' class='payment-error' style='display:none'>
+  <strong>Please enter values for all required fields.</strong>
+  <br/>
+</div>
+
+<form method="POST" id='cc-form'>
+
+  [% FOR xact IN CGI.param('xact') %]
+    <input type="hidden" name="xact" value="[% xact | html %]" />
+  [% END %]
+
+  [% FOR xact IN CGI.param('xact_misc') %]
+    <input type="hidden" name="xact_misc" value="[% xact | html %]" />
+  [% END %]
+
+  <table>
+    <tbody>
+      <tr>
+        <td>[% l('First Name *') %]</td>
+        <td><input type="text" name="BILLTOFIRSTNAME" 
+          value="[% ctx.payflow_hosted_ctx.payflow_params.BILLTOFIRSTNAME 
+            || ctx.user.first_given_name | html %]" /></td>
+      </tr>
+      <tr>
+        <td>[% l('Last Name *') %]</td>
+        <td><input type="text" name="BILLTOLASTNAME" 
+          value="[% ctx.payflow_hosted_ctx.payflow_params.BILLTOLASTNAME 
+            || ctx.user.family_name | html %]" /></td>
+      </tr>
+      <tr>
+        <td>[% l('Email Address') %]</td>
+        <td>
+          <input type="text" name="BILLTOEMAIL"
+            value="[% ctx.payflow_hosted_ctx.payflow_params.BILLTOEMAIL
+              || ctx.user.email | html %]" />
+        </td>
+      </tr>
+      <tr>
+        <td colspan='3'><strong>
+          [% l('Please use the address that is on your bank/credit card statement.') %]
+        </strong></td>
+      </tr>
+      <tr>
+        <td>[% l('Street Address *') %]</td>
+        <td><input type="text" name="BILLTOSTREET" 
+          value="[% ctx.payflow_hosted_ctx.payflow_params.BILLTOSTREET | html %]"/></td>
+      </tr>
+      <tr>
+        <td>[% l('City *')%]</td>
+        <td><input type="text" name="BILLTOCITY" 
+          value="[% ctx.payflow_hosted_ctx.payflow_params.BILLTOCITY 
+            || ctx.user.billing_address.city | html %]" /></td>
+      </tr>
+      <tr>
+        <td>[% l('State or Province *') %]</td>
+        <td><input type="text" name="BILLTOSTATE" 
+          value="[% ctx.payflow_hosted_ctx.payflow_params.BILLTOSTATE 
+            || ctx.user.billing_address.state | html %]" /></td>
+      </tr>
+      <tr>
+        <td>[% l('ZIP or Postal Code *') %]</td>
+        <td><input type="text" name="BILLTOZIP" 
+          value="[% ctx.payflow_hosted_ctx.payflow_params.BILLTOZIP 
+            || ctx.user.billing_address.post_code | html %]" /></td>
+      </tr>
+      <tr>
+        <td colspan='2' align="center">
+        <input type="submit" value="[% l('Next') %]" 
+          onclick='return check_cc_params()'/>
+        <a href="[% mkurl(ctx.opac_root _ '/biblio/main_fines', {}, 1) %]">
+          [% l('Cancel') %]
+        </a>
+        </td>
+      </tr>
+    </tbody>
+  </table>
+</form>
+
+<script>
+  //var post_regex = new RegExp(/^\d{5}(?:[-\s]\d{4})?$/);
+  var fields = ['ZIP', 'STATE', 'CITY', 'STREET', 'FIRSTNAME', 'LASTNAME'];
+
+  function check_cc_params() {
+    var form = document.getElementById('cc-form');
+    var msg = document.getElementById('cc-form-warning');
+    msg.style.display = 'none';
+
+    var valid = true;
+    for (var i = 0; i < fields.length; i++) {
+      var fdom = form['BILLTO' + fields[i]];
+      if (fdom.value) {
+        fdom.className = '';
+      } else {
+        fdom.className = 'cc-form-invalid-field';
+        valid = false;
+        msg.style.display = 'block';
+      }
+    }
+
+    return valid;
+  }
+</script>
+
+
diff --git a/KCLS/openils/var/templates_kcls/opac/payflow/form2.tt2 b/KCLS/openils/var/templates_kcls/opac/payflow/form2.tt2
new file mode 100644 (file)
index 0000000..3c89cbd
--- /dev/null
@@ -0,0 +1,32 @@
+<table>
+  <tbody>
+    <tr>
+      <td> 
+        <!-- paypal CC payment iframe -->
+
+        [% IF ctx.payflow_hosted_error %]
+          <strong>
+            An error occurred communicating with PayPal.  
+            Unable to process payment at this time.
+          </strong>
+          [% STOP %]
+        [% END %]
+
+        [%
+          token = ctx.payflow_hosted_ctx.secure_token | uri;
+          token_id = ctx.payflow_hosted_ctx.secure_token_id | uri;
+          iframe_url = ctx.payflow_hosted_ctx.hosted_server _ 
+            '?SECURETOKEN=' _ token _ '&SECURETOKENID=' _ token_id;
+          IF ctx.payflow_hosted_ctx.test_mode;
+            iframe_url = iframe_url _ '&MODE=TEST';
+          END;
+        %]
+        <style>
+          #pp-iframe { width: 600px; height: 650px ; scroll: auto}
+        </style>
+        <iframe id='pp-iframe' src="[% iframe_url %]"> </iframe>
+      </td>
+    </tr>
+  </tbody>
+</table>
+
diff --git a/KCLS/openils/var/templates_kcls/opac/payflow/pay_form.tt2 b/KCLS/openils/var/templates_kcls/opac/payflow/pay_form.tt2
new file mode 100644 (file)
index 0000000..e5d539c
--- /dev/null
@@ -0,0 +1,96 @@
+[%
+    PROCESS "opac/parts/header.tt2";
+    PROCESS "opac/parts/misc_util.tt2";
+    WRAPPER "opac/biblio/base.tt2";
+    last_chance = CGI.param("last_chance");
+%]
+[% INCLUDE "opac/biblio/topnav.tt2" %]
+
+<div id='fines_payments_wrapper'>
+       <div id='acct_fines_tabs'>
+               <a href='[% ctx.opac_root %]/biblio/main_fines'>
+      <img src='[% ctx.media_prefix %]/images/acct_fines_off.jpg'/></a>
+               <a href='[% ctx.opac_root %]/biblio/main_payments'>
+      <img src='[% ctx.media_prefix %]/images/acct_payments_on.jpg'/></a>
+       </div>
+</div>
+
+<style>
+  #pay-form-container {
+    background: #fff;
+    width: 98%;
+    padding: 5px;
+  }
+  #pay-form-div {
+    float:left;
+  }
+  #pay-xacts-div {
+    float:left;
+    margin-left: 20px;
+  }
+  .cc-form-invalid-field {
+    color: black;
+    background-color: red;
+  }
+</style>
+
+<div id="pay-form-container">
+
+  <div>
+    <h2>[% l('KCLS only accepts Visa or MasterCard') %]</h2>
+    <br/>
+    <strong>[% l('Billing Information') %]</strong>
+  </div>
+  <div class="clear-both"></div>
+
+  <div id='pay-form-div'>
+
+    [% IF ctx.payflow_hosted_ctx.secure_token_id 
+      AND NOT ctx.payflow_hosted_ctx.pay_result_code %]
+
+      <!-- We have a new secure token.  Display the PayPal iframe -->
+      [% INCLUDE 'opac/payflow/form2.tt2' %]
+
+    [% ELSE %]
+
+      [% IF ctx.payflow_hosted_ctx.pay_result_code %]
+        <!-- Previous payment attempt was rejected by PP -->
+        [% INCLUDE 'opac/payflow/errors.tt2' %]
+      [% ELSIF ctx.payflow_hosted_ctx.init_error %]
+        <div class="payment-error">
+          [% l('Error initializing credit card payments.  Unable to make payments at this time.') %]
+        </div>
+      [% END %]
+
+      <!-- before we talk to paypal, collect address, etc. info from patron -->
+      [% INCLUDE 'opac/payflow/form1.tt2' %]
+
+    [% END %]
+  </div>
+  <div id='pay-xacts-div'>
+    [% INCLUDE "opac/parts/myopac/payment_xacts.tt2" %]
+    <br />
+    <span>
+      Click 
+      <strong>
+        <a href="[% mkurl(ctx.opac_root _ '/biblio/main_fines', {}, 1) %]">
+        [% l('Cancel') %]
+        </a>
+      </strong> 
+      to go back and change your selection.
+    </span>
+  </div>
+  <div class="clear-both"></div>
+
+  <br/>
+  <table>
+  [% INCLUDE "opac/biblio/main_refund_policy.tt2" %]
+  </table>
+
+</div>
+
+[% INCLUDE 'opac/payflow/footer.tt2' %]
+
+<script src='/js/ui/default/opac/kcls.js'></script>
+
+[% END %]
diff --git a/KCLS/openils/var/templates_kcls/opac/payflow/pay_receipt.tt2 b/KCLS/openils/var/templates_kcls/opac/payflow/pay_receipt.tt2
new file mode 100644 (file)
index 0000000..96b3498
--- /dev/null
@@ -0,0 +1,58 @@
+[%
+    WRAPPER "opac/biblio/base.tt2";
+    PROCESS "opac/parts/header.tt2";
+%]
+
+[% INCLUDE "opac/biblio/topnav.tt2" %]
+
+<div id='fines_payments_wrapper'>
+       <div id='acct_fines_tabs'>
+               <a href='[% ctx.opac_root %]/biblio/main_fines'>
+      <img src='[% ctx.media_prefix %]/images/acct_fines_on.jpg'/></a>
+               <a href='[% ctx.opac_root %]/biblio/main_payments'>
+      <img src='[% ctx.media_prefix %]/images/acct_payments_off.jpg'/></a>
+       </div>
+</div>
+
+<div id="myopac_summary_div" style="background-color:#FFF;">
+
+  <p><big>[% l('Your payment has been approved.') %]</big></p>
+
+  [% IF ctx.printable_receipt.template_output;
+    print_args = [];
+    FOR p IN ctx.payflow_hosted_ctx.payments;
+        print_args.push('payment=' _ p);
+    END %]
+
+    <p>[ 
+      <a href="[% ctx.opac_root %]/biblio/receipt_print?[% print_args.join('&amp;') %]"
+        target="_egrecpt"
+        onclick="try { print_node('printable-receipt'); } catch (e) { window.print(); } return false;">[% l('Print receipt') %]</a> 
+      ]
+    </p>
+    <tt id="printable-receipt">
+        [% ctx.printable_receipt.template_output.data %]
+    </tt>
+  [% ELSE %]
+    <div class="payment-error">
+      [% l(
+          'Error creating receipt: [_1]',
+              ( ctx.printable_receipt.textcode ? 
+                ctx.printable_receipt.textcode _ ' / ' _ 
+                ctx.printable_receipt.desc : 0
+              ) ||
+              ctx.printable_receipt.error_output.data ||
+              l('No receipt data returned from server')
+          ) | html 
+      %]
+    </div>
+  [% END %]
+
+  <p>[ <a href="[% ctx.opac_root %]/biblio/main_fines">[%
+      l("Back to Account Summary") %]</a> ]</p>
+
+</div>
+
+[% INCLUDE 'opac/payflow/footer.tt2' %]
+
+[% END %]
diff --git a/KCLS/openils/var/templates_kcls/opac/payflow/pay_response.tt2 b/KCLS/openils/var/templates_kcls/opac/payflow/pay_response.tt2
new file mode 100644 (file)
index 0000000..7fc9367
--- /dev/null
@@ -0,0 +1,95 @@
+<!-- 
+  This page is loaded within an iframe.  Keep it as slim as possible.
+  No footers, etc.
+-->
+[% WRAPPER "opac/biblio/base.tt2" %]
+<div id="myopac_summary_div" style="background:#fff">
+
+  <script>
+    function go_to_url(url) {
+      // If we're inside an iframe, this redirects the parent frame
+      // to the requested URL.  Otherwise, redirects the current page.
+      var win = window.top ? window.top : window;
+      win.location.href = url;
+    }
+  </script>
+
+[% IF ctx.payflow_hosted_ctx.pay_result_code 
+  AND ctx.payflow_hosted_ctx.pay_result_code != '0' %]
+
+  <!-- 
+    Payment rejected.  
+    Redirect the parent frame to the form1 page to display the error message.
+  -->
+  <script>
+    setTimeout(
+      function(){ go_to_url(
+        '[% ctx.opac_root _ "/payflow/pay_form/" _ 
+          ctx.payflow_hosted_ctx.secure_token_id %]') },
+      100
+    );
+  </script>
+
+[% ELSIF ctx.on_processing_page %]
+
+  <div class="payment-processing">
+      [% l('Processing...') %] <br/><br/>
+      [% l("Please do not Refresh or use your browser's Back button") %]<br/>
+  </div>
+
+[% ELSIF ctx.payment_response.textcode %]
+  <!-- 
+    Payment tracking attempt failed.  Display the error message 
+    then offer a button to escape the iframe by redirecting the
+    parent frame to the main fines page. 
+  -->
+
+  <div class="payment-error">
+    <span>
+      Error processing payment after credit card payment succeeded.
+      Please see staff to complete transaction.
+      <br/><br/>
+      Payment order number: <b>[% ctx.payflow_hosted_ctx.order_number %]</b>.
+    </span>
+
+    <span title="[% ctx.payment_response.textcode %]">
+      [% ctx.payment_response.desc || ctx.payment_response.textcode %]
+    </span>
+    <br/>
+    [% ctx.payment_response.note %]
+    [% ctx.payment_response.payload.error_message %]
+
+  </div>
+  <p>
+    [%
+      url_args = {xact => [], xact_misc => []};
+      FOR k IN ['xact', 'xact_misc'];
+        FOR val IN CGI.param(k);
+          url_args.$k.push(val);
+        END;
+      END;
+      retry_url =  mkurl(ctx.opac_root _ '/payflow/pay_form', url_args, 1);
+    %]
+    <br/>
+    <a href="javascript:go_to_url('[% retry_url %]')">[% l('Go back') %]</a>
+    [% l('to try again or to cancel this payment attempt.') %]
+  </p>
+
+[% ELSE %]
+  <!-- 
+    Payment succeeded.  
+    Redirect the parent frame to the receipts page. 
+  -->
+  <script>
+    setTimeout(
+      function(){ go_to_url(
+        '[% ctx.opac_root _ "/payflow/pay_receipt/" _ 
+          ctx.payflow_hosted_ctx.secure_token_id %]') },
+      100
+    );
+  </script>
+
+[% END %]
+
+</div>
+[% END %]
diff --git a/KCLS/sql/schema/deploy/payflow-hosted-org-settings.sql b/KCLS/sql/schema/deploy/payflow-hosted-org-settings.sql
new file mode 100644 (file)
index 0000000..e4d2d6c
--- /dev/null
@@ -0,0 +1,39 @@
+-- Deploy kcls-evergreen:payflow-hosted-org-settings to pg
+-- requires: vand-auth-edit-date
+
+BEGIN;
+
+INSERT INTO config.org_unit_setting_type 
+    (name, label, description, datatype, view_perm, update_perm, grp)
+VALUES
+    ('credit.processor.payflowhosted.partner', 'PayflowHosted Partner', 
+        'PayPal Partner value.  Typically set to ''PayPal''',
+        'string', 1005, 1006, 'credit'),
+    ('credit.processor.payflowhosted.vendor', 'PayflowHosted Vendor', 
+        'PayPal Vendor account name',
+        'string', 1005, 1006, 'credit'),
+    ('credit.processor.payflowhosted.login', 'PayflowHosted User/Login', 
+        'PayPal login used for facilitating transactions',
+        'string', 1005, 1006, 'credit'),
+    ('credit.processor.payflowhosted.password', 'PayflowHosted Password', 
+        'Password for PayPal login account',
+        'string', 1005, 1006, 'credit'),
+    ('credit.processor.payflowhosted.testmode', 'PayflowHosted Test Mode', 
+        'Set to true if transactions should be sent to the PayPal test ' ||
+        'server as test transactions.  When activated, no credit cards ' ||
+        'will be charged, but payments will be generated in Evergreen',
+        'bool', 1005, 1006, 'credit'),
+    ('credit.processor.payflowhosted.enabled', 'PayflowHosted Enabled', 
+        'Set to true if this credit card processor should be used',
+        'bool', 1005, 1006, 'credit'),
+    ('credit.processor.payflowhosted.autohosts', 
+        'PayflowHosted Dynamic Response Hosts', 
+        'Set to true if PayPal should send responses to the same server ' ||
+        'where the credit card payment was initiated (using the hostname ' ||
+        'reported by the web server).  Otherwise, all responses are ' ||
+        'delivered to the return/error/cancel URLs configured with PayPay',
+        'bool', 1005, 1006, 'credit')
+
+;
+
+COMMIT;
diff --git a/KCLS/sql/schema/revert/payflow-hosted-org-settings.sql b/KCLS/sql/schema/revert/payflow-hosted-org-settings.sql
new file mode 100644 (file)
index 0000000..4b94e8c
--- /dev/null
@@ -0,0 +1,9 @@
+-- Revert kcls-evergreen:payflow-hosted-org-settings from pg
+
+BEGIN;
+
+DELETE FROM config.org_unit_setting_type 
+    WHERE name LIKE 'credit.processor.payflowhosted.%';
+
+COMMIT;
+
index a17cdbc..b3188da 100644 (file)
@@ -27,3 +27,4 @@ drop-cc-cols [sip-activity-types] 2016-05-03T15:26:50Z Bill Erickson <berickxx@g
 connexion-auth-imports [sip-activity-types] 2016-05-11T15:10:49Z Bill Erickson,,, <berick@teapot> # OCLC Connexion Authority Record Imports Data
 purge-user-activity [sip-activity-types] 2016-04-29T17:07:46Z Bill Erickson <berickxx@gmail.com> # Clean up actor.usr_activity
 vand-auth-edit-date [purge-user-activity] 2016-06-01T18:24:54Z Bill Erickson <berickxx@gmail.com> # Vandelay authority import sets edit[or|_date]
+payflow-hosted-org-settings [vand-auth-edit-date] 2016-07-06T18:39:40Z Bill Erickson <berickxx@gmail.com> # PayflowPro Hosted Pages org unit settings
diff --git a/KCLS/sql/schema/verify/payflow-hosted-org-settings.sql b/KCLS/sql/schema/verify/payflow-hosted-org-settings.sql
new file mode 100644 (file)
index 0000000..b399ad2
--- /dev/null
@@ -0,0 +1,7 @@
+-- Verify kcls-evergreen:payflow-hosted-org-settings on pg
+
+BEGIN;
+
+-- XXX Add verifications here.
+
+ROLLBACK;
index 3b6bfe9..1bd8947 100644 (file)
@@ -321,7 +321,7 @@ sub can_close_circ {
     my ($class, $e, $circ) = @_;
     my $can_close = 0;
 
-    my $reason = $circ->stop_fines;
+    my $reason = $circ->stop_fines || '';
 
     # We definitely want to close if this circulation was
     # checked in or renewed.
index b00ad01..2a50f05 100644 (file)
@@ -451,6 +451,10 @@ sub make_payments {
             return OpenILS::Event->new(
                 'BAD_PARAMS', note => 'Need approval code'
             ) if not $cc_args->{approval_code};
+
+            # Out of band processors send completed payment info via cc_args.
+            $cc_processor = $cc_args->{processor};
+            $cc_order_number = $cc_args->{order_number};
         }
     }
 
index 63c1fac..22d3361 100644 (file)
@@ -230,6 +230,7 @@ sub load {
     return $self->load_myopac_payments if $path =~ m|opac/myopac/main_payments|;
     return $self->load_myopac_pay_init if $path =~ m|opac/myopac/main_pay_init|;
     return $self->load_myopac_pay if $path =~ m|opac/myopac/main_pay|;
+    return $self->load_myopac_pay_response if $path =~ m|opac/myopac/pay_response|;
     return $self->load_myopac_main if $path =~ m|opac/myopac/main|;
     return $self->load_myopac_receipt_email if $path =~ m|opac/myopac/receipt_email|;
     return $self->load_myopac_receipt_print if $path =~ m|opac/myopac/receipt_print|;
@@ -247,7 +248,12 @@ sub load {
     return $self->load_myopac_prefs_my_lists if $path =~ m|opac/myopac/prefs_my_lists|;
     return $self->load_myopac_prefs if $path =~ m|opac/myopac/prefs|;
     return $self->load_sms_cn if $path =~ m|opac/sms_cn|;
-    
+
+    # PayflowHosted E-Com pages.
+    return $self->load_myopac_payflow_form if $path =~ m|opac/payflow/pay_form|;
+    return $self->load_myopac_payflow_response if $path =~ m|opac/payflow/pay_response|;
+    return $self->load_myopac_payflow_receipt if $path =~ m|opac/payflow/pay_receipt|;
+
     #BiblioCommons E-Commerce Screens
     return $self->load_myopac_payment_form if $path =~ m|opac/biblio/main_payment_form|;
     return $self->load_myopac_payments if $path =~ m|opac/biblio/main_payments|;
index e0ddbd2..9716e4d 100644 (file)
@@ -10,6 +10,7 @@ use OpenSRF::Utils::JSON;
 use OpenSRF::Utils::Cache;
 use Digest::MD5 qw(md5_hex);
 use Data::Dumper;
+use OpenILS::WWW::EGCatLoader::PayflowHosted;
 $Data::Dumper::Indent = 0;
 use DateTime;
 use DateTime::Format::ISO8601;
@@ -709,7 +710,7 @@ sub load_myopac_prefs_my_lists {
 
     my @user_prefs = qw/
         opac.lists_per_page
-        opac.lists_per_page,
+        opac.lists_per_page
     /;
 
     my $stat = $self->_load_user_with_prefs;
@@ -1805,6 +1806,315 @@ sub load_myopac_payment_form {
     return Apache2::Const::OK;
 }
 
+# UI for entering billing address, etc. data.
+sub load_myopac_payflow_form {
+    my $self = shift;
+    my $token_id = $self->ctx->{page_args}->[0];
+    my $tokens;
+
+    my $stat = $self->prepare_extended_user_info;
+    return $stat if $stat;
+
+    # If this is a new payment, the transactions will come from GET params.
+    my $xacts = [$self->cgi->param('xact'), $self->cgi->param('xact_misc')];
+
+    if ($token_id) {
+        # We were directed back to the form1 page after a payment attempt
+        # at PP was rejected with a non-zero status.
+        # Re-display form1 with error info.
+        
+        my $cache = OpenSRF::Utils::Cache->new('global');
+        $tokens = $cache->get_cache($token_id);
+
+        if (!$tokens) {
+
+            $logger->error("PayflowHosted payment was rejected, but ".
+                "we were unable to retrieve the payment context data ".
+                "from the cache.  Unable to continue payment!");
+
+            $self->ctx->{payflow_hosted_ctx} = {error => 1};
+            return Apache2::Const::OK;
+        }
+
+        # After we render this page, this payment context is no longer valid.  
+        $self->ctx->{payflow_hosted_ctx} = $tokens;
+
+        # If this is an existing payment, pull the transactions from
+        # the payment context blob.
+        $xacts = $tokens->{xacts};
+    } 
+
+    $stat = $self->prepare_fines(undef, undef, $xacts);
+    return $stat if $stat;
+
+    if ($self->cgi->param('BILLTOLASTNAME')) {
+        # We received form1 POST data from the patron.
+        # Request a secure token from PP en route to form 2.
+        # TODO: check for reasonable values for all required fields.
+        
+        $self->generate_payflow_secure_token($xacts);
+    }
+
+    return Apache2::Const::OK;
+}
+
+sub payflow_hosted_enabled {
+    my $self = shift;
+    return $self->ctx->{get_org_setting}->(
+        $self->editor->requestor->home_ou,
+        'credit.processor.payflowhosted.enabled'
+    );
+}
+
+sub payflow_hosted_is_default {
+    my $self = shift;
+    my $org = $self->editor->requestor->home_ou;
+    my $ctx = $self->ctx;
+
+    my $cc_default = 
+        $ctx->{get_org_setting}->($org, 'credit.processor.default') || '';
+
+    return $cc_default eq 'PayflowHosted' && $self->payflow_hosted_enabled;
+}
+
+# Generates a PayPal secure token using address, etc. data collected
+# from payflow form1 and adds the necessary data to the template context
+# to direct the user to the hosted payment page.
+sub generate_payflow_secure_token {
+    my $self = shift;
+    my $xacts = shift;
+    my $ctx = $self->ctx;
+    my $cgi = $self->cgi;
+    my $org = $self->editor->requestor->home_ou;
+
+    return unless $self->payflow_hosted_enabled;
+
+    # Collect all BILLTO* params from form1.
+    my %payflow_params = 
+        map {$_ => $cgi->param($_)} 
+        grep /^BILLTO/, $cgi->param;
+
+    # amount comes from prepare_fines()
+    $payflow_params{AMT} = sprintf("%.2f", $ctx->{fines}->{balance_owed});
+
+    # Generate the PayPal secure token.
+    my $tokens = OpenILS::WWW::EGCatLoader::PayflowHosted::create_xact_token(
+        billing_org => $org,
+        response_host => "https://" . $self->ctx->{hostname},
+        payflow_params => \%payflow_params
+    );
+
+    # The payment form needs these when re-rendering values.
+    $tokens->{payflow_params} = \%payflow_params;
+
+    unless ($tokens && $tokens->{secure_token}) {
+        # Let the template gracefully warn the user.
+        $ctx->{payflow_hosted_ctx} = {init_error => 1};
+        return;
+    }
+
+    # Cache the transactions so the payment can be made internally
+    # after it's made externally.
+    $tokens->{xacts} = $xacts;
+    $tokens->{user} = $self->editor->requestor->id;
+
+    $ctx->{payflow_hosted_ctx} = $tokens;
+
+    # Cache the tokens so we can link the results data from PayPal
+    # back to the original transaction data.  Cache time is set to
+    # match PayPal's secure token timeout of 30 minutes.
+    my $cache = OpenSRF::Utils::Cache->new('global');
+    $cache->put_cache($tokens->{secure_token_id}, $tokens, 1800);
+}
+
+# Called from 3rd-party credit card processors to POST payment results data.
+# Caller will not have an authentication token.
+# This happens within an iframe.
+# Either we process the PP POST response data and redirect the user to
+# an intermidiate 'Processing...' page, or if that's already happened,
+# create the payment internally, then let the iframe redirect the 
+# browser to the receipts page.
+sub load_myopac_payflow_response {
+    my $self = shift;
+    my $cgi = $self->cgi;
+    my $ctx = $self->ctx;
+
+    my $token_id = $self->ctx->{page_args}->[0];
+
+    if ($token_id) {
+        # Payflow response has already been received.
+        # Create the payment internally now.
+        
+        $logger->info("PayflowHosted creating payment for token: $token_id");
+        return $self->payflow_create_payment($token_id);
+
+    } else {
+        # Handling PayFlow POST response.  Collect data, put 
+        # it into the cache, then redirect the caller back to
+        # this page to finalize processing.
+
+        $logger->info("PayflowHosted processing POST results");
+        return $self->handle_payflow_response;
+    }
+}
+
+sub handle_payflow_response {
+    my $self = shift;
+    my $cgi = $self->cgi;
+    my $ctx = $self->ctx;
+
+    my $respmsg = $cgi->param('RESPMSG');
+    my $order_number = $cgi->param('PNREF');
+    my $appr_code = $cgi->param('AUTHCODE');
+    my $token_id = $cgi->param('SECURETOKENID');
+    my $result = $cgi->param('RESULT');
+
+    # Toss the CGI params into a string for easier error logging.
+    my $log_params = '';
+    $log_params .= "$_=" . $cgi->param($_) . "; " for ($cgi->param);
+
+    $logger->info("PayflowHosted responded with: $log_params");
+
+    if (!$token_id) {
+        $logger->error("PayflowHosted processor responsded with success but ".
+            "failed to return a SECURETOKENID.  Cannot complete payment!");
+        $ctx->{payflow_hosted_ctx} = {error => 1};
+        return Apache2::Const::OK;
+    }
+
+    my $cache = OpenSRF::Utils::Cache->new('global');
+    my $tokens = $cache->get_cache($token_id);
+
+    if (!$tokens) {
+
+        $logger->error("PayflowHosted payment succeeded, but no matching ".
+            "transaction data found in memcache. Cannot complete payment!");
+
+        $ctx->{payflow_hosted_ctx} = {
+            error => 1,
+            cc_args => {order_number => $order_number}
+        };
+
+        return Apache2::Const::OK;
+    }
+
+    $tokens->{$_} = $cgi->param($_) for
+        (qw/RESULT PNREF AVSADDR AVSZIP PROCCVV2/);
+
+    $tokens->{pay_result_code} = $result;
+    $ctx->{payflow_hosted_ctx} = $tokens;
+
+    if ($result eq '0') {
+        # Payment processed successfully at PP.  Track the payment locally.
+        $logger->info("PayflowHosted processor returned success: $respmsg");
+
+        # Add the completed payment data to the cache.
+        $tokens->{cc_args} = {
+            where_process => -1, # processed externally
+            approval_code => $appr_code,
+            order_number  => $order_number,
+            processor     => 'PayflowHosted'
+        };
+
+        # Redirect to intermediate 'Processing...' page.
+        $self->ctx->{refresh} = "1; url=pay_response/$token_id";
+        $ctx->{on_processing_page} = 1;
+
+    } else {
+        # Payment failed.  Iframe will automatically redirect
+        # back to the form1 page to display the message allow
+        # for a re-pay attempt.
+        if ($result < 0) {
+            $logger->error("PayflowHosted processor returned a".
+                "communication error response code=$result : $respmsg");
+        } else {
+            $logger->warn("PayflowHosted processor returned a non-success ".
+                "(but recoverable) response code=$result : $respmsg");
+        }
+    }
+
+    $cache->put_cache($token_id, $tokens, 1800);
+
+    return Apache2::Const::OK;
+}
+
+sub payflow_create_payment {
+    my ($self, $token_id) = @_;
+
+    my $cgi = $self->cgi;
+    my $ctx = $self->ctx;
+
+    my $cache = OpenSRF::Utils::Cache->new('global');
+    my $tokens = $cache->get_cache($token_id);
+
+    # Must be called before prepare_fines_for_payment();
+    $self->prepare_fines(undef, undef, $tokens->{xacts});
+
+    my $args = {
+        cc_args      => $tokens->{cc_args},
+        userid       => $tokens->{user},
+        payments     => $self->prepare_fines_for_payment,
+        payment_type => "credit_card_payment"
+    };
+
+    $logger->info("PayflowHosted sending payments: ".Dumper($args));
+
+    my $resp = $U->simplereq(
+        "open-ils.circ", 
+        "open-ils.circ.money.payment",
+        $self->editor->authtoken, $args, 
+        $self->ctx->{user}->last_xact_id
+    );
+
+    $self->ctx->{payment_response} = $resp;
+    $self->ctx->{token_id} = $token_id;
+
+    if ($resp->{textcode}) {
+        $logger->error("PayflowHosted CC internal payment tracking failed");
+    } else {
+        $logger->info("PayflowHosted CC internal payment tracking succeeded");
+        $logger->info("PayflowHosted created payments: ".Dumper($resp->{payments}));
+        $tokens->{payments} = $resp->{payments};
+    }
+
+    $ctx->{payflow_hosted_ctx} = $tokens;
+
+    # Cache the payment info so we can generate a receipt on the receipts
+    # page, but only cache it for a minute so the token can expire.
+    $cache->put_cache($token_id, $tokens, 60);
+
+    return Apache2::Const::OK;
+}
+
+# Generate a printable receipt from a CC payment.
+sub load_myopac_payflow_receipt {
+    my $self = shift;
+
+    my $token_id = $self->ctx->{page_args}->[0];
+    return Apache2::Const::HTTP_BAD_REQUEST unless $token_id;
+
+    my $cache = OpenSRF::Utils::Cache->new('global');
+    my $tokens = $cache->get_cache($token_id);
+
+    # this page is loaded immediately after the token is created.
+    # if the cached data is not there, it's because of an invalid
+    # token (or cache failure) and not because of a timeout.
+    return Apache2::Const::HTTP_BAD_REQUEST 
+        unless $tokens && $tokens->{payments};
+
+    $self->ctx->{printable_receipt} = $U->simplereq(
+       "open-ils.circ", "open-ils.circ.money.payment_receipt.print",
+       $self->editor->authtoken, $tokens->{payments}
+    );
+
+    $self->ctx->{payflow_hosted_ctx} = $tokens;
+
+    # NOTE: not deleting the cache data since it's set to expire 
+    # within 60 seconds above.
+
+    return Apache2::Const::OK;
+}
+
 # TODO: add other filter options as params/configs/etc.
 sub load_myopac_payments {
     my $self = shift;
@@ -2159,6 +2469,10 @@ sub load_myopac_main {
             pub => 't'
         })
     );
+
+    # determines which payment form page the user is directed to.
+    $self->ctx->{using_payflow} = $self->payflow_hosted_is_default();
+
     return $self->prepare_fines($limit, $offset) || Apache2::Const::OK;
 }
 
diff --git a/Open-ILS/src/perlmods/lib/OpenILS/WWW/EGCatLoader/PayflowHosted.pm b/Open-ILS/src/perlmods/lib/OpenILS/WWW/EGCatLoader/PayflowHosted.pm
new file mode 100644 (file)
index 0000000..b881399
--- /dev/null
@@ -0,0 +1,220 @@
+package OpenILS::WWW::EGCatLoader::PayflowHosted;
+use strict;
+use warnings;
+use CGI::Util;
+use LWP::UserAgent;
+use UUID::Tiny qw/:std/;
+use OpenSRF::Utils::Logger qw/$logger/;
+my $U = 'OpenILS::Application::AppUtils';
+
+my $test_server = 'https://pilot-payflowpro.paypal.com'; 
+my $live_server = 'https://payflowpro.paypal.com';
+
+# Hosted Pages server.  This is the same for test or live mode.
+my $hosted_server = 'https://payflowlink.paypal.com';
+
+# Creates a transaction token so the calling code can send the user
+# to the hosted pages site.
+#
+# Params (hash):
+#
+# response_host => https://my-host.example.org -- used for RETURNURL, etc.
+# billing_org => org unit for checking credit card AOUS values.
+# payflow_params => {billing params sent to paypal}
+#
+# Returns (hash):
+#
+# secure_token      => paypal_generated_token
+# secure_tokan_id   => locally_generated_token_id
+# test_mode         => true/false if we're operating in test mode
+# server            => paypal server where users should be directed
+#
+# Returns undef on error (and logs to error log).
+sub create_xact_token {
+    my %params = @_;
+
+    my %settings = get_settings($params{billing_org});
+    return undef unless %settings;
+
+    my %pf_params = %{$params{payflow_params}};
+
+    # Per-transaction unique token
+    (my $tokenid = create_uuid_as_string(UUID_V4)) =~ s/-//g;
+
+    $pf_params{PARTNER}   = $settings{partner};
+    $pf_params{VENDOR}    = $settings{vendor};
+    $pf_params{TRXTYPE}   = 'S';         # sale
+    $pf_params{TEMPLATE}  = 'MINLAYOUT'; # or MOBILE (AKA "Layout C") 
+    $pf_params{URLMETHOD} = 'POST';
+    $pf_params{SECURETOKENID}     = $tokenid;
+    $pf_params{CREATESECURETOKEN} = 'Y';
+
+    if ($settings{autohosts}) {
+        # Tell PP to send POST response data to this host, 
+        # regardless of what's configured within PayPal.
+        my $host = $params{response_host};
+        $pf_params{RETURNURL} = "$host/eg/opac/payflow/pay_response";
+        $pf_params{CANCELURL} = "$host/eg/opac/biblio/main_fines";
+        $pf_params{ERRORURL}  = $pf_params{RETURNURL};
+    }
+
+    my $server = $settings{testmode} ? $test_server : $live_server;
+
+    # Log the request to be sent, minus the user and password values.
+    $logger->info("PayflowHosted sending to server $server: ".
+        encode_params(%pf_params));
+
+    # Now that we've logged the params, add the user and password
+    $pf_params{USER} = $settings{login},
+    $pf_params{PWD} = $settings{password},
+
+    my $req = HTTP::Request->new(POST => $server);
+    $req->header('content-type' => 'text/namevalue');
+    $req->content(encode_params(%pf_params));
+
+    my $resp = LWP::UserAgent->new->request($req);
+
+    unless ($resp->is_success) {
+        $logger->error(sprintf(
+            "PayflowHosted HTTPS error code=%s, message=%s",
+            $resp->code, $resp->message
+        ));
+        return undef;
+    }
+
+    my $content = $resp->decoded_content;
+
+    # $content does not contain passwords, etc.
+    $logger->info("PayflowHosted response: $content");
+
+    my %results = parse_response($content);
+
+    unless ($results{SECURETOKEN}) {
+        $logger->error("PayflowHosted failed to return a secure token.  ".
+            "Response message => " . $results{RESPMSG});
+
+        return undef;
+    }
+
+    return {
+        secure_token    => $results{SECURETOKEN},
+        secure_token_id => $results{SECURETOKENID},
+        test_mode       => $settings{testmode},
+        hosted_server   => $hosted_server
+    };
+}
+
+# Returns a hash of settings values if the caller should continue 
+# (i.e. payflowhosted is used).  Returns undef otherwise.
+sub get_settings {
+    my $org = shift;
+
+    my %params;
+    my $spfx = 'credit.processor.payflowhosted';
+
+    for my $p (qw/partner vendor login password testmode enabled autohosts/) {
+        $params{$p} = $U->ou_ancestor_setting_value($org, "$spfx.$p");
+
+        if (!$params{$p} && $p ne 'testmode' && $p ne 'autohosts') {
+            $logger->error("Attempt to make payment via 'payflowhosted' ".
+                "with no value for org unit setting: '$spfx.$p'");
+            return undef;
+        }
+    }
+
+    return %params;
+}
+
+# Business::OnlinePayment::PayflowPro does not support the parameters
+# necessary for Payflow Hosted Pages.  It also internally maps POST
+# parameter names from "friendly" names to their PayPal equivalents,
+# making it impossible to pass ad-hoc parameters.  Instead of using
+# ::PayflowPro, use its request encoding and response reading code,
+# since that's all we really need.
+
+
+# Body (and comments) for this function copied practically verbatim from submit()
+# http://search.cpan.org/~plobbes/Business-OnlinePayment-PayflowPro-1.01/PayflowPro.pm
+sub encode_params {
+    my %params = @_;
+
+    # Payflow Pro does not use URL encoding for the request.  The
+    # following implements their custom encoding scheme.  Per the
+    # developer docs, the PARMLIST Syntax Guidelines are:
+    # - Spaces are allowed in values
+    # - Enclose the PARMLIST in quotation marks ("")
+    # - Do not place quotation marks ("") within the body of the PARMLIST
+    # - Separate all PARMLIST name-value pairs using an ampersand (&)
+    #
+    # Because '&' and '=' have special meanings/uses values containing
+    # these special characters must be encoded using a special "length
+    # tag".  The "length tag" is simply the length of the "value"
+    # enclosed in square brackets ([]) and appended to the "name"
+    # portion of the name-value pair.
+    #
+    # For more details see the sections 'Using Special Characters in
+    # Values' and 'PARMLIST Syntax Guidelines' in the PayPal Payflow
+    # Pro Developer's Guide
+    return join(
+        '&',
+        map {
+            my $key = $_;
+            my $value = defined( $params{$key} ) ? $params{$key} : '';
+            if ( index( $value, '&' ) != -1 || index( $value, '=' ) != -1 ) {
+                $key = $key . "[" . length($value) . "]";
+            }
+            "$key=$value";
+          } keys %params
+    );
+}
+
+# Body for this function copied heavily from _get_response()
+# http://search.cpan.org/~plobbes/Business-OnlinePayment-PayflowPro-1.01/PayflowPro.pm
+sub parse_response {
+    my $content = shift;
+    my %response;
+    return %response unless $content;
+
+    foreach (split( /[&;]/, $content)) {
+        my ($param, $value) = split('=', $_, 2);
+
+        next unless defined $param;
+        $value = '' unless defined $value;
+
+        $param = CGI::Util::unescape($param);
+        $value = CGI::Util::unescape($value);
+        $response{$param} = $value;
+    }
+
+    return %response;
+}
+
+1;
+
+
+__DATA__
+
+Response blob:
+
+TYPE=S&
+RESPMSG=Approved&
+ACCT=1234&
+COUNTRY=US&
+VISACARDLEVEL=12&
+TAX=0.00&
+CARDTYPE=0&
+PNREF=12341EE308F6&
+TENDER=CC&
+AVSDATA=XXN&
+METHOD=CC&
+SECURETOKEN=123456NYslUGMy0tlKafELwct&
+SHIPTOCOUNTRY=US&
+AMT=40.00&
+SECURETOKENID=12528208de1413abc3d60c86cb15&
+TRANSTIME=2012-03-26+14%3A07%3A59&
+HOSTCODE=A&
+COUNTRYTOSHIP=US&
+RESULT=0&
+AUTHCODE=124PNI&
+
+