View | Details | Raw Unified | Return to bug 15654
Collapse All | Expand All

(-)a/C4/Accounts.pm (-1 / +1 lines)
Lines 601-607 sub recordpayment_selectaccts { Link Here
601
    my $dbh        = C4::Context->dbh;
601
    my $dbh        = C4::Context->dbh;
602
    my $newamtos   = 0;
602
    my $newamtos   = 0;
603
    my $accdata    = q{};
603
    my $accdata    = q{};
604
    my $branch     = C4::Context->userenv->{branch};
604
    my $branch     = C4::Context->userenv->{branch} if C4::Context->userenv;
605
    my $amountleft = $amount;
605
    my $amountleft = $amount;
606
    my $manager_id = 0;
606
    my $manager_id = 0;
607
    $manager_id = C4::Context->userenv->{'number'} if C4::Context->userenv;
607
    $manager_id = C4::Context->userenv->{'number'} if C4::Context->userenv;
(-)a/C4/OPLIB/CPUIntegration.pm (+443 lines)
Line 0 Link Here
1
package C4::OPLIB::CPUIntegration;
2
3
use Modern::Perl;
4
5
use C4::Accounts;
6
use C4::Branch;
7
use C4::Context;
8
use C4::Log;
9
10
use Data::Dumper qw(Dumper);
11
use Digest::SHA qw(sha256_hex);
12
use Encode;
13
use Net::SSL;
14
use YAML::XS;
15
16
use Koha::Borrower;
17
use Koha::Borrowers;
18
use Koha::PaymentsTransaction;
19
use Koha::PaymentsTransactions;
20
21
use Koha::Exception::NoSystemPreference;
22
23
use bignum;
24
25
=head1 FUNCTIONS
26
27
=head2 InitializePayment
28
29
  &InitializePayment($args);
30
31
Initializes the accountlines that will be sent to CPU.
32
33
Returns the payment HASH.
34
35
=cut
36
37
sub InitializePayment {
38
    my ($args) = shift;
39
40
    my $dbh        = C4::Context->dbh;
41
    my $borrowernumber = $args->{borrowernumber};
42
    my @selected = @{ $args->{selected} };
43
44
    # Hash containing CPU format of payment
45
    my $payment;
46
    $payment->{Office} = $args->{office};
47
    $payment->{Products} = [];
48
49
    @selected = sort { $a <=> $b } @selected if @selected > 1;
50
51
    my $total_price = 0;
52
    my $money_left = _convert_to_cents($args->{total_paid});
53
54
    my $use_selected = (@selected > 0) ? "AND accountlines_id IN (?".+(",?") x (@selected-1).")" : "";
55
    my $sql = "SELECT * FROM accountlines WHERE borrowernumber=? AND (amountoutstanding<>0) ".$use_selected." ORDER BY date";
56
    my $sth = $dbh->prepare($sql);
57
58
    $sth->execute($borrowernumber, @selected);
59
60
    # Create a new transaction
61
    my $transaction = Koha::PaymentsTransaction->new()->set({
62
            borrowernumber          => $borrowernumber,
63
            status                  => "unsent",
64
            description             => $args->{payment_note} || '',
65
    })->store();
66
67
    while ( (my $accdata = $sth->fetchrow_hashref) and $money_left > 0) {
68
        my $product;
69
70
        $product->{Code} = $accdata->{'accounttype'};
71
        $product->{Amount} = 1;
72
        $product->{Description} = $accdata->{'description'};
73
74
        if ( _convert_to_cents($accdata->{'amountoutstanding'}) >= $money_left ) {
75
            $product->{Price} = $money_left;
76
            $money_left = 0;
77
        } else {
78
            $product->{Price} = _convert_to_cents($accdata->{'amountoutstanding'});
79
            $money_left -= _convert_to_cents($accdata->{'amountoutstanding'});
80
        }
81
        push $payment->{Products}, $product;
82
        $total_price += $product->{Price};
83
84
        $transaction->AddRelatedAccountline($accdata->{'accountlines_id'}, $product->{Price});
85
    }
86
87
    $transaction->set({ price_in_cents => $total_price })->store();
88
89
    my $borrower = Koha::Borrowers->cast($transaction->borrowernumber);
90
91
    my $description = $borrower->surname . ", " . $borrower->firstname . " (".$borrower->cardnumber.")";
92
93
    $payment->{ApiVersion}  = "2.0";
94
    $payment->{Source}      = C4::Context->config('pos')->{'CPU'}->{'source'};
95
    $payment->{Id}          = $transaction->transaction_id;
96
    $payment->{Mode}        = C4::Context->config('pos')->{'CPU'}->{'mode'};
97
    $payment->{Description} = $description;
98
    $payment->{Products} = AccountTypesToItemNumbers($transaction->GetProducts(), C4::Branch::mybranch());
99
100
    my $notificationAddress = C4::Context->config('pos')->{'CPU'}->{'notificationAddress'};
101
    my $transactionNumber = $transaction->transaction_id;
102
    $notificationAddress =~ s/{invoicenumber}/$transactionNumber/g;
103
104
    $payment->{NotificationAddress} = $notificationAddress; # url for report
105
106
    $payment = _validate_cpu_hash($payment); # Remove semicolons
107
    $payment->{Hash}        = CalculatePaymentHash($payment);
108
109
    $payment = _validate_cpu_hash($payment); # Convert strings to int
110
    $payment->{"send_payment"} = "POST";
111
112
    return $payment;
113
}
114
115
=head2 SendPayment
116
117
  SendPayment($payment);
118
119
Sends a payment to CPU. $payment is a HASH that needs to be in the CPU format with
120
SHA-256 hash calculated correctly.
121
122
Returns JSON-encoded response from CPU. See the CPU document for response protocol.
123
124
=cut
125
126
sub SendPayment {
127
    my $content = shift;
128
    my $response;
129
130
    $response = eval {
131
        my $payment = $content;
132
133
        delete $payment->{send_payment} if $payment->{send_payment};
134
135
        # Convert strings to integer for JSON
136
        $payment = _validate_cpu_hash($payment);
137
138
        # Construct JSON object
139
        $content = JSON->new->utf8->canonical(1)->encode($payment);
140
141
        my $transaction = Koha::PaymentsTransactions->find($payment->{Id});
142
143
        if (C4::Context->config('pos')->{'CPU'}->{'ssl_cert'}) {
144
            # Define SSL certificate
145
            $ENV{HTTPS_CERT_FILE} = C4::Context->config('pos')->{'CPU'}->{'ssl_cert'};
146
            $ENV{HTTPS_KEY_FILE}  = C4::Context->config('pos')->{'CPU'}->{'ssl_key'};
147
            $ENV{HTTPS_CA_FILE} = C4::Context->config('pos')->{'CPU'}->{'ssl_ca_file'};
148
        }
149
150
        my $ua = LWP::UserAgent->new;
151
152
        if (C4::Context->config('pos')->{'CPU'}->{'ssl_cert'}) {
153
            $ua->ssl_opts({
154
                SSL_use_cert    => 1,
155
            });
156
        }
157
158
        $ua->timeout(500);
159
160
        my $req = HTTP::Request->new(POST => C4::Context->config('pos')->{'CPU'}->{'url'});
161
        $req->header('content-type' => 'application/json');
162
        $req->content($content);
163
164
        $transaction->set({ status => "pending" })->store();
165
166
        my $request = $ua->request($req);
167
168
        # There is an issue where the call above fails for unknown reasons, but REST API got
169
        # confirmation of successful payment. We need to be able to recognize payments
170
        # that have been completed during $ua->request($req) by REST API and not set them to
171
        # "cancelled" status even if $ua->request($req) returns some HTTP error code.
172
        # At this point, payment should still be "pending". Refresh payment status.
173
174
        $transaction = Koha::PaymentsTransactions->find($payment->{Id});
175
        my $payment_already_paid = 1 if $transaction->status eq "paid"; # Already paid via REST API!
176
        return JSON->new->utf8->canonical(1)->encode({ Status => '1' }) if $payment_already_paid;
177
178
        if ($request->{_rc} != 200) {
179
            # Did not get HTTP 200, some error happened!
180
            $transaction->set({ status => "cancelled", description => $request->{_content} })->store();
181
            return JSON->new->utf8->canonical(1)->encode({ error => $request->{_content}, Status => '89' });
182
        }
183
184
        my $response = JSON->new->utf8->canonical(1)->decode($request->{_content});
185
186
        # Calculate response checksum and return error if they do not match
187
        my $hash = CalculateResponseHash($response);
188
189
        if ($hash ne $response->{Hash}) {
190
            $transaction->set({ status => "cancelled", description => "Invalid hash" })->store();
191
            return JSON->new->utf8->canonical(1)->encode({ error => "Invalid hash", Status => $response->{Status} });
192
        }
193
194
        return JSON->new->utf8->canonical(1)->encode($response);
195
    };
196
197
    if ($@) {
198
        my $transaction = Koha::PaymentsTransactions->find($content->{Id});
199
        my $payment_already_paid = 1 if $transaction->status eq "paid"; # Already paid via REST API!
200
        return JSON->new->utf8->canonical(1)->encode({ Status => '1' }) if $payment_already_paid;
201
        $transaction->set({ status => "cancelled", description => $@ })->store();
202
        return JSON->new->utf8->canonical(1)->encode({ error => "Error: " . $@, Status => '88' });
203
    }
204
205
    return $response;
206
}
207
208
=head2 HandleResponseStatus
209
210
  HandleResponseStatus($code, $transaction)
211
212
Sets the correct transaction status according to the status code in CPU response.
213
214
Returns a Koha::PaymentsTransaction object
215
216
=cut
217
sub HandleResponseStatus {
218
    my ($code, $transaction) = @_;
219
220
    my $status = getResponseString($code);
221
222
    $transaction->set($status)->store(); # set the status
223
224
    return $transaction;
225
}
226
227
=head2 GetResponseString
228
229
  GetResponseString($statuscode)
230
231
  Converts CPU Status code into string recognized by payments_transactions.status
232
  e.g. paid, cancelled, pending
233
234
Returns status as string
235
236
=cut
237
sub GetResponseString {
238
    my ($code) = @_;
239
240
    my $status;
241
    $status->{status} = "cancelled"; # default status
242
243
    if ($code == 0) {
244
        # Payment was cancelled
245
    }
246
    elsif ($code == 1) {
247
        # Payment was successful
248
        $status->{status} = "paid";
249
    }
250
    elsif ($code == 2) {
251
        # Payment is pending
252
        $status->{status} = "pending";
253
    }
254
    elsif ($code == 97) {
255
        # Id was duplicate (duplicate transaction id - different hash)
256
        $status->{description} = "ERROR 97: Duplicate id";
257
    }
258
    elsif ($code == 98) {
259
        # System error
260
        $status->{description} = "ERROR 98: System error";
261
    }
262
    elsif ($code == 99) {
263
        # Invalid invoice
264
        $status->{description} = "ERROR 99: Invalid invoice";
265
    }
266
    else {
267
        $status->{description} = "Unknown status";
268
    }
269
270
    return $status;
271
}
272
273
=head2 hasBranchEnabledIntegration
274
275
  hasBranchEnabledIntegration($branch);
276
277
  Checks if the $branch has enabled POS integration. Integration is enabled
278
  when the systempreference "cpuitemnumber" YAML config has mapping of
279
  Koha-itemtypes to CPU-itemnumbers for $branch.
280
281
Returns 1 if yes, otherwise 0.
282
283
=cut
284
sub hasBranchEnabledIntegration {
285
    my ($branch) = @_;
286
287
    # Load YAML conf from syspref cpuitemnumbers
288
    my $pref = C4::Context->preference("cpuitemnumbers");
289
    return 0 unless $pref;
290
    my $config = YAML::XS::Load(
291
                        Encode::encode(
292
                            'UTF-8',
293
                            $pref,
294
                            Encode::FB_CROAK
295
                        ));
296
297
    return 0 unless $config->{$branch};
298
    return 1;
299
}
300
301
=head2 AccountTypesToItemNumbers
302
303
  AccountTypesToItemNumbers($products, $branch);
304
305
Maps Koha-itemtypes (accountlines.accounttype) to CPU itemnumbers.
306
307
This is defined in system preference "cpuitemnumbers".
308
309
Products is an array of Product (HASH) that are in the format of CPU-document.
310
311
Returns an ARRAY of products (HASH).
312
313
=cut
314
sub AccountTypesToItemNumbers {
315
    my ($products, $branch) = @_;
316
317
    # Load YAML conf from syspref cpuitemnumbers
318
    my $pref = C4::Context->preference("cpuitemnumbers");
319
    Koha::Exception::NoSystemPreference->throw( error => "YAML configuration in system preference 'cpuitemnumbers' is not defined! Cannot assign item numbers for accounttypes." ) unless $pref;
320
    my $config = YAML::XS::Load(
321
                        Encode::encode(
322
                            'UTF-8',
323
                            $pref,
324
                            Encode::FB_CROAK
325
                        ));
326
327
    Koha::Exception::NoSystemPreference->throw( error => "No item number configuration for branch '".$branch."'. Configure system preference 'cpuitemnumbers'") unless $config->{$branch};
328
329
    my $modified_products;
330
331
    for my $product (@$products){
332
        my $mapped_product = $product;
333
334
        # If accounttype is mapped to an item number
335
        if ($config->{$branch}->{$product->{Code}}) {
336
            $mapped_product->{Code} = $config->{$branch}->{$product->{Code}}
337
        } else {
338
            # Else, try to use accounttype "Default"
339
            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'};
340
341
            $mapped_product->{Code} = $config->{$branch}->{'Default'};
342
        }
343
344
        push @$modified_products, $mapped_product;
345
    }
346
347
    return $modified_products;
348
}
349
350
351
=head2 CalculatePaymentHash
352
353
  CalculatePaymentHash($response);
354
355
Calculates SHA-256 hash from our payment hash. Returns the SHA-256 string.
356
357
=cut
358
359
sub CalculatePaymentHash {
360
    my $invoice = shift;
361
    my $data;
362
363
    foreach my $param (sort keys $invoice){
364
        next if $param eq "Hash";
365
        my $value = $invoice->{$param};
366
367
        if (ref($invoice->{$param}) eq 'ARRAY') {
368
            my $product_hash = $value;
369
            $value = "";
370
            foreach my $product (values $product_hash){
371
                foreach my $product_data (sort keys $product){
372
                    $value .= $product->{$product_data} . "&";
373
                }
374
            }
375
            $value =~ s/&$//g
376
        }
377
378
        $data .= $value . "&";
379
    }
380
381
    $data .= C4::Context->config('pos')->{'CPU'}->{'secretKey'};
382
    $data = Encode::encode_utf8($data);
383
    return Digest::SHA::sha256_hex($data);
384
}
385
386
=head2 CalculateResponseHash
387
388
  CalculateResponseHash($response);
389
390
Calculates SHA-256 hash from CPU's response. Returns the SHA-256 string.
391
392
=cut
393
394
sub CalculateResponseHash {
395
    my $resp = shift;
396
    my $data = "";
397
398
    $data .= $resp->{Source} if defined $resp->{Source};
399
    $data .= "&" . $resp->{Id} if defined $resp->{Id};
400
    $data .= "&" . $resp->{Status} if defined $resp->{Status};
401
    $data .= "&" . $resp->{Reference} if defined $resp->{Reference};
402
    $data .= "&" . C4::Context->config('pos')->{'CPU'}->{'secretKey'};
403
404
    $data =~ s/^&//g;
405
406
    $data = Digest::SHA::sha256_hex($data);
407
    return $data;
408
}
409
410
sub _validate_cpu_hash {
411
    my $invoice = shift;
412
413
    # CPU does not like a semicolon. Go through the fields and make sure
414
    # none of the fields contain ';' character (from CPU documentation)
415
    foreach my $field (keys $invoice){
416
        $invoice->{$field} =~ s/;/\x{037E}/g; # Replace semicolon with a Greek question mark (;)
417
    }
418
419
    $invoice->{Mode} = int($invoice->{Mode});
420
    foreach my $product (@{ $invoice->{Products} }){
421
        foreach my $product_field (keys $product){
422
            $product->{$product_field} =~ s/;/\x{037E}/g; # Replace semicolon with a Greek question mark (;)
423
        }
424
        $product->{Amount} = int($product->{Amount}) if $product->{Amount};
425
        $product->{Price} = int($product->{Price}) if $product->{Price};
426
    }
427
428
    return $invoice;
429
}
430
431
sub _convert_to_cents {
432
    my ($price) = @_;
433
434
    return int($price*100); # transform into cents
435
}
436
437
sub _convert_to_euros {
438
    my ($price) = @_;
439
440
    return $price/100;
441
}
442
443
1;
(-)a/Koha/PaymentsTransaction.pm (+271 lines)
Line 0 Link Here
1
package Koha::PaymentsTransaction;
2
3
# Copyright Open Source Freedom Fighters
4
#
5
# This file is part of Koha.
6
#
7
# Koha is free software; you can redistribute it and/or modify it under the
8
# terms of the GNU General Public License as published by the Free Software
9
# Foundation; either version 3 of the License, or (at your option) any later
10
# version.
11
#
12
# Koha is distributed in the hope that it will be useful, but WITHOUT ANY
13
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
14
# A PARTICULAR PURPOSE.  See the GNU General Public License for more details.
15
#
16
# You should have received a copy of the GNU General Public License along
17
# with Koha; if not, write to the Free Software Foundation, Inc.,
18
# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
19
20
use Modern::Perl;
21
use Data::Dumper;
22
23
use C4::Accounts;
24
use C4::Context;
25
use C4::Log;
26
use C4::Stats;
27
28
use Koha::Database;
29
30
use bignum;
31
32
use base qw(Koha::Object);
33
34
sub type {
35
    return 'PaymentsTransaction';
36
}
37
38
sub AddRelatedAccountline {
39
    my ($self, $accountlines_id, $paid_price) = @_;
40
41
    return 0 unless defined $accountlines_id and defined $paid_price;
42
43
    my $dbh = C4::Context->dbh;
44
    my $sql = "INSERT INTO payments_transactions_accountlines (transaction_id, accountlines_id, paid_price_cents) VALUES (?, ?, ?)";
45
46
    my $sth = $dbh->prepare($sql);
47
    $sth->execute($self->transaction_id, $accountlines_id, $paid_price);
48
49
    return $dbh->last_insert_id(undef,undef,'payments_transactions_accountlines',undef);
50
}
51
52
sub GetRelatedAccountlines {
53
    my ($self) = @_;
54
55
    my $dbh = C4::Context->dbh;
56
    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
57
ON payments_transactions_accountlines.accountlines_id = accountlines.accountlines_id AND payments_transactions_accountlines.transaction_id=?";
58
    my $sth = $dbh->prepare($sql);
59
    $sth->execute($self->transaction_id);
60
61
    my $hash_ref = $sth->fetchall_arrayref({});
62
    $sth->finish;
63
    return $hash_ref;
64
}
65
66
sub GetProducts {
67
    my ($self) = @_;
68
69
    my $dbh = C4::Context->dbh;
70
    my $sql = "SELECT accountlines.accounttype, payments_transactions_accountlines.paid_price_cents, accountlines.description FROM accountlines INNER JOIN payments_transactions_accountlines
71
ON payments_transactions_accountlines.accountlines_id = accountlines.accountlines_id AND payments_transactions_accountlines.transaction_id=?";
72
    my $sth = $dbh->prepare($sql);
73
    $sth->execute($self->transaction_id);
74
75
    my @products;
76
77
    while (my $accountline = $sth->fetchrow_hashref) {
78
        my $product;
79
        $product->{Code} = $accountline->{'accounttype'};
80
        $product->{Price} = $accountline->{'paid_price_cents'};
81
        $product->{Description} = $accountline->{'description'};
82
        push @products, $product;
83
    }
84
85
    return \@products;
86
}
87
88
=head2 CompletePayment
89
90
  &CompletePayment($transaction_number);
91
92
Completes the payment in Koha from the given transaction number.
93
94
This subroutine will be called after payment is completed,
95
(after payment report is received to REST API)
96
97
=cut
98
99
sub CompletePayment {
100
    my ($self, $status) = @_;
101
    my $dbh                 = C4::Context->dbh;
102
    my $manager_id          = 0;
103
    $manager_id             = C4::Context->userenv->{'number'} if C4::Context->userenv;
104
    my $branch              = C4::Context->userenv->{'branch'} if C4::Context->userenv;
105
    my $description = "";
106
    my $itemnumber;
107
    my $old_status;
108
    my $new_status;
109
110
    my $transaction = $self;
111
    return if not $transaction;
112
113
    # It's important that we don't process this subroutine twice at the same time!
114
    $transaction = Koha::PaymentsTransactions->find($transaction->transaction_id);
115
116
    $old_status = $transaction->status;
117
    $new_status = $status->{status};
118
119
    if ($old_status eq $new_status){
120
        # Trying to complete with same status, makes no sense
121
        return;
122
    }
123
124
    if ($old_status ne "processing"){
125
        $transaction->set({ status => "processing" })->store();
126
    } else {
127
        # Another process is already processing the payment
128
        return;
129
    }
130
131
    # Defined accountlines_id means that the payment is already completed in Koha.
132
    # We don't want to make duplicate payments. So make sure it is not defined!
133
    #return if defined $transaction->accountlines_id;
134
    # Reverse the payment if old status is different than new status (and either paid or cancelled)
135
    if (defined $transaction->accountlines_id && (($old_status eq "paid" and $new_status eq "cancelled") or ($old_status eq "cancelled" and $new_status eq "paid"))){
136
        C4::Accounts::ReversePayment($transaction->accountlines_id);
137
        $transaction->set($status)->store();
138
        return;
139
    }
140
141
    # Payment was cancelled
142
    if ($new_status eq "cancelled") {
143
        $transaction->set({ status => "cancelled" })->store();
144
        &logaction(
145
        "PAYMENTS",
146
        "PAY",
147
            $transaction->transaction_id,
148
            $transaction->status
149
        );
150
        return;
151
    }
152
153
    # If transaction is found, pay the accountlines associated with the transaction.
154
    my $accountlines = $transaction->GetRelatedAccountlines();
155
156
    # Define a variable for leftovers. This should not be needed, but it's a fail-safe.
157
    my $leftovers = 0;
158
159
    my $sth = $dbh->prepare('UPDATE accountlines SET amountoutstanding= ? ' .
160
        'WHERE accountlines_id=?');
161
162
    my @ids;
163
    foreach my $acct (@$accountlines){
164
        if (_convert_to_cents($acct->{amountoutstanding}) == 0) {
165
            $leftovers += _convert_to_euros($acct->{paid_price_cents});
166
            next;
167
        }
168
169
        my $paidamount = _convert_to_euros($acct->{paid_price_cents});
170
        my $newamount = 0;
171
172
        $itemnumber = $acct->{itemnumber} if @$accountlines == 1;
173
174
        if ($acct->{amountoutstanding} >= $paidamount) {
175
            $newamount = $acct->{amountoutstanding}-$paidamount;
176
        }
177
        else {
178
            $leftovers += $paidamount-$acct->{amountoutstanding};
179
        }
180
181
        $sth->execute( $newamount, $acct->{accountlines_id} );
182
183
        $description .= ((length($description) > 0) ? "\n" : "") . $acct->{description};
184
185
        if ( C4::Context->preference("FinesLog") ) {
186
            C4::Log::logaction("FINES", 'MODIFY', $transaction->borrowernumber, Dumper({
187
                action                => 'fee_payment',
188
                borrowernumber        => $transaction->borrowernumber,
189
                old_amountoutstanding => $acct->{'amountoutstanding'},
190
                new_amountoutstanding => $newamount,
191
                amount_paid           => $paidamount,
192
                accountlines_id       => $acct->{'accountlines_id'},
193
                accountno             => $acct->{'accountno'},
194
                manager_id            => $manager_id,
195
            }));
196
            push( @ids, $acct->{'accountlines_id'} );
197
        }
198
    }
199
200
    if ($leftovers > 0) {
201
        C4::Accounts::recordpayment_selectaccts($transaction->borrowernumber, $leftovers, [], "Leftovers from transaction ".$transaction->transaction_id);
202
        $transaction->set({ status => $new_status })->store();
203
    }
204
205
    if ($transaction->price_in_cents-_convert_to_cents($leftovers) > 0) {
206
        my $nextacctno = C4::Accounts::getnextacctno($transaction->borrowernumber);
207
        # create new line
208
        my $sql = 'INSERT INTO accountlines ' .
209
        '(borrowernumber, accountno,date,amount,description,accounttype,amountoutstanding,itemnumber,manager_id,note) ' .
210
        q|VALUES (?,?,now(),?,?,'Pay',?,?,?,?)|;
211
        $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);
212
213
        $transaction->set({ status => $new_status, accountlines_id => $dbh->last_insert_id( undef, undef, 'accountlines', undef ) })->store();
214
215
        C4::Stats::UpdateStats($branch, 'payment', _convert_to_euros($transaction->price_in_cents), '', '', '', $transaction->borrowernumber, $nextacctno);
216
217
        if ( C4::Context->preference("FinesLog") ) {
218
            C4::Log::logaction("FINES", 'CREATE',$transaction->borrowernumber,Dumper({
219
                action            => 'create_payment',
220
                borrowernumber    => $transaction->borrowernumber,
221
                accountno         => $nextacctno,
222
                amount            => 0 - _convert_to_euros($transaction->price_in_cents),
223
                amountoutstanding => 0 - $leftovers,
224
                accounttype       => 'Pay',
225
                accountlines_paid => \@ids,
226
                manager_id        => $manager_id,
227
            }));
228
        }
229
        &logaction(
230
        "PAYMENTS",
231
        "PAY",
232
            $transaction->transaction_id,
233
            $transaction->status
234
        );
235
    }
236
}
237
238
=head2 RevertPayment
239
240
  &RevertPayment();
