Electronic Invoicing (e-Invoicing)
The Electronic Invoicing framework of VikBooking is designed to be extended through custom WordPress plugins. By default, the system includes an Electronic Invoicing integration for a few countries. In order to add your own integration, it is necessary to write a custom WordPress plugin that will install a new e-invoicing driver, and that will declare the class to build the actual e-invoicing interface within VikBooking.
The code below highlights how to create your custom plugin, but it is strongly advised to look at the native source code for an existing e-invoicing integration in VikBooking (i.e. "Agenzia delle Entrate" for Italy "agenzia_entrate.php" or "myDATA - ΑΑΔΕ" for Greece "mydata_aade.php") to understand how to properly build your own integration. Of course, the e-invoicing driver will need to generate the electronic invoices in the requested digital format by your local authority (usually in XML or JSON format), and so a technical documentation for developers will be probably needed also from the authority side. The electronic invoicing systems usually support data transmission, and your custom WordPress plugin will let you handle all of these operations.
Similarly to the PMS Reports framework, the E-Invoicing framework will likely require the following implementations for your custom driver:
- Declare the driver settings (usually the authentication details to transmit the e-invoices and some other invoicing or company settings for the generation of the invoices).
- Declare the driver filters (usually a range of dates) for fetching the reservations.
- Bookings fetching method according to filters (i.e. fetch all confirmed reservations within a range of dates).
- Generation of the electronic invoices for the selected reservations.
- Transmit one or more electronic invoices generated.
- Obliterate (delete) one or more electronic invoices previously transmitted, if needed and if supported.
Let's take a look at how to create the custom WordPress plugin to install the custom e-invoicing driver.
As stated above, mainly 2 PHP files are needed, one to declare the main WordPress plugin, and another to declare the e-invoicing driver for VikBooking. The screenshot below shows the directory tree for our custom WordPress plugin, and of course developers are free to include as many other folders and files as needed, in order to accomplish all the necessary operations. However, the 2 mentioned files are technically mandatory for the base plugin structure as we can see from the screenshot below.
Main WordPress plugin file
The code snippet below shows the full source code of the file vbocustomeinvoicingdriver.php
, which should be placed in the root of the plugin's directory /vbocustomeinvoicingdriver
.
<?php
/*
Plugin Name: VikBooking Custom e-Invoicing Driver
Plugin URI: https://vikwp.com/plugin/vikbooking
Description: Custom e-invoicing driver for VikBooking.
Version: 1.0
Author: E4J s.r.l.
Author URI: https://vikwp.com
License: GPL2
License URI: https://www.gnu.org/licenses/gpl-2.0.html
Text Domain: vbocustomeinvoicingdriver
*/
// no direct access
defined('ABSPATH') or die('No script kiddies please!');
// register callback for filter "vikbooking_load_einvoicing_drivers"
add_filter('vikbooking_load_einvoicing_drivers', function($return)
{
if (!$return)
{
$return = [];
}
/**
* Since multiple plugins could add their own custom drivers, it is
* necessary to always merge the paths to the driver files of this
* plugin to any previous path that may have already been injected.
* Simply define an array of absolute paths to the PHP files that declare
* the needed class by VikBooking to load a custom e-invoicing driver.
*
* In this example, we are telling VikBooking to load one e-invoicing driver called "custom.php".
*/
return array_merge($return, [
// WP_PLUGIN_DIR = absolute server path to plugin's directory
// vbocustomeinvoicingdriver = the name of this custom plugin
// drivers = a private/internal folder of this custom plugin
// custom.php = the class file that declares the driver
WP_PLUGIN_DIR . '/vbocustomeinvoicingdriver/drivers/custom.php',
]);
});
// make sure to load (require) the needed PHP Class(es) for this driver
add_action('plugins_loaded', function()
{
// ensure the VikBooking framework is available
if (!class_exists('VBOEinvoicingFactory')) {
// VikBooking is either outdated or deactivated
return;
}
// ensure the e-Invoicing framework gets loaded first
VBOEinvoicingFactory::getInstance();
// load main driver file class
require_once dirname(__FILE__) . DIRECTORY_SEPARATOR . 'drivers' . DIRECTORY_SEPARATOR . 'custom.php';
}, PHP_INT_MAX);
VikBooking main driver file
The code snippet below shows the full source code of the file custom.php
, which should be placed inside the drivers directory of the plugin: /vbocustomeinvoicingdriver/drivers
.
<?php
/**
* @package VikBooking
* @subpackage einvoicing_custom
*/
// No direct access
defined('ABSPATH') or die('No script kiddies please!');
/**
* Custom implementation of an e-invoicing driver called "Custom".
* The implementation extends the methods provided by VikBookingEInvoicing.
*
* @see The name of this PHP file should match the class name
* (i.e "VikBookingEInvoicing" + integration name in Pascal case).
*
* @see VikBookingEInvoicing (file einvoicing.php).
*/
final class VikBookingEInvoicingCustom extends VikBookingEInvoicing
{
/**
* An array of bookings.
*
* @var array
*/
private $bookings = [];
/**
* Class constructor should define the name of the driver and
* other properties. Call the parent constructor for initialization.
*/
public function __construct()
{
// main properties
$this->driverFile = basename(__FILE__, '.php');
$this->driverName = 'e-Invoicing - My Custom Implementation';
$this->driverFilters = [];
$this->driverButtons = [];
// this driver has settings
$this->hasSettings = true;
// reset columns, rows and footer row indicating how to fetch the bookings
$this->cols = [];
$this->rows = [];
$this->footerRow = [];
// call parent constructor for initialization
parent::__construct();
}
/**
* Returns the name of this file without the trailing .php.
*
* @return string
*/
public function getFileName()
{
return $this->driverFile;
}
/**
* Returns the name of this driver.
*
* @return string
*/
public function getName()
{
return $this->driverName;
}
/**
* Returns the filters of this driver for fetching the bookings.
*
* @return array
*/
public function getFilters()
{
if ($this->driverFilters) {
// do not run this method twice, as it could load JS and CSS file assets
return $this->driverFilters;
}
// get the application environments
$app = JFactory::getApplication();
$vbo_app = VikBooking::getVboApplication();
// from date filter
$filter_opt = [
'label' => '<label for="fromdate">' . __('From Date', 'vbocustomeinvoicingdriver') . '</label>',
'html' => $vbo_app->getCalendar($val = $app->input->getString('fromdate'), $name = 'fromdate', $id = 'fromdate'),
'type' => 'calendar',
'name' => 'fromdate',
];
$this->driverFilters[] = $filter_opt;
// to date filter
$filter_opt = [
'label' => '<label for="todate">' . __('To Date', 'vbocustomeinvoicingdriver') . '</label>',
'html' => $vbo_app->getCalendar($val = $app->input->getString('todate'), $name = 'todate', $id = 'todate'),
'type' => 'calendar',
'name' => 'todate',
];
$this->driverFilters[] = $filter_opt;
// invoice type filter
$filter_opt = [
'label' => '<label for="einvtype">' . __('Show', 'vbocustomeinvoicingdriver') . '</label>',
'html' => '<select name="einvtype" id="einvtype">
<option value="0">' . __('All reservations', 'vbocustomeinvoicingdriver') . '</option>
<option value="1"' . ($app->input->getInt('einvtype', 0) == 1 ? ' selected="selected"' : '') . '>' . __('- To be invoiced', 'vbocustomeinvoicingdriver') . '</option>
<option value="-1"' . ($app->input->getInt('einvtype', 0) == -1 ? ' selected="selected"' : '') . '>' . __('- To be transmitted', 'vbocustomeinvoicingdriver') . '</option>
<option value="-2"' . ($app->input->getInt('einvtype', 0) == -2 ? ' selected="selected"' : '') . '>' . __('- Trasmitted', 'vbocustomeinvoicingdriver') . '</option>
</select>',
'type' => 'select',
'name' => 'einvtype',
];
$this->driverFilters[] = $filter_opt;
// append the JavaScript code required to handle the actions for (re-)generating, (re-)transmitting the e-invoices
// any other useful JavaScript code can be set here within the method to build the interface filters
$this->setScript(
<<<JAVASCRIPT
VBOCore.DOMLoaded(() => {
// handle "generate/do NOT generate" invoice action
document
.querySelectorAll('.vbo-einvoicing-selaction')
.forEach((target) => {
target
.addEventListener('change', (e) => {
let action_element = e.target;
let prop = 'excludebid' + action_element.getAttribute('data-bid');
let pobj = {};
let actval = parseInt(action_element.value);
pobj[prop] = actval;
vboSetFilters(pobj, false);
if (actval > 0) {
// update cell data attribute for CSS to not-generate
action_element.closest('td').getAttribute('data-einvaction', 0);
} else {
// update cell data attribute for CSS to generate
action_element.closest('td').getAttribute('data-einvaction', 1);
}
});
});
// handle "transmit/do NOT transmit" invoice action
document
.querySelectorAll('.vbo-einvoicing-existaction')
.forEach((target) => {
target
.addEventListener('change', (e) => {
let action_element = e.target;
let prop = 'regeneratebid' + action_element.getAttribute('data-bid');
let propexcl = 'excludesendbid' + action_element.getAttribute('data-bid');
let einvid = parseInt(action_element.value);
let pobj = {};
if (einvid > 0) {
// update cell data attribute for CSS to generate
action_element.closest('td').getAttribute('data-einvaction', 1);
// set re-generate and exclude send
pobj[prop] = einvid;
pobj[propexcl] = 1;
} else {
if (einvid < 0) {
// update cell data attribute for CSS to not-transmit
action_element.closest('td').getAttribute('data-einvaction', 0);
// set exclude send and not re-generate
pobj[prop] = 0;
pobj[propexcl] = 1;
} else {
// update cell data attribute for CSS to transmit (value = 0)
action_element.closest('td').getAttribute('data-einvaction', -2);
// set send and not re-generate
pobj[prop] = 0;
pobj[propexcl] = 0;
}
}
vboSetFilters(pobj, false);
});
});
// handle "re-generate/re-transmit" invoice action
document
.querySelectorAll('.vbo-einvoicing-sentaction')
.forEach((target) => {
target
.addEventListener('change', (e) => {
let action_element = e.target;
let propregen = 'regeneratebid' + action_element.getAttribute('data-bid');
let propresend = 'resendbid' + action_element.getAttribute('data-bid');
let curval = action_element.value;
let splitval = curval.split('-');
let einvid = parseInt(splitval[0]);
let pobj = {};
if (einvid === 0) {
// update cell data attribute for CSS to transmitted
action_element.closest('td').getAttribute('data-einvaction', -1);
pobj[propregen] = einvid;
pobj[propresend] = einvid;
} else {
if (splitval[1] == 'regen') {
// update cell data attribute for CSS to generate
action_element.closest('td').getAttribute('data-einvaction', 1);
pobj[propregen] = einvid;
pobj[propresend] = 0;
} else if (splitval[1] == 'resend') {
// update cell data attribute for CSS to transmitted
action_element.closest('td').getAttribute('data-einvaction', -1);
pobj[propregen] = 0;
pobj[propresend] = einvid;
}
}
vboSetFilters(pobj, false);
});
});
// handle "view invoice" command
document
.querySelectorAll('.vbo-driver-output-vieweinv')
.forEach((target) => {
target
.addEventListener('click', (e) => {
let id = e.target.getAttribute('data-einvid');
vboSetFilters({einvid: id}, false);
vboDriverDoAction('viewEInvoice', true);
});
});
// handle "delete invoice" command
document
.querySelectorAll('.vbo-driver-output-rmeinv')
.forEach((target) => {
target
.addEventListener('click', (e) => {
let id = e.target.getAttribute('data-einvid');
if (confirm('Do you really wish to delete this e-invoice?')) {
vboSetFilters({einvid: id}, false);
vboDriverDoAction('removeEInvoice', false);
}
});
});
});
JAVASCRIPT
);
// return the cached filters just built
return $this->driverFilters;
}
/**
* Returns the buttons for the driver actions to be displayed in
* the E-Invoicing interface of the back-end section of VikBooking.
*
* @return array
*/
public function getButtons()
{
// generate invoices button will call the method "generateEInvoices"
array_push($this->driverButtons, '
<a href="JavaScript: void(0);" onclick="vboDriverDoAction(\'generateEInvoices\', false);" class="vbcsvexport"><i class="' . VikBookingIcons::i('file') . '"></i> <span>' . __('Generate e-Invoices', 'vbocustomeinvoicingdriver') . '</span></a>
');
// transmit invoices button will call the method "transmitEInvoices"
array_push($this->driverButtons, '
<a href="JavaScript: void(0);" onclick="vboDriverDoAction(\'transmitEInvoices\', false);" class="vbo-perms-operators"><i class="' . VikBookingIcons::i('rocket') . '"></i> <span>' . __('Transmit e-Invoices', 'vbocustomeinvoicingdriver') . '</span></a>
');
return $this->driverButtons;
}
/**
* Prepares the data for saving the driver settings.
* Validate post values to make sure they are correct.
*
* @return stdClass The settings object to store.
*/
public function prepareSavingSettings()
{
// access the settings submitted for saving
$submitted = JFactory::getApplication()->input->get('settings', [], 'array');
// start the data objects
$data = new stdClass;
$params = new stdClass;
// base settings
$automatic = $submitted['automatic'] ?? 0;
$progcount = $submitted['progcount'] ?? 1;
// custom parameters
$user_id = $submitted['user_id'] ?? '';
$user_pwd = $submitted['user_pwd'] ?? '';
$companyname = $submitted['companyname'] ?? '';
$vatid = $submitted['vatid'] ?? '';
$address = $submitted['address'] ?? '';
$city = $submitted['city'] ?? '';
// fields validation
$mandatory = [
/**
* @todo Implement your own validation of the mandatory fields
*/
];
foreach ($mandatory as $field) {
if (empty($field)) {
$this->setError(__('Please fill all mandatory fields', 'vbocustomeinvoicingdriver'));
return false;
}
}
// build data for saving
$params->user_id = $user_id;
$params->user_pwd = $user_pwd;
$params->companyname = $companyname;
$params->vatid = $vatid;
$params->address = $address;
$params->city = $city;
// build the data to return for saving the drvier's settings
$data->driver = $this->getFileName();
$data->params = json_encode($params);
// whether e-invoices should be generated automatically together with the courtesy (PDF) format
$data->automatic = $automatic;
// invoice auto-increment counter value
$data->progcount = $progcount;
return $data;
}
/**
* Echoes the HTML required for the driver settings form.
*
* @return void
*/
public function printSettings()
{
// load current driver settings by ensuring we've got an array
$settings = $this->loadSettings() ?: [];
// build the list of driver settings
$params = [
'user_id' => [
'type' => 'text',
'label' => __('User ID', 'vbocustomeinvoicingdriver'),
],
'user_pwd' => [
'type' => 'password',
'label' => __('User Password', 'vbocustomeinvoicingdriver'),
],
'companyname' => [
'type' => 'text',
'label' => __('Company Name', 'vbocustomeinvoicingdriver'),
],
'vatid' => [
'type' => 'text',
'label' => __('VAT ID', 'vbocustomeinvoicingdriver'),
],
'address' => [
'type' => 'text',
'label' => __('Address', 'vbocustomeinvoicingdriver'),
],
'city' => [
'type' => 'text',
'label' => __('City', 'vbocustomeinvoicingdriver'),
],
// this hidden setting is required to ensure the settings will be saved
'driveraction' => [
'type' => 'custom',
'hidden' => true,
'html' => '<input type="hidden" name="driveraction" value="saveSettings" />',
],
/**
* @todo Add or modify settings required for the generation of the e-invoices.
*/
];
// give the form a bit of default styling
echo <<<HTML
<div class="vbo-admin-container vbo-admin-container-full vbo-admin-container-compact">
<div class="vbo-params-wrap">
<div class="vbo-params-container">
<div class="vbo-params-block">
HTML;
// render all params/settings for the driver
echo VBOParamsRendering::getInstance(
// the list of parameters to render
$params,
// the currently saved driver settings array
(array) ($settings['params'] ?? [])
)->setInputName('settings')->getHtml();
// close the form HTML styling
echo <<<HTML
</div>
</div>
</div>
</div>
HTML;
}
/**
* Loads the bookings from the DB according to the filters set.
* Gathers the information for the electronic invoices generation.
* Sets the columns and rows for the page and commands to be displayed.
*
* @return bool
*/
public function getBookingsData()
{
if ($this->getError()) {
// do not proceed in case of errors
return false;
}
if ($this->bookings) {
// this method may be called by other generation methods, so it's useless to run it twice
return true;
}
// access the application and database
$app = JFactory::getApplication();
$dbo = JFactory::getDbo();
// gather the request variables to filter the bookings
$peinvtype = $app->input->getInt('einvtype', 0);
$pfromdate = $app->input->getString('fromdate', date('Y-m-d'));
$ptodate = $app->input->getString('todate', date('Y-m-d'));
// get dates timestamps
$from_ts = VikBooking::getDateTimestamp($pfromdate, 0, 0);
$to_ts = VikBooking::getDateTimestamp($ptodate, 23, 59, 59);
// get the currency symbol
$currency_symb = VikBooking::getCurrencySymb();
// query the database to fetch the bookings and the related e-invoicing information
$q = $dbo->getQuery(true)
->select([
$dbo->qn('o.id'),
$dbo->qn('o.ts'),
$dbo->qn('o.days'),
$dbo->qn('o.checkin'),
$dbo->qn('o.checkout'),
$dbo->qn('o.totpaid'),
$dbo->qn('o.idpayment'),
$dbo->qn('o.coupon'),
$dbo->qn('o.roomsnum'),
$dbo->qn('o.total'),
$dbo->qn('o.idorderota'),
$dbo->qn('o.channel'),
$dbo->qn('o.chcurrency'),
$dbo->qn('o.country'),
$dbo->qn('o.tot_taxes'),
$dbo->qn('o.tot_city_taxes'),
$dbo->qn('o.tot_fees'),
$dbo->qn('o.cmms'),
$dbo->qn('o.pkg'),
$dbo->qn('o.refund'),
$dbo->qn('or.idorder'),
$dbo->qn('or.idroom'),
$dbo->qn('or.adults'),
$dbo->qn('or.children'),
$dbo->qn('or.idtar'),
$dbo->qn('or.optionals'),
$dbo->qn('or.cust_cost'),
$dbo->qn('or.cust_idiva'),
$dbo->qn('or.extracosts'),
$dbo->qn('or.room_cost'),
$dbo->qn('c.country_name'),
$dbo->qn('c.country_2_code'),
$dbo->qn('r.name', 'room_name'),
$dbo->qn('r.fromadult'),
$dbo->qn('r.toadult'),
$dbo->qn('ei.id', 'einvid'),
$dbo->qn('ei.driverid', 'einvdriver'),
$dbo->qn('ei.for_date', 'einvdate'),
$dbo->qn('ei.number', 'einvnum'),
$dbo->qn('ei.transmitted', 'einvsent'),
])
->from($dbo->qn('#__vikbooking_orders', 'o'))
->leftJoin($dbo->qn('#__vikbooking_ordersrooms', 'or') . ' ON ' . $dbo->qn('or.idorder') . ' = ' . $dbo->qn('o.id'))
->leftJoin($dbo->qn('#__vikbooking_rooms', 'r') . ' ON ' . $dbo->qn('or.idroom') . ' = ' . $dbo->qn('r.id'))
->leftJoin($dbo->qn('#__vikbooking_countries', 'c') . ' ON ' . $dbo->qn('o.country') . ' = ' . $dbo->qn('c.country_3_code'))
->leftJoin($dbo->qn('#__vikbooking_einvoicing_data', 'ei') . ' ON ' . $dbo->qn('o.id') . ' = ' . $dbo->qn('ei.idorder') . ' AND ' . $dbo->qn('ei.obliterated') . ' = 0')
// get the confirmed bookings, or the cancelled ones with an amount paid greater than zero
->where(
'(' . $dbo->qn('o.status') . ' = ' . $dbo->q('confirmed') . ' OR (' . $dbo->qn('o.status') . ' = ' . $dbo->q('cancelled') . ' AND ' . $dbo->qn('o.totpaid') . ' > 0))'
)
// exclude "false" reservations
->where($dbo->qn('o.closure') . ' = 0')
// get all bookings whose creation date is within the range of dates selected
->where($dbo->qn('o.ts') . ' >= ' . $from_ts)
->where($dbo->qn('o.ts') . ' <= ' . $to_ts)
//
->order($dbo->qn('o.ts') . ' ASC')
->order($dbo->qn('o.id') . ' ASC');
// check the "show invoice type" filter
if ($peinvtype === 1) {
// fetch only the bookings to be invoiced
$q->where($dbo->qn('ei.id') . ' IS NULL');
} elseif ($peinvtype === -1) {
// fetch the bookings that were invoiced, but the invoices were not transmitted
$q->where(
'(' . $dbo->qn('ei.id') . ' IS NOT NULL AND ' . $dbo->qn('ei.transmitted') . ' = 0)'
);
} elseif ($peinvtype === -2) {
// fetch the bookings that were invoiced and the invoices were transmitted
$q->where(
'(' . $dbo->qn('ei.id') . ' IS NOT NULL AND ' . $dbo->qn('ei.transmitted') . ' = 1)'
);
}
$dbo->setQuery($q);
$records = $dbo->loadAssocList();
if (!$records) {
// no bookings found, abort
$this->setError(__('No reservations or invoices found with the specified filters.', 'vbocustomeinvoicingdriver'));
return false;
}
// nest records with multiple rooms booked inside sub-array
$bookings = $this->nestBookingsData($records);
// define the columns of the page
$this->cols = [
// booking id
[
'key' => 'id',
'sortable' => 1,
'label' => __('ID', 'vbocustomeinvoicingdriver'),
],
// booking creation date
[
'key' => 'ts',
'attr' => [
'class="center"'
],
'sortable' => 1,
'label' => __('Created on', 'vbocustomeinvoicingdriver'),
],
// checkin
[
'key' => 'checkin',
'sortable' => 1,
'label' => __('Check-in', 'vbocustomeinvoicingdriver'),
],
// checkout
[
'key' => 'checkout',
'sortable' => 1,
'label' => __('Check-out', 'vbocustomeinvoicingdriver'),
],
// customer
[
'key' => 'customer',
'sortable' => 1,
'label' => __('Customer', 'vbocustomeinvoicingdriver'),
],
// country
[
'key' => 'country',
'sortable' => 1,
'label' => __('Country', 'vbocustomeinvoicingdriver'),
],
// city
[
'key' => 'city',
'attr' => [
'class="center"'
],
'sortable' => 1,
'label' => __('City', 'vbocustomeinvoicingdriver'),
],
// customer VAT ID
[
'key' => 'vat',
'attr' => [
'class="center"'
],
'sortable' => 1,
'label' => __('VAT ID', 'vbocustomeinvoicingdriver'),
],
// business name
[
'key' => 'company',
'sortable' => 1,
'label' => __('Business name', 'vbocustomeinvoicingdriver'),
],
// booking total amount
[
'key' => 'tot',
'attr' => [
'class="center"'
],
'sortable' => 1,
'label' => __('Total', 'vbocustomeinvoicingdriver'),
],
// commands
[
'key' => 'commands',
'attr' => [
'class="center"'
],
'label' => '',
],
// action
[
'key' => 'action',
'attr' => [
'class="center"'
],
'sortable' => 1,
'label' => __('Action', 'vbocustomeinvoicingdriver'),
],
];
// build the rows of the page
foreach ($bookings as $bk => $gbook) {
$bid = $gbook[0]['id'];
$analog_id = ($gbook[0]['invid'] ?? 0) ?: null;
/**
* Manual invoices could have the same number and so negative id order across multiple years.
* For this reason, searching for an invoice by number may display invalid links to the manual
* invoices, and so we build a list of invoice IDs with related dates to be displayed.
*/
$multi_analog_ids = [];
if (!empty($analog_id) && count($gbook) > 1) {
$all_analog_ids = [];
foreach ($gbook as $subinv) {
if (!isset($subinv['invid']) || !isset($subinv['inv_fordate_ts'])) {
continue;
}
$inv_key_identifier = $subinv['invid'] . $subinv['inv_fordate_ts'];
if (in_array($inv_key_identifier, $all_analog_ids)) {
continue;
}
array_push($all_analog_ids, $inv_key_identifier);
array_push($multi_analog_ids, array(
'invid' => $subinv['invid'],
'for_date' => date(str_replace("/", $datesep, $df), $subinv['inv_fordate_ts']),
));
}
}
//
$tsinfo = getdate($gbook[0]['ts']);
$tswday = $this->getWdayString($tsinfo['wday'], 'short');
$ininfo = getdate($gbook[0]['checkin']);
$inwday = $this->getWdayString($ininfo['wday'], 'short');
$outinfo = getdate($gbook[0]['checkout']);
$outwday = $this->getWdayString($outinfo['wday'], 'short');
$customer = $gbook[0]['customer'];
$country3 = $gbook[0]['country'];
$country2 = $gbook[0]['country_2_code'];
$countryfull = $gbook[0]['country_name'];
if (empty($country3) && count($customer) && !empty($customer['country'])) {
$country3 = $customer['country'];
$gbook[0]['country'] = $country3;
}
if (empty($country2) && count($customer) && !empty($customer['country_2_code'])) {
$country2 = $customer['country_2_code'];
$gbook[0]['country_2_code'] = $country2;
}
if (empty($countryfull) && count($customer) && !empty($customer['country_name'])) {
$countryfull = $customer['country_name'];
$gbook[0]['country_name'] = $countryfull;
}
$totguests = 0;
$rooms_map = [];
$rooms_str = [];
foreach ($gbook as $book) {
$totguests += $book['adults'] + $book['children'];
if (!isset($book['room_name'])) {
// custom (manual) invoice records may be missing this property
continue;
}
if (!isset($rooms_map[$book['room_name']])) {
$rooms_map[$book['room_name']] = 0;
}
$rooms_map[$book['room_name']]++;
}
foreach ($rooms_map as $rname => $rcount) {
array_push($rooms_str, $rname . ($rcount > 1 ? ' x'.$rcount : ''));
}
$rooms_str = implode(', ', $rooms_str);
// einvnum (if exists)
$einvnum = !empty($gbook[0]['einvnum']) ? $gbook[0]['einvnum'] : 0;
// always update the main array reference
$bookings[$bk] = $gbook;
// check whether the invoice can be issued
list($canbeinvoiced, $noinvoicereason) = $this->canBookingBeInvoiced($bookings[$bk]);
// push fields in the rows array as a new row
array_push($this->rows, array(
array(
'key' => 'id',
'callback' => function ($val) use ($analog_id, $multi_analog_ids) {
if ($val < 0 && !empty($analog_id)) {
// custom (manual) invoices have a negative idorder (-number)
$returi = base64_encode('index.php?option=com_vikbooking&task=einvoicing');
if (count($multi_analog_ids) < 2) {
// just one manual invoice found
return '<a href="index.php?option=com_vikbooking&task=editmaninvoice&cid[]='.$analog_id.'&goto='.$returi.'"><i class="'.VikBookingIcons::i('external-link').'"></i> '.__('Custom invoice', 'vbocustomeinvoicingdriver').'</a>';
}
/**
* There can be conflictual manual invoices with the same number and negative order
* across multiple years, so we print a link to display them all with an alert.
* @since 1.13.5
*/
$all_links = [];
foreach ($multi_analog_ids as $analog_info) {
array_push($all_links, '<a href="index.php?option=com_vikbooking&task=editmaninvoice&cid[]='.$analog_info['invid'].'&goto='.$returi.'" onclick="alert(\'Use date filters to not list manual invoices with the same number\'); return true;"><i class="'.VikBookingIcons::i('external-link').'"></i> '.__('Custom invoice', 'vbocustomeinvoicingdriver').' (' . $analog_info['for_date'] . ')</a>');
}
return implode('<br/>', $all_links);
}
return '<a href="index.php?option=com_vikbooking&task=editorder&cid[]='.$val.'" target="_blank"><i class="'.VikBookingIcons::i('external-link').'"></i> '.$val.'</a>';
},
'value' => $bid
),
array(
'key' => 'ts',
'attr' => array(
'class="center"'
),
'callback' => function ($val) use ($tswday) {
return $tswday.', '.date('Y-m-d', $val);
},
'value' => $gbook[0]['ts']
),
array(
'key' => 'checkin',
'callback' => function ($val) use ($inwday) {
if (empty($val)) {
// custom (manual) invoices have an empty timestamp
return '-----';
}
return $inwday.', '.date('Y-m-d', $val);
},
'value' => $gbook[0]['checkin']
),
array(
'key' => 'checkout',
'callback' => function ($val) use ($outwday) {
if (empty($val)) {
// custom (manual) invoices have an empty timestamp
return '-----';
}
return $outwday.', '.date('Y-m-d', $val);
},
'value' => $gbook[0]['checkout']
),
array(
'key' => 'customer',
'callback' => function ($val) use ($customer, $bid) {
$goto = base64_encode('index.php?option=com_vikbooking&task=einvoicing');
if (!empty($val)) {
$cont = count($customer) ? '<a href="index.php?option=com_vikbooking&task=editcustomer&cid[]='.$customer['id'].'&goto='.$goto.'">'.$val.'</a>' : $val;
if (count($customer) && !empty($customer['country'])) {
if (is_file(VBO_ADMIN_PATH.DIRECTORY_SEPARATOR.'resources'.DIRECTORY_SEPARATOR.'countries'.DIRECTORY_SEPARATOR.$customer['country'].'.png')) {
$cont .= '<img src="'.VBO_ADMIN_URI.'resources/countries/'.$customer['country'].'.png'.'" title="'.$customer['country'].'" class="vbo-country-flag vbo-country-flag-left"/>';
}
}
} else {
// if empty customer ($val) print danger button to assign a customer to this booking ID
$cont = '<a class="btn btn-danger" href="index.php?option=com_vikbooking&task=newcustomer&bid='.$bid.'&goto='.$goto.'">' . __('Create new customer', 'vbocustomeinvoicingdriver') . '</a>';
}
return $cont;
},
'value' => ($customer ? $customer['first_name'].' '.$customer['last_name'] : '')
),
array(
'key' => 'country',
'callback' => function ($val) {
return !empty($val) ? $val : '-----';
},
'value' => $countryfull
),
array(
'key' => 'city',
'attr' => array(
'class="center"'
),
'callback' => function ($val) use ($customer) {
$goto = base64_encode('index.php?option=com_vikbooking&task=einvoicing');
if (empty($val)) {
if (count($customer) && !empty($customer['id'])) {
// just an empty City, edit the customer
$cont = '<a class="btn btn-danger" href="index.php?option=com_vikbooking&task=editcustomer&cid[]='.$customer['id'].'&goto='.$goto.'">' . __('Add', 'vbocustomeinvoicingdriver') . '</a>';
} else {
$cont = '-----';
}
return $cont;
}
if (count($customer) && empty($customer['zip'])) {
// postal code is mandatory
return '<a class="btn btn-danger" href="index.php?option=com_vikbooking&task=editcustomer&cid[]='.$customer['id'].'&goto='.$goto.'">No Postal Code</a>';
}
if (count($customer) && empty($customer['address'])) {
// address is mandatory
return '<a class="btn btn-secondary" href="index.php?option=com_vikbooking&task=editcustomer&cid[]='.$customer['id'].'&goto='.$goto.'">No Address</a>';
}
return $val;
},
'value' => (count($customer) && !empty($customer['city']) ? $customer['city'] : '')
),
array(
'key' => 'vat',
'attr' => array(
'class="center"'
),
'callback' => function ($val) use ($customer, $bid) {
if (!empty($val)) {
$cont = $val;
} else {
$goto = base64_encode('index.php?option=com_vikbooking&task=einvoicing');
if (count($customer) && !empty($customer['id'])) {
// empty VAT Number, which is mandatory for both issuer and counterpart
$cont = '<a class="btn btn-danger" href="index.php?option=com_vikbooking&task=editcustomer&cid[]='.$customer['id'].'&goto='.$goto.'">' . __('Add', 'vbocustomeinvoicingdriver') . '</a>';
} else {
// if empty customer ($val) print danger button to assign a customer to this booking ID
$cont = '<a class="btn btn-danger" href="index.php?option=com_vikbooking&task=newcustomer&bid='.$bid.'&goto='.$goto.'">' . __('Add', 'vbocustomeinvoicingdriver') . '</a>';
}
}
return $cont;
},
'value' => (count($customer) && !empty($customer['vat']) ? $customer['vat'] : '')
),
array(
'key' => 'company',
'callback' => function ($val) use ($customer) {
$cont = !empty($val) ? $val : '-----';
if (count($customer)) {
$goto = base64_encode('index.php?option=com_vikbooking&task=einvoicing');
$cont = '<a href="index.php?option=com_vikbooking&task=editcustomer&cid[]='.$customer['id'].'&goto='.$goto.'">'.$cont.'</a>';
}
return $cont;
},
'value' => (count($customer) && !empty($customer['company']) ? $customer['company'] : '')
),
array(
'key' => 'tot',
'attr' => array(
'class="center"'
),
'callback' => function ($val) use ($currency_symb) {
return $currency_symb.' '.VikBooking::numberFormat($val);
},
'value' => $gbook[0]['total']
),
array(
'key' => 'commands',
'attr' => array(
'class="center"'
),
'callback' => function ($val) use ($bid, $noinvoicereason) {
if ($val === 0 || $val === 1) {
// invoice cannot be issued or is about to be issued
return '';
}
$buttons = [];
if ($val === -1 || $val === -2) {
// invoice generated or generated and transmitted, print buttons to view or delete the invoice
array_push($buttons, '<i class="vboicn-eye icn-nomargin vbo-driver-customoutput vbo-driver-output-vieweinv" title="View invoice" data-einvid="' . $noinvoicereason . '"></i>');
array_push($buttons, '<i class="vboicn-bin icn-nomargin vbo-driver-customoutput vbo-driver-output-rmeinv" title="Delete invoice" data-einvid="' . $noinvoicereason . '"></i>');
}
return implode("\n", $buttons);
},
'value' => $canbeinvoiced
),
array(
'key' => 'action',
'attr' => array(
'class="center vbo-einvoicing-cellaction"',
'data-einvaction="'.$canbeinvoiced.'"'
),
'callback' => function ($val) use ($bid, $noinvoicereason, $einvnum) {
if ($val === 0) {
// invoice cannot be issued
$noinvoicereason = empty($noinvoicereason) ? 'Missing data to generate the invoice' : $noinvoicereason;
return '<button type="button" class="btn btn-secondary" onclick="alert(\''.addslashes($noinvoicereason).'\');"><i class="vboicn-blocked icn-nomargin"></i> Not billable</button>';
}
if ($val === -1) {
// e-invoice already issued and transmitted: print drop down to let the customer regenerate this invoice and obliterate the other or to re-send
return '<select class="vbo-einvoicing-sentaction" data-bid="'.$bid.'"><option value="0-none">Invoice #'.$einvnum.' transmitted</option><option value="'.$noinvoicereason.'-regen">- Regenerate invoice</option><option value="'.$noinvoicereason.'-resend">- Retransmit invoice</option></select>';
}
if ($val === -2) {
// e-invoice already issued but NOT transmitted: print drop down to let the customer regenerate this invoice and obliterate the other
return '<select class="vbo-einvoicing-existaction" data-bid="'.$bid.'"><option value="0">Transmit invoice #'.$einvnum.'</option><option value="-1">- Do NOT transmit invoice</option><option value="'.$noinvoicereason.'">- Regenerate invoice</option></select>';
}
// invoice can be issued: print drop down to let the customer skip this generation
return '<select class="vbo-einvoicing-selaction" data-bid="'.$bid.'"><option value="0">Generate invoice</option><option value="1">- Do NOT generate invoice</option></select>';
},
'value' => $canbeinvoiced,
),
));
}
// build footer rows
$totcols = count($this->cols);
$footerstats = [];
foreach ($this->rows as $k => $row) {
foreach ($row as $col) {
if ($col['key'] != 'action') {
continue;
}
if (!isset($footerstats[$col['value']])) {
$footerstats[$col['value']] = 0;
}
$footerstats[$col['value']]++;
}
}
$avgcolspan = floor($totcols / count($footerstats));
$footercells = [];
foreach ($footerstats as $canbeinvoiced => $tot) {
switch ($canbeinvoiced) {
case 1:
$descr = __('To be invoiced', 'vbocustomeinvoicingdriver');
break;
case -1:
$descr = __('Transmitted invoices', 'vbocustomeinvoicingdriver');
break;
case -2:
$descr = __('Generated invoices', 'vbocustomeinvoicingdriver');
break;
default:
$descr = __('Not billable', 'vbocustomeinvoicingdriver');
break;
}
array_push($footercells, array(
'attr' => array(
'class="vbo-report-total vbo-driver-total"',
'colspan="'.$avgcolspan.'"'
),
'value' => '<h3>'.$descr.': '.$tot.'</h3>'
));
}
$this->footerRow[0] = $footercells;
$missingcols = $totcols - ($avgcolspan * count($footerstats));
if ($missingcols > 0) {
array_push($this->footerRow[0], array(
'attr' => array(
'class="vbo-report-total vbo-driver-total"',
'colspan="'.$missingcols.'"'
),
'value' => ''
));
}
// update bookings array for the other methods to avoid double executions
$this->bookings = $bookings;
return true;
}
/**
* Generates the electronic invoices according to the input parameters.
* This is a 'driver action', and so it's called before getBookingsData()
* in the view. This method will save/update records in the DB so that when
* the view re-calls getBookingsData(), the information will be up to date.
*
* @return bool True if at least one e-invoice was generated.
*/
public function generateEInvoices()
{
// access the application
$app = JFactory::getApplication();
// call the main method to generate rows, cols and bookings array
$this->getBookingsData();
if ($this->getError() || !$this->bookings) {
return false;
}
// start counter
$generated = 0;
foreach ($this->bookings as $gbook) {
// check whether this booking ID was set to be skipped
$exclude = $app->input->getInt('excludebid' . $gbook[0]['id'], 0);
if ($exclude > 0) {
// skipping this invoice
continue;
}
// check if an electronic invoice was already issued for this booking ID by this driver
if (!empty($gbook[0]['einvid']) && $gbook[0]['einvdriver'] == $this->getDriverId()) {
// check if this booking ID was set to be re-generated through an action
$regenerate = $app->input->getInt('regeneratebid' . $gbook[0]['id'], 0);
if (!($regenerate > 0)) {
// we do not re-generate an invoice for this booking ID
continue;
}
}
// generate invoice
if ($this->generateEInvoice($gbook)) {
$generated++;
}
}
// we need to unset the bookings property so that the later call to getBookingsData() made by the View will reload the information
$this->bookings = [];
// unset also cols, rows and footer row to not merge data
$this->cols = [];
$this->rows = [];
$this->footerRow = [];
// set info message
$this->setInfo('Invoices generated: ' . $generated);
return ($generated > 0);
}
/**
* Generates one single electronic invoice. If no array data provided, the booking ID should
* be passed as argument. In this case the method would fetch and nest the booking data.
* This method is responsible for generating the e-invoice body and store a new record on the db.
*
* @param mixed $data either the booking ID or the booking array (one room info per index)
*
* @return bool True if the e-invoice was generated
*/
public function generateEInvoice($data)
{
// access the database
$dbo = JFactory::getDbo();
// load driver settings
$settings = $this->loadSettings() ?: [];
if (!$settings || empty($settings['params'])) {
$this->setError('Missing driver settings for generating the electronic invoice');
return false;
}
if (is_int($data)) {
// query the database to obtain the booking records from the given reservation ID
$q = $dbo->getQuery(true)
->select([
$dbo->qn('o.id'),
$dbo->qn('o.ts'),
$dbo->qn('o.days'),
$dbo->qn('o.checkin'),
$dbo->qn('o.checkout'),
$dbo->qn('o.totpaid'),
$dbo->qn('o.idpayment'),
$dbo->qn('o.coupon'),
$dbo->qn('o.roomsnum'),
$dbo->qn('o.total'),
$dbo->qn('o.idorderota'),
$dbo->qn('o.channel'),
$dbo->qn('o.chcurrency'),
$dbo->qn('o.country'),
$dbo->qn('o.tot_taxes'),
$dbo->qn('o.tot_city_taxes'),
$dbo->qn('o.tot_fees'),
$dbo->qn('o.cmms'),
$dbo->qn('o.pkg'),
$dbo->qn('o.refund'),
$dbo->qn('or.idorder'),
$dbo->qn('or.idroom'),
$dbo->qn('or.adults'),
$dbo->qn('or.children'),
$dbo->qn('or.idtar'),
$dbo->qn('or.optionals'),
$dbo->qn('or.cust_cost'),
$dbo->qn('or.cust_idiva'),
$dbo->qn('or.extracosts'),
$dbo->qn('or.room_cost'),
$dbo->qn('c.country_name'),
$dbo->qn('c.country_2_code'),
$dbo->qn('r.name', 'room_name'),
$dbo->qn('r.fromadult'),
$dbo->qn('r.toadult'),
$dbo->qn('ei.id', 'einvid'),
$dbo->qn('ei.driverid', 'einvdriver'),
$dbo->qn('ei.for_date', 'einvdate'),
$dbo->qn('ei.number', 'einvnum'),
$dbo->qn('ei.transmitted', 'einvsent'),
])
->from($dbo->qn('#__vikbooking_orders', 'o'))
->leftJoin($dbo->qn('#__vikbooking_ordersrooms', 'or') . ' ON ' . $dbo->qn('or.idorder') . ' = ' . $dbo->qn('o.id'))
->leftJoin($dbo->qn('#__vikbooking_rooms', 'r') . ' ON ' . $dbo->qn('or.idroom') . ' = ' . $dbo->qn('r.id'))
->leftJoin($dbo->qn('#__vikbooking_countries', 'c') . ' ON ' . $dbo->qn('o.country') . ' = ' . $dbo->qn('c.country_3_code'))
->leftJoin($dbo->qn('#__vikbooking_einvoicing_data', 'ei') . ' ON ' . $dbo->qn('o.id') . ' = ' . $dbo->qn('ei.idorder') . ' AND ' . $dbo->qn('ei.obliterated') . ' = 0')
// get the confirmed bookings, or the cancelled ones with an amount paid greater than zero
->where(
'(' . $dbo->qn('o.status') . ' = ' . $dbo->q('confirmed') . ' OR (' . $dbo->qn('o.status') . ' = ' . $dbo->q('cancelled') . ' AND ' . $dbo->qn('o.totpaid') . ' > 0))'
)
// exclude "false" reservations
->where($dbo->qn('o.closure') . ' = 0')
// get the records related to the current reservation ID
->where($dbo->qn('o.id') . ' = ' . $data)
->order($dbo->qn('o.ts') . ' ASC')
->order($dbo->qn('o.id') . ' ASC');
$dbo->setQuery($q);
$records = $dbo->loadAssocList();
if (!$records) {
$this->setError('Could not find the booking information');
return false;
}
// nest records with multiple rooms booked inside sub-array
$records = $this->nestBookingsData($records);
// overwrite the data variable
$data = $records[$data];
}
if (!is_array($data) || !$data) {
$this->setError('No bookings found');
return false;
}
// check whether the invoice can be issued
list($canbeinvoiced, $noinvoicereason) = $this->canBookingBeInvoiced($data);
if ($canbeinvoiced === 0) {
// the invoice cannot be generated due to missing information
// do not raise any errors unless called externally, we just skip this booking because it cannot be invoiced
if ($this->externalCall) {
if ($data[0]['id'] < 0) {
$message = "Could not generate electronic invoice from custom invoice: {$noinvoicereason}";
} else {
$message = "Could not generate electronic invoice for booking ID {$data[0]['id']} ({$noinvoicereason})";
}
$this->setError($message);
}
return false;
}
// access some booking and customer values from the current reservation data
// customer name
$client_name = trim($data[0]['customer']['first_name'] . ' ' . $data[0]['customer']['last_name']);
// company business name
$company_name = $data[0]['customer']['company'] ?: $client_name;
// company VAT ID
$company_vat_id = $data[0]['customer']['vat'] ?: '0000000';
// booking total amount
$booking_total = round($data[0]['total'], 2);
// booking total tax
$booking_tax = round($data[0]['tot_taxes'], 2);
// access some driver custom settings previously defined and configured
$issuer_user_id = $settings['params']['user_id'] ?? '';
$issuer_companyname = $settings['params']['companyname'] ?? '';
$issuer_vatid = $settings['params']['vatid'] ?? '';
// get the current invoice number from the driver settings
$invoice_number = $settings['progcount'];
/**
* @todo Perform the necessary operations to generate the invoice from the given booking data.
* Create the necessary digital body of the electronic invoice, whether the format is XML, JSON or others.
* The goal is to build an object with a property that contains the electronic invoice body to be stored on the DB.
*
* The example below shows an example of an e-invoice in XML format with some booking values by using the driver settings.
*/
$invoice_body = <<<XML
<InvoiceBodyExample>
<IssuerDetails>
<IssuerId>$issuer_user_id</IssuerId>
<IssuerCompany>$issuer_companyname</IssuerCompany>
<IssuerVat>$issuer_vatid</IssuerVat>
</IssuerDetails>
<RecipientDetails>
<Name>$client_name</Name>
<Business>$company_name</Business>
<VatId>$company_vat_id</VatId>
</RecipientDetails>
<Summary>
<InvoiceNumber>$invoice_number</InvoiceNumber>
<Total>$booking_total</Total>
<Tax>$booking_tax</Tax>
</Summary>
</InvoiceBodyExample>
XML;
// once the e-invoice body string has been generated, the e-invoice record can be created and stored onto the database
// prepare object for storing the invoice
$einvobj = new stdClass;
$einvobj->driverid = $settings['id'];
$einvobj->created_on = JFactory::getDate('now', JFactory::getApplication()->get('offset'))->toSql();
// e-invoice target date
$einvobj->for_date = date('Y-m-d');
// e-invoice file name, if needed
$einvobj->filename = $issuer_user_id . '.xml';
// invoice number, if needed
$einvobj->number = $invoice_number;
// the booking ID to which the invoice refers to
$einvobj->idorder = $data[0]['id'];
// the customer ID to which the invoice refers to
$einvobj->idcustomer = $data[0]['customer']['id'] ?: 0;
$einvobj->country = $data[0]['customer']['country'] ?: null;
// the column "recipientcode" should not be null, but can be empty - it identifies the customer business ID
$einvobj->recipientcode = '';
// this is the actual e-invoice body string generated above
$einvobj->xml = $invoice_body;
// always reset transmitted and obliterated values for new e-invoices
$einvobj->transmitted = 0;
$einvobj->obliterated = 0;
// attempt to store the e-invoice record onto the db
$newinvid = $this->storeEInvoice($einvobj);
if ($newinvid === false) {
$this->setError('Error storing the electronic invoice for the reservation ID ' . $data[0]['id']);
return false;
}
// update auto-increment driver setting by increasing it for the next run
$this->updateProgressiveNumber(++$settings['progcount']);
// the operation completed with success
return true;
}
/**
* Given two arguments, the current analogic invoice record and the customer record, this
* method should prepare and return an array that can be later passed onto generateEInvoice().
* This originally abstract method must be implemented for the generation of the custom (manual) invoices
* that are not related to any bookings (idorder = -number), that were manually created for certain customers.
*
* @param array $invoice the analogic invoice record
* @param array $customer the customer record obtained through
*
* @return array the data array compatible with generateEInvoice()
*
* @see generateEInvoice()
*/
public function prepareCustomInvoiceData($invoice, $customer)
{
if (!isset($invoice['number']) && !empty($invoice['einvnum'])) {
// getBookingsData() may call this method by knowing only the electronic invoice number
$invoice['number'] = $invoice['einvnum'];
}
// make sure to get an integer value from the invoice number, which is a string with a probable suffix
$numnumber = intval(preg_replace("/[^\d]+/", '', $invoice['number']));
// make sure the key rawcont is an array
if (!is_array($invoice['rawcont'])) {
$rawcont = !empty($invoice['rawcont']) ? (array) json_decode($invoice['rawcont'], true) : [];
$invoice['rawcont'] = $rawcont;
}
// build necessary data array compatible with generateEInvoice()
$data = [
'id' => ($numnumber - ($numnumber * 2)),
'ts' => (isset($invoice['created_on']) ? strtotime($invoice['created_on']) : time()),
'checkin' => 0,
'checkout' => 0,
'adults' => 0,
'children' => 0,
'total' => $invoice['rawcont']['totaltot'],
'country' => $customer['country'],
'country_name' => $customer['country_name'],
'country_2_code' => $customer['country_2_code'] ?? null,
'tot_taxes' => $invoice['rawcont']['totaltax'],
'tot_city_taxes' => 0,
'tot_fees' => 0,
'customer' => $customer,
'pkg' => null,
'einvid' => $invoice['einvid'] ?? null,
'einvdriver' => $invoice['einvdriver'] ?? null,
'einvdate' => $invoice['einvdate'] ?? null,
'einvnum' => $invoice['einvnum'] ?? null,
'einvsent' => $invoice['einvsent'] ?? null,
// this could be the ID of the analogic invoice
'invid' => $invoice['invid'] ?? null,
// this could be the for date timestamp of the analogic invoice
'inv_fordate_ts' => $invoice['inv_fordate_ts'] ?? null,
];
// make sure to inject the raw content of the custom invoice
$this->externalData['einvrawcont'] = $invoice['rawcont'];
// original data array contains nested rooms booked so we need to return it as the 0th value
return [$data];
}
/**
* Checks whether an active electronic invoice already exists from the given details.
*
* @param mixed $data array or stdClass object with properties to identify the e-invoice
*
* @return mixed False if the invoice does not exist, its ID otherwise.
*/
public function eInvoiceExists($data)
{
if (is_object($data)) {
// cast to array
$data = (array) $data;
}
// allowed properties to check
$properties = [
'id' => 'einvid',
'idorder' => 'idorder',
'number' => 'number',
];
$filters = [];
foreach ($properties as $k => $v) {
if (!empty($data[$v])) {
$filters[$k] = $data[$v];
} elseif (!empty($data[$k])) {
$filters[$k] = $data[$k];
}
}
if (!$filters) {
return false;
}
// access the database
$dbo = JFactory::getDbo();
// query the database to check if the e-invoice exists
$q = $dbo->getQuery(true)
->select($dbo->qn('id'))
->from($dbo->qn('#__vikbooking_einvoicing_data'))
->where($dbo->qn('driverid') . ' = ' . (int) $this->getDriverId())
->where($dbo->qn('obliterated') . ' = 0')
->order($dbo->qn('id') . ' DESC');
// iterate over the filter fields
foreach ($filters as $col => $val) {
$q->where($dbo->qn($col) . ' = ' . $dbo->q($val));
}
$dbo->setQuery($q);
$einv_id = $dbo->loadResult();
return $einv_id ?: false;
}
/**
* Transmits the electronic invoices to the local authority, usually to a remote endpoint.
* This is a 'driver action', and so it's called before getBookingsData() in the View.
* This method will save/update records in the DB so that when the View re-calls getBookingsData(),
* the information will be up to date.
*
* @return bool True if at least one e-invoice was transmitted
*/
public function transmitEInvoices()
{
// access the application
$app = JFactory::getApplication();
// make sure the transmission settings are not empty
$settings = $this->loadSettings() ?: [];
if (!$settings || empty($settings['params'])) {
$this->setError('Missing driver settings for transmitting the electronic invoices');
return false;
}
// call the main method to generate rows, cols and bookings array
$this->getBookingsData();
if ($this->getError() || !$this->bookings) {
return false;
}
// pool of e-invoice IDs to transmit
$einvspool = [];
foreach ($this->bookings as $gbook) {
// check whether this booking ID was set to be skipped from transmission
$exclude = $app->input->getInt('excludesendbid' . $gbook[0]['id'], 0);
if ($exclude > 0) {
// skipping this invoice from transmission
continue;
}
// make sure an electronic invoice was already issued for this booking ID by this driver
if (empty($gbook[0]['einvid']) || $gbook[0]['einvdriver'] != $this->getDriverId()) {
// no e-invoices available for this booking, skipping
continue;
}
// check if an e-invoice was already sent for this booking
if ($gbook[0]['einvsent'] > 0) {
$resend = $app->input->getInt('resendbid' . $gbook[0]['id'], 0);
if (!($resend > 0)) {
// we do not re-send the invoice for this booking ID
continue;
}
}
// push e-invoice ID to the pool
array_push($einvspool, $gbook[0]['einvid']);
}
if (!$einvspool) {
// no e-invoices generated or ready to be transmitted
$this->setWarning('No e-invoices generated or ready to be transmitted.');
return false;
}
/**
* @todo Implement your own transmission of the electronic invoice to the remote endpoint URL.
*/
return true;
}
/**
* Attempts to set one e-invoice to obliterated. If needed, a remote HTTP request could be performed.
*
* @param mixed $data array or stdClass object with properties to identify the e-invoice
*
* @return void
*/
public function obliterateEInvoice($data)
{
if (is_object($data)) {
// cast to array
$data = (array) $data;
}
// allowed properties to check
$properties = [
'id' => 'einvid',
'idorder' => 'idorder',
'number' => 'number',
];
$filters = [];
foreach ($properties as $k => $v) {
if (!empty($data[$v])) {
$filters[$k] = $data[$v];
} elseif (!empty($data[$k])) {
$filters[$k] = $data[$k];
}
}
if (!$filters) {
return;
}
// access the database
$dbo = JFactory::getDbo();
// query the database to update the e-invoice record
$q = $dbo->getQuery(true)
->update($dbo->qn('#__vikbooking_einvoicing_data'))
->set($dbo->qn('obliterated') . ' = 1')
->where($dbo->qn('driverid') . ' = ' . (int) $this->getDriverId());
// iterate over the filter fields
foreach ($filters as $col => $val) {
$q->where($dbo->qn($col) . ' = ' . $dbo->q($val));
}
$dbo->setQuery($q);
$dbo->execute();
}
/**
* Forces the display of an electronic invoice. This is a 'driver action', and so it's called
* before getBookingsData() in the view. This method will not save/update records in the DB.
* This method truncates the execution of the script to read the XML data.
*
* @todo Implement your own method for displaying the content of the electronic invoice, either in XML, JSON or other formats.
*
* @return void
*/
public function viewEInvoice()
{
$einvid = JFactory::getApplication()->input->getInt('einvid', 0);
$einv_data = $this->loadEInvoiceDetails($einvid);
if (!$einv_data) {
exit('e-Invoice ID not found');
}
// force the output by reading the 'xml' property of the e-invoice record (could be any other format than XML)
header("Content-type:text/xml");
echo $einv_data['xml'];
exit;
}
/**
* Removes an electonic invoice. This is a 'driver action', and so it's called before getBookingsData() in the view.
*
* @todo Implement your own method for displaying the content of the electronic invoice, either in XML, JSON or other formats.
*
* @return void
*/
public function removeEInvoice()
{
// access the database
$dbo = JFactory::getDbo();
$einvid = JFactory::getApplication()->input->getInt('einvid', 0);
$einv_data = $this->loadEInvoiceDetails($einvid);
if (!$einv_data) {
$this->setError('e-Invoice ID not found, could not remove the invoice.');
return false;
}
// remove the requested e-invoice ID
$dbo->setQuery(
$dbo->getQuery(true)
->delete($dbo->qn('#__vikbooking_einvoicing_data'))
->where($dbo->qn('id') . ' = ' . (int) $einv_data['id'])
);
$dbo->execute();
// append info message
$this->setInfo('Electronic invoice successfully removed');
}
/**
* This method converts each booking array into a matrix with one room-booking per index.
* It also adds information about the customer and the invoices generated for each booking.
*
* @param array $records the array containing the bookings before nesting
*
* @return array
*/
private function nestBookingsData($records)
{
// access the database
$dbo = JFactory::getDbo();
// build an associative list between bookings and customers
$customers_books = [];
// to avoid heavy and extra joins, we load all customers for the returned booking ids
$all_booking_ids = array_column($records, 'id');
if ($all_booking_ids) {
// get the customer records from the current booking IDs
$dbo->setQuery(
$dbo->getQuery(true)
->select([
$dbo->qn('c') . '.*',
$dbo->qn('co.idorder'),
$dbo->qn('cy.country_name'),
$dbo->qn('cy.country_2_code'),
])
->from($dbo->qn('#__vikbooking_customers', 'c'))
->leftJoin($dbo->qn('#__vikbooking_customers_orders', 'co') . ' ON ' . $dbo->qn('c.id') . ' = ' . $dbo->qn('co.idcustomer'))
->leftJoin($dbo->qn('#__vikbooking_countries', 'cy') . ' ON ' . $dbo->qn('c.country') . ' = ' . $dbo->qn('cy.country_3_code'))
->where($dbo->qn('co.idorder') . ' IN (' . implode(', ', array_map('intval', $all_booking_ids)) . ')')
);
foreach ($dbo->loadAssocList() as $customer) {
$customers_books[$customer['idorder']] = $customer;
}
}
// nest records with multiple rooms booked inside sub-array
$bookings = [];
foreach ($records as $v) {
if (!isset($bookings[$v['id']])) {
$bookings[$v['id']] = [];
}
// to avoid heavy joins, we put the customer record onto the first nested room booked
if (!isset($v['customer']) && !$bookings[$v['id']]) {
$v['customer'] = $customers_books[$v['id']] ?? [];
}
// push room sub-array
$bookings[$v['id']][] = $v;
}
return $bookings;
}
/**
* Checks whether an e-invoice can be issued for this booking.
*
* @param array the booking array with one array-room per array value
*
* @return array to be used with list(): 0 => (int) can be invoiced, 1 => (string) reason message
*/
protected function canBookingBeInvoiced($booking)
{
if (empty($booking[0]['customer']) || empty($booking[0]['customer']['vat'])) {
// the VAT number is a mandatory field for both issuer and counterpart
return array(0, 'Missing VAT Number');
}
if (empty($booking[0]['customer']) || empty($booking[0]['customer']['country']) || empty($booking[0]['customer']['country_2_code'])) {
return array(0, 'Missing country');
}
if (empty($booking[0]['customer']) || empty($booking[0]['customer']['city'])) {
return array(0, 'Missing City');
}
if (empty($booking[0]['customer']) || empty($booking[0]['customer']['zip'])) {
return array(0, 'Missing Postal Code');
}
// check if an electronic invoice was already issued for this booking ID by this driver
if (!empty($booking[0]['einvid']) && $booking[0]['einvdriver'] == $this->getDriverId()) {
if ($booking[0]['einvsent'] > 0) {
// in this case we return -1 because an e-invoice was already issued and transmitted. We use the second key for the ID of the e-invoice
return array(-1, $booking[0]['einvid']);
}
// in this case we return -2 because an e-invoice was already issued but NOT transmitted. We use the second key for the ID of the e-invoice
return array(-2, $booking[0]['einvid']);
}
return array(1, '');
}
/**
* Loads the details of the given e-invoice ID. The given ID should not be obliterated.
*
* @param int $einvid the ID of the e-invoice
*
* @return ?array
*/
private function loadEInvoiceDetails(int $einvid)
{
// access the database
$dbo = JFactory::getDbo();
$dbo->setQuery(
$dbo->getQuery(true)
->select('*')
->from($dbo->qn('#__vikbooking_einvoicing_data'))
->where($dbo->qn('id') . ' = ' . $einvid)
->where($dbo->qn('obliterated') . ' = 0')
);
return $dbo->loadAssoc();
}
}
Conclusion
Building a custom e-invoicing driver from scratch is definitely a complex task. However, the base implementation documented above should get you the biggest part of coding done.
All that needs to be done is to implement the required settings, the generation of the e-invoice body (XML, JSON or any required format) and the remote HTTP transmission of the information, if needed or supported. Get the technical documentation ready from your local authority and start coding your custom e-invoicing implementation with VikBooking.
The above code snippet is fully documented, and the parts that most likely require a custom implementation through coding have been commented with @todo
.