Browse Source

Bug 23354: Add a Point Of Sale 'pay' screen

This patch adds a new Point Of Sale module to Koha's staff client front
page. The module button leads directly to a 'Pay' page giving the staff
user the ability to record anonymous payments for items that would not
normally require a patron to be registered at the library.

Test plan:
1) Enable `UseCashRegisters` via the system preferences.
2) Ensure your user has the 'manage_cash_registers' permission.
3) Add a cash register for your current branch.
4) Add at least one 'MANUAL_INV' authorized value.
5) Navigate to the new 'POS' pay page via the main menu.
6) Add an item to the 'sale' by clicking 'add' from the right side of
   the screen.
7) Note that said item was added to the table of items this sale on the
   left.
8) At this point you should be able to 'click to edit' the quantity or
   price of the item in the table on the left.
9) Enter an amount greater than the price of the item into the 'amount
   collected from patron' box.
10) Click 'Confirm'
11) Varify that the same change to give modal from the paycollect pages
    appears here.
12) Click 'Confirm'
13) Payment will have been recorded (check the database) and you will be
    back at a fresh 'Pay' page ready for the next transaction.
14) Signoff

Sponsored-by: PTFS Europe
Sponsored-by: Cheshire Libraries Shared Services
Signed-off-by: Kyle M Hall <kyle@bywatersolutions.com>
Signed-off-by: Josef Moravec <josef.moravec@gmail.com>
Signed-off-by: Martin Renvoize <martin.renvoize@ptfs-europe.com>
20.05.x
Martin Renvoize 3 years ago
parent
commit
3e6ef8614a
Signed by: martin.renvoize GPG Key ID: 422B469130441A0F
  1. 285
      Koha/Charges/Sales.pm
  2. 1
      installer/data/mysql/account_offset_types.sql
  3. 10
      installer/data/mysql/atomicupdate/bug_23354.perl
  4. 16
      koha-tmpl/intranet-tmpl/prog/en/includes/pos-menu.inc
  5. 4
      koha-tmpl/intranet-tmpl/prog/en/modules/intranet-main.tt
  6. 319
      koha-tmpl/intranet-tmpl/prog/en/modules/pos/pay.tt
  7. 79
      pos/pay.pl

285
Koha/Charges/Sales.pm