241
242
Reverts the already completed payment.
243
244
=cut
245
246
sub RevertPayment {
247
    my ($self) = @_;
248
    my $dbh                 = C4::Context->dbh;
249
250
    my $transaction = $self;
251
252
    return if not $transaction;
253
254
    return if not defined $transaction->accountlines_id;
255
256
    C4::Accounts::ReversePayment($transaction->accountlines_id);
257
}
258
259
260
sub _convert_to_cents {
261
    my ($price) = @_;
262
263
    return int($price*100); # transform into cents
264
}
265
266
sub _convert_to_euros {
267
    my ($price) = @_;
268
269
    return $price/100;
270
}
271
1;
(-)a/Koha/PaymentsTransactions.pm (+36 lines)
Line 0 Link Here
1
package Koha::PaymentsTransactions;
2
3
# Copyright Open Source Freedom Fighters
4
#
5
# This file is part of Koha.
6
#
7
# Koha is free software; you can redistribute it and/or modify it under the
8
# terms of the GNU General Public License as published by the Free Software
9
# Foundation; either version 3 of the License, or (at your option) any later
10
# version.
11
#
12
# Koha is distributed in the hope that it will be useful, but WITHOUT ANY
13
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
14
# A PARTICULAR PURPOSE.  See the GNU General Public License for more details.
15
#
16
# You should have received a copy of the GNU General Public License along
17
# with Koha; if not, write to the Free Software Foundation, Inc.,
18
# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
19
20
use Modern::Perl;
21
use Koha::Database;
22
use base qw(Koha::Objects);
23
24
sub type {
25
    return 'PaymentsTransaction';
26
}
27
28
sub object_class {
29
    return 'Koha::PaymentsTransaction';
30
}
31
32
sub _get_castable_unique_columns {
33
    return ['transaction_id'];
34
}
35
36
1;
(-)a/Koha/REST/V1/POSIntegration.pm (+57 lines)
Line 0 Link Here
1
package Koha::REST::V1::POSIntegration;
2
3
use Modern::Perl;
4
use Mojo::Base 'Mojolicious::Controller';
5
6
use C4::Log;
7
use C4::OPLIB::CPUIntegration;
8
9
use Koha::PaymentsTransaction;
10
use Koha::PaymentsTransactions;
11
12
sub get_transaction {
13
    my ($c, $args, $cb) = @_;
14
15
    return $c->$cb({ error => "Missing transaction number"}, 400) if not $args->{'invoicenumber'};
16
17
    # Find transaction
18
    my $transaction = Koha::PaymentsTransactions->find($args->{invoicenumber});
19
20
    return $c->$cb({ error => "Transaction not found"}, 404) if not $transaction;
21
22
    return $c->$cb({
23
                    transaction_id        => $transaction->transaction_id,
24
                    borrowernumber        => $transaction->borrowernumber,
25
                    status                => $transaction->status,
26
                    timestamp             => $transaction->timestamp,
27
                    description           => $transaction->description || "",
28
                    price_in_cents => int($transaction->price_in_cents),
29
                    }, 200);
30
}
31
32
33
=head2 CPU_report($c, $args, $cb)
34
35
Receives the success report from CPU.
36
37
=cut
38
sub cpu_report {
39
    my ($c, $args, $cb) = @_;
40
41
    my $invoicenumber = $args->{'invoicenumber'};
42
    $args = $args->{body};
43
44
    # Check that the request is valid
45
    return $c->$cb({ error => "Invalid Hash" }, 400) if C4::OPLIB::CPUIntegration::CalculateResponseHash($args) ne $args->{Hash};
46
47
    # Find the transaction
48
    my $transaction = Koha::PaymentsTransactions->find($invoicenumber);
49
    return $c->$cb({ error => "Transaction not found"}, 404) if not $transaction;
50
51
    my $report_status = C4::OPLIB::CPUIntegration::GetResponseString($args->{Status});
52
    $transaction->CompletePayment($report_status);
53
54
    return $c->$cb("", 200);
55
}
56
57
1;
(-)a/Koha/Schema/Result/PaymentsTransaction.pm (+169 lines)
Line 0 Link Here
1
use utf8;
2
package Koha::Schema::Result::PaymentsTransaction;
3
4
# Created by DBIx::Class::Schema::Loader
5
# DO NOT MODIFY THE FIRST PART OF THIS FILE
6
7
=head1 NAME
8
9
Koha::Schema::Result::PaymentsTransaction
10
11
=cut
12
13
use strict;
14
use warnings;
15
16
use base 'DBIx::Class::Core';
17
18
=head1 TABLE: C<payments_transactions>
19
20
=cut
21
22
__PACKAGE__->table("payments_transactions");
23
24
=head1 ACCESSORS
25
26
=head2 transaction_id
27
28
  data_type: 'integer'
