From e8560852bee86575442c9fce9fdc5c6e66880a87 Mon Sep 17 00:00:00 2001 From: Lari Taskula Date: Fri, 16 Oct 2015 11:32:13 +0000 Subject: [PATCH] Bug 15654: Integrate cash register system into Koha (An Example: case CPU) Integrates Koha with cash register system. Adds "payments_transactions" and "payments_transactions_accountlines" database tables to hold the cash register system payments. Adds REST API endpoints - /api/v1/pos/cpu/100 GET information about a payment - /api/v1/pos/cpu/100/report POST to tell Koha that payment is completed / cancelled. Adds some configurations for the integration to koha-conf. Intranet template modifications for the integration. After payment is sent to the cash register server, a loading screen is shown until the payment is either completed or cancelled at the cash register. Adds system preferences to enable/disable the integration and to map Koha fines to codes recognized by the cash register (as YAML-config). Ability to set SSL certificates for the connection between Koha server and the cash register server. ----------------------------------------------------------------------- Step by step how it works: - Borrower wants to pay fines. - Librarian confirms to pay the fines. - Payment is created into payments_transactions. The selected accountlines related to this payment are stored into payments_transactions_accountlines. - A new loading screen opens. An Ajax call is made to Koha server. - Koha receives the call and starts to go through the selected payments. - Koha "translates" Koha-fine-types (accounttypes) into codes recognized by the cash register and forms a JSON object for the payment. - JSON is sent as a long polling request to cash register server. - Librarian completes payment at cash register. Koha receives a response. - Ajax call gets an response and tells the librarian whether the payment was completed/cancelled. - REST API receives a message from the cash register server. The message tells whether the payment was completed/cancelled. According to this information, the payment at "payments_transactions" table gets an updated status. - Librarian is forwarded into Account tab of Borrower's Fines. If the payment was paid, it should be now shown. ----------------------------------------------------------------------- THIS PATCH ONLY SUPPORTS OUR LOCAL PROVIDER. It is provided as an example in hope to start discussion whether a feature like this is wanted in the Koha community. This patch cannot easily be tested with the actual cash register server, because the server is not open source. However, if you wish to proceed into actually testing the integration, leave a comment in Bug 15654 and we can discuss it further. One possibility would be to create a simple script that responds the way the integration expects. --- C4/Accounts.pm | 2 +- C4/OPLIB/CPUIntegration.pm | 443 +++++++++++++++++++++ Koha/PaymentsTransaction.pm | 271 +++++++++++++ Koha/PaymentsTransactions.pm | 36 ++ Koha/REST/V1/POSIntegration.pm | 57 +++ Koha/Schema/Result/PaymentsTransaction.pm | 169 ++++++++ .../Result/PaymentsTransactionsAccountline.pm | 112 ++++++ api/v1/swagger.json | 129 ++++++ etc/koha-conf.xml | 18 + etc/koha-httpd.conf | 12 + ...7-CPU_Integration-Add_table_for_transactions.pl | 51 +++ installer/data/mysql/kohastructure.sql | 41 ++ .../prog/en/modules/admin/preferences/tools.pref | 14 + .../prog/en/modules/members/boraccount.tt | 49 ++- .../prog/en/modules/members/paycollect.tt | 361 ++++++++++++++++- members/boraccount.pl | 20 + members/paycollect.pl | 44 ++ t/db_dependent/CPUIntegration.t | 208 ++++++++++ 18 files changed, 2032 insertions(+), 5 deletions(-) create mode 100644 C4/OPLIB/CPUIntegration.pm create mode 100644 Koha/PaymentsTransaction.pm create mode 100644 Koha/PaymentsTransactions.pm create mode 100644 Koha/REST/V1/POSIntegration.pm create mode 100644 Koha/Schema/Result/PaymentsTransaction.pm create mode 100644 Koha/Schema/Result/PaymentsTransactionsAccountline.pm create mode 100644 installer/data/mysql/atomicupdate/KD#377-CPU_Integration-Add_table_for_transactions.pl create mode 100644 t/db_dependent/CPUIntegration.t diff --git a/C4/Accounts.pm b/C4/Accounts.pm index 4f5f8d4..7079815 100644 --- a/C4/Accounts.pm +++ b/C4/Accounts.pm @@ -601,7 +601,7 @@ sub recordpayment_selectaccts { my $dbh = C4::Context->dbh; my $newamtos = 0; my $accdata = q{}; - my $branch = C4::Context->userenv->{branch}; + my $branch = C4::Context->userenv->{branch} if C4::Context->userenv; my $amountleft = $amount; my $manager_id = 0; $manager_id = C4::Context->userenv->{'number'} if C4::Context->userenv; diff --git a/C4/OPLIB/CPUIntegration.pm b/C4/OPLIB/CPUIntegration.pm new file mode 100644 index 0000000..6373c02 --- /dev/null +++ b/C4/OPLIB/CPUIntegration.pm @@ -0,0 +1,443 @@ +package C4::OPLIB::CPUIntegration; + +use Modern::Perl; + +use C4::Accounts; +use C4::Branch; +use C4::Context; +use C4::Log; + +use Data::Dumper qw(Dumper); +use Digest::SHA qw(sha256_hex); +use Encode; +use Net::SSL; +use YAML::XS; + +use Koha::Borrower; +use Koha::Borrowers; +use Koha::PaymentsTransaction; +use Koha::PaymentsTransactions; + +use Koha::Exception::NoSystemPreference; + +use bignum; + +=head1 FUNCTIONS + +=head2 InitializePayment + + &InitializePayment($args); + +Initializes the accountlines that will be sent to CPU. + +Returns the payment HASH. + +=cut + +sub InitializePayment { + my ($args) = shift; + + my $dbh = C4::Context->dbh; + my $borrowernumber = $args->{borrowernumber}; + my @selected = @{ $args->{selected} }; + + # Hash containing CPU format of payment + my $payment; + $payment->{Office} = $args->{office}; + $payment->{Products} = []; + + @selected = sort { $a <=> $b } @selected if @selected > 1; + + my $total_price = 0; + my $money_left = _convert_to_cents($args->{total_paid}); + + my $use_selected = (@selected > 0) ? "AND accountlines_id IN (?".+(",?") x (@selected-1).")" : ""; + my $sql = "SELECT * FROM accountlines WHERE borrowernumber=? AND (amountoutstanding<>0) ".$use_selected." ORDER BY date"; + my $sth = $dbh->prepare($sql); + + $sth->execute($borrowernumber, @selected); + + # Create a new transaction + my $transaction = Koha::PaymentsTransaction->new()->set({ + borrowernumber => $borrowernumber, + status => "unsent", + description => $args->{payment_note} || '', + })->store(); + + while ( (my $accdata = $sth->fetchrow_hashref) and $money_left > 0) { + my $product; + + $product->{Code} = $accdata->{'accounttype'}; + $product->{Amount} = 1; + $product->{Description} = $accdata->{'description'}; + + if ( _convert_to_cents($accdata->{'amountoutstanding'}) >= $money_left ) { + $product->{Price} = $money_left; + $money_left = 0; + } else { + $product->{Price} = _convert_to_cents($accdata->{'amountoutstanding'}); + $money_left -= _convert_to_cents($accdata->{'amountoutstanding'}); + } + push $payment->{Products}, $product; + $total_price += $product->{Price}; + + $transaction->AddRelatedAccountline($accdata->{'accountlines_id'}, $product->{Price}); + } + + $transaction->set({ price_in_cents => $total_price })->store(); + + my $borrower = Koha::Borrowers->cast($transaction->borrowernumber); + + my $description = $borrower->surname . ", " . $borrower->firstname . " (".$borrower->cardnumber.")"; + + $payment->{ApiVersion} = "2.0"; + $payment->{Source} = C4::Context->config('pos')->{'CPU'}->{'source'}; + $payment->{Id} = $transaction->transaction_id; + $payment->{Mode} = C4::Context->config('pos')->{'CPU'}->{'mode'}; + $payment->{Description} = $description; + $payment->{Products} = AccountTypesToItemNumbers($transaction->GetProducts(), C4::Branch::mybranch()); + + my $notificationAddress = C4::Context->config('pos')->{'CPU'}->{'notificationAddress'}; + my $transactionNumber = $transaction->transaction_id; + $notificationAddress =~ s/{invoicenumber}/$transactionNumber/g; + + $payment->{NotificationAddress} = $notificationAddress; # url for report + + $payment = _validate_cpu_hash($payment); # Remove semicolons + $payment->{Hash} = CalculatePaymentHash($payment); + + $payment = _validate_cpu_hash($payment); # Convert strings to int + $payment->{"send_payment"} = "POST"; + + return $payment; +} + +=head2 SendPayment + + SendPayment($payment); + +Sends a payment to CPU. $payment is a HASH that needs to be in the CPU format with +SHA-256 hash calculated correctly. + +Returns JSON-encoded response from CPU. See the CPU document for response protocol. + +=cut + +sub SendPayment { + my $content = shift; + my $response; + + $response = eval { + my $payment = $content; + + delete $payment->{send_payment} if $payment->{send_payment}; + + # Convert strings to integer for JSON + $payment = _validate_cpu_hash($payment); + + # Construct JSON object + $content = JSON->new->utf8->canonical(1)->encode($payment); + + my $transaction = Koha::PaymentsTransactions->find($payment->{Id}); + + if (C4::Context->config('pos')->{'CPU'}->{'ssl_cert'}) { + # Define SSL certificate + $ENV{HTTPS_CERT_FILE} = C4::Context->config('pos')->{'CPU'}->{'ssl_cert'}; + $ENV{HTTPS_KEY_FILE} = C4::Context->config('pos')->{'CPU'}->{'ssl_key'}; + $ENV{HTTPS_CA_FILE} = C4::Context->config('pos')->{'CPU'}->{'ssl_ca_file'}; + } + + my $ua = LWP::UserAgent->new; + + if (C4::Context->config('pos')->{'CPU'}->{'ssl_cert'}) { + $ua->ssl_opts({ + SSL_use_cert => 1, + }); + } + + $ua->timeout(500); + + my $req = HTTP::Request->new(POST => C4::Context->config('pos')->{'CPU'}->{'url'}); + $req->header('content-type' => 'application/json'); + $req->content($content); + + $transaction->set({ status => "pending" })->store(); + + my $request = $ua->request($req); + + # There is an issue where the call above fails for unknown reasons, but REST API got + # confirmation of successful payment. We need to be able to recognize payments + # that have been completed during $ua->request($req) by REST API and not set them to + # "cancelled" status even if $ua->request($req) returns some HTTP error code. + # At this point, payment should still be "pending". Refresh payment status. + + $transaction = Koha::PaymentsTransactions->find($payment->{Id}); + my $payment_already_paid = 1 if $transaction->status eq "paid"; # Already paid via REST API! + return JSON->new->utf8->canonical(1)->encode({ Status => '1' }) if $payment_already_paid; + + if ($request->{_rc} != 200) { + # Did not get HTTP 200, some error happened! + $transaction->set({ status => "cancelled", description => $request->{_content} })->store(); + return JSON->new->utf8->canonical(1)->encode({ error => $request->{_content}, Status => '89' }); + } + + my $response = JSON->new->utf8->canonical(1)->decode($request->{_content}); + + # Calculate response checksum and return error if they do not match + my $hash = CalculateResponseHash($response); + + if ($hash ne $response->{Hash}) { + $transaction->set({ status => "cancelled", description => "Invalid hash" })->store(); + return JSON->new->utf8->canonical(1)->encode({ error => "Invalid hash", Status => $response->{Status} }); + } + + return JSON->new->utf8->canonical(1)->encode($response); + }; + + if ($@) { + my $transaction = Koha::PaymentsTransactions->find($content->{Id}); + my $payment_already_paid = 1 if $transaction->status eq "paid"; # Already paid via REST API! + return JSON->new->utf8->canonical(1)->encode({ Status => '1' }) if $payment_already_paid; + $transaction->set({ status => "cancelled", description => $@ })->store(); + return JSON->new->utf8->canonical(1)->encode({ error => "Error: " . $@, Status => '88' }); + } + + return $response; +} + +=head2 HandleResponseStatus + + HandleResponseStatus($code, $transaction) + +Sets the correct transaction status according to the status code in CPU response. + +Returns a Koha::PaymentsTransaction object + +=cut +sub HandleResponseStatus { + my ($code, $transaction) = @_; + + my $status = getResponseString($code); + + $transaction->set($status)->store(); # set the status + + return $transaction; +} + +=head2 GetResponseString + + GetResponseString($statuscode) + + Converts CPU Status code into string recognized by payments_transactions.status + e.g. paid, cancelled, pending + +Returns status as string + +=cut +sub GetResponseString { + my ($code) = @_; + + my $status; + $status->{status} = "cancelled"; # default status + + if ($code == 0) { + # Payment was cancelled + } + elsif ($code == 1) { + # Payment was successful + $status->{status} = "paid"; + } + elsif ($code == 2) { + # Payment is pending + $status->{status} = "pending"; + } + elsif ($code == 97) { + # Id was duplicate (duplicate transaction id - different hash) + $status->{description} = "ERROR 97: Duplicate id"; + } + elsif ($code == 98) { + # System error + $status->{description} = "ERROR 98: System error"; + } + elsif ($code == 99) { + # Invalid invoice + $status->{description} = "ERROR 99: Invalid invoice"; + } + else { + $status->{description} = "Unknown status"; + } + + return $status; +} + +=head2 hasBranchEnabledIntegration + + hasBranchEnabledIntegration($branch); + + Checks if the $branch has enabled POS integration. Integration is enabled + when the systempreference "cpuitemnumber" YAML config has mapping of + Koha-itemtypes to CPU-itemnumbers for $branch. + +Returns 1 if yes, otherwise 0. + +=cut +sub hasBranchEnabledIntegration { + my ($branch) = @_; + + # Load YAML conf from syspref cpuitemnumbers + my $pref = C4::Context->preference("cpuitemnumbers"); + return 0 unless $pref; + my $config = YAML::XS::Load( + Encode::encode( + 'UTF-8', + $pref, + Encode::FB_CROAK + )); + + return 0 unless $config->{$branch}; + return 1; +} + +=head2 AccountTypesToItemNumbers + + AccountTypesToItemNumbers($products, $branch); + +Maps Koha-itemtypes (accountlines.accounttype) to CPU itemnumbers. + +This is defined in system preference "cpuitemnumbers". + +Products is an array of Product (HASH) that are in the format of CPU-document. + +Returns an ARRAY of products (HASH). + +=cut +sub AccountTypesToItemNumbers { + my ($products, $branch) = @_; + + # Load YAML conf from syspref cpuitemnumbers + my $pref = C4::Context->preference("cpuitemnumbers"); + Koha::Exception::NoSystemPreference->throw( error => "YAML configuration in system preference 'cpuitemnumbers' is not defined! Cannot assign item numbers for accounttypes." ) unless $pref; + my $config = YAML::XS::Load( + Encode::encode( + 'UTF-8', + $pref, + Encode::FB_CROAK + )); + + Koha::Exception::NoSystemPreference->throw( error => "No item number configuration for branch '".$branch."'. Configure system preference 'cpuitemnumbers'") unless $config->{$branch}; + + my $modified_products; + + for my $product (@$products){ + my $mapped_product = $product; + + # If accounttype is mapped to an item number + if ($config->{$branch}->{$product->{Code}}) { + $mapped_product->{Code} = $config->{$branch}->{$product->{Code}} + } else { + # Else, try to use accounttype "Default" + Koha::Exception::NoSystemPreference->throw( error => "Could not assign item number to accounttype '".$product->{Code}."'. Configure system preference 'cpuitemnumbers' with parameters 'Default'.") unless $config->{$branch}->{'Default'}; + + $mapped_product->{Code} = $config->{$branch}->{'Default'}; + } + + push @$modified_products, $mapped_product; + } + + return $modified_products; +} + + +=head2 CalculatePaymentHash + + CalculatePaymentHash($response); + +Calculates SHA-256 hash from our payment hash. Returns the SHA-256 string. + +=cut + +sub CalculatePaymentHash { + my $invoice = shift; + my $data; + + foreach my $param (sort keys $invoice){ + next if $param eq "Hash"; + my $value = $invoice->{$param}; + + if (ref($invoice->{$param}) eq 'ARRAY') { + my $product_hash = $value; + $value = ""; + foreach my $product (values $product_hash){ + foreach my $product_data (sort keys $product){ + $value .= $product->{$product_data} . "&"; + } + } + $value =~ s/&$//g + } + + $data .= $value . "&"; + } + + $data .= C4::Context->config('pos')->{'CPU'}->{'secretKey'}; + $data = Encode::encode_utf8($data); + return Digest::SHA::sha256_hex($data); +} + +=head2 CalculateResponseHash + + CalculateResponseHash($response); + +Calculates SHA-256 hash from CPU's response. Returns the SHA-256 string. + +=cut + +sub CalculateResponseHash { + my $resp = shift; + my $data = ""; + + $data .= $resp->{Source} if defined $resp->{Source}; + $data .= "&" . $resp->{Id} if defined $resp->{Id}; + $data .= "&" . $resp->{Status} if defined $resp->{Status}; + $data .= "&" . $resp->{Reference} if defined $resp->{Reference}; + $data .= "&" . C4::Context->config('pos')->{'CPU'}->{'secretKey'}; + + $data =~ s/^&//g; + + $data = Digest::SHA::sha256_hex($data); + return $data; +} + +sub _validate_cpu_hash { + my $invoice = shift; + + # CPU does not like a semicolon. Go through the fields and make sure + # none of the fields contain ';' character (from CPU documentation) + foreach my $field (keys $invoice){ + $invoice->{$field} =~ s/;/\x{037E}/g; # Replace semicolon with a Greek question mark (;) + } + + $invoice->{Mode} = int($invoice->{Mode}); + foreach my $product (@{ $invoice->{Products} }){ + foreach my $product_field (keys $product){ + $product->{$product_field} =~ s/;/\x{037E}/g; # Replace semicolon with a Greek question mark (;) + } + $product->{Amount} = int($product->{Amount}) if $product->{Amount}; + $product->{Price} = int($product->{Price}) if $product->{Price}; + } + + return $invoice; +} + +sub _convert_to_cents { + my ($price) = @_; + + return int($price*100); # transform into cents +} + +sub _convert_to_euros { + my ($price) = @_; + + return $price/100; +} + +1; diff --git a/Koha/PaymentsTransaction.pm b/Koha/PaymentsTransaction.pm new file mode 100644 index 0000000..5178556 --- /dev/null +++ b/Koha/PaymentsTransaction.pm @@ -0,0 +1,271 @@ +package Koha::PaymentsTransaction; + +# Copyright Open Source Freedom Fighters +# +# 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 Data::Dumper; + +use C4::Accounts; +use C4::Context; +use C4::Log; +use C4::Stats; + +use Koha::Database; + +use bignum; + +use base qw(Koha::Object); + +sub type { + return 'PaymentsTransaction'; +} + +sub AddRelatedAccountline { + my ($self, $accountlines_id, $paid_price) = @_; + + return 0 unless defined $accountlines_id and defined $paid_price; + + my $dbh = C4::Context->dbh; + my $sql = "INSERT INTO payments_transactions_accountlines (transaction_id, accountlines_id, paid_price_cents) VALUES (?, ?, ?)"; + + my $sth = $dbh->prepare($sql); + $sth->execute($self->transaction_id, $accountlines_id, $paid_price); + + return $dbh->last_insert_id(undef,undef,'payments_transactions_accountlines',undef); +} + +sub GetRelatedAccountlines { + my ($self) = @_; + + my $dbh = C4::Context->dbh; + my $sql = "SELECT accountlines.accountlines_id, accountlines.amountoutstanding, accountlines.accountno, payments_transactions_accountlines.paid_price_cents, payments_transactions_accountlines.transaction_id, accountlines.description, accountlines.itemnumber FROM accountlines INNER JOIN payments_transactions_accountlines +ON payments_transactions_accountlines.accountlines_id = accountlines.accountlines_id AND payments_transactions_accountlines.transaction_id=?"; + my $sth = $dbh->prepare($sql); + $sth->execute($self->transaction_id); + + my $hash_ref = $sth->fetchall_arrayref({}); + $sth->finish; + return $hash_ref; +} + +sub GetProducts { + my ($self) = @_; + + my $dbh = C4::Context->dbh; + my $sql = "SELECT accountlines.accounttype, payments_transactions_accountlines.paid_price_cents, accountlines.description FROM accountlines INNER JOIN payments_transactions_accountlines +ON payments_transactions_accountlines.accountlines_id = accountlines.accountlines_id AND payments_transactions_accountlines.transaction_id=?"; + my $sth = $dbh->prepare($sql); + $sth->execute($self->transaction_id); + + my @products; + + while (my $accountline = $sth->fetchrow_hashref) { + my $product; + $product->{Code} = $accountline->{'accounttype'}; + $product->{Price} = $accountline->{'paid_price_cents'}; + $product->{Description} = $accountline->{'description'}; + push @products, $product; + } + + return \@products; +} + +=head2 CompletePayment + + &CompletePayment($transaction_number); + +Completes the payment in Koha from the given transaction number. + +This subroutine will be called after payment is completed, +(after payment report is received to REST API) + +=cut + +sub CompletePayment { + my ($self, $status) = @_; + my $dbh = C4::Context->dbh; + my $manager_id = 0; + $manager_id = C4::Context->userenv->{'number'} if C4::Context->userenv; + my $branch = C4::Context->userenv->{'branch'} if C4::Context->userenv; + my $description = ""; + my $itemnumber; + my $old_status; + my $new_status; + + my $transaction = $self; + return if not $transaction; + + # It's important that we don't process this subroutine twice at the same time! + $transaction = Koha::PaymentsTransactions->find($transaction->transaction_id); + + $old_status = $transaction->status; + $new_status = $status->{status}; + + if ($old_status eq $new_status){ + # Trying to complete with same status, makes no sense + return; + } + + if ($old_status ne "processing"){ + $transaction->set({ status => "processing" })->store(); + } else { + # Another process is already processing the payment + return; + } + + # Defined accountlines_id means that the payment is already completed in Koha. + # We don't want to make duplicate payments. So make sure it is not defined! + #return if defined $transaction->accountlines_id; + # Reverse the payment if old status is different than new status (and either paid or cancelled) + if (defined $transaction->accountlines_id && (($old_status eq "paid" and $new_status eq "cancelled") or ($old_status eq "cancelled" and $new_status eq "paid"))){ + C4::Accounts::ReversePayment($transaction->accountlines_id); + $transaction->set($status)->store(); + return; + } + + # Payment was cancelled + if ($new_status eq "cancelled") { + $transaction->set({ status => "cancelled" })->store(); + &logaction( + "PAYMENTS", + "PAY", + $transaction->transaction_id, + $transaction->status + ); + return; + } + + # If transaction is found, pay the accountlines associated with the transaction. + my $accountlines = $transaction->GetRelatedAccountlines(); + + # Define a variable for leftovers. This should not be needed, but it's a fail-safe. + my $leftovers = 0; + + my $sth = $dbh->prepare('UPDATE accountlines SET amountoutstanding= ? ' . + 'WHERE accountlines_id=?'); + + my @ids; + foreach my $acct (@$accountlines){ + if (_convert_to_cents($acct->{amountoutstanding}) == 0) { + $leftovers += _convert_to_euros($acct->{paid_price_cents}); + next; + } + + my $paidamount = _convert_to_euros($acct->{paid_price_cents}); + my $newamount = 0; + + $itemnumber = $acct->{itemnumber} if @$accountlines == 1; + + if ($acct->{amountoutstanding} >= $paidamount) { + $newamount = $acct->{amountoutstanding}-$paidamount; + } + else { + $leftovers += $paidamount-$acct->{amountoutstanding}; + } + + $sth->execute( $newamount, $acct->{accountlines_id} ); + + $description .= ((length($description) > 0) ? "\n" : "") . $acct->{description}; + + if ( C4::Context->preference("FinesLog") ) { + C4::Log::logaction("FINES", 'MODIFY', $transaction->borrowernumber, Dumper({ + action => 'fee_payment', + borrowernumber => $transaction->borrowernumber, + old_amountoutstanding => $acct->{'amountoutstanding'}, + new_amountoutstanding => $newamount, + amount_paid => $paidamount, + accountlines_id => $acct->{'accountlines_id'}, + accountno => $acct->{'accountno'}, + manager_id => $manager_id, + })); + push( @ids, $acct->{'accountlines_id'} ); + } + } + + if ($leftovers > 0) { + C4::Accounts::recordpayment_selectaccts($transaction->borrowernumber, $leftovers, [], "Leftovers from transaction ".$transaction->transaction_id); + $transaction->set({ status => $new_status })->store(); + } + + if ($transaction->price_in_cents-_convert_to_cents($leftovers) > 0) { + my $nextacctno = C4::Accounts::getnextacctno($transaction->borrowernumber); + # create new line + my $sql = 'INSERT INTO accountlines ' . + '(borrowernumber, accountno,date,amount,description,accounttype,amountoutstanding,itemnumber,manager_id,note) ' . + q|VALUES (?,?,now(),?,?,'Pay',?,?,?,?)|; + $dbh->do($sql,{},$transaction->borrowernumber, $nextacctno , (-1)*_convert_to_euros($transaction->price_in_cents-_convert_to_cents($leftovers)), $description, 0, $itemnumber, $manager_id, $transaction->description); + + $transaction->set({ status => $new_status, accountlines_id => $dbh->last_insert_id( undef, undef, 'accountlines', undef ) })->store(); + + C4::Stats::UpdateStats($branch, 'payment', _convert_to_euros($transaction->price_in_cents), '', '', '', $transaction->borrowernumber, $nextacctno); + + if ( C4::Context->preference("FinesLog") ) { + C4::Log::logaction("FINES", 'CREATE',$transaction->borrowernumber,Dumper({ + action => 'create_payment', + borrowernumber => $transaction->borrowernumber, + accountno => $nextacctno, + amount => 0 - _convert_to_euros($transaction->price_in_cents), + amountoutstanding => 0 - $leftovers, + accounttype => 'Pay', + accountlines_paid => \@ids, + manager_id => $manager_id, + })); + } + &logaction( + "PAYMENTS", + "PAY", + $transaction->transaction_id, + $transaction->status + ); + } +} + +=head2 RevertPayment + + &RevertPayment(); + +Reverts the already completed payment. + +=cut + +sub RevertPayment { + my ($self) = @_; + my $dbh = C4::Context->dbh; + + my $transaction = $self; + + return if not $transaction; + + return if not defined $transaction->accountlines_id; + + C4::Accounts::ReversePayment($transaction->accountlines_id); +} + + +sub _convert_to_cents { + my ($price) = @_; + + return int($price*100); # transform into cents +} + +sub _convert_to_euros { + my ($price) = @_; + + return $price/100; +} +1; \ No newline at end of file diff --git a/Koha/PaymentsTransactions.pm b/Koha/PaymentsTransactions.pm new file mode 100644 index 0000000..5cac117 --- /dev/null +++ b/Koha/PaymentsTransactions.pm @@ -0,0 +1,36 @@ +package Koha::PaymentsTransactions; + +# Copyright Open Source Freedom Fighters +# +# 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 Koha::Database; +use base qw(Koha::Objects); + +sub type { + return 'PaymentsTransaction'; +} + +sub object_class { + return 'Koha::PaymentsTransaction'; +} + +sub _get_castable_unique_columns { + return ['transaction_id']; +} + +1; \ No newline at end of file diff --git a/Koha/REST/V1/POSIntegration.pm b/Koha/REST/V1/POSIntegration.pm new file mode 100644 index 0000000..611d33b --- /dev/null +++ b/Koha/REST/V1/POSIntegration.pm @@ -0,0 +1,57 @@ +package Koha::REST::V1::POSIntegration; + +use Modern::Perl; +use Mojo::Base 'Mojolicious::Controller'; + +use C4::Log; +use C4::OPLIB::CPUIntegration; + +use Koha::PaymentsTransaction; +use Koha::PaymentsTransactions; + +sub get_transaction { + my ($c, $args, $cb) = @_; + + return $c->$cb({ error => "Missing transaction number"}, 400) if not $args->{'invoicenumber'}; + + # Find transaction + my $transaction = Koha::PaymentsTransactions->find($args->{invoicenumber}); + + return $c->$cb({ error => "Transaction not found"}, 404) if not $transaction; + + return $c->$cb({ + transaction_id => $transaction->transaction_id, + borrowernumber => $transaction->borrowernumber, + status => $transaction->status, + timestamp => $transaction->timestamp, + description => $transaction->description || "", + price_in_cents => int($transaction->price_in_cents), + }, 200); +} + + +=head2 CPU_report($c, $args, $cb) + +Receives the success report from CPU. + +=cut +sub cpu_report { + my ($c, $args, $cb) = @_; + + my $invoicenumber = $args->{'invoicenumber'}; + $args = $args->{body}; + + # Check that the request is valid + return $c->$cb({ error => "Invalid Hash" }, 400) if C4::OPLIB::CPUIntegration::CalculateResponseHash($args) ne $args->{Hash}; + + # Find the transaction + my $transaction = Koha::PaymentsTransactions->find($invoicenumber); + return $c->$cb({ error => "Transaction not found"}, 404) if not $transaction; + + my $report_status = C4::OPLIB::CPUIntegration::GetResponseString($args->{Status}); + $transaction->CompletePayment($report_status); + + return $c->$cb("", 200); +} + +1; diff --git a/Koha/Schema/Result/PaymentsTransaction.pm b/Koha/Schema/Result/PaymentsTransaction.pm new file mode 100644 index 0000000..6b8301c --- /dev/null +++ b/Koha/Schema/Result/PaymentsTransaction.pm @@ -0,0 +1,169 @@ +use utf8; +package Koha::Schema::Result::PaymentsTransaction; + +# Created by DBIx::Class::Schema::Loader +# DO NOT MODIFY THE FIRST PART OF THIS FILE + +=head1 NAME + +Koha::Schema::Result::PaymentsTransaction + +=cut + +use strict; +use warnings; + +use base 'DBIx::Class::Core'; + +=head1 TABLE: C + +=cut + +__PACKAGE__->table("payments_transactions"); + +=head1 ACCESSORS + +=head2 transaction_id + + data_type: 'integer' + is_auto_increment: 1 + is_nullable: 0 + +=head2 borrowernumber + + data_type: 'integer' + is_foreign_key: 1 + is_nullable: 0 + +=head2 accountlines_id + + data_type: 'integer' + is_foreign_key: 1 + is_nullable: 1 + +=head2 status + + data_type: 'enum' + default_value: 'pending' + extra: {list => ["paid","pending","cancelled","unsent","processing"]} + is_nullable: 1 + +=head2 timestamp + + data_type: 'timestamp' + datetime_undef_if_invalid: 1 + default_value: current_timestamp + is_nullable: 0 + +=head2 description + + data_type: 'text' + is_nullable: 0 + +=head2 price_in_cents + + data_type: 'integer' + is_nullable: 0 + +=cut + +__PACKAGE__->add_columns( + "transaction_id", + { data_type => "integer", is_auto_increment => 1, is_nullable => 0 }, + "borrowernumber", + { data_type => "integer", is_foreign_key => 1, is_nullable => 0 }, + "accountlines_id", + { data_type => "integer", is_foreign_key => 1, is_nullable => 1 }, + "status", + { + data_type => "enum", + default_value => "pending", + extra => { + list => ["paid", "pending", "cancelled", "unsent", "processing"], + }, + is_nullable => 1, + }, + "timestamp", + { + data_type => "timestamp", + datetime_undef_if_invalid => 1, + default_value => \"current_timestamp", + is_nullable => 0, + }, + "description", + { data_type => "text", is_nullable => 0 }, + "price_in_cents", + { data_type => "integer", is_nullable => 0 }, +); + +=head1 PRIMARY KEY + +=over 4 + +=item * L + +=back + +=cut + +__PACKAGE__->set_primary_key("transaction_id"); + +=head1 RELATIONS + +=head2 accountline + +Type: belongs_to + +Related object: L + +=cut + +__PACKAGE__->belongs_to( + "accountline", + "Koha::Schema::Result::Accountline", + { accountlines_id => "accountlines_id" }, + { + is_deferrable => 1, + join_type => "LEFT", + on_delete => "CASCADE", + on_update => "RESTRICT", + }, +); + +=head2 borrowernumber + +Type: belongs_to + +Related object: L + +=cut + +__PACKAGE__->belongs_to( + "borrowernumber", + "Koha::Schema::Result::Borrower", + { borrowernumber => "borrowernumber" }, + { is_deferrable => 1, on_delete => "CASCADE", on_update => "RESTRICT" }, +); + +=head2 payments_transactions_accountlines + +Type: has_many + +Related object: L + +=cut + +__PACKAGE__->has_many( + "payments_transactions_accountlines", + "Koha::Schema::Result::PaymentsTransactionsAccountline", + { "foreign.transaction_id" => "self.transaction_id" }, + { cascade_copy => 0, cascade_delete => 0 }, +); + + +# Created by DBIx::Class::Schema::Loader v0.07039 @ 2015-12-10 17:49:20 +# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:TtY1X7ynTADbZtcFxzeKeg + + +# You can replace this text with custom code or comments, and it will be preserved on regeneration +1; diff --git a/Koha/Schema/Result/PaymentsTransactionsAccountline.pm b/Koha/Schema/Result/PaymentsTransactionsAccountline.pm new file mode 100644 index 0000000..832857f --- /dev/null +++ b/Koha/Schema/Result/PaymentsTransactionsAccountline.pm @@ -0,0 +1,112 @@ +use utf8; +package Koha::Schema::Result::PaymentsTransactionsAccountline; + +# Created by DBIx::Class::Schema::Loader +# DO NOT MODIFY THE FIRST PART OF THIS FILE + +=head1 NAME + +Koha::Schema::Result::PaymentsTransactionsAccountline + +=cut + +use strict; +use warnings; + +use base 'DBIx::Class::Core'; + +=head1 TABLE: C + +=cut + +__PACKAGE__->table("payments_transactions_accountlines"); + +=head1 ACCESSORS + +=head2 transactions_accountlines_id + + data_type: 'integer' + is_auto_increment: 1 + is_nullable: 0 + +=head2 transaction_id + + data_type: 'integer' + is_foreign_key: 1 + is_nullable: 0 + +=head2 accountlines_id + + data_type: 'integer' + is_foreign_key: 1 + is_nullable: 0 + +=head2 paid_price_cents + + data_type: 'integer' + is_nullable: 0 + +=cut + +__PACKAGE__->add_columns( + "transactions_accountlines_id", + { data_type => "integer", is_auto_increment => 1, is_nullable => 0 }, + "transaction_id", + { data_type => "integer", is_foreign_key => 1, is_nullable => 0 }, + "accountlines_id", + { data_type => "integer", is_foreign_key => 1, is_nullable => 0 }, + "paid_price_cents", + { data_type => "integer", is_nullable => 0 }, +); + +=head1 PRIMARY KEY + +=over 4 + +=item * L + +=back + +=cut + +__PACKAGE__->set_primary_key("transactions_accountlines_id"); + +=head1 RELATIONS + +=head2 accountline + +Type: belongs_to + +Related object: L + +=cut + +__PACKAGE__->belongs_to( + "accountline", + "Koha::Schema::Result::Accountline", + { accountlines_id => "accountlines_id" }, + { is_deferrable => 1, on_delete => "CASCADE", on_update => "RESTRICT" }, +); + +=head2 transaction + +Type: belongs_to + +Related object: L + +=cut + +__PACKAGE__->belongs_to( + "transaction", + "Koha::Schema::Result::PaymentsTransaction", + { transaction_id => "transaction_id" }, + { is_deferrable => 1, on_delete => "CASCADE", on_update => "RESTRICT" }, +); + + +# Created by DBIx::Class::Schema::Loader v0.07039 @ 2015-11-19 10:32:53 +# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:ItfVA6ePztGiqVcJ/VPabQ + + +# You can replace this text with custom code or comments, and it will be preserved on regeneration +1; diff --git a/api/v1/swagger.json b/api/v1/swagger.json index 9c2f436..9fe90a8 100644 --- a/api/v1/swagger.json +++ b/api/v1/swagger.json @@ -75,6 +75,76 @@ } } } + }, + "/pos/cpu/{invoicenumber}": { + "get": { + "x-mojo-controller": "Koha::REST::V1::POSIntegration", + "operationId": "getTransaction", + "x-koha-permission": { + "updatecharges": "remaining_permissions" + }, + "tags": ["POS Integration"], + "parameters": [ + { + "$ref": "#/parameters/invoicenumberPathParam" + } + ], + "consumes": ["application/json"], + "produces": ["application/json"], + "responses": { + "200": { + "description": "A transaction", + "schema": { + "$ref" : "#/definitions/transaction" + } + }, + "404": { + "description": "Transaction not found", + "schema": { + "$ref": "#/definitions/error" + } + } + } + } + }, + "/pos/cpu/{invoicenumber}/report": { + "post": { + "x-mojo-controller": "Koha::REST::V1::POSIntegration", + "operationId": "cpuReport", + "tags": ["POS Integration"], + "parameters": [ + { + "$ref": "#/parameters/invoicenumberPathParam" + }, + { + "name": "body", + "in": "body", + "type": "string", + "description": "New report", + "schema": { "$ref": "#/definitions/CPUinvoiceReport" } + } + ], + "consumes": ["application/json"], + "produces": ["application/json"], + "responses": { + "200": { + "description": "Response for receiving report", + "type": "string" + }, + "400": { + "description": "Bad parameters", + "schema": { + "$ref": "#/definitions/error" + } + }, + "404": { + "description": "Transaction not found", + "schema": { + "$ref": "#/definitions/error" + } + } + } + } } }, "definitions": { @@ -353,6 +423,58 @@ "borrowernumber": { "description": "Patron internal identifier" }, + "transaction": { + "type": "object", + "properties": { + "borrowernumber": { + "$ref": "#/definitions/borrowernumber" + }, + "accountlines_id": { + "description": "Reference to related accountlines row where accounttype is Pay. If null, transaction is incomplete. Else it is completed.", + "type": "integer" + }, + "status": { + "description": "Status of transaction", + "type": "string" + }, + "timestamp": { + "description": "Creation time", + "type": "string" + }, + "description": { + "type": "string" + }, + "price_in_cents": { + "description": "Total price of transaction", + "type": "integer" + } + } + }, + "CPUinvoiceReport": { + "type": "object", + "properties": { + "Source": { + "type": "string" + }, + "Id": { + "description": "Invoice identification number", + "type": "string" + }, + "Status": { + "description": "Status of payment", + "type": "integer" + }, + "Reference": { + "description": "Receipt number for successful payments", + "type": "string", + "required": false + }, + "Hash": { + "description": "Hash for response parameters", + "type": "string" + } + } + }, "error": { "type": "object", "properties": { @@ -370,6 +492,13 @@ "description": "Internal patron identifier", "required": true, "type": "integer" + }, + "invoicenumberPathParam": { + "name": "invoicenumber", + "in": "path", + "description": "Internal invoice identifier", + "required": "true", + "type": "integer" } } } diff --git a/etc/koha-conf.xml b/etc/koha-conf.xml index 74a5720..c920215 100644 --- a/etc/koha-conf.xml +++ b/etc/koha-conf.xml @@ -136,5 +136,23 @@ __PAZPAR2_TOGGLE_XML_POST__ /usr/share/fonts/truetype/ttf-dejavu/DejaVuSans-BoldOblique.ttf + + + + + + + + + + + + + + + + + + diff --git a/etc/koha-httpd.conf b/etc/koha-httpd.conf index 32cdf0a..0884788 100644 --- a/etc/koha-httpd.conf +++ b/etc/koha-httpd.conf @@ -37,6 +37,12 @@ Deny from all +# +# Order Deny,Allow +# Deny from all +# Allow from 10.1.62.83 +# + mod_gzip_on yes mod_gzip_dechunk yes @@ -170,6 +176,12 @@ Deny from all +# +# Order Deny,Allow +# Deny from all +# Allow from 10.1.62.83 +# + mod_gzip_on yes mod_gzip_dechunk yes diff --git a/installer/data/mysql/atomicupdate/KD#377-CPU_Integration-Add_table_for_transactions.pl b/installer/data/mysql/atomicupdate/KD#377-CPU_Integration-Add_table_for_transactions.pl new file mode 100644 index 0000000..d4a530f --- /dev/null +++ b/installer/data/mysql/atomicupdate/KD#377-CPU_Integration-Add_table_for_transactions.pl @@ -0,0 +1,51 @@ +#! /usr/bin/perl + +use strict; +use warnings; +use C4::Context; +use Koha::AtomicUpdater; + +my $dbh = C4::Context->dbh; +my $atomicUpdater = Koha::AtomicUpdater->new(); + +unless($atomicUpdater->find('KD#377')) { + $dbh->do(" + CREATE TABLE payments_transactions ( + transaction_id int(11) NOT NULL auto_increment, + borrowernumber int(11) NOT NULL, + accountlines_id int(11), + status ENUM('paid','pending','cancelled','unsent','processing') DEFAULT 'unsent', + timestamp timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + description TEXT NOT NULL, + price_in_cents int(11) NOT NULL, + PRIMARY KEY (transaction_id), + FOREIGN KEY (accountlines_id) + REFERENCES accountlines(accountlines_id) + ON DELETE CASCADE, + FOREIGN KEY (borrowernumber) + REFERENCES borrowers(borrowernumber) + ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci; + "); + $dbh->do(" + CREATE TABLE payments_transactions_accountlines ( + transactions_accountlines_id int(11) NOT NULL auto_increment, + transaction_id int(11) NOT NULL, + accountlines_id int(11) NOT NULL, + paid_price_cents int(11) NOT NULL, + PRIMARY KEY (transactions_accountlines_id), + FOREIGN KEY (transaction_id) + REFERENCES payments_transactions(transaction_id) + ON DELETE CASCADE, + FOREIGN KEY (accountlines_id) + REFERENCES accountlines(accountlines_id) + ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci; + "); + + # Add system preferences + $dbh->do("INSERT INTO systempreferences (variable, value, options, explanation, type) VALUES ('cpuitemnumbers', '', '', 'Maps Koha account types into Ceepos items', 'textarea')"); + $dbh->do("INSERT INTO systempreferences (variable, value, options, explanation, type) VALUES ('POSIntegration', 'OFF', 'cpu|OFF', 'Selects used POS integration', 'choice')"); + + print "Upgrade to done (KD#377 CPU integration: Add table for transactions)\n"; +} \ No newline at end of file diff --git a/installer/data/mysql/kohastructure.sql b/installer/data/mysql/kohastructure.sql index de53c0c..7144f89 100644 --- a/installer/data/mysql/kohastructure.sql +++ b/installer/data/mysql/kohastructure.sql @@ -1751,6 +1751,47 @@ CREATE TABLE `patronimage` ( -- information related to patron images CONSTRAINT `patronimage_fk1` FOREIGN KEY (`borrowernumber`) REFERENCES `borrowers` (`borrowernumber`) ON DELETE CASCADE ON UPDATE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci; +- +-- Table structure for table `payments_transactions` +-- + +DROP TABLE IF EXISTS `payments_transactions`; +CREATE TABLE `payments_transactions` ( -- information related to payments via POS integration + transaction_id int(11) NOT NULL auto_increment, -- transaction number + borrowernumber int(11) NOT NULL, -- the borrowernumber that the payment is for + accountlines_id int(11), -- the accountlines_id of the payment (the accounttype is Pay) + status ENUM('paid','pending','cancelled','unsent') DEFAULT 'pending', -- status of transaction + timestamp timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, -- timestamp for payment initialization + description TEXT NOT NULL, -- additional description that can hold notes. Prints into the accountlines Pay event once the payment is completed + price_in_cents int(11) NOT NULL, -- total price of the payment in cents + PRIMARY KEY (transaction_id), + FOREIGN KEY (accountlines_id) + REFERENCES accountlines(accountlines_id) + ON DELETE CASCADE, + FOREIGN KEY (borrowernumber) + REFERENCES borrowers(borrowernumber) + ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +-- +-- Table structure for table `payments_transactions_accountlines` +-- + +DROP TABLE IF EXISTS `payments_transactions_accountlines`; +CREATE TABLE `payments_transactions` ( -- related accountlines for payments (transactions) + transactions_accountlines_id int(11) NOT NULL auto_increment, + transaction_id int(11) NOT NULL, -- referenced transaction_id from payments_transactions + accountlines_id int(11) NOT NULL, -- referenced accountlines_id from accountlines + paid_price_cents int(11) NOT NULL, -- price (in cents) of the item in accountlines + PRIMARY KEY (transactions_accountlines_id), + FOREIGN KEY (transaction_id) + REFERENCES payments_transactions(transaction_id) + ON DELETE CASCADE, + FOREIGN KEY (accountlines_id) + REFERENCES accountlines(accountlines_id) + ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + -- Table structure for table `pending_offline_operations` -- -- this table is MyISAM, InnoDB tables are growing only and this table is filled/emptied/filled/emptied... diff --git a/koha-tmpl/intranet-tmpl/prog/en/modules/admin/preferences/tools.pref b/koha-tmpl/intranet-tmpl/prog/en/modules/admin/preferences/tools.pref index 5a3ddce..3989ea7 100644 --- a/koha-tmpl/intranet-tmpl/prog/en/modules/admin/preferences/tools.pref +++ b/koha-tmpl/intranet-tmpl/prog/en/modules/admin/preferences/tools.pref @@ -21,3 +21,17 @@ Tools: staff: "Staff client only" both: "Both OPAC and staff client" - + Cash registers: + - + - Use + - pref: POSIntegration + choices: + cpu: CPU integration + "OFF": None + - component to handle Borrower's fine payments. + - + - CPU / Ceepos integration. Map accountlines' accounttypes to item numbers for cash registers in different branches. + - pref: cpuitemnumbers + type: textarea + class: code + - Use parameter "Default" to define an item number for other types than defined. 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 824c41c..b5ef12b 100644 --- a/koha-tmpl/intranet-tmpl/prog/en/modules/members/boraccount.tt +++ b/koha-tmpl/intranet-tmpl/prog/en/modules/members/boraccount.tt @@ -32,6 +32,14 @@ $(document).ready(function() { }); }); + [% INCLUDE 'header.inc' %] @@ -61,6 +69,7 @@ $(document).ready(function() { Date + Transaction number Description of charges Note Amount @@ -75,8 +84,12 @@ $(document).ready(function() { [% FOREACH account IN accounts %] - [% IF ( loop.odd ) %][% ELSE %][% END %] + [% IF ( loop.odd ) %][% ELSE %][% END %] + [% FOREACH relline IN relatedaccounts.${account.accountlines_id} %] + + [% END %] [% account.date |$KohaDates %] + [% IF account.transactionnumber %][% account.transactionnumber %][% ELSE %]-[% END %] [% SWITCH account.accounttype %] [% CASE 'Pay' %]Payment, thanks @@ -125,14 +138,14 @@ $(document).ready(function() { [% END %] - Total due + Total due [% IF ( totalcredit ) %] [% total %] [% ELSE %] [% total %] [% END %] [% IF ( reverse_col ) %] - + [% ELSE %] [% END %] @@ -143,7 +156,37 @@ $(document).ready(function() { +
[% INCLUDE 'circ-menu.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 cb6902c..0c9ffdb 100644 --- a/koha-tmpl/intranet-tmpl/prog/en/modules/members/paycollect.tt +++ b/koha-tmpl/intranet-tmpl/prog/en/modules/members/paycollect.tt @@ -61,6 +61,102 @@ function moneyFormat(textObj) { } //]]> + [% INCLUDE 'header.inc' %] @@ -98,8 +194,106 @@ function moneyFormat(textObj) { [% END %] +[% IF ( startSending ) %] +

Processing payment [% payment.Id %] - Please complete the payment [% IF ( payment.Office ) %]at cash register [% payment.Office %][% ELSE %] at any cash register[% END %].

+
+
+
+

+ Current status: + + Connecting to the cash register. + + + + + +

+ +
+ +[% ELSE %] [% IF ( pay_individual ) %] -
+ @@ -147,7 +341,11 @@ function moneyFormat(textObj) { +
  • + [% INCLUDE offices %] +
  • +
    @@ -216,12 +414,16 @@ function moneyFormat(textObj) { +
  • + [% INCLUDE offices %] +
  • [% END %] +[% END %]
    @@ -231,4 +433,161 @@ function moneyFormat(textObj) { [% INCLUDE 'intranet-bottom.inc' %] +[% BLOCK offices %] + [% IF POSIntegration %] + [% IF POSIntegration_in_branch %] + +
    + + Add new office + + [% ELSE %] + Add new office + + [% END%] + [% END %] +[% END %] +[% IF POSIntegration %] + +[% END %] diff --git a/members/boraccount.pl b/members/boraccount.pl index 0200c5c..463f5c0 100755 --- a/members/boraccount.pl +++ b/members/boraccount.pl @@ -33,6 +33,9 @@ use C4::Branch; use C4::Accounts; use C4::Members::Attributes qw(GetBorrowerAttributes); +use Koha::PaymentsTransaction; +use Koha::PaymentsTransactions; + my $input=new CGI; @@ -71,6 +74,7 @@ my $totalcredit; if($total <= 0){ $totalcredit = 1; } +my $related_accountlines; my $reverse_col = 0; # Flag whether we need to show the reverse column foreach my $accountline ( @{$accts}) { @@ -89,6 +93,21 @@ foreach my $accountline ( @{$accts}) { $accountline->{payment} = 1; $reverse_col = 1; } + + my $transaction = Koha::PaymentsTransactions->find({ accountlines_id => $accountline->{accountlines_id} }) if $accountline->{accounttype} eq "Pay"; + + # If transaction is found, find all related accountlines and store them so we can highlight + # them in the Fines tab. + if ($transaction) { + $accountline->{transactionnumber} = $transaction->transaction_id if $transaction; + + my $relacclines = $transaction->GetRelatedAccountlines(); + foreach my $relaccline (@$relacclines){ + $related_accountlines->{$relaccline->{accountlines_id}} = [] if not exists $related_accountlines->{$relaccline->{accountlines_id}}; + + push $related_accountlines->{$relaccline->{accountlines_id}}, $transaction->transaction_id; + } + } } $template->param( adultborrower => 1 ) if ( $data->{'category_type'} eq 'A' ); @@ -115,6 +134,7 @@ $template->param( is_child => ($data->{'category_type'} eq 'C'), reverse_col => $reverse_col, accounts => $accts, + relatedaccounts => $related_accountlines, activeBorrowerRelationship => (C4::Context->preference('borrowerRelationship') ne ''), RoutingSerials => C4::Context->preference('RoutingSerials'), ); diff --git a/members/paycollect.pl b/members/paycollect.pl index 7254a3a..4ded4ff 100755 --- a/members/paycollect.pl +++ b/members/paycollect.pl @@ -29,6 +29,8 @@ use C4::Members::Attributes qw(GetBorrowerAttributes); use C4::Accounts; use C4::Koha; use C4::Branch; +use C4::OPLIB::CPUIntegration; +use JSON; my $input = CGI->new(); @@ -43,6 +45,18 @@ my ( $template, $loggedinuser, $cookie ) = get_template_and_user( } ); +# POS integration AJAX call +my $posintegration = 1 if (C4::Context->preference("POSIntegration") ne "OFF"); +my $posintegration_in_branch = 1 if C4::OPLIB::CPUIntegration::hasBranchEnabledIntegration(C4::Branch::mybranch()); +if ($posintegration && $posintegration_in_branch && $input->param('POSTDATA')) { + my $payment = JSON->new->utf8->canonical(1)->decode($input->param('POSTDATA')); + + if ($payment->{send_payment} && $payment->{send_payment} eq "POST") { + output_ajax_with_http_headers $input, C4::OPLIB::CPUIntegration::SendPayment($payment); + exit 1; + } +} + # get borrower details my $borrowernumber = $input->param('borrowernumber'); my $borrower = GetMember( borrowernumber => $borrowernumber ); @@ -58,9 +72,14 @@ my $individual = $input->param('pay_individual'); my $writeoff = $input->param('writeoff_individual'); my $select_lines = $input->param('selected'); my $select = $input->param('selected_accts'); +my $office = $input->param('Office'); my $payment_note = uri_unescape $input->param('payment_note'); my $accountno; my $accountlines_id; + +$template->param( POSIntegration => 1 ) if $posintegration; +$template->param( POSIntegration_in_branch => 1 ) if $posintegration_in_branch; + if ( $individual || $writeoff ) { if ($individual) { $template->param( pay_individual => 1 ); @@ -107,6 +126,28 @@ if ( $total_paid and $total_paid ne '0.00' ) { total_due => $total_due ); } else { + if ($posintegration and C4::Context->preference("POSIntegration") eq "cpu" and $posintegration_in_branch) { + my $payment; + + $payment->{borrowernumber} = $borrowernumber; + $payment->{total_paid} = $total_paid; + $payment->{total_due} = $total_due; + $payment->{payment_note} = $payment_note || $input->param('notes') || $input->param('selected_accts_notes'); + $payment->{office} = $office; + my @selected = (defined $select) ? split /,/, $select : $accountlines_id; + $payment->{selected} = \@selected; + + my $CPUPayment = C4::OPLIB::CPUIntegration::InitializePayment($payment); + + $template->param( + startSending => 1, + payment => $CPUPayment, + posdestination => C4::Context->config('pos')->{'CPU'}->{'url'}, + json_payment => JSON::encode_json($CPUPayment), + office => $office, + ); + } else { + if ($individual) { if ( $total_paid == $total_due ) { makepayment( $accountlines_id, $borrowernumber, $accountno, $total_paid, $user, @@ -136,6 +177,8 @@ if ( $total_paid and $total_paid ne '0.00' ) { "/cgi-bin/koha/members/boraccount.pl?borrowernumber=$borrowernumber" ); } + + } } } else { $total_paid = '0.00'; #TODO not right with pay_individual @@ -148,6 +191,7 @@ $template->param(%$borrower); $template->param( borrowernumber => $borrowernumber, # some templates require global borrower => $borrower, + branch => C4::Branch::mybranch(), total => $total_due, activeBorrowerRelationship => (C4::Context->preference('borrowerRelationship') ne ''), RoutingSerials => C4::Context->preference('RoutingSerials'), diff --git a/t/db_dependent/CPUIntegration.t b/t/db_dependent/CPUIntegration.t new file mode 100644 index 0000000..7ed02c0 --- /dev/null +++ b/t/db_dependent/CPUIntegration.t @@ -0,0 +1,208 @@ +#!/usr/bin/env perl + +# Copyright 2015 Open Source Freedom Fighters +# +# 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 . +$ENV{KOHA_PAGEOBJECT_DEBUG} = 1; +use Modern::Perl; + +use Test::More; +use Try::Tiny; #Even Selenium::Remote::Driver uses Try::Tiny :) + +use Koha::Auth::PermissionManager; +use Koha::PaymentsTransaction; +use Koha::PaymentsTransactions; + +use t::lib::Page::Mainpage; +use t::lib::Page::Members::Boraccount; +use t::lib::Page::Members::Pay; +use t::lib::Page::Members::Paycollect; + +use t::lib::TestObjects::BorrowerFactory; +use t::lib::TestObjects::SystemPreferenceFactory; +use t::lib::TestObjects::FinesFactory; + +use bignum; + +##Setting up the test context +my $testContext = {}; + +my $password = '1234'; +my $borrowerFactory = t::lib::TestObjects::BorrowerFactory->new(); +my $borrowers = $borrowerFactory->createTestGroup([ + {firstname => 'Testthree', + surname => 'Testfour', + cardnumber => 'superuberadmin', + branchcode => 'CPL', + userid => 'god', + address => 'testi', + city => 'joensuu', + zipcode => '80100', + password => $password, + }, + {firstname => 'Iral', + surname => 'Aluksat', + cardnumber => 'superuberadmin2', + branchcode => 'CPL', + userid => 'god2', + address => 'testi', + city => 'joensuu', + zipcode => '80100', + password => $password, + }, + ], undef, $testContext); + +my $systempreferences = t::lib::TestObjects::SystemPreferenceFactory->createTestGroup([ + {preference => 'POSIntegration', + value => 'cpu', + }, + {preference => 'cpuitemnumbers', + value => ' + CPL: + Default: 0000 + ', + }, + ], undef, $testContext); + +my $fines = t::lib::TestObjects::FinesFactory->createTestGroup([ + { + note => "First", + cardnumber => $borrowers->{'superuberadmin'}->cardnumber, + amount => int(rand(9)+1) . "" . int(rand(10)) . "." . int(rand(10)) . "" . int(rand(10)) + }, + { + note => "Second", + cardnumber => $borrowers->{'superuberadmin'}->cardnumber, + amount => int(rand(9)+1) . "" . int(rand(10)) . "." . int(rand(10)) . "" . int(rand(10)) + }, + { + note => "First2", + cardnumber => $borrowers->{'superuberadmin2'}->cardnumber, + amount => int(rand(9)+1) . "" . int(rand(10)) . "." . int(rand(10)) . "" . int(rand(10)) + }, + { + note => "Second2", + cardnumber => $borrowers->{'superuberadmin2'}->cardnumber, + amount => int(rand(9)+1) . "" . int(rand(10)) . "." . int(rand(10)) . "" . int(rand(10)) + }, +], undef, $testContext); + +my $permissionManager = Koha::Auth::PermissionManager->new(); +$permissionManager->grantPermissions($borrowers->{'superuberadmin'}, {superlibrarian => 'superlibrarian'}); +$permissionManager->grantPermissions($borrowers->{'superuberadmin2'}, {superlibrarian => 'superlibrarian'}); +eval { + MakeFullPayment($fines); + MakePartialPayment($fines); +}; +if ($@) { #Catch all leaking errors and gracefully terminate. + warn $@; + tearDown(); + exit 1; +} + +##All tests done, tear down test context +tearDown(); +done_testing; + +sub tearDown { + t::lib::TestObjects::ObjectFactory->tearDownTestContext($testContext); +} + + + +sub MakeFullPayment { + my ($fines) = @_; + # Make random amount for payments + my $firstAmount = $fines->{"First"}->{amount}; + my $secondAmount = $fines->{"Second"}->{amount}; + + # staff client + my $boraccount = t::lib::Page::Members::Boraccount->new({borrowernumber => $borrowers->{'superuberadmin'}->borrowernumber, op => 'modify', destination => 'circ', categorycode => 'PT'}); + + $boraccount = $boraccount->doPasswordLogin($borrowers->{'superuberadmin'}->userid(), $password) + ->findFine("First") # find the two fines created... + ->findFine("Second") # ...by FinesFactory + ->isFineAmountOutstanding("First", $firstAmount) + ->isFineAmountOutstanding("Second", $secondAmount) + ->navigateToPayFinesTab() + ->PaySelected() + ->addNoteToSelected("Transaction that pays everything ;)") + ->openAddNewCashRegister() + ->addNewCashRegister(100) # add cash register number 100 + ->selectCashRegister(100) # and select it + ->sendPaymentToPOS() + ->paymentLoadingScreen() + ->waitUntilPaymentIsAcceptedAtPOS(); + + # Get transaction ids + my $transactions = Koha::PaymentsTransactions->find({ borrowernumber => $borrowers->{'superuberadmin'}->borrowernumber }); + + # Check that there is a transaction completed + foreach my $transaction ($transactions){ + $boraccount = $boraccount->isTransactionComplete($transaction->transaction_id); + $boraccount + ->isFinePaid("Transaction that pays everything ;)") # note of transaction + ->isFineAmount("Transaction that pays everything ;)", "-".sprintf("%.2f",$firstAmount+$secondAmount)); + } + $boraccount + ->isFineAmount("First", $firstAmount) + ->isFineAmount("Second", $secondAmount) + ->isFinePaid("First") # Make sure fines are paid + ->isFinePaid("Second"); # Also the second :) +} + +sub MakePartialPayment { + my ($fines) = @_; + # Make random amount for payments + my $firstAmount = $fines->{"First2"}->{amount}; + my $secondAmount = $fines->{"Second2"}->{amount}; + + my $partialPayment = $firstAmount-(int(rand(9)+1) . "." . int(rand(10)) . "" . int(rand(10))); + # staff client + my $boraccount = t::lib::Page::Members::Boraccount->new({borrowernumber => $borrowers->{'superuberadmin2'}->borrowernumber, op => 'modify', destination => 'circ', categorycode => 'PT'}); + + $boraccount = $boraccount->doPasswordLogin($borrowers->{'superuberadmin2'}->userid(), $password) + ->findFine("First2") # find the two fines created... + ->findFine("Second2") # ...by FinesFactory + ->isFineAmountOutstanding("First2", $firstAmount) + ->isFineAmountOutstanding("Second2", $secondAmount) + ->navigateToPayFinesTab() + ->PaySelected() + ->setAmount($partialPayment) + ->addNoteToSelected("Transaction that pays everything ;)2") + ->openAddNewCashRegister() + ->addNewCashRegister(100) # add cash register number 100 + ->selectCashRegister(100) # and select it + ->sendPaymentToPOS() + ->paymentLoadingScreen() + ->waitUntilPaymentIsAcceptedAtPOS(); + + # Get transaction ids + my $transactions = Koha::PaymentsTransactions->find({ borrowernumber => $borrowers->{'superuberadmin2'}->borrowernumber }); + + # Check that there is a transaction completed + foreach my $transaction ($transactions){ + $boraccount = $boraccount->isTransactionComplete($transaction->transaction_id); + $boraccount + ->isFinePaid("Transaction that pays everything ;)2") # note of transaction + ->isFineAmount("Transaction that pays everything ;)2", "-".(sprintf("%.2f",$partialPayment))); + } + $boraccount + ->isFineAmount("First2", $firstAmount) + ->isFineAmount("Second2", $secondAmount) + ->isFineAmountOutstanding("First2", sprintf("%.2f",$firstAmount-$partialPayment)) + ->isFineAmountOutstanding("Second2", $secondAmount); +} \ No newline at end of file -- 1.9.1