@ -0,0 +1,285 @@
package Koha::Charges::Sales;
# Copyright 2019 PTFS Europe
#
# 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 <http://www.gnu.org/licenses>.
use Modern::Perl;
use Koha::Account::Lines;
use Koha::Account::Offsets;
use Koha::DateUtils qw( dt_from_string );
use Koha::Exceptions;
=head1 NAME
Koha::Charges::Sale - Module for collecting sales in Koha
=head1 SYNOPSIS
use Koha::Charges::Sale;
my $sale =
Koha::Charges::Sale->new( { cash_register => $register, staff_id => $staff_id } );
$sale->add_item($item);
$sale->purchase( { payment_type => 'CASH' } );
=head2 Class methods
=head3 new
Koha::Charges::Sale->new(
{
cash_register => $cash_register,
staff_id => $staff_id,
[ payment_type => $payment_type ],
[ items => $items ],
[ patron => $patron ],
}
);
=cut
sub new {
my ( $class, $params ) = @_;
Koha::Exceptions::MissingParameter->throw(
"Missing mandatory parameter: cash_register")
unless $params->{cash_register};
Koha::Exceptions::MissingParameter->throw(
"Missing mandatory parameter: staff_id")
unless $params->{staff_id};
Carp::confess("Key 'cash_register' is not a Koha::Cash::Register object!")
unless $params->{cash_register}->isa('Koha::Cash::Register');
return bless( $params, $class );
}
=head3 payment_type
my $payment_type = $sale->payment_type( $payment_type );
A getter/setter for this instances associated payment type.
=cut
sub payment_type {
my ( $self, $payment_type ) = @_;
if ($payment_type) {
Koha::Exceptions::Account::UnrecognisedType->throw(
error => 'Type of payment not recognised' )
unless ( exists( $self->_get_valid_payments->{$payment_type} ) );
$self->{payment_type} = $payment_type;
}
return $self->{payment_type};
}
=head3 _get_valid_payments
my $valid_payments = $sale->_get_valid_payments;
A getter which returns a hashref whose keys represent valid payment types.
=cut
sub _get_valid_payments {
my $self = shift;
$self->{valid_payments} //= {
map { $_ => 1 } Koha::AuthorisedValues->search(
{
category => 'PAYMENT_TYPE',
branchcode => $self->{cash_register}->branch
}
)->get_column('authorised_value')
};
return $self->{valid_payments};
}
=head3 add_item
my $item = { price => 0.25, quantity => 1, code => 'COPY' };
$sale->add_item( $item );
=cut
sub add_item {
my ( $self, $item ) = @_;
Koha::Exceptions::MissingParameter->throw(
"Missing mandatory parameter: code")
unless $item->{code};
Koha::Exceptions::Account::UnrecognisedType->throw(
error => 'Type of debit not recognised' )
unless ( exists( $self->_get_valid_items->{ $item->{code} } ) );
Koha::Exceptions::MissingParameter->throw(
"Missing mandatory parameter: price")
unless $item->{price};
Koha::Exceptions::MissingParameter->throw(
"Missing mandatory parameter: quantity")
unless $item->{quantity};
push @{ $self->{items} }, $item;
return $self;
}
=head3 _get_valid_items
my $valid_items = $sale->_get_valid_items;
A getter which returns a hashref whose keys represent valid sale items.
=cut
sub _get_valid_items {
my $self = shift;
$self->{valid_items} //= {
map { $_ => 1 } Koha::AuthorisedValues->search(
{
category => 'MANUAL_INV',
branchcode => $self->{cash_register}->branch
}
)->get_column('authorised_value')
};
return $self->{valid_items};
}
=head3 purchase
my $credit_line = $sale->purchase;
=cut
sub purchase {
my ( $self, $params ) = @_;
if ( $params->{payment_type} ) {
Koha::Exceptions::Account::UnrecognisedType->throw(
error => 'Type of payment not recognised' )
unless (
exists( $self->_get_valid_payments->{ $params->{payment_type} } ) );
$self->{payment_type} = $params->{payment_type};
}
Koha::Exceptions::MissingParameter->throw(
"Missing mandatory parameter: payment_type")
unless $self->{payment_type};
Koha::Exceptions::NoChanges->throw(
"Cannot purchase before calling add_item")
unless $self->{items};
my $schema = Koha::Database->new->schema;
my $dt = dt_from_string();
my $total_owed = 0;
my $credit;
$schema->txn_do(
sub {
# Add accountlines for each item being purchased
my $debits;
for my $item ( @{ $self->{items} } ) {
my $amount = $item->{quantity} * $item->{price};
$total_owed = $total_owed + $amount;
# Insert the account line
my $debit = Koha::Account::Line->new(
{
amount => $amount,
accounttype => $item->{code},
amountoutstanding => 0,
note => $item->{quantity},
manager_id => $self->{staff_id},
interface => 'intranet',
branchcode => $self->{cash_register}->branch,
date => $dt
}
)->store();
push @{$debits}, $debit;
# Record the account offset
my $account_offset = Koha::Account::Offset->new(
{
debit_id => $debit->id,
type => 'Purchase',
amount => $amount
}
)->store();
}
# Add accountline for payment
$credit = Koha::Account::Line->new(
{
amount => 0 - $total_owed,
accounttype => 'Purchase',
payment_type => $self->{payment_type},
amountoutstanding => 0,
manager_id => $self->{staff_id},
interface => 'intranet',
branchcode => $self->{cash_register}->branch,
register_id => $self->{cash_register}->id,
date => $dt,
note => "POS SALE"
}
)->store();
# Record the account offset
my $credit_offset = Koha::Account::Offset->new(
{
credit_id => $credit->id,
type => 'Purchase',
amount => $credit->amount
}
)->store();
# Link payment to debits
for my $debit ( @{$debits} ) {
Koha::Account::Offset->new(
{
credit_id => $credit->accountlines_id,
debit_id => $debit->id,
amount => $debit->amount * -1,
type => 'Payment',
}
)->store();
}
}
);
return $credit;
}
=head1 AUTHOR
Martin Renvoize <martin.renvoize@ptfs-europe.com>
=cut
1;

1
installer/data/mysql/account_offset_types.sql

@ -1,6 +1,7 @@
INSERT INTO account_offset_types ( type ) VALUES
('Writeoff'),
('Payment'),
('Purchase'),
('Lost Item'),
('Processing Fee'),
('Manual Credit'),

10
installer/data/mysql/atomicupdate/bug_23354.perl

