--- /dev/null
+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;
INSERT INTO account_offset_types ( type ) VALUES
('Writeoff'),
('Payment'),
+('Purchase'),
('Lost Item'),
('Processing Fee'),
('Manual Credit'),
--- /dev/null
+$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";
+}
--- /dev/null
+<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>
<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>
--- /dev/null
+[% USE raw %]
+[% USE Asset %]
+[% USE Koha %]
+[% USE AuthorisedValues %]
+[% USE Price %]
+[% SET footerjs = 1 %]
+[% INCLUDE 'doc-head-open.inc' %]
+<title>Koha › 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> › 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' %]
--- /dev/null
+#!/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;