@@ -, +, @@ Example: case CPU) - /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. ----------------------------------------------------------------------- - 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. ----------------------------------------------------------------------- --- 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 --- a/C4/Accounts.pm +++ a/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; --- a/C4/OPLIB/CPUIntegration.pm +++ a/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; --- a/Koha/PaymentsTransaction.pm +++ a/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; --- a/Koha/PaymentsTransactions.pm +++ a/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; --- a/Koha/REST/V1/POSIntegration.pm +++ a/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; --- a/Koha/Schema/Result/PaymentsTransaction.pm +++ a/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; --- a/Koha/Schema/Result/PaymentsTransactionsAccountline.pm +++ a/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; --- a/api/v1/swagger.json +++ a/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" } } } --- a/etc/koha-conf.xml +++ a/etc/koha-conf.xml @@ -136,5 +136,23 @@ __PAZPAR2_TOGGLE_XML_POST__ /usr/share/fonts/truetype/ttf-dejavu/DejaVuSans-BoldOblique.ttf + + + + + + + + + + + + + + + + + + --- a/etc/koha-httpd.conf +++ a/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 --- a/installer/data/mysql/atomicupdate/KD#377-CPU_Integration-Add_table_for_transactions.pl +++ a/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"; +} --- a/installer/data/mysql/kohastructure.sql +++ a/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... --- a/koha-tmpl/intranet-tmpl/prog/en/modules/admin/preferences/tools.pref +++ a/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. --- a/koha-tmpl/intranet-tmpl/prog/en/modules/members/boraccount.tt +++ a/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' %]
--- a/koha-tmpl/intranet-tmpl/prog/en/modules/members/paycollect.tt +++ a/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 %] --- a/members/boraccount.pl +++ a/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'), ); --- a/members/paycollect.pl +++ a/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'), --- a/t/db_dependent/CPUIntegration.t +++ a/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); +} --