@ -0,0 +1,10 @@
$DBversion = 'XXX'; # will be replaced by the RM
if( CheckVersion( $DBversion ) ) {
$dbh->do(q{
INSERT IGNORE INTO account_offset_types ( type ) VALUES ( 'Purchase' );
});
SetVersion( $DBversion );
print "Upgrade to $DBversion done (Bug 23354 - Add 'Purchase' account offset type)\n";
}

16
koha-tmpl/intranet-tmpl/prog/en/includes/pos-menu.inc

@ -0,0 +1,16 @@
<div id="navmenu">
<div id="navmenulist">
[% IF ( CAN_user_cash_management_manage_cash_registers || CAN_user_parameters_manage_auth_values) %]
<h5>Administration</h5>
<ul>
[% IF ( CAN_user_cash_management_manage_cash_registers ) %]
<li><a href="/cgi-bin/koha/admin/cash_registers.pl">Cash registers</a></li>
[% END %]
[% IF ( CAN_user_parameters_manage_auth_values ) %]
<li><a href="/cgi-bin/koha/admin/authorised_values.pl?searchfield=MANUAL_INV">Purchase items</a></li>
[% END %]
</ul>
[% END %]
</div>
</div>

4
koha-tmpl/intranet-tmpl/prog/en/modules/intranet-main.tt

@ -80,6 +80,10 @@
<div class="col-xs-6">
<ul class="biglinks-list">
<li>
<a class="icon_general icon_pos" href="/cgi-bin/koha/pos/pay.pl">Point of sale</a>
</li>
[% IF ( CAN_user_editcatalogue_edit_catalogue || CAN_user_editcatalogue_edit_items ) %]
<li>
<a class="icon_general icon_cataloging" href="/cgi-bin/koha/cataloguing/addbooks.pl"><i class="fa fa-tag"></i>Cataloging</a>

319
koha-tmpl/intranet-tmpl/prog/en/modules/pos/pay.tt