29
  is_auto_increment: 1
30
  is_nullable: 0
31
32
=head2 borrowernumber
33
34
  data_type: 'integer'
35
  is_foreign_key: 1
36
  is_nullable: 0
37
38
=head2 accountlines_id
39
40
  data_type: 'integer'
41
  is_foreign_key: 1
42
  is_nullable: 1
43
44
=head2 status
45
46
  data_type: 'enum'
47
  default_value: 'pending'
48
  extra: {list => ["paid","pending","cancelled","unsent","processing"]}
49
  is_nullable: 1
50
51
=head2 timestamp
52
53
  data_type: 'timestamp'
54
  datetime_undef_if_invalid: 1
55
  default_value: current_timestamp
56
  is_nullable: 0
57
58
=head2 description
59
60
  data_type: 'text'
61
  is_nullable: 0
62
63
=head2 price_in_cents
64
65
  data_type: 'integer'
66
  is_nullable: 0
67
68
=cut
69
70
__PACKAGE__->add_columns(
71
  "transaction_id",
72
  { data_type => "integer", is_auto_increment => 1, is_nullable => 0 },
73
  "borrowernumber",
74
  { data_type => "integer", is_foreign_key => 1, is_nullable => 0 },
75
  "accountlines_id",
76
  { data_type => "integer", is_foreign_key => 1, is_nullable => 1 },
77
  "status",
78
  {
79
    data_type => "enum",
80
    default_value => "pending",
81
    extra => {
82
      list => ["paid", "pending", "cancelled", "unsent", "processing"],
83
    },
84
    is_nullable => 1,
85
  },
86
  "timestamp",
87
  {
88
    data_type => "timestamp",
89
    datetime_undef_if_invalid => 1,
90
    default_value => \"current_timestamp",
91
    is_nullable => 0,
92
  },
93
  "description",
94
  { data_type => "text", is_nullable => 0 },
95
  "price_in_cents",
96
  { data_type => "integer", is_nullable => 0 },
97
);
98
99
=head1 PRIMARY KEY
100
101
=over 4
102
103
=item * L</transaction_id>
104
105
=back
106
107
=cut
108
109
__PACKAGE__->set_primary_key("transaction_id");
110
111
=head1 RELATIONS
112
113
=head2 accountline
114
115
Type: belongs_to
116
117
Related object: L<Koha::Schema::Result::Accountline>
118
119
=cut
120
121
__PACKAGE__->belongs_to(
122
  "accountline",
123
  "Koha::Schema::Result::Accountline",
124
  { accountlines_id => "accountlines_id" },
125
  {
126
    is_deferrable => 1,
127
    join_type     => "LEFT",
128
    on_delete     => "CASCADE",
129
    on_update     => "RESTRICT",
130
  },
131
);
132
133
=head2 borrowernumber
134
135
Type: belongs_to
136
137
Related object: L<Koha::Schema::Result::Borrower>
138
139
=cut
140
141
__PACKAGE__->belongs_to(
142
  "borrowernumber",
143
  "Koha::Schema::Result::Borrower",
144
  { borrowernumber => "borrowernumber" },
145
  { is_deferrable => 1, on_delete => "CASCADE", on_update => "RESTRICT" },
146
);
147
148
=head2 payments_transactions_accountlines
149
150
Type: has_many
151
152
Related object: L<Koha::Schema::Result::PaymentsTransactionsAccountline>
153
154
=cut
155
156
__PACKAGE__->has_many(
157
  "payments_transactions_accountlines",
158
  "Koha::Schema::Result::PaymentsTransactionsAccountline",
159
  { "foreign.transaction_id" => "self.transaction_id" },
160
  { cascade_copy => 0, cascade_delete => 0 },
161
);
162
163
164
# Created by DBIx::Class::Schema::Loader v0.07039 @ 2015-12-10 17:49:20
165
# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:TtY1X7ynTADbZtcFxzeKeg
166
167
168
# You can replace this text with custom code or comments, and it will be preserved on regeneration
169
1;
(-)a/Koha/Schema/Result/PaymentsTransactionsAccountline.pm (+112 lines)
Line 0 Link Here
1
use utf8;
2
package Koha::Schema::Result::PaymentsTransactionsAccountline;
3
4
# Created by DBIx::Class::Schema::Loader
5
# DO NOT MODIFY THE FIRST PART OF THIS FILE
6
7
=head1 NAME
8
9
Koha::Schema::Result::PaymentsTransactionsAccountline
10
11
=cut
12
13
use strict;
14
use warnings;
15
16
use base 'DBIx::Class::Core';
17
18
=head1 TABLE: C<payments_transactions_accountlines>
19
20
=cut
21
22
__PACKAGE__->table("payments_transactions_accountlines");
23
24
=head1 ACCESSORS
25
26
=head2 transactions_accountlines_id
27
28
  data_type: 'integer'
