From 641b38bab2c30345a26301059ce6ab336721c6e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radek=20=C5=A0iman?= Date: Thu, 22 Jun 2017 09:37:15 +0200 Subject: [PATCH] Bug 17705: payments using payment terminal This patch expects already installed and configured payment terminal, e.g Ingenico, provided by GPE (Global Payments Europe). This piece of code already acquired an official certification in GPE Headquarter, Prague, Czech Republic. Bank accounts connected to banks KB or CSOB should work perfectly. Other banks will probably require additional certifications and changes in the code. Test plan 1) Appy this patch 2) Connect and/or start terminal device 3) Go to administration setting and look for "PosTerminal" items 4) Setup desired values (IP and port) 5) Make patron to pay a single fine (eg. lost book) 6) Tick the box "Pay by card" and confirm the payment 7) Popup window will raise displaying transaction status 8) Let the patron pay his amount using the terminal 9) When transaction finishes, close popup window 10) Optional: try to refund an amount by clicking "Refund to card" button within fines overview (members/boraccount.pl). Steps 7-9 will repeat accordingly. --- Koha/PosTerminal/Client.pm | 101 +++++++++ Koha/PosTerminal/Message.pm | 232 +++++++++++++++++++ Koha/PosTerminal/Message/Field.pm | 26 +++ Koha/PosTerminal/Message/Header.pm | 126 +++++++++++ Koha/PosTerminal/Transaction.pm | 50 +++++ Koha/PosTerminal/Transactions.pm | 56 +++++ .../en/includes/members-pos-terminal-dialog.inc | 16 ++ .../en/includes/members-pos-terminal-messages.inc | 21 ++ .../en/modules/admin/preferences/circulation.pref | 12 + .../prog/en/modules/members/boraccount.tt | 6 + .../prog/en/modules/members/paycollect.tt | 13 +- koha-tmpl/intranet-tmpl/prog/js/payments.js | 205 +++++++++++++++++ members/paycollect.pl | 1 + svc/pos_terminal | 250 +++++++++++++++++++++ 14 files changed, 1114 insertions(+), 1 deletion(-) create mode 100644 Koha/PosTerminal/Client.pm create mode 100644 Koha/PosTerminal/Message.pm create mode 100644 Koha/PosTerminal/Message/Field.pm create mode 100644 Koha/PosTerminal/Message/Header.pm create mode 100644 Koha/PosTerminal/Transaction.pm create mode 100644 Koha/PosTerminal/Transactions.pm create mode 100644 koha-tmpl/intranet-tmpl/prog/en/includes/members-pos-terminal-dialog.inc create mode 100644 koha-tmpl/intranet-tmpl/prog/en/includes/members-pos-terminal-messages.inc create mode 100644 koha-tmpl/intranet-tmpl/prog/js/payments.js create mode 100755 svc/pos_terminal diff --git a/Koha/PosTerminal/Client.pm b/Koha/PosTerminal/Client.pm new file mode 100644 index 0000000..2974cac --- /dev/null +++ b/Koha/PosTerminal/Client.pm @@ -0,0 +1,101 @@ +package Koha::PosTerminal::Client; + +# This file is part of Koha. +# +# Copyright 2014 BibLibre +# +# Koha 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 3 of the License, or +# (at your option) any later version. +# +# Koha 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. +# +# You should have received a copy of the GNU General Public License +# along with Koha; if not, see . + +use IO::Socket::INET; +use IO::Socket::Timeout; +use Koha::PosTerminal::Message; +use Errno qw(ETIMEDOUT EWOULDBLOCK); + +use constant { + ERR_CONNECTION_FAILED => -1, + ERR_NO_RESPONSE => -2 +}; + +# auto-flush on socket +$| = 1; + +sub new { + my $class = shift; + my $self = { + _ip => shift, + _port => shift, + _socket => 0, + }; + + bless $self, $class; + return $self; +} + +sub connect { + my ( $self ) = @_; + + $self->{_socket} = new IO::Socket::INET ( + PeerHost => $self->{_ip}, + PeerPort => $self->{_port}, + Proto => 'tcp', + Timeout => 5 + ); + + if ($self->{_socket}) { + IO::Socket::Timeout->enable_timeouts_on($self->{_socket}); + $self->{_socket}->read_timeout(60); + $self->{_socket}->write_timeout(60); + } + + return !!$self->{_socket}; +} + +sub disconnect { + my ( $self ) = @_; + + $self->{_socket}->close(); +} + +sub send { + my ( $self, $message ) = @_; + + # data to send to a server + my $req = $message->getContent(); + my $size = $self->{_socket}->send($req); +} + +sub receive { + my ( $self ) = @_; + + my $socket = $self->{_socket}; + +# my $response = <$socket>; + my $response; + $self->{_socket}->recv($response, 1024); + if (!$response) { + if (( 0+$! == ETIMEDOUT) || (0+$! == EWOULDBLOCK )) { + return ERR_CONNECTION_FAILED; + } + else { + return 0+$!; #ERR_NO_RESPONSE; + } + } + + my $msg = new Koha::PosTerminal::Message(Koha::PosTerminal::Message::DIR_RECEIVED); + $msg->parse($response); + + return $msg; +} + +1; \ No newline at end of file diff --git a/Koha/PosTerminal/Message.pm b/Koha/PosTerminal/Message.pm new file mode 100644 index 0000000..1206337 --- /dev/null +++ b/Koha/PosTerminal/Message.pm @@ -0,0 +1,232 @@ +package Koha::PosTerminal::Message; + +# Copyright 2017 R-Bit Technology, s.r.o. +# +# This file is part of Koha. +# +# Koha 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 3 of the License, or +# (at your option) any later version. +# +# Koha 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. +# +# You should have received a copy of the GNU General Public License +# along with Koha; if not, see . + +use strict; +use warnings; + +use Digest::CRC; +use Koha::PosTerminal::Message::Header; +use Koha::PosTerminal::Message::Field; +use Data::Dumper qw( Dumper ); + +use constant { + STX => "\x02", + ETX => "\x03", + FS => "\x1C", + GS => "\x1D", + CR => "\x0D", + LF => "\x0A" +}; + +use constant { + F_PAID_AMOUNT => "B", + F_CURRENCY_CODE => "I", + F_TRANSACTION_TYPE => "T", + F_RESPONSE_CODE => "R", + F_CARD_NUMBER => "P", + F_CARD_PRODUCT => "J", + F_INVOICE_NUMBER => "S", + F_CODE_PAGE => "f", + F_RECEIPT => "t", + F_TRANSACTION_ID => "n", + F_APPLICATION_ID => "a" +}; + +use constant { + TTYPE_SALE => "00", + TTYPE_PREAUTH => "01", + TTYPE_PREAUTH_COMPLETION => "02", + TTYPE_REVERSAL => "10", + TTYPE_REFUND => "04", + TTYPE_ABORT => "12", + TTYPE_POST_DATA_PRINTING => "16", + TTYPE_REPEAT_LAST_TRANSACTION => "17" +}; + +use constant { + DIR_SENT => "SENT", + DIR_RECEIVED => "RCVD" +}; + +sub new { + my $class = shift; + + my $self = {}; + $self->{_header} = Koha::PosTerminal::Message::Header->new(); + $self->{_fields} = (); + $self->{_isValid} = 0; + $self->{_direction} = shift; + + bless $self, $class; + return $self; +} + +sub getDirection { + my( $self ) = @_; + return $self->{_direction}; +} + +sub getHeader { + my( $self ) = @_; + return $self->{_header}; +} + +sub getContent { + my( $self ) = @_; + + my $msg = $self->getHeader()->getContent(); + foreach my $field (@{$self->{_fields}}) { + $msg .= FS.$field->name.$field->value; + } + + return STX.$msg.ETX; +} + +sub addField { + my ( $self, $fieldName, $value ) = @_; + my $field = Koha::PosTerminal::Message::Field->new({ name => $fieldName, value => $value }); + push(@{$self->{_fields}}, $field); + $self->updateHeader(); +} + +sub getField { + my ( $self, $fieldName ) = @_; + foreach my $field (@{$self->{_fields}}) { + if ( $field->name eq $fieldName ) { + return $field; + } + } + return 0; +} + +sub fieldCount { + my( $self ) = @_; + + return $self->{_fields} ? scalar @{$self->{_fields}} : 0; +} + +sub updateHeader { + my( $self ) = @_; + + my $dataPart = ""; + foreach my $field (@{$self->{_fields}}) { + $dataPart .= FS.$field->name.$field->value; + } + $self->getHeader()->crc($self->getCrcHex($dataPart)); + $self->getHeader()->length(sprintf("%04X", length($dataPart))); +} + +sub getCrcHex { + my( $self, $data ) = @_; + + my $crc = Digest::CRC->new(width=>16, init => 0x0000, xorout => 0x0000, + refout => 0, poly => 0x11021, refin => 0, cont => 0); + $crc->add($data); + my $crcBin = $crc->digest; + return sprintf("%04X",$crcBin); +} + +sub isValid { + my( $self ) = @_; + + return $self->{_isValid}; +} + +sub setValid { + my ( $self, $valid ) = @_; + $self->{_isValid} = $valid; +} + +sub parse { + my ( $self, $response ) = @_; + + my $hdr = $self->getHeader(); + + my $first = substr $response, 0, 1; + my $last = substr $response, -1; + + if (($first eq STX) && ($last eq ETX)) { + $hdr->protocolType(substr $response, 1, 2); + $hdr->protocolVersion(substr $response, 3, 2); + $hdr->terminalID(substr $response, 5, 8); + $hdr->dateTime(substr $response, 13, 12); + $hdr->tags(substr $response, 25, 4); + $hdr->length(substr $response, 29, 4); + $hdr->crc(substr $response, 33, 4); + $self->{_fields} = (); + my $dataPart = substr $response, 37, -1; + if ($hdr->crc eq $self->getCrcHex($dataPart)) { + $self->parseFields($dataPart); + $self->setValid(1); + } + else { + $self->setValid(0); + } + } +# print Dumper($self); + +} + +sub parseFields { + my ( $self, $dataPart ) = @_; + my $fs = FS; + my @fields = split /$fs/, substr $dataPart, 1; + + foreach my $field (@fields) { + my $fname = substr $field, 0, 1; + my $fvalue = substr $field, 1; + $self->addField($fname, $fvalue); + } + return 0; +} + +sub decodeControlCharacters { + my( $self, $msg ) = @_; + + $msg =~ s/[\x02]/\/g; + $msg =~ s/[\x03]/\/g; + $msg =~ s/[\x1C]/\/g; + $msg =~ s/[\x1D]/\/g; + $msg =~ s/[\x0D]/\/g; + $msg =~ s/[\x0A]/\/g; + $msg =~ s/[\xFF]/\<0xFF\>/g; + $msg =~ s/ /\/g; + + return $msg; +} + +sub dumpString { + my( $self ) = @_; + return $self->decodeControlCharacters($self->getContent()); +} + +sub dumpObject { + my( $self ) = @_; + my $msg = $self->getHeader()->dumpObject(); + + $msg .= "data:\n"; +# print Dumper($self); +#die(); + foreach my $field (@{$self->{_fields}}) { + $msg .= " ".$field->name.": '".$field->value."'\n"; + } + return $msg; +} + +1; \ No newline at end of file diff --git a/Koha/PosTerminal/Message/Field.pm b/Koha/PosTerminal/Message/Field.pm new file mode 100644 index 0000000..04547b8 --- /dev/null +++ b/Koha/PosTerminal/Message/Field.pm @@ -0,0 +1,26 @@ +package Koha::PosTerminal::Message::Field; + +# Copyright 2017 R-Bit Technology, s.r.o. +# +# This file is part of Koha. +# +# Koha 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 3 of the License, or +# (at your option) any later version. +# +# Koha 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. +# +# You should have received a copy of the GNU General Public License +# along with Koha; if not, see . + +use strict; +use warnings; + +use base qw(Class::Accessor); +Koha::PosTerminal::Message::Field->mk_accessors(qw(name value)); + +1; \ No newline at end of file diff --git a/Koha/PosTerminal/Message/Header.pm b/Koha/PosTerminal/Message/Header.pm new file mode 100644 index 0000000..129b79e --- /dev/null +++ b/Koha/PosTerminal/Message/Header.pm @@ -0,0 +1,126 @@ +package Koha::PosTerminal::Message::Header; + +# Copyright 2017 R-Bit Technology, s.r.o. +# +# This file is part of Koha. +# +# Koha 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 3 of the License, or +# (at your option) any later version. +# +# Koha 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. +# +# You should have received a copy of the GNU General Public License +# along with Koha; if not, see . + +use strict; +use warnings; + +use DateTime qw(); + +use constant SPC => ' '; +use constant PROTOCOL_TYPE => "B1"; +use constant PROTOCOL_VERSION => "01"; +use constant TIMEZONE => "Europe/Prague"; +use constant CRC_NO_DATA => "A5A5"; +use constant NO_DATA_LENGTH => "0000"; +use constant TAGS_EMPTY => "0000"; +use constant TAGS_SIGNATURE_CHECK => 0x0001; + +use base qw(Class::Accessor); +Koha::PosTerminal::Message::Header->mk_accessors(qw(protocolType protocolVersion terminalID dateTime tags length crc)); + +sub new { + my $class = shift @_; + + my $self = $class->SUPER::new(@_); + $self->protocolType(PROTOCOL_TYPE); + $self->protocolVersion(PROTOCOL_VERSION); + $self->terminalID(0); + $self->dateTime(0); + $self->tags(TAGS_EMPTY); + $self->length(NO_DATA_LENGTH); + $self->crc(CRC_NO_DATA); + + return $self; +} + +sub terminalID { + my($self) = shift; + + if( @_ ) { # Setting + my($terminalID) = @_; + + if (!$terminalID) { + $terminalID = " " x 8; + } + return $self->set('terminalID', $terminalID); + } + else { + return $self->get('terminalID'); + } +} + +sub dateTime { + my($self) = shift; + + if( @_ ) { # Setting + my($dateTime) = @_; + + if (!$dateTime) { + my $dt = DateTime->now(time_zone => TIMEZONE); + $dateTime = $dt->strftime('%y%m%d%H%M%S'); + } + return $self->set('dateTime', $dateTime); + } + else { + return $self->get('dateTime'); + } +} + +sub isSignatureCheckRequired { + my( $self ) = @_; + return hex("0x" . $self->tags) & TAGS_SIGNATURE_CHECK; +} + +sub getContent { + my( $self ) = @_; + my $content = + $self->protocolType + . $self->protocolVersion + . $self->terminalID + . $self->dateTime + . $self->tags + . $self->length + . $self->crc; + return $content; +} + +sub dumpObject { + my( $self ) = @_; + my @dt = ( $self->dateTime =~ m/../g ); + my $obj = + "protocol:\n" + . " type: '".$self->protocolType."'\n" + . " version: '".$self->protocolVersion."'\n" + . "terminal ID: '".$self->terminalID."'\n" + . "date:\n" + . " year: '".$dt[0]."'\n" + . " month: '".$dt[1]."'\n" + . " day: '".$dt[2]."'\n" + . "time:\n" + . " hours: '".$dt[3]."'\n" + . " minutes: '".$dt[4]."'\n" + . " seconds: '".$dt[5]."'\n" + . "tags: '".$self->tags."'\n" + . "length: '".$self->length."'\n" + . "crc: '".$self->crc."'\n"; + + return $obj; +} + +1; \ No newline at end of file diff --git a/Koha/PosTerminal/Transaction.pm b/Koha/PosTerminal/Transaction.pm new file mode 100644 index 0000000..8167fac --- /dev/null +++ b/Koha/PosTerminal/Transaction.pm @@ -0,0 +1,50 @@ +package Koha::PosTerminal::Transaction; + +# This file is part of Koha. +# +# Koha 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 3 of the License, or (at your option) any later +# version. +# +# Koha 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. +# +# You should have received a copy of the GNU General Public License along +# with Koha; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +use Modern::Perl; + +use Carp; + +use Koha::Database; + +use base qw(Koha::Object); + +=head1 NAME + +Koha::PosTerminal::Transactions - Koha pos_terminal_transaction Object class + +=head1 API + +=head2 Class Methods + +=cut + +=head3 type + +=cut + +sub _type { + return 'PosTerminalTransaction'; +} + +1; + +=head1 AUTHOR + +Radek Šiman + +=cut diff --git a/Koha/PosTerminal/Transactions.pm b/Koha/PosTerminal/Transactions.pm new file mode 100644 index 0000000..5bf0b2c --- /dev/null +++ b/Koha/PosTerminal/Transactions.pm @@ -0,0 +1,56 @@ +package Koha::PosTerminal::Transactions; + +# This file is part of Koha. +# +# Koha 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 3 of the License, or (at your option) any later +# version. +# +# Koha 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. +# +# You should have received a copy of the GNU General Public License along +# with Koha; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +use Modern::Perl; + +use Carp; + +use Koha::Database; + +use Koha::PosTerminal::Transaction; + +use base qw(Koha::Objects); + +=head1 NAME + +Koha::PosTerminal::Transactions - Koha PosTerminal Transaction Object set class + +=head1 API + +=head2 Class Methods + +=cut + +=head3 type + +=cut + +sub _type { + return 'PosTerminalTransaction'; +} + +sub object_class { + return 'Koha::PosTerminal::Transaction'; +} + +1; + +=head1 AUTHOR + +Radek Šiman + +=cut diff --git a/koha-tmpl/intranet-tmpl/prog/en/includes/members-pos-terminal-dialog.inc b/koha-tmpl/intranet-tmpl/prog/en/includes/members-pos-terminal-dialog.inc new file mode 100644 index 0000000..f7820f4 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/en/includes/members-pos-terminal-dialog.inc @@ -0,0 +1,16 @@ + + diff --git a/koha-tmpl/intranet-tmpl/prog/en/includes/members-pos-terminal-messages.inc b/koha-tmpl/intranet-tmpl/prog/en/includes/members-pos-terminal-messages.inc new file mode 100644 index 0000000..78a5d7e --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/en/includes/members-pos-terminal-messages.inc @@ -0,0 +1,21 @@ + diff --git a/koha-tmpl/intranet-tmpl/prog/en/modules/admin/preferences/circulation.pref b/koha-tmpl/intranet-tmpl/prog/en/modules/admin/preferences/circulation.pref index 8c66afe..ce19435 100644 --- a/koha-tmpl/intranet-tmpl/prog/en/modules/admin/preferences/circulation.pref +++ b/koha-tmpl/intranet-tmpl/prog/en/modules/admin/preferences/circulation.pref @@ -157,6 +157,18 @@ Circulation: yes: Allow no: "Don't allow" - patrons to submit notes about checked out items. + - + - Payment terminal communicates at IP address + - pref: PosTerminalIP + - and port + - pref: PosTerminalPort + class: integer + - . Leave the IP address blank to disable this payment option. + - + - Payment terminal uses + - pref: PosTerminalCurrencyCode + class: integer + - as currency code number. Please see ISO 4217 for a full list of assigned numbers. Checkout Policy: - diff --git a/koha-tmpl/intranet-tmpl/prog/en/modules/members/boraccount.tt b/koha-tmpl/intranet-tmpl/prog/en/modules/members/boraccount.tt index dc1ff99..07cb04e 100644 --- a/koha-tmpl/intranet-tmpl/prog/en/modules/members/boraccount.tt +++ b/koha-tmpl/intranet-tmpl/prog/en/modules/members/boraccount.tt @@ -75,6 +75,9 @@ [% IF ( account.payment ) %] Reverse Void + [% IF (account.amountoutstanding+0 + account.amount+0 != 0 ) %] + Refund to card + [% END %] [% ELSE %][% SET footerjs = 1 %]   [% END %] @@ -108,7 +111,9 @@ [% MACRO jsinclude BLOCK %] [% INCLUDE 'datatables.inc' %] [% INCLUDE 'columns_settings.inc' %] + [% INCLUDE 'members-pos-terminal-messages.inc' %] [% Asset.js("js/members-menu.js") %] + [% Asset.js("js/payments.js") %] [% END %] +[% INCLUDE 'members-pos-terminal-dialog.inc' %] [% INCLUDE 'intranet-bottom.inc' %] diff --git a/koha-tmpl/intranet-tmpl/prog/en/modules/members/paycollect.tt b/koha-tmpl/intranet-tmpl/prog/en/modules/members/paycollect.tt index fb42d43..459eb6b 100644 --- a/koha-tmpl/intranet-tmpl/prog/en/modules/members/paycollect.tt +++ b/koha-tmpl/intranet-tmpl/prog/en/modules/members/paycollect.tt @@ -46,7 +46,7 @@ [% END %] [% IF ( pay_individual ) %] -
+ @@ -100,6 +100,14 @@ [% END %] +[%# FIXME - add this to payment type select box %] +[% IF Koha.Preference('PosTerminalIP') %] +
  • + + +
  • +[% END %] + @@ -203,7 +211,9 @@ [% MACRO jsinclude BLOCK %] + [% INCLUDE 'members-pos-terminal-messages.inc' %] [% Asset.js("js/members-menu.js") %] + [% Asset.js("js/payments.js") %] [% END %] +[% INCLUDE 'members-pos-terminal-dialog.inc' %] [% INCLUDE 'intranet-bottom.inc' %] diff --git a/koha-tmpl/intranet-tmpl/prog/js/payments.js b/koha-tmpl/intranet-tmpl/prog/js/payments.js new file mode 100644 index 0000000..ac7766c --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/payments.js @@ -0,0 +1,205 @@ +var posTransactionTimer = 0; +var formPayment = 0; +var posTransactionSucceeded = 0; + +function callSvcApi(data, callbacks) { + $.post('/cgi-bin/koha/svc/pos_terminal', data, function( response ) { + if (callbacks.success) { + callbacks.success(response); + } + }) + .fail(function(response) { + if (callbacks.fail) { + callbacks.fail(response); + } + }) + .always(function(response) { + if (callbacks.always) { + callbacks.always(response); + } + }); +} + +function showMessage(status) { + var msg = ""; + + if (status == "new") { + msg = MSG_POS_IN_PROGRESS; + } + else if (status == "success") { + msg = MSG_POS_SUCESS; + } + else if (status == "init") { + msg = MSG_POS_INIT; + } + else if (status == "request-payment") { + msg = MSG_POS_REQUEST_PAYMENT; + } + else if (status == "request-refund") { + msg = MSG_POS_REQUEST_REFUND; + } + else if (status == "sent-request") { + msg = MSG_POS_SENT_REQUEST; + } + else if (status == "received-message") { + msg = MSG_POS_RECEIVED_MESSAGE; + } + else if (status == "received-response") { + msg = MSG_POS_RECEIVED_RESPONSE; + } + else if (status == "sent-confirmation") { + msg = MSG_POS_SENT_CONFIRMATION; + } + else if (status == "activity-message") { + msg = MSG_POS_ACTIVITY_MESSAGE; + } + else if (status == "disconnected") { + msg = MSG_POS_DISCONNECTED; + } + else if (status == "connection-error") { + msg = "Connection error."; + } + else if (status == "ERR_TRANSACTION_REJECTED") { + msg = MSG_POS_ERR_TRANSACTION_REJECTED; + } + else if (status == "ERR_REQUEST_REJECTED") { + msg = MSG_POS_ERR_REQUEST_REJECTED; + } + else if (status == "ERR_CONNECTION_FAILED") { + msg = MSG_POS_ERR_CONNECTION_FAILED; + } + else if (status.lastIndexOf("ERR_") === 0) { + msg = MSG_POS_ERR + " " + status + ". " + MSG_POS_ERR_CONNECTION_ABORTED; + } + else if (status == "expired") { + msg = MSG_POS_ERR_EXPIRED + } + else { + msg = status; + } + + $("#card_payment_dialog .transaction-message").text(msg); +} + +function checkStatus(transaction_id) { + callSvcApi( + {transaction_id: transaction_id, action: "status"}, + { + success: function(xml) { + var status = $(xml).find('status').first().text(); + showMessage(status); + if ((status.lastIndexOf("ERR_") === 0) || (status == "success") || (status == "expired")) { + formPayment = (status == "success") ? formPayment : 0; + posTransactionSucceeded = (status == "success"); + $("#card_payment_dialog button").prop("disabled", false); + } + else { + posTransactionTimer = setTimeout(function() { checkStatus(transaction_id); }, 5000); + } + } + } + ); +} + +// ---------------- payments +function requestPayment(transaction_id) { + showMessage("request-payment"); + callSvcApi( + { + transaction_id: transaction_id, + action: "request-payment", + paid: parseInt($('#paid').val()) + }, + { } + ); +} + +function startPaymentTransaction(accountlines_id) { + showMessage('init'); + $("#card_payment_dialog button").click(closePaymentTransaction); + $("#card_payment_dialog button").prop("disabled", true); + $("#card_payment_dialog").modal({ + backdrop: 'static', + keyboard: false + }); + callSvcApi( + {accountlines_id: accountlines_id}, + { + success: function(xml) { + var transaction_id = $(xml).find('transaction_id').first().text(); + posTransactionTimer = setTimeout(function() { checkStatus(transaction_id); }, 5000); + requestPayment(transaction_id); + }, + fail: function(xml) { alert(MSG_POS_ERR + " " + $(xml).find('status').first().text()); }, + } + ); +} + +function closePaymentTransaction() { + clearTimeout(posTransactionTimer); + if (formPayment && posTransactionSucceeded) { + formPayment.submit(); + } + else { + $("body, form input[type='submit'], form button[type='submit'], form a").removeClass('waiting'); + } +} + +function makePayment(form) { + if ($("#bycard").is(':checked')) { + formPayment = form; + startPaymentTransaction($("#accountlines_id").val()) + } + else { + formPayment = 0; + form.submit(); + } + + return false; // always return false not to submit the form automatically +} + +// ---------------- refund +function refundPayment(href, accountlines_id, amount) { + showMessage('init'); + $("#card_payment_dialog button").click((function() { closeRefundTransaction(href); })); + $("#card_payment_dialog button").prop("disabled", true); + $("#card_payment_dialog").modal({ + backdrop: 'static', + keyboard: false + }); + callSvcApi( + {accountlines_id: accountlines_id}, + { + success: function(xml) { + var transaction_id = $(xml).find('transaction_id').first().text(); + $("#card_payment_dialog button").click((function() { closeRefundTransaction(href, transaction_id); })); + posTransactionTimer = setTimeout(function() { checkStatus(transaction_id); }, 5000); + requestRefund(transaction_id, amount); + }, + fail: function(xml) { alert(MSG_POS_ERR + " " + $(xml).find('status').first().text()); }, + } + ); +} + +function requestRefund(transaction_id, amount) { + showMessage("request-refund"); + callSvcApi( + { + transaction_id: transaction_id, + action: "request-refund", + paid: amount + }, + { } + ); +} + +function closeRefundTransaction(href, transaction_id) { + clearTimeout(posTransactionTimer); + if (posTransactionSucceeded) { + window.location.href = href; + } + else { + $("body, form input[type='submit'], form button[type='submit'], form a").removeClass('waiting'); + } + posTransactionSucceeded = 0; +} diff --git a/members/paycollect.pl b/members/paycollect.pl index 0f98a9d..e8da8ec 100755 --- a/members/paycollect.pl +++ b/members/paycollect.pl @@ -62,6 +62,7 @@ my $branch = C4::Context->userenv->{'branch'}; my $total_due = $patron->account->balance; my $total_paid = $input->param('paid'); +my $by_card = $input->param('bycard'); my $individual = $input->param('pay_individual'); my $writeoff = $input->param('writeoff_individual'); diff --git a/svc/pos_terminal b/svc/pos_terminal new file mode 100755 index 0000000..593b59f --- /dev/null +++ b/svc/pos_terminal @@ -0,0 +1,250 @@ +#!/usr/bin/perl + +# Copyright 2017 R-Bit technology, s.r.o. +# +# This file is part of Koha. +# +# Koha 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 3 of the License, or +# (at your option) any later version. +# +# Koha 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. +# +# You should have received a copy of the GNU General Public License +# along with Koha; if not, see . +# + +use Modern::Perl; + +use CGI qw ( -utf8 ); +use C4::Auth qw( check_api_auth ); +use C4::Context; +use JSON qw( to_json ); +use IO::Socket::INET; +use Koha::PosTerminal::Message; +use Koha::PosTerminal::Client; +use Koha::PosTerminal::Transaction; +use Koha::PosTerminal::Transactions; +use XML::Simple; +use Scalar::Util qw( looks_like_number ); +use DateTime; +use Data::Dumper; + +my $query = new CGI; +my ($status, $cookie, $sessionID) = check_api_auth($query, { updatecharges => 1 } ); +my $transactionId = $query->param('transaction_id'); +my $accountlinesId = $query->param('accountlines_id'); +my $action = $query->param('action') || q{}; +my $result = { + status => $status, + transaction_id => defined($transactionId) ? $transactionId : -1 +}; + +binmode STDOUT, ":encoding(UTF-8)"; +print $query->header( + -type => 'text/xml', + -charset => 'UTF-8' +); + +if ($status eq 'ok') { + + if (!$action) { + start_transaction($result, $accountlinesId); + } + + elsif ($action eq 'status') { + get_transaction_status($result, $transactionId); + } + + elsif (($action eq 'request-payment') || ($action eq 'request-refund') || ($action eq 'abort')) { + my $transaction = Koha::PosTerminal::Transactions->find($transactionId); + my $client = new Koha::PosTerminal::Client( + C4::Context->preference('PosTerminalIP'), + C4::Context->preference('PosTerminalPort'), + ); + + if ($client->connect()) { + $transaction->set({status => 'connected'})->store(); + + if ($action eq 'abort') { + my $abort = abort_transaction($client, $transaction); + my $field = $abort->getField($abort->F_RESPONSE_CODE); + $result->{response_code} = $field ? $field->value : 0; + $result->{status} = "abort"; + $transaction->set({status => $result->{status}, response_code => $result->{response_code}})->store(); + print XMLout($result, NoAttr => 1, RootName => 'response', XMLDecl => 1); + exit 0; + } + + my $transactionRequest = send_transaction_request($client, $transaction, $action eq 'request-payment' ? Koha::PosTerminal::Message->TTYPE_SALE : Koha::PosTerminal::Message->TTYPE_REFUND, scalar $query->param('paid')); + + my $transactionMessage = receive_transaction_message($client, $transaction); + + my $field = $transactionMessage->getField($transactionMessage->F_RESPONSE_CODE); + $result->{response_code} = $field ? $field->value : 0; + if ($result->{response_code} <= 10) { + + my $transactionResponse = receive_transaction_response($client, $transaction); + if (!looks_like_number($transactionResponse)) { + send_confirmation_message($client, $transaction, $transactionResponse); + + $client->disconnect(); + $transaction->set({status => 'disconnected'})->store(); + + $field = $transactionResponse->getField($transactionResponse->F_RESPONSE_CODE); + $result->{response_code} = $field ? $field->value : 0; + + if ($result->{response_code} <= 10) { + if ( $field = $transactionResponse->getField($transactionResponse->F_CARD_NUMBER) ) { + $result->{cardnumber} = $field->value; + $result->{status} = "success"; + } + else { + $result->{status} = "ERR_NO_CARD_NUMBER"; + } + } + else { + $result->{status} = "ERR_TRANSACTION_REJECTED"; + } + } + else { + $result->{status} = "ERR_CONNECTION_FAILED"; + } + } + else { + $result->{status} = "ERR_REQUEST_REJECTED"; + } + } + else { + $result->{status} = "ERR_NO_CONNECTION"; + } + $transaction->set({status => $result->{status}, response_code => $result->{response_code}})->store(); + } + elsif ($action eq 'request-refund') { + } +} + +print XMLout($result, NoAttr => 1, RootName => 'response', XMLDecl => 1); + +exit 0; + +sub log_communication { + my ( $terminalMsg, $transactionId ) = @_; + + my $transaction = Koha::PosTerminal::Transactions->find($transactionId); + my $now = DateTime->now(); + my $message = "[" . $now->ymd . " " . $now->hms ."] data: "; + if (looks_like_number($terminalMsg)) { + $message .= "error " . $terminalMsg . "\n"; + } + else { + $message .= $terminalMsg->fieldCount() . ", " . $terminalMsg->getDirection() . ": " . $terminalMsg->decodeControlCharacters($terminalMsg->getContent()) . "\n"; + } + $transaction->set({message_log => (defined $transaction->message_log ? $transaction->message_log : "") . $message})->store(); +} + +sub start_transaction { + my ( $result, $accountlinesId ) = @_; + + my $transaction = Koha::PosTerminal::Transaction->new({accountlines_id => $accountlinesId, status => 'new' })->store(); + $result->{status} = $transaction->status; + $result->{transaction_id} = $transaction->id; +} + +sub abort_transaction { + my ( $client, $transaction ) = @_; + + # send abort message + my $abort = new Koha::PosTerminal::Message(Koha::PosTerminal::Message::DIR_SENT); + my $hdrAbort = $abort->getHeader(); +die(Dumper($transaction ? $transaction->getHeader() : "BUBU")); + my $hdrTransaction = $transaction->getHeader(); + + $hdrAbort->dateTime($hdrTransaction->dateTime()); + $hdrAbort->terminalID($hdrTransaction->terminalID()); + $hdrAbort->protocolType($hdrTransaction->protocolType()); + $hdrAbort->protocolVersion($hdrTransaction->protocolVersion()); + $abort->addField($abort->F_TRANSACTION_TYPE, $abort->TTYPE_ABORT); + $client->send($abort); + + $abort->set({status => 'abort'})->store(); + log_communication($abort, $transaction->id); + + return $abort; +} + +sub get_transaction_status { + my ( $result, $transactionId ) = @_; + + my $transaction = Koha::PosTerminal::Transactions->find($transactionId); + $result->{status} = $transaction->status; +} + +sub send_transaction_request { + my ( $client, $transaction, $type, $paid ) = @_; + + # send transaction request + my $transactionRequest = new Koha::PosTerminal::Message(Koha::PosTerminal::Message::DIR_SENT); + $transactionRequest->getHeader()->dateTime(0); + $transactionRequest->addField($transactionRequest->F_TRANSACTION_TYPE, $type); + $transactionRequest->addField($transactionRequest->F_PAID_AMOUNT, $paid * 100); + $transactionRequest->addField($transactionRequest->F_INVOICE_NUMBER, $transaction->accountlines_id); + $transactionRequest->addField($transactionRequest->F_CURRENCY_CODE, C4::Context->preference('PosTerminalCurrencyCode')); + $client->send($transactionRequest); + $transaction->set({status => 'sent-request'})->store(); + log_communication($transactionRequest, $transaction->id); + + return $transactionRequest; +} + +sub receive_transaction_message { + my ( $client, $transaction ) = @_; + + # receive transaction message + my $transactionMessage = $client->receive(); + + $transaction->set({status => 'received-message'})->store(); + log_communication($transactionMessage, $transaction->id); + + return $transactionMessage; +} + +sub receive_transaction_response { + my ( $client, $transaction ) = @_; + my $transactionResponse; + my $status; + + # receive transaction response + for (;;) { + $transactionResponse = $client->receive(); + $status = looks_like_number($transactionResponse) ? 'ERR_CONNECTION_FAILED' + : ($transactionResponse->fieldCount() ? 'received-response' : 'activity-message'); + $transaction->set({status => $status})->store(); + log_communication($transactionResponse, $transaction->id); + + last if (looks_like_number($transactionResponse) || $transactionResponse->fieldCount()); + } + + return $transactionResponse; +} + +sub send_confirmation_message { + my ( $client, $transaction, $transactionResponse ) = @_; + + # send confirmation message + my $confirmation = new Koha::PosTerminal::Message(Koha::PosTerminal::Message::DIR_SENT); + my $hdrConfirm = $confirmation->getHeader(); + my $hdrResponse = $transactionResponse->getHeader(); + $hdrConfirm->dateTime($hdrResponse->dateTime()); + $hdrConfirm->terminalID($hdrResponse->terminalID()); + $hdrConfirm->protocolType($hdrResponse->protocolType()); + $hdrConfirm->protocolVersion($hdrResponse->protocolVersion()); + $client->send($confirmation); + + $transaction->set({status => 'sent-confirmation'})->store(); + log_communication($confirmation, $transaction->id); +} -- 2.1.4