@ -0,0 +1,319 @@
[% USE raw %]
[% USE Asset %]
[% USE Koha %]
[% USE AuthorisedValues %]
[% USE Price %]
[% SET footerjs = 1 %]
[% INCLUDE 'doc-head-open.inc' %]
<title>Koha &rsaquo; Payments</title>
[% INCLUDE 'doc-head-close.inc' %]
</head>
<body id="payments" class="pos">
[% INCLUDE 'header.inc' %]
[% INCLUDE 'circ-search.inc' %]
<div id="breadcrumbs"><a href="/cgi-bin/koha/mainpage.pl">Home</a> &rsaquo; Point of sale</div>
<div class="main container-fluid">
<div class="row">
<div class="col-sm-10 col-sm-push-2">
[% IF ( error_registers ) %]
<div id="error_message" class="dialog alert">
You must have at least one cash register associated with this branch before you can record payments.
</div>
[% ELSE %]
<form name="payForm" id="payForm" method="post" action="/cgi-bin/koha/pos/pay.pl">
<div class="row">
<div class="col-sm-6">
<fieldset class="rows">
<legend>This sale</legend>
<p>Click to edit item cost or quantities</p>
<table id="sale" class="table_sale">
<thead>
<tr>
<th>Item</th>
<th>Cost</th>
<th>Quantity</th>
<th>Total</th>
</tr>
</thead>
<tbody>
</tbody>
<tfoot>
<tr>
<td colspan="3">Total payable:</td>
<td></td>
</tr>
</tfoot>
</table>
</fieldset>
<fieldset class="rows">
<legend>Collect payment</legend>
<ol>
<li>
<label for="paid">Amount being paid: </label>
<input name="paid" id="paid" value="[% amountoutstanding | $Price on_editing => 1 %]"/>
</li>
<li>
<label for="collected">Collected from patron: </label>
<input id="collected" value="[% amountoutstanding | $Price on_editing => 1 %]"/>
</li>
<li>
<label>Change to give: </label>
<span id="change">0.00</span>
</li>
[% SET payment_types = AuthorisedValues.GetAuthValueDropbox('PAYMENT_TYPE') %]
[% IF payment_types %]
<li>
<label for="payment_type">Payment type: </label>
<select name="payment_type" id="payment_type">
[% FOREACH pt IN payment_types %]
<option value="[% pt.authorised_value | html %]">[% pt.lib | html %]</option>
[% END %]
</select>
</li>
[% END %]
[% IF Koha.Preference('UseCashRegisters') %]
<li>
<label for="cash_register">Cash register: </label>
<select name="cash_register" id="cash_register">
[% FOREACH register IN registers %]
[% IF register.id == registerid %]
<option value="[% register.id %]" selected="selected">[% register.name | html %]</option>
[% ELSE %]
<option value="[% register.id %]">[% register.name | html %]</option>
[% END %]
[% END %]
</select>
</li>
[% END %]
</ol>
</fieldset>
</div>
<div class="col-sm-6">
<fieldset class="rows">
<legend>Items for purchase</legend>
[% SET invoice_types = AuthorisedValues.GetAuthValueDropbox('MANUAL_INV') %]
[% IF invoice_types %]
<table id="invoices">
<thead>
<tr>
<th>Code</th>
<th>Description</th>
<th>Cost</th>
<th>Action</th>
</tr>
</thead>
<tbody>
[% FOREACH invoice IN invoice_types %]
<tr>
<td>[% invoice.authorised_value | html %]</td>
<td>[% invoice.lib_opac | html %]</td>
<td>[% invoice.lib | html %]</td>
<td>
<button class="add_button" data-invoice-code="[% invoice.lib_opac %]" data-invoice-title="[% invoice.authorised_value | html %]" data-invoice-price="[% invoice.lib | html %]"><i class="fa fa-plus"></i> Add</button>
</td>
</tr>
[% END %]
</table>
[% ELSE %]
You have no manual invoice types defined
[% END %]
</fieldset>
</div>
<div class="action">
<input type="submit" name="submitbutton" value="Confirm" />
<a class="cancel" href="/cgi-bin/koha/pos/pay.pl">Cancel</a>
</div>
</div>
</form>
[% END %]
</div>
<div class="col-sm-2 col-sm-pull-10">
<aside>
[% INCLUDE 'pos-menu.inc' %]
</aside>
</div>
</div> <!-- /.row -->
[% MACRO jsinclude BLOCK %]
[% Asset.js("js/admin-menu.js") | $raw %]
[% INCLUDE 'datatables.inc' %]
[% Asset.js("lib/jquery/plugins/jquery.jeditable.mini.js") | $raw %]
<script>
function fnClickAddRow( table, invoiceTitle, invoicePrice ) {
table.fnAddData( [
invoiceTitle,
invoicePrice,
1,
null
]
);
}
function moneyFormat(textObj) {
var newValue = textObj.value;
var decAmount = "";
var dolAmount = "";
var decFlag = false;
var aChar = "";
for(i=0; i < newValue.length; i++) {
aChar = newValue.substring(i, i+1);
if (aChar >= "0" && aChar <= "9") {
if(decFlag) {
decAmount = "" + decAmount + aChar;
}
else {
dolAmount = "" + dolAmount + aChar;
}
}
if (aChar == ".") {
if (decFlag) {
dolAmount = "";
break;
}
decFlag = true;
}
}
if (dolAmount == "") {
dolAmount = "0";
}
// Strip leading 0s
if (dolAmount.length > 1) {
while(dolAmount.length > 1 && dolAmount.substring(0,1) == "0") {
dolAmount = dolAmount.substring(1,dolAmount.length);
}
}
if (decAmount.length > 2) {
decAmount = decAmount.substring(0,2);
}
// Pad right side
if (decAmount.length == 1) {
decAmount = decAmount + "0";
}
if (decAmount.length == 0) {
decAmount = decAmount + "00";
}
textObj.value = dolAmount + "." + decAmount;
}
function updateChangeValues() {
var change = $('#change')[0];
change.innerHTML = Math.round(($('#collected')[0].value - $('#paid')[0].value) * 100) / 100;
if (change.innerHTML <= 0) {
change.innerHTML = "0.00";
} else {
change.value = change.innerHTML;
moneyFormat(change);
change.innerHTML = change.value;
}
$('#modal_change').html(change.innerHTML);
}
$(document).ready(function() {
var sale_table = $("#sale").dataTable($.extend(true, {}, dataTablesDefaults, {
"bPaginate": false,
"bFilter": false,
"bInfo": false,
"bAutoWidth": false,
"aoColumnDefs": [{
"aTargets": [-2],
"bSortable": false,
"bSearchable": false,
}, {
"aTargets": [-1],
"mRender": function ( data, type, full ) {
var price = Number.parseFloat(data).toFixed(2);
return '£'+price;
}
}, {
"aTargets": [-2, -3],
"sClass" : "editable",
}],
"aaSorting": [
[1, "asc"]
],
"fnDrawCallback": function (oSettings) {
var local = this;
local.$('.editable').editable( function(value, settings) {
var aPos = local.fnGetPosition( this );
local.fnUpdate( value, aPos[0], aPos[1], true, false );
return value;
},{
type : 'text'
})
},
"fnRowCallback": function( nRow, aData, iDisplayIndex, iDisplayIndexFull ) {
var iTotal = aData[1] * aData[2];
this.fnUpdate( iTotal, nRow, 3, false, false );
},
"fnFooterCallback": function(nFoot, aData, iStart, iEnd, aiDisplay) {
var iTotalPrice = 0;
for ( var i=0 ; i<aData.length ; i++ )
{
iTotalPrice += aData[i][3]*1;
}
iTotalPrice = Number.parseFloat(iTotalPrice).toFixed(2);
nFoot.getElementsByTagName('td')[1].innerHTML = iTotalPrice;
$('#paid').val(iTotalPrice);
}
}));
var items_table = $("#invoices").dataTable($.extend(true,{}, dataTablesDefaults, {
"aoColumnDefs": [
{ "aTargets": [ -1, -2 ], "bSortable": false, "bSearchable":false },
],
"aaSorting": [[ 0, "asc" ]],
"paginationType": "full",
}));
$(".add_button").on("click", function(ev) {
ev.preventDefault();
fnClickAddRow(sale_table, $( this ).data('invoiceTitle'), $( this ).data('invoicePrice') );
items_table.fnFilter( '' );
});
$("#paid, #collected").on("change",function() {
moneyFormat( this );
if (change != undefined) {
updateChangeValues();
}
});
$("#payForm").submit(function(e){
var rows = sale_table.fnGetData();
rows.forEach(function (row, index) {
var sale = {
code: row[0],
price: row[1],
quantity: row[2]
};
$('<input>').attr({
type: 'hidden',
name: 'sales',
value: JSON.stringify(sale)
}).appendTo('#payForm');
});
return true;
});
});
</script>
[% END %]
[% INCLUDE 'intranet-bottom.inc' %]

