From 76c04a1435b312d5af29dae6e70c132e81dad7cb 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 +++++ installer/data/mysql/sysprefs.sql | 3 + .../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 | 12 +- koha-tmpl/intranet-tmpl/prog/js/payments.js | 205 +++++++++++++++++ members/paycollect.pl | 1 + svc/pos_terminal | 250 +++++++++++++++++++++ 15 files changed, 1115 insertions(+), 2 deletions(-) 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..bc5be26 --- /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/installer/data/mysql/sysprefs.sql b/installer/data/mysql/sysprefs.sql index ef83c3d..794a3d0 100644 --- a/installer/data/mysql/sysprefs.sql +++ b/installer/data/mysql/sysprefs.sql @@ -410,6 +410,9 @@ INSERT INTO systempreferences ( `variable`, `value`, `options`, `explanation`, ` ('PayPalSignature', '', NULL , 'Your PayPal API signature', 'Free'), ('PayPalUser', '', NULL , 'Your PayPal API username ( email address )', 'Free'), ('Persona','0','','Use Mozilla Persona for login','YesNo'), +('PosTerminalCurrencyCode', '', NULL , 'POS Terminal currency code number', 'Integer') +('PosTerminalIP', '', NULL , 'POS Terminal IP address', 'Free'), +('PosTerminalPort', '', NULL , 'POS Terminal port', 'Integer'), ('PrefillItem','0','','When a new item is added, should it be prefilled with last created item values?','YesNo'), ('previousIssuesDefaultSortOrder','asc','asc|desc','Specify the sort order of Previous Issues on the circulation page','Choice'), ('printcirculationslips','1','','If ON, enable printing circulation receipts','YesNo'), 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..686c934 --- /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 e69e37f..350f2a9 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 @@ -136,6 +136,18 @@ Circulation: yes: Show no: "Do not show" - all items in the "Checked-in items" list, even items that were not checked out. + - + - 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 ca0a804..dd8216c 100644 --- a/koha-tmpl/intranet-tmpl/prog/en/modules/members/boraccount.tt +++ b/koha-tmpl/intranet-tmpl/prog/en/modules/members/boraccount.tt @@ -3,6 +3,8 @@ [% INCLUDE 'doc-head-open.inc' %] Koha › Patrons › Account for [% INCLUDE 'patron-title.inc' %] [% INCLUDE 'doc-head-close.inc' %] +[% INCLUDE 'members-pos-terminal-messages.inc' %] + [% INCLUDE 'datatables.inc' %]