29
  is_auto_increment: 1
30
  is_nullable: 0
31
32
=head2 transaction_id
33
34
  data_type: 'integer'
35
  is_foreign_key: 1
36
  is_nullable: 0
37
38
=head2 accountlines_id
39
40
  data_type: 'integer'
41
  is_foreign_key: 1
42
  is_nullable: 0
43
44
=head2 paid_price_cents
45
46
  data_type: 'integer'
47
  is_nullable: 0
48
49
=cut
50
51
__PACKAGE__->add_columns(
52
  "transactions_accountlines_id",
53
  { data_type => "integer", is_auto_increment => 1, is_nullable => 0 },
54
  "transaction_id",
55
  { data_type => "integer", is_foreign_key => 1, is_nullable => 0 },
56
  "accountlines_id",
57
  { data_type => "integer", is_foreign_key => 1, is_nullable => 0 },
58
  "paid_price_cents",
59
  { data_type => "integer", is_nullable => 0 },
60
);
61
62
=head1 PRIMARY KEY
63
64
=over 4
65
66
=item * L</transactions_accountlines_id>
67
68
=back
69
70
=cut
71
72
__PACKAGE__->set_primary_key("transactions_accountlines_id");
73
74
=head1 RELATIONS
75
76
=head2 accountline
77
78
Type: belongs_to
79
80
Related object: L<Koha::Schema::Result::Accountline>
81
82
=cut
83
84
__PACKAGE__->belongs_to(
85
  "accountline",
86
  "Koha::Schema::Result::Accountline",
87
  { accountlines_id => "accountlines_id" },
88
  { is_deferrable => 1, on_delete => "CASCADE", on_update => "RESTRICT" },
89
);
90
91
=head2 transaction
92
93
Type: belongs_to
94
95
Related object: L<Koha::Schema::Result::PaymentsTransaction>
96
97
=cut
98
99
__PACKAGE__->belongs_to(
100
  "transaction",
101
  "Koha::Schema::Result::PaymentsTransaction",
102
  { transaction_id => "transaction_id" },
103
  { is_deferrable => 1, on_delete => "CASCADE", on_update => "RESTRICT" },
104
);
105
106
107
# Created by DBIx::Class::Schema::Loader v0.07039 @ 2015-11-19 10:32:53
108
# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:ItfVA6ePztGiqVcJ/VPabQ
109
110
111
# You can replace this text with custom code or comments, and it will be preserved on regeneration
112
1;
(-)a/api/v1/swagger.json (+129 lines)
Lines 75-80 Link Here
75
          }
75
          }
76
        }
76
        }
77
      }
77
      }
78
    },
79
    "/pos/cpu/{invoicenumber}": {
80
      "get": {
81
        "x-mojo-controller": "Koha::REST::V1::POSIntegration",
82
        "operationId": "getTransaction",
83
        "x-koha-permission": {
84
          "updatecharges": "remaining_permissions"
85
        },
86
        "tags": ["POS Integration"],
87
        "parameters": [
88
          {
89
            "$ref": "#/parameters/invoicenumberPathParam"
90
          }
91
        ],
92
        "consumes": ["application/json"],
93
        "produces": ["application/json"],
94
        "responses": {
95
          "200": {
96
            "description": "A transaction",
97
            "schema": {
98
              "$ref" : "#/definitions/transaction"
99
            }
100
          },
101
          "404": {
102
            "description": "Transaction not found",
103
            "schema": {
104
              "$ref": "#/definitions/error"
105
            }
106
          }
107
        }
108
      }
109
    },
110
    "/pos/cpu/{invoicenumber}/report": {
111
      "post": {
112
        "x-mojo-controller": "Koha::REST::V1::POSIntegration",
113
        "operationId": "cpuReport",
114
        "tags": ["POS Integration"],
115
        "parameters": [
116
          {
117
            "$ref": "#/parameters/invoicenumberPathParam"
118
          },
119
          {
120
            "name": "body",
121
            "in": "body",
122
            "type": "string",
123
            "description": "New report",
124
            "schema": { "$ref": "#/definitions/CPUinvoiceReport" }
125
          }
126
        ],
127
        "consumes": ["application/json"],
128
        "produces": ["application/json"],
129
        "responses": {
130
          "200": {
131
            "description": "Response for receiving report",
132
            "type": "string"
133
          },
134
          "400": {
135
            "description": "Bad parameters",
136
            "schema": {
137
              "$ref": "#/definitions/error"
138
            }
139
          },
140
          "404": {
141
            "description": "Transaction not found",
142
            "schema": {
143
              "$ref": "#/definitions/error"
144
            }
145
          }
146
        }
147
      }
78
    }
148
    }
79
  },
149
  },
80
  "definitions": {
150
  "definitions": {
Lines 353-358 Link Here
353
    "borrowernumber": {
423
    "borrowernumber": {
354
      "description": "Patron internal identifier"
424
      "description": "Patron internal identifier"
355
    },
425
    },
426
    "transaction": {                                                                                                                                           
427
      "type": "object",
428
      "properties": {
429
        "borrowernumber": {
430
          "$ref": "#/definitions/borrowernumber"
431
        },
432
        "accountlines_id": {
433
          "description": "Reference to related accountlines row where accounttype is Pay. If null, transaction is incomplete. Else it is completed.",
434
          "type": "integer"
435
        },
436
        "status": {
437
          "description": "Status of transaction",
438
          "type": "string"
439
        },
440
        "timestamp": {
441
          "description": "Creation time",
442
          "type": "string"
443
        },
444
        "description": {
445
          "type": "string"
446
        },
447
        "price_in_cents": {
448
          "description": "Total price of transaction",
449
          "type": "integer"
450
        }
451
      }
452
    },
453
    "CPUinvoiceReport": {
454
      "type": "object",
455
      "properties": {
456
        "Source": {
457
          "type": "string"
458
        },
459
        "Id": {
460
          "description": "Invoice identification number",
461
          "type": "string"
462
        },
463
        "Status": {
464
          "description": "Status of payment",
465
          "type": "integer"
466
        },
467
        "Reference": {
468
          "description": "Receipt number for successful payments",
469
          "type": "string",
470
          "required": false
471
        },
472
        "Hash": {
473
          "description": "Hash for response parameters",
474
          "type": "string"
475
        }
476
      }
477
    },
356
    "error": {
478
    "error": {
357
      "type": "object",
479
      "type": "object",
358
      "properties": {
480
      "properties": {
Lines 370-375 Link Here
370
      "description": "Internal patron identifier",
492
      "description": "Internal patron identifier",
371
      "required": true,
493
      "required": true,
372
      "type": "integer"
494
      "type": "integer"
495
    },
496
    "invoicenumberPathParam": {
497
      "name": "invoicenumber",
498
      "in": "path",
499
      "description": "Internal invoice identifier",
500
      "required": "true",
501
      "type": "integer"
373
    }
502
    }
374
  }
503
  }
375
}
504
}
(-)a/etc/koha-conf.xml (+18 lines)
Lines 136-140 __PAZPAR2_TOGGLE_XML_POST__ Link Here
136
    <font type="HBO">/usr/share/fonts/truetype/ttf-dejavu/DejaVuSans-BoldOblique.ttf</font>
136
    <font type="HBO">/usr/share/fonts/truetype/ttf-dejavu/DejaVuSans-BoldOblique.ttf</font>
137
 </ttf>
137
 </ttf>
138
138
139
 <pos>
140
    <CPU>
141
        <!-- Delivered by CPU: -->
142
        <source></source>                           <!-- Source id -->
143
        <secretKey></secretKey>                     <!-- Secret key for generating SHA-256 hash -->
144
        <url></url>                                 <!-- Address to the cash register server -->
145
146
        <!-- Koha settings -->
147
        <mode></mode>                               <!-- Use 2 for synchronized mode -->
148
        <notificationAddress></notificationAddress> <!-- https://server/api/v1/pos/cpu/{invoicenumber}/report -->
149
        <!-- Replace "server" with your server address, but keep {invoicenumber} as it is (it will be converted later into real id) -->
150
151
        <!-- SSL certificates -->
152
        <ssl_cert></ssl_cert>                       <!-- SSL certificate path -->
153
        <ssl_key></ssl_key>                         <!-- SSL key path -->
154
        <ssl_ca_file></ssl_ca_file>                 <!-- CA certificate path -->
155
  </CPU>
156
 </pos>
139
</config>
157
</config>
140
</yazgfs>
158
</yazgfs>
(-)a/etc/koha-httpd.conf (+12 lines)
Lines 37-42 Link Here
37
      Deny from all
37
      Deny from all
38
   </DirectoryMatch>
38
   </DirectoryMatch>
39
39
40
#   <LocationMatch "/api/v1/pos/cpu/\d+/report">
41
#      Order Deny,Allow
42
#      Deny from all
43
#      Allow from 10.1.62.83
44
#   </LocationMatch>
45
40
   <IfModule mod_gzip.c>
46
   <IfModule mod_gzip.c>
41
     mod_gzip_on yes
47
     mod_gzip_on yes
42
     mod_gzip_dechunk yes
48
     mod_gzip_dechunk yes
Lines 170-175 Link Here
170
      Deny from all
176
      Deny from all
171
   </DirectoryMatch>
177
   </DirectoryMatch>
172
178
179
#   <LocationMatch "/api/v1/pos/cpu/\d+/report">
180
#      Order Deny,Allow
181
#      Deny from all
182
#      Allow from 10.1.62.83
183
#   </LocationMatch>
184
173
   <IfModule mod_gzip.c>
185
   <IfModule mod_gzip.c>
174
     mod_gzip_on yes
186
     mod_gzip_on yes
175
     mod_gzip_dechunk yes
187
     mod_gzip_dechunk yes