79
pos/pay.pl

@ -0,0 +1,79 @@
#!/usr/bin/perl
use Modern::Perl;
use CGI;
use JSON qw( from_json );
use C4::Auth qw/:DEFAULT get_session/;
use C4::Output;
use C4::Context;
use Koha::AuthorisedValues;
use Koha::Cash::Registers;
use Koha::Charges::Sales;
use Koha::Database;
use Koha::Libraries;
my $q = CGI->new();
my $sessionID = $q->cookie('CGISESSID');
my $session = get_session($sessionID);
my ( $template, $loggedinuser, $cookie, $user_flags ) = get_template_and_user(
{
template_name => 'pos/pay.tt',
query => $q,
type => 'intranet',
authnotrequired => 0,
}
);
my $logged_in_user = Koha::Patrons->find($loggedinuser) or die "Not logged in";
my $library_id = C4::Context->userenv->{'branch'};
my $registerid = $q->param('registerid');
my $registers = Koha::Cash::Registers->search(
{ branch => $library_id, archived => 0 },
{ order_by => { '-asc' => 'name' } }
);
if ( !$registers->count ) {
$template->param( error_registers => 1 );
}
else {
if ( !$registerid ) {
my $default_register = Koha::Cash::Registers->find(
{ branch => $library_id, branch_default => 1 } );
$registerid = $default_register->id if $default_register;
}
$registerid = $registers->next->id if !$registerid;
$template->param(
registerid => $registerid,
registers => $registers,
);
}
my $total_paid = $q->param('paid');
if ( $total_paid and $total_paid ne '0.00' ) {
warn "total_paid: $total_paid\n";
my $cash_register = Koha::Cash::Registers->find( { id => $registerid } );
my $payment_type = $q->param('payment_type');
my $sale = Koha::Charges::Sales->new(
{
cash_register => $cash_register,
staff_id => $logged_in_user->id
}
);
my @sales = $q->multi_param('sales');
for my $item (@sales) {
$item = from_json $item;
$sale->add_item($item);
}
$sale->purchase( { payment_type => $payment_type } );
}
output_html_with_http_headers( $q, $cookie, $template->output );
1;
Loading…
Cancel
Save