(-)a/installer/data/mysql/atomicupdate/KD#377-CPU_Integration-Add_table_for_transactions.pl (+51 lines)
Line 0 Link Here
1
#! /usr/bin/perl
2
3
use strict;
4
use warnings;
5
use C4::Context;
6
use Koha::AtomicUpdater;
7
8
my $dbh = C4::Context->dbh;
9
my $atomicUpdater = Koha::AtomicUpdater->new();
10
11
unless($atomicUpdater->find('KD#377')) {
12
    $dbh->do("
13
            CREATE TABLE payments_transactions (
14
                transaction_id int(11) NOT NULL auto_increment,
15
                borrowernumber int(11) NOT NULL,
16
                accountlines_id int(11),
17
                status ENUM('paid','pending','cancelled','unsent','processing') DEFAULT 'unsent',
18
                timestamp timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
19
                description TEXT NOT NULL,
20
                price_in_cents int(11) NOT NULL,
21
                PRIMARY KEY (transaction_id),
22
                FOREIGN KEY (accountlines_id)
23
                    REFERENCES accountlines(accountlines_id)
24
                    ON DELETE CASCADE,
25
                FOREIGN KEY (borrowernumber)
26
                    REFERENCES borrowers(borrowernumber)
27
                    ON DELETE CASCADE
28
            ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;
29
            ");
30
    $dbh->do("
31
            CREATE TABLE payments_transactions_accountlines (
32
                transactions_accountlines_id int(11) NOT NULL auto_increment,
33
                transaction_id int(11) NOT NULL,
34
                accountlines_id int(11) NOT NULL,
35
                paid_price_cents int(11) NOT NULL,
36
                PRIMARY KEY (transactions_accountlines_id),
37
                FOREIGN KEY (transaction_id)
38
                    REFERENCES payments_transactions(transaction_id)
39
                    ON DELETE CASCADE,
40
                FOREIGN KEY (accountlines_id)
41
                    REFERENCES accountlines(accountlines_id)
42
                    ON DELETE CASCADE
43
            ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;
44
        ");
45
46
    # Add system preferences
47
    $dbh->do("INSERT INTO systempreferences (variable, value, options, explanation, type) VALUES ('cpuitemnumbers', '', '', 'Maps Koha account types into Ceepos items', 'textarea')");
48
    $dbh->do("INSERT INTO systempreferences (variable, value, options, explanation, type) VALUES ('POSIntegration', 'OFF', 'cpu|OFF', 'Selects used POS integration', 'choice')");
49
50
    print "Upgrade to done (KD#377 CPU integration: Add table for transactions)\n";
51
}
(-)a/installer/data/mysql/kohastructure.sql (+41 lines)
Lines 1751-1756 CREATE TABLE `patronimage` ( -- information related to patron images Link Here
1751
  CONSTRAINT `patronimage_fk1` FOREIGN KEY (`borrowernumber`) REFERENCES `borrowers` (`borrowernumber`) ON DELETE CASCADE ON UPDATE CASCADE
1751
  CONSTRAINT `patronimage_fk1` FOREIGN KEY (`borrowernumber`) REFERENCES `borrowers` (`borrowernumber`) ON DELETE CASCADE ON UPDATE CASCADE
1752
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;
1752
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;
1753
1753
1754
-
1755
-- Table structure for table `payments_transactions`
1756
--
1757
1758
DROP TABLE IF EXISTS `payments_transactions`;
1759
CREATE TABLE `payments_transactions` ( -- information related to payments via POS integration
1760
  transaction_id int(11) NOT NULL auto_increment, -- transaction number
1761
  borrowernumber int(11) NOT NULL, -- the borrowernumber that the payment is for
1762
  accountlines_id int(11), -- the accountlines_id of the payment (the accounttype is Pay)
1763
  status ENUM('paid','pending','cancelled','unsent') DEFAULT 'pending', -- status of transaction
1764
  timestamp timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, -- timestamp for payment initialization
1765
  description TEXT NOT NULL, -- additional description that can hold notes. Prints into the accountlines Pay event once the payment is completed
1766
  price_in_cents int(11) NOT NULL, -- total price of the payment in cents
1767
  PRIMARY KEY (transaction_id),
1768
  FOREIGN KEY (accountlines_id)
1769
    REFERENCES accountlines(accountlines_id)
1770
    ON DELETE CASCADE,
1771
  FOREIGN KEY (borrowernumber)
1772
    REFERENCES borrowers(borrowernumber)
1773
    ON DELETE CASCADE
1774
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
1775
1776
--
1777
-- Table structure for table `payments_transactions_accountlines`
1778
--
1779
1780
DROP TABLE IF EXISTS `payments_transactions_accountlines`;
1781
CREATE TABLE `payments_transactions` ( -- related accountlines for payments (transactions)
1782
  transactions_accountlines_id int(11) NOT NULL auto_increment,
1783
  transaction_id int(11) NOT NULL, -- referenced transaction_id from payments_transactions
1784
  accountlines_id int(11) NOT NULL, -- referenced accountlines_id from accountlines
1785
  paid_price_cents int(11) NOT NULL, -- price (in cents) of the item in accountlines
1786
  PRIMARY KEY (transactions_accountlines_id),
1787
  FOREIGN KEY (transaction_id)
1788
    REFERENCES payments_transactions(transaction_id)
1789
    ON DELETE CASCADE,
1790
  FOREIGN KEY (accountlines_id)
1791
    REFERENCES accountlines(accountlines_id)
1792
    ON DELETE CASCADE
1793
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
1794
1754
-- Table structure for table `pending_offline_operations`
1795
-- Table structure for table `pending_offline_operations`
1755
--
1796
--
1756
-- this table is MyISAM, InnoDB tables are growing only and this table is filled/emptied/filled/emptied...
1797
-- 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 (+14 lines)
Lines 21-23 Tools: Link Here
21
                  staff: "Staff client only"
21
                  staff: "Staff client only"
22
                  both: "Both OPAC and staff client"
22
                  both: "Both OPAC and staff client"
23
            -
23
            -
24
    Cash registers:
25
        -
26
            - Use
27
            - pref: POSIntegration
28
              choices:
29
                  cpu: CPU integration
30
                  "OFF": None
31
            - component to handle Borrower's fine payments.
32
        -
33
            - CPU / Ceepos integration. Map accountlines' accounttypes to item numbers for cash registers in different branches.
34
            - pref: cpuitemnumbers
35
              type: textarea
36
              class: code
37
            - Use parameter "Default" to define an item number for other types than defined.
(-)a/koha-tmpl/intranet-tmpl/prog/en/modules/members/boraccount.tt (-3 / +46 lines)
Lines 32-37 $(document).ready(function() { Link Here
32
    });
32
    });
33
});
33
});
34
</script>
34
</script>
35
<style type="text/css">
36
   td.transaction-selected {
37
         background:#FFBC8F !important;
38
   }
39
   tr:hover td.transaction-selected  {
40
         background:#F0A16C !important;
41
   }
42
</style>
35
</head>
43
</head>
36
<body id="pat_borraccount" class="pat">
44
<body id="pat_borraccount" class="pat">
37
[% INCLUDE 'header.inc' %]
45
[% INCLUDE 'header.inc' %]
Lines 61-66 $(document).ready(function() { Link Here
61
    <thead>
69
    <thead>
62
      <tr>
70
      <tr>
63
          <th class="title-string">Date</th>
71
          <th class="title-string">Date</th>
72
          <th>Transaction number</th>
64
          <th>Description of charges</th>
73
          <th>Description of charges</th>
65
          <th>Note</th>
74
          <th>Note</th>
66
          <th>Amount</th>
75
          <th>Amount</th>
Lines 75-82 $(document).ready(function() { Link Here
75
	<!-- FIXME: Shouldn't hardcode dollar signs, since Euro or Pound might be needed -->
84
	<!-- FIXME: Shouldn't hardcode dollar signs, since Euro or Pound might be needed -->
76
  [% FOREACH account IN accounts %]
85
  [% FOREACH account IN accounts %]
77
86
78
   [% IF ( loop.odd ) %]<tr>[% ELSE %]<tr class="highlight">[% END %]
87
   [% IF ( loop.odd ) %]<tr [% IF account.transactionnumber %]class="transaction"[% END %]>[% ELSE %]<tr class="highlight [% IF account.transactionnumber %]transaction[% END %]">[% END %]
88
                [% FOREACH relline IN relatedaccounts.${account.accountlines_id} %]
89
                       <input type="hidden" name="parentTransaction" value="[% relline %]" />
90
                [% END %]
79
   <td><span title="[% account.date %]">[% account.date |$KohaDates %]</span></td>
91
   <td><span title="[% account.date %]">[% account.date |$KohaDates %]</span></td>
92
      <td class="transactionnumber">[% IF account.transactionnumber %][% account.transactionnumber %][% ELSE %]-[% END %]</td>
80
      <td>
93
      <td>
81
        [% SWITCH account.accounttype %]
94
        [% SWITCH account.accounttype %]
82
          [% CASE 'Pay' %]Payment, thanks
95
          [% CASE 'Pay' %]Payment, thanks
Lines 125-138 $(document).ready(function() { Link Here
125
  [% END %]
138
  [% END %]
126
<tfoot>
139
<tfoot>
127
  <tr>
140
  <tr>
128
    <td colspan="4">Total due</td>
141
    <td colspan="5">Total due</td>
129
    [% IF ( totalcredit ) %]
142
    [% IF ( totalcredit ) %]
130
        <td class="credit" style="text-align: right;">[% total %]</td>
143
        <td class="credit" style="text-align: right;">[% total %]</td>
131
    [% ELSE %]
144
    [% ELSE %]
132
       <td class="debit"style="text-align: right;">[% total %]</td>
145
       <td class="debit"style="text-align: right;">[% total %]</td>
133
    [% END %]
146
    [% END %]
134
    [% IF ( reverse_col ) %]
147
    [% IF ( reverse_col ) %]
135
      <td colspan="2"></td>
148
      <td colspan="3"></td>
136
      [% ELSE %]
149
      [% ELSE %]
137
        <td></td>
150
        <td></td>
138
    [% END %]
151
    [% END %]
Lines 143-149 $(document).ready(function() { Link Here
143
156
144
</div>
157
</div>
145
</div>
158
</div>
159
<script type="text/javascript">
160
   $("tr").on("click", function(e) {
161
	  $("td").each(function() {
162
		 if ($(this).hasClass("transaction-selected")) {
163
			$(this).removeClass("transaction-selected");
164
		 }
165
	  });
166
	  if ($(this).hasClass("transaction")) {
167
		 $(this).children("td").addClass("transaction-selected");
168
		 $(this).children("td").each(function() {
169
			if ($(this).hasClass("transactionnumber")) {
170
			   var transactionnumber = $(this).html();
146
171
172
			   $("input[name*='parentTransaction']").each(function() {
173
				  if ($(this).val() == transactionnumber) {
174
					 $(this).parent().children("td").addClass("transaction-selected");
175
				  }
176
			   });
177
			}
178
		 });
179
	  }
180
	  e.stopPropagation();
181
   })
182
   $(document).click(function(e) {
183
	  $("td").each(function() {
184
		 if ($(this).hasClass("transaction-selected")) {
185
			$(this).removeClass("transaction-selected");
186
		 }
187
	  });
188
   });
189
</script>
147
<div class="yui-b">
190
<div class="yui-b">
148
[% INCLUDE 'circ-menu.inc' %]
191
[% INCLUDE 'circ-menu.inc' %]
149
</div>
192
</div>
(-)a/koha-tmpl/intranet-tmpl/prog/en/modules/members/paycollect.tt (-1 / +360 lines)
Lines 61-66 function moneyFormat(textObj) { Link Here
61
}
61
}
62
//]]>
62
//]]>
63
</script>
63
</script>
64
<style type="text/css">
65
    .ball {
66
        background-color: rgba(0,0,0,0);
67
        border: 25px solid rgba(240,192,71,0.9);
68
        opacity: .9;
69
        border-top: 25px solid rgba(0,0,0,0);
70
        border-left: 25px solid rgba(0,0,0,0);
71
        border-radius: 150px;
72
        box-shadow: 0 0 35px #f0c047;
73
        width: 140px;
74
        height: 140px;
75
        margin: 50px auto;
76
        -moz-animation: spin 1s infinite linear;
77
        -webkit-animation: spin 1s infinite linear;
78
    }
79
80
    .ball1 {
81
        background-color: rgba(0,0,0,0);
82
        border: 25px solid rgba(240,192,71,0.9);
83
        opacity: .9;
84
        border-top: 25px solid rgba(0,0,0,0);
85
        border-left: 25px solid rgba(0,0,0,0);
86
        border-radius: 150px;
87
        box-shadow: 0 0 15px #f0c047;
88
        width: 70px;
89
        height: 70px;
90
        margin: 0 auto;
91
        position: relative;
92
        top: -205px;
93
        -moz-animation: spinoff .5s infinite linear;
94
        -webkit-animation: spinoff .5s infinite linear;
95
    }
96
97
    @-moz-keyframes spin {
98
        0% {
99
            -moz-transform: rotate(0deg);
100
        }
101
102
        100% {
103
            -moz-transform: rotate(360deg);
104
        };
105
    }
106
107
    @-moz-keyframes spinoff {
108
        0% {
109
            -moz-transform: rotate(0deg);
110
        }
111
112
        100% {
113
            -moz-transform: rotate(-360deg);
114
        };
115
    }
116
117
    @-webkit-keyframes spin {
118
        0% {
119
            -webkit-transform: rotate(0deg);
120
        }
121
122
        100% {
123
            -webkit-transform: rotate(360deg);
124
        };
125
    }
126
127
    @-webkit-keyframes spinoff {
128
        0% {
129
            -webkit-transform: rotate(0deg);
130
        }
131
132
        100% {
133
            -webkit-transform: rotate(-360deg);
134
        };
135
    }
136
137
    .office-button {
138
        background:#eee;
139
        border:solid 2px rgba(240,192,71,1);
140
        border-radius: 5px;
141
        font:3em Verdana;
142
        margin:10px;
143
        min-width:100px;
144
        height:100px;
145
        outline:none;
146
        box-shadow:0 0 2px rgba(240,192,71,1);
147
    }
148
    .selected {
149
        background:#afa;
150
        border:solid 4px rgba(50,2020,50,1);
151
        box-shadow:0 0 2px rgba(50,2020,50,1);
152
    }
153
    .office-button::-moz-focus-inner {
154
        border:0;
155
    }
156
    #add_new_office {
157
        cursor:pointer;
158
    }
159
</style>
64
</head>
160
</head>
65
<body id="pat_paycollect" class="pat">
161
<body id="pat_paycollect" class="pat">
66
[% INCLUDE 'header.inc' %]
162
[% INCLUDE 'header.inc' %]
Lines 98-105 function moneyFormat(textObj) { Link Here
98
    </div>
194
    </div>
99
[% END %]
195
[% END %]
100
196
197
[% IF ( startSending ) %]
198
    <div><h2>Processing payment [% payment.Id %] - Please complete the payment [% IF ( payment.Office ) %]at cash register [% payment.Office %][% ELSE %] at any cash register[% END %].</h2>
199
        <div class="ball"></div>
200
        <div class="ball1"></div>
201
    </div>
202
    <div><p>
203
        Current status:
204
            <span id="status">
205
                <span class="connecting">Connecting to the cash register.</span>
206
                <span class="pending" style="display:none">Payment is pending. Please complete the payment and navigate back to <a href="/cgi-bin/koha/members/boraccount.pl?borrowernumber=10902939">Account</a>.</span>
207
                <span class="paid" style="display:none">Payment is completed.</span>
208
                <span class="cancelled" style="display:none">Payment was cancelled.</span>
209
                <span class="processing" style="display:none">Payment is completed, but Koha is still processing it.</span>
210
            </span>
211
        </p>
212
        <button type="button" id="recheck">Update payment status</button>
213
    </div>
214
    <script type="text/javascript">
215
        $(document).ready(function() {
216
            $.ajax({
217
                url: "/cgi-bin/koha/members/paycollect.pl",
218
                type: "post",
219
                dataType: "json",
220
                data: JSON.stringify([% json_payment %]),
221
                contentType: "application/json"
222
            }).done(function(data){
223
                var response = jQuery.parseJSON(JSON.stringify(data));
224
225
                handlePOSResponse(data);
226
            });
227
            $("#recheck").click(function() {
228
                $.get("/api/v1/pos/cpu/" + [% payment.Id %], function(data) {
229
                    var response = jQuery.parseJSON(JSON.stringify(data));
230
231
                    handlePOSResponse(response);
232
                });
233
            });
234
        });
235
236
        function handlePOSResponse(response){
237
            $("span[id='status']").children().css("display","none");
238
            if (response.Status == 0 || response.status == "cancelled") {
239
                $("span.cancelled").css("display", "inline-block");
240
                colorBall("red");
241
242
                if (response.error) {
243
                    alert(_("Error: ") + JSON.stringify(response));
244
                }
245
246
                window.location.replace("/cgi-bin/koha/members/boraccount.pl?borrowernumber=[% borrowernumber %]");
247
            }
248
            else if (response.Status == 1 || response.status == "paid") {
249
                $("span.paid").css("display", "inline-block");
250
                colorBall("green");
251
                window.location.replace("/cgi-bin/koha/members/boraccount.pl?borrowernumber=[% borrowernumber %]");
252
            }
253
            else if (response.Status == 2 || response.status == "pending") {
254
                $("span.pending").css("display", "inline-block");
255
            }
256
            else if (response.status == "processing") {
257
                $("span.processing").css("display", "inline-block");
258
            }
259
            else if (response.Status == 97) {
260
                $("span.cancelled").css("display", "inline-block");
261
                colorBall("red");
262
                alert(_("Duplicate transaction number: ") + JSON.stringify(response));
263
            }
264
            else if (response.Status == 98) {
265
                $("span.cancelled").css("display", "inline-block");
266
                colorBall("red");
267
                alert(_("Cash register server system error: ") + JSON.stringify(response));
268
            }
269
            else if (response.Status == 99) {
270
                $("span.cancelled").css("display", "inline-block");
271
                colorBall("red");
272
                alert(_("Payment request is malformed: ") + JSON.stringify(response));
273
            }
274
            else {
275
                $("span.cancelled").css("display", "inline-block");
276
                alert(_("Error: ") + JSON.stringify(response));
277
            }
278
        }
279
280
        function colorBall(color) {
281
            if (color == "red"){
282
                $(".ball").css("border", "25px solid rgba(204,0,0,0.9)");
283
                $(".ball1").css("border", "25px solid rgba(204,0,0,0.9)");
284
                $(".ball").css("box-shadow", "0 0 35px rgb(204,0,0)");
285
                $(".ball1").css("box-shadow", "0 0 15px rgb(204,0,0)");
286
            } else if (color == "green") {
287
                $(".ball").css("border", "25px solid rgba(0,204,0,0.9)");
288
                $(".ball1").css("border", "25px solid rgba(0,204,0,0.9)");
289
                $(".ball").css("box-shadow", "0 0 35px rgb(0,204,0)");
290
                $(".ball1").css("box-shadow", "0 0 15px rgb(0,204,0)");
291
            }
292
        }
293
    </script>
294
[% ELSE %]
101
[% IF ( pay_individual ) %]
295
[% IF ( pay_individual ) %]
102
    <form name="payindivfine" id="payindivfine" onsubmit="return validatePayment(this);" method="post" action="/cgi-bin/koha/members/paycollect.pl">
296
    <form name="payindivfine" id="payindivfine" onsubmit="return validatePayment(this)" method="post" action="/cgi-bin/koha/members/paycollect.pl">
103
    <input type="hidden" name="borrowernumber" id="borrowernumber" value="[% borrower.borrowernumber %]" />
297
    <input type="hidden" name="borrowernumber" id="borrowernumber" value="[% borrower.borrowernumber %]" />
104
    <input type="hidden" name="pay_individual" id="pay_individual" value="[% pay_individual %]" />
298
    <input type="hidden" name="pay_individual" id="pay_individual" value="[% pay_individual %]" />
105
    <input type="hidden" name="itemnumber" id="itemnumber" value="[% itemnumber %]" />
299
    <input type="hidden" name="itemnumber" id="itemnumber" value="[% itemnumber %]" />
Lines 147-153 function moneyFormat(textObj) { Link Here
147
            <!-- default to paying all -->
341
            <!-- default to paying all -->
148
        <input name="paid" id="paid" value="[% amountoutstanding | format('%.2f') %]" onchange="moneyFormat(document.payindivfine.paid)"/>
342
        <input name="paid" id="paid" value="[% amountoutstanding | format('%.2f') %]" onchange="moneyFormat(document.payindivfine.paid)"/>
149
    </li>
343
    </li>
344
    <li>
345
        [% INCLUDE offices %]
346
    </li>
150
</ol>
347
</ol>
348
<div>
151
</fieldset>
349
</fieldset>
152
350
153
    <div class="action"><input type="submit" name="submitbutton" value="Confirm" />
351
    <div class="action"><input type="submit" name="submitbutton" value="Confirm" />
Lines 216-227 function moneyFormat(textObj) { Link Here
216
        <label for="selected_accts_notes">Note: </label>
414
        <label for="selected_accts_notes">Note: </label>
217
        <textarea name="selected_accts_notes" id="selected_accts_notes">[% selected_accts_notes %]</textarea>
415
        <textarea name="selected_accts_notes" id="selected_accts_notes">[% selected_accts_notes %]</textarea>
218
    </li>
416
    </li>
417
    <li>
418
        [% INCLUDE offices %]
419
    </li>
219
    </ol>
420
    </ol>
220
    </fieldset>
421
    </fieldset>
221
    <div class="action"><input type="submit" name="submitbutton" value="Confirm" />
422
    <div class="action"><input type="submit" name="submitbutton" value="Confirm" />
222
        <a class="cancel" href="/cgi-bin/koha/members/boraccount.pl?borrowernumber=[% borrower.borrowernumber %]">Cancel</a></div>
423
        <a class="cancel" href="/cgi-bin/koha/members/boraccount.pl?borrowernumber=[% borrower.borrowernumber %]">Cancel</a></div>
223
    </form>
424
    </form>
224
[% END %]
425
[% END %]
426
[% END %]
225
</div></div>
427
</div></div>
226
</div>
428
</div>
227
</div>
429
</div>
Lines 231-234 function moneyFormat(textObj) { Link Here
231
</div>
433
</div>
232
</div>
434
</div>
233
[% INCLUDE 'intranet-bottom.inc' %]
435
[% INCLUDE 'intranet-bottom.inc' %]
436
[% BLOCK offices %]
437
    [% IF POSIntegration %]
438
        [% IF POSIntegration_in_branch %]
439
    <label for="offices">Select office:</label>
440
    <div id="offices"></div>
441
    <input type="hidden" name="Office" id="office" />
442
    <span id="add_new_office"><a>Add new office</a></span>
443
    <div id="office_form" style="display:none">
444
        <label for="office_new">Office:</label><input type="text" name="office_new" id="office_new" />
445
        <button id="new_office" type="button">Add new office</button>
446
        <p>Offices are stored as a browser cookie. Currently stored offices will disappear and need to be added again if you clear the browser cookies.</p>
447
        <p>You can delete a stored office by first selecting it, and then pressing the delete-key on your keyboard.</p>
448
    </div>
449
        [% ELSE %]
450
    <span id="add_new_office"><a>Add new office</a></span>
451
    <div id="office_form" style="display:none">
452
        <p>Cash register integration has not been enabled for your branch. In order to enable cash register integration in this branch ([% branch %]), please contact a superlibrarian. Koha payment types need to be mapped into your cash register item numbers.</p>
453
    </div>
454
        [% END%]
455
    [% END %]
456
[% END %]
457
[% IF POSIntegration %]
458
<script type="text/javascript">
459
    $(document).ready(function() {
460
        $("#add_new_office").click(function() {
461
            if ($("#office_form").css("display") == "none") {
462
                $("#office_form").css("display", "block");
463
            } else {
464
                $("#office_form").css("display", "none");
465
            }
466
        });
467
        [% IF POSIntegration_in_branch %]
468
        loadCashRegisters();
469
        $("#new_office").click(function() {
470
            if ($("#office_new").val().length > 0) {
471
                newOffice($("#office_new").val());
472
            }
473
        });
474
475
        $("#office").bind("enterKey",function(e) {
476
            if ($("#office_new").val().length > 0) {
477
                newOffice($("#office_new").val());
478
            }
479
        });
480
        $("#office_new").keyup(function(e) {
481
            if(e.keyCode == 13) {
482
                $(this).trigger("enterKey");
483
            }
484
        });
485
486
        $("body").on("click", "button[id*=office-]", function() {
487
            selectOffice(this);
488
        });
489
        $("body").on("keyup", "button[id*=office-]", function(e) {
490
            if(e.keyCode == 46) {
491
                deleteOffice(this);
492
            }
493
        });
494
        [% END %]
495
    });
496
    [% IF POSIntegration_in_branch %]
497
    function selectOffice(button) {
498
        // Deselect all other buttons
499
        $.each($("button[id*=office-]"), function(key, obj) {
500
            $(obj).attr("class", "office-button");
501
        })
502
        $(button).attr("class", "office-button selected");
503
504
        $("#office").val($(button).attr("id").substr(7));
505
    }
506
    function newOffice(office) {
507
        var offices = getOffices();
508
509
        if (offices == null) {
510
            offices = [];
511
        }
512
        if (offices.indexOf(office) > -1) {
513
            return offices;
514
        }
515
        offices.push(office);
234
516
517
        $.each(offices, function(key, val){
518
            if (!$("#office-"+val).length) {
519
                $("#offices").append('<button id="office-'+val+'" type="button" class="office-button">'+val+'</button>');
520
            }
521
        });
522
523
        setCookie("offices", JSON.stringify(offices), "100*365");
524
525
        $("#office_new").val("");
526
        return offices;
527
    }
528
    function deleteOffice(obj) {
529
        var offices = getOffices();
530
531
        if (offices == null) {
532
            return null;
533
        }
534
535
        $(obj).remove();
536
537
        var index = offices.indexOf($(obj).attr("id").substr(7));
538
539
        offices.splice(index, 1);
540
541
        if ($("#office").val() == $(obj).attr("id").substr(7)) {
542
            $("#office").val("");
543
        }
544
        setCookie("offices", JSON.stringify(offices), "100*365");
545
546
        return offices;
547
    }
548
    function loadCashRegisters() {
549
        var offices = getOffices();
550
551
        if (offices == null) {
552
            return null;
553
        }
554
        $.each(offices, function(key, val){
555
            if (!$("#office-"+val).length) {
556
                $("#offices").append('<button id="office-'+val+'" type="button" class="office-button">'+val+'</button>');
557
            }
558
        });
559
    }
560
    function getOffices() {
561
        var cookie;
562
        var name = "offices=";
563
        var ca = document.cookie.split(';');
564
        for(var i=0; i<ca.length; i++) {
565
            var c = ca[i];
566
            while (c.charAt(0)==' ') c = c.substring(1);
567
            if (c.indexOf(name) == 0) cookie = c.substring(name.length,c.length);
568
        }
569
570
        if (!cookie) {
571
            return null;
572
        }
573
574
        return JSON.parse(cookie);
575
    }
576
577
    function setCookie(cname, cvalue, exdays) {
578
        var d = new Date();
579
        d.setTime(d.getTime() + (exdays*24*60*60*1000));
580
        var expires = "expires="+d.toUTCString();
581
        document.cookie = cname + "=" + cvalue + "; " + expires;
582
    }
583
584
    function validatePayment(obj) {
585
        if ($("#office").val().length == 0) {
586
            alert(_("Please select office"));
587
            return false;
588
        }
589
        $(obj)[0].submit();
590
    }
591
    [% END %]
592
</script>
593
[% END %]
(-)a/members/boraccount.pl (+20 lines)
Lines 33-38 use C4::Branch; Link Here
33
use C4::Accounts;
33
use C4::Accounts;
34
use C4::Members::Attributes qw(GetBorrowerAttributes);
34
use C4::Members::Attributes qw(GetBorrowerAttributes);
35
35
36
use Koha::PaymentsTransaction;
37
use Koha::PaymentsTransactions;
38
36
my $input=new CGI;
39
my $input=new CGI;
37
40
38
41
Lines 71-76 my $totalcredit; Link Here
71
if($total <= 0){
74
if($total <= 0){
72
        $totalcredit = 1;
75
        $totalcredit = 1;
73
}
76
}
77
my $related_accountlines;
74
78
75
my $reverse_col = 0; # Flag whether we need to show the reverse column
79
my $reverse_col = 0; # Flag whether we need to show the reverse column
76
foreach my $accountline ( @{$accts}) {
80
foreach my $accountline ( @{$accts}) {
Lines 89-94 foreach my $accountline ( @{$accts}) { Link Here
89
        $accountline->{payment} = 1;
93
        $accountline->{payment} = 1;
90
        $reverse_col = 1;
94
        $reverse_col = 1;
91
    }
95
    }
96
97
    my $transaction = Koha::PaymentsTransactions->find({ accountlines_id => $accountline->{accountlines_id} }) if $accountline->{accounttype} eq "Pay";
98
99
    # If transaction is found, find all related accountlines and store them so we can highlight
100
    # them in the Fines tab.
101
    if ($transaction) {
102
        $accountline->{transactionnumber} = $transaction->transaction_id if $transaction;
103
104
        my $relacclines = $transaction->GetRelatedAccountlines();
105
        foreach my $relaccline (@$relacclines){
106
            $related_accountlines->{$relaccline->{accountlines_id}} = [] if not exists $related_accountlines->{$relaccline->{accountlines_id}};
107
108
            push $related_accountlines->{$relaccline->{accountlines_id}}, $transaction->transaction_id;
109
        }
110
    }
92
}
111
}
93
112
94
$template->param( adultborrower => 1 ) if ( $data->{'category_type'} eq 'A' );
113
$template->param( adultborrower => 1 ) if ( $data->{'category_type'} eq 'A' );
Lines 115-120 $template->param( Link Here
115
    is_child            => ($data->{'category_type'} eq 'C'),
134
    is_child            => ($data->{'category_type'} eq 'C'),
116
    reverse_col         => $reverse_col,
135
    reverse_col         => $reverse_col,
117
    accounts            => $accts,
136
    accounts            => $accts,
137
    relatedaccounts     => $related_accountlines,
118
    activeBorrowerRelationship => (C4::Context->preference('borrowerRelationship') ne ''),
138
    activeBorrowerRelationship => (C4::Context->preference('borrowerRelationship') ne ''),
119
    RoutingSerials => C4::Context->preference('RoutingSerials'),
139
    RoutingSerials => C4::Context->preference('RoutingSerials'),
120
);
140
);
(-)a/members/paycollect.pl (+44 lines)
Lines 29-34 use C4::Members::Attributes qw(GetBorrowerAttributes); Link Here
29
use C4::Accounts;
29
use C4::Accounts;
30
use C4::Koha;
30
use C4::Koha;
31
use C4::Branch;
31
use C4::Branch;
32
use C4::OPLIB::CPUIntegration;
33
use JSON;
32
34
33
my $input = CGI->new();
35
my $input = CGI->new();
34
36
Lines 43-48 my ( $template, $loggedinuser, $cookie ) = get_template_and_user( Link Here
43
    }
45
    }
44
);
46
);
45
47
48
# POS integration AJAX call
49
my $posintegration = 1 if (C4::Context->preference("POSIntegration") ne "OFF");
50
my $posintegration_in_branch = 1 if C4::OPLIB::CPUIntegration::hasBranchEnabledIntegration(C4::Branch::mybranch());
51
if ($posintegration && $posintegration_in_branch && $input->param('POSTDATA')) {
52
    my $payment = JSON->new->utf8->canonical(1)->decode($input->param('POSTDATA'));
53
54
    if ($payment->{send_payment} && $payment->{send_payment} eq "POST") {
55
        output_ajax_with_http_headers $input, C4::OPLIB::CPUIntegration::SendPayment($payment);
56
        exit 1;
57
    }
58
}
59
46
# get borrower details
60
# get borrower details
47
my $borrowernumber = $input->param('borrowernumber');
61
my $borrowernumber = $input->param('borrowernumber');
48
my $borrower       = GetMember( borrowernumber => $borrowernumber );
62
my $borrower       = GetMember( borrowernumber => $borrowernumber );
Lines 58-66 my $individual = $input->param('pay_individual'); Link Here
58
my $writeoff     = $input->param('writeoff_individual');
72
my $writeoff     = $input->param('writeoff_individual');
59
my $select_lines = $input->param('selected');
73
my $select_lines = $input->param('selected');
60
my $select       = $input->param('selected_accts');
74
my $select       = $input->param('selected_accts');
75
my $office       = $input->param('Office');
61
my $payment_note = uri_unescape $input->param('payment_note');
76
my $payment_note = uri_unescape $input->param('payment_note');
62
my $accountno;
77
my $accountno;
63
my $accountlines_id;
78
my $accountlines_id;
79
80
$template->param( POSIntegration => 1 ) if $posintegration;
81
$template->param( POSIntegration_in_branch => 1 ) if $posintegration_in_branch;
82
64
if ( $individual || $writeoff ) {
83
if ( $individual || $writeoff ) {
65
    if ($individual) {
84
    if ($individual) {
66
        $template->param( pay_individual => 1 );
85
        $template->param( pay_individual => 1 );
Lines 107-112 if ( $total_paid and $total_paid ne '0.00' ) { Link Here
107
            total_due => $total_due
126
            total_due => $total_due
108
        );
127
        );
109
    } else {
128
    } else {
129
        if ($posintegration and C4::Context->preference("POSIntegration") eq "cpu" and $posintegration_in_branch) {
130
            my $payment;
131
132
            $payment->{borrowernumber}      = $borrowernumber;
133
            $payment->{total_paid}          = $total_paid;
134
            $payment->{total_due}           = $total_due;
135
            $payment->{payment_note}        = $payment_note || $input->param('notes') || $input->param('selected_accts_notes');
136
            $payment->{office}              = $office;
137
            my @selected = (defined $select) ? split /,/, $select : $accountlines_id;
138
            $payment->{selected}             = \@selected;
139
140
            my $CPUPayment = C4::OPLIB::CPUIntegration::InitializePayment($payment);
141
142
            $template->param(
143
                startSending => 1,
144
                payment => $CPUPayment,
145
                posdestination => C4::Context->config('pos')->{'CPU'}->{'url'},
146
                json_payment => JSON::encode_json($CPUPayment),
147
                office          => $office,
148
            );
149
        } else {
150
110
        if ($individual) {
151
        if ($individual) {
111
            if ( $total_paid == $total_due ) {
152
            if ( $total_paid == $total_due ) {
112
                makepayment( $accountlines_id, $borrowernumber, $accountno, $total_paid, $user,
153
                makepayment( $accountlines_id, $borrowernumber, $accountno, $total_paid, $user,
Lines 136-141 if ( $total_paid and $total_paid ne '0.00' ) { Link Here
136
"/cgi-bin/koha/members/boraccount.pl?borrowernumber=$borrowernumber"
177
"/cgi-bin/koha/members/boraccount.pl?borrowernumber=$borrowernumber"
137
            );
178
            );
138
        }
179
        }
180
181
        }
139
    }
182
    }
140
} else {
183
} else {
141
    $total_paid = '0.00';    #TODO not right with pay_individual
184
    $total_paid = '0.00';    #TODO not right with pay_individual
Lines 148-153 $template->param(%$borrower); Link Here
148
$template->param(
191
$template->param(
149
    borrowernumber => $borrowernumber,    # some templates require global
192
    borrowernumber => $borrowernumber,    # some templates require global
150
    borrower      => $borrower,
193
    borrower      => $borrower,
194
    branch        => C4::Branch::mybranch(),
151
    total         => $total_due,
195
    total         => $total_due,
152
    activeBorrowerRelationship => (C4::Context->preference('borrowerRelationship') ne ''),
196
    activeBorrowerRelationship => (C4::Context->preference('borrowerRelationship') ne ''),
153
    RoutingSerials => C4::Context->preference('RoutingSerials'),
197
    RoutingSerials => C4::Context->preference('RoutingSerials'),
(-)a/t/db_dependent/CPUIntegration.t (-1 / +208 lines)
Line 0 Link Here
0
- 
1
#!/usr/bin/env perl
2
3
# Copyright 2015 Open Source Freedom Fighters
4
#
5
# This file is part of Koha.
6
#
7
# Koha is free software; you can redistribute it and/or modify it
8
# under the terms of the GNU General Public License as published by
9
# the Free Software Foundation; either version 3 of the License, or
10
# (at your option) any later version.
11
#
12
# Koha is distributed in the hope that it will be useful, but
13
# WITHOUT ANY WARRANTY; without even the implied warranty of
14
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15
# GNU General Public License for more details.
16
#
17
# You should have received a copy of the GNU General Public License
18
# along with Koha; if not, see <http://www.gnu.org/licenses>.
19
$ENV{KOHA_PAGEOBJECT_DEBUG} = 1;
20
use Modern::Perl;
21
22
use Test::More;
23
use Try::Tiny; #Even Selenium::Remote::Driver uses Try::Tiny :)
24
25
use Koha::Auth::PermissionManager;
26
use Koha::PaymentsTransaction;
27
use Koha::PaymentsTransactions;
28
29
use t::lib::Page::Mainpage;
30
use t::lib::Page::Members::Boraccount;
31
use t::lib::Page::Members::Pay;
32
use t::lib::Page::Members::Paycollect;
33
34
use t::lib::TestObjects::BorrowerFactory;
35
use t::lib::TestObjects::SystemPreferenceFactory;
36
use t::lib::TestObjects::FinesFactory;
37
38
use bignum;
39
40
##Setting up the test context
41
my $testContext = {};
42
43
my $password = '1234';
44
my $borrowerFactory = t::lib::TestObjects::BorrowerFactory->new();
45
my $borrowers = $borrowerFactory->createTestGroup([
46
            {firstname  => 'Testthree',
47
             surname    => 'Testfour',
48
             cardnumber => 'superuberadmin',
49
             branchcode => 'CPL',
50
             userid     => 'god',
51
             address    => 'testi',
52
             city       => 'joensuu',
53
             zipcode    => '80100',
54
             password   => $password,
55
            },
56
            {firstname  => 'Iral',
57
             surname    => 'Aluksat',
58
             cardnumber => 'superuberadmin2',
59
             branchcode => 'CPL',
60
             userid     => 'god2',
61
             address    => 'testi',
62
             city       => 'joensuu',
63
             zipcode    => '80100',
64
             password   => $password,
65
            },
66
        ], undef, $testContext);
67
68
my $systempreferences = t::lib::TestObjects::SystemPreferenceFactory->createTestGroup([
69
            {preference => 'POSIntegration',
70
             value      => 'cpu',
71
            },
72
            {preference => 'cpuitemnumbers',
73
             value      => '
74
             CPL:
75
               Default: 0000
76
             ',
77
            },
78
        ], undef, $testContext);
79
80
my $fines = t::lib::TestObjects::FinesFactory->createTestGroup([
81
    {
82
        note => "First",
83
        cardnumber => $borrowers->{'superuberadmin'}->cardnumber,
84
        amount => int(rand(9)+1) . "" . int(rand(10)) . "." . int(rand(10)) . "" . int(rand(10))
85
    },
86
    {
87
        note => "Second",
88
        cardnumber => $borrowers->{'superuberadmin'}->cardnumber,
89
        amount => int(rand(9)+1) . "" . int(rand(10)) . "." . int(rand(10)) . "" . int(rand(10))
90
    },
91
    {
92
        note => "First2",
93
        cardnumber => $borrowers->{'superuberadmin2'}->cardnumber,
94
        amount => int(rand(9)+1) . "" . int(rand(10)) . "." . int(rand(10)) . "" . int(rand(10))
95
    },
96
    {
97
        note => "Second2",
98
        cardnumber => $borrowers->{'superuberadmin2'}->cardnumber,
99
        amount => int(rand(9)+1) . "" . int(rand(10)) . "." . int(rand(10)) . "" . int(rand(10))
100
    },
101
], undef, $testContext);
102
103
my $permissionManager = Koha::Auth::PermissionManager->new();
104
$permissionManager->grantPermissions($borrowers->{'superuberadmin'}, {superlibrarian => 'superlibrarian'});
105
$permissionManager->grantPermissions($borrowers->{'superuberadmin2'}, {superlibrarian => 'superlibrarian'});
106
eval {
107
    MakeFullPayment($fines);
108
    MakePartialPayment($fines);
109
};
110
if ($@) { #Catch all leaking errors and gracefully terminate.
111
    warn $@;
112
    tearDown();
113
    exit 1;
114
}
115
116
##All tests done, tear down test context
117
tearDown();
118
done_testing;
119
120
sub tearDown {
121
    t::lib::TestObjects::ObjectFactory->tearDownTestContext($testContext);
122
}
123
124
125
126
sub MakeFullPayment {
127
    my ($fines) = @_;
128
    # Make random amount for payments
129
    my $firstAmount = $fines->{"First"}->{amount};
130
    my $secondAmount = $fines->{"Second"}->{amount};
131
132
    # staff client
133
    my $boraccount = t::lib::Page::Members::Boraccount->new({borrowernumber => $borrowers->{'superuberadmin'}->borrowernumber, op => 'modify', destination => 'circ', categorycode => 'PT'});
134
135
    $boraccount = $boraccount->doPasswordLogin($borrowers->{'superuberadmin'}->userid(), $password)
136
    ->findFine("First")     # find the two fines created...
137
    ->findFine("Second")    # ...by FinesFactory
138
    ->isFineAmountOutstanding("First", $firstAmount)
139
    ->isFineAmountOutstanding("Second", $secondAmount)
140
    ->navigateToPayFinesTab()
141
    ->PaySelected()
142
    ->addNoteToSelected("Transaction that pays everything ;)")
143
    ->openAddNewCashRegister()
144
    ->addNewCashRegister(100) # add cash register number 100
145
    ->selectCashRegister(100) # and select it
146
    ->sendPaymentToPOS()
147
    ->paymentLoadingScreen()
148
    ->waitUntilPaymentIsAcceptedAtPOS();
149
150
    # Get transaction ids
151
    my $transactions = Koha::PaymentsTransactions->find({ borrowernumber => $borrowers->{'superuberadmin'}->borrowernumber });
152
153
    # Check that there is a transaction completed
154
    foreach my $transaction ($transactions){
155
        $boraccount = $boraccount->isTransactionComplete($transaction->transaction_id);
156
        $boraccount
157
        ->isFinePaid("Transaction that pays everything ;)") # note of transaction
158
        ->isFineAmount("Transaction that pays everything ;)", "-".sprintf("%.2f",$firstAmount+$secondAmount));
159
    }
160
    $boraccount
161
    ->isFineAmount("First", $firstAmount)
162
    ->isFineAmount("Second", $secondAmount)
163
    ->isFinePaid("First")       # Make sure fines are paid
164
    ->isFinePaid("Second");     # Also the second :)
165
}
166
167
sub MakePartialPayment {
168
    my ($fines) = @_;
169
    # Make random amount for payments
170
    my $firstAmount = $fines->{"First2"}->{amount};
171
    my $secondAmount = $fines->{"Second2"}->{amount};
172
173
    my $partialPayment = $firstAmount-(int(rand(9)+1) . "." . int(rand(10)) . "" . int(rand(10)));
174
    # staff client
175
    my $boraccount = t::lib::Page::Members::Boraccount->new({borrowernumber => $borrowers->{'superuberadmin2'}->borrowernumber, op => 'modify', destination => 'circ', categorycode => 'PT'});
176
177
    $boraccount = $boraccount->doPasswordLogin($borrowers->{'superuberadmin2'}->userid(), $password)
178
    ->findFine("First2")     # find the two fines created...
179
    ->findFine("Second2")    # ...by FinesFactory
180
    ->isFineAmountOutstanding("First2", $firstAmount)
181
    ->isFineAmountOutstanding("Second2", $secondAmount)
182
    ->navigateToPayFinesTab()
183
    ->PaySelected()
184
    ->setAmount($partialPayment)
185
    ->addNoteToSelected("Transaction that pays everything ;)2")
186
    ->openAddNewCashRegister()
187
    ->addNewCashRegister(100) # add cash register number 100
188
    ->selectCashRegister(100) # and select it
189
    ->sendPaymentToPOS()
190
    ->paymentLoadingScreen()
191
    ->waitUntilPaymentIsAcceptedAtPOS();
192
193
    # Get transaction ids
194
    my $transactions = Koha::PaymentsTransactions->find({ borrowernumber => $borrowers->{'superuberadmin2'}->borrowernumber });
195
196
    # Check that there is a transaction completed
197
    foreach my $transaction ($transactions){
198
        $boraccount = $boraccount->isTransactionComplete($transaction->transaction_id);
199
        $boraccount
200
        ->isFinePaid("Transaction that pays everything ;)2") # note of transaction
201
        ->isFineAmount("Transaction that pays everything ;)2", "-".(sprintf("%.2f",$partialPayment)));
202
    }
203
    $boraccount
204
    ->isFineAmount("First2", $firstAmount)
205
    ->isFineAmount("Second2", $secondAmount)
206
    ->isFineAmountOutstanding("First2", sprintf("%.2f",$firstAmount-$partialPayment))
207
    ->isFineAmountOutstanding("Second2", $secondAmount);
208
}

Return to bug 15654