[ADD] l10n_ke_edi_tremol: KE fiscal device

This module implements communication with the fiscal device for
submitting invoices to the KRA (Kenya Revenue Authority). The device
supported in this module is the Tremol G03.

-- COMMUNICATION FLOW SUMMARY --
1. "Send Invoice To Device" on account move is clicked
2. l10n_ke_action_cu_post triggers a client action, with serialised
   invoice data. (more details below)
3. The client action uses the 'post_send' function defined in javascript
   to forward the request to the /hw_proxy/l10n_ke_cu_send endpoint on
   the proxy server (more details below)
4. The proxy server wraps the serialised data with the appropriate bytes
   (for instance, a couple of a checksum bytes), and sends them to the
   device through serial communication (more details below)
5. The data returned from the fiscal device is communicated back in the
   request. The client action 'post_send' then triggers the
   'l10n_ke_cu_response' with an rpc call, with the aforementioned data
   from the fiscal device

(more details)
2. The module l10n_ke_edi_tremol inherits from the account_move model in
order to provide methods for serialising the data of the account_move
and sending it to the proxy server. Fields have been added to the
product template to define the HS Code and HS Name (data which is
required by the KRA in some circumstances). A field has also been added
to the company defining the address of the proxy server. The fields
added on the account move are populated by the data returned by the
fiscal device, this includes the device serial number, the invoice
number on the device, the URL of the invoice on the KRA web portal, and
the date/time the invoice was signed.

3. Communication between the client database and the proxy server is
defined using a client action defined in
l10n_ke_edi_tremol/static/src/js/send_invoice.js. This allows users who
aren't on-premise to communicate with the device, provided the proxy is
accessible on the network that the user is on.

4. The proxy server is an intermediary server that should be connected
to the tremol G03, and running the IOT drivers from hw_drivers. The
driver that supports communication between the proxy server and the
fiscal device has been defined in this commit inside of
hw_drivers/iot_handlers/drivers//L10nKeEDISerialDriver.py.
The proxy server can be run on the IOT box, or on odoo community by
running:
./odoo-bin addons-path=.... -d dbname --load hw_drivers --proxy-mode

** all messages are encoded/decoded with cp1251, as defined in the
protocol. The company vat code is sent along with the request to compare
that sent with that of the device. The 'serial_number' of the device is
always returned along with the request, since it is required for the
invoice details, and it is retrieved as part of the query to find the
registered VAT code on the device.

--- DATA and VIEWS ---
product_view:
adds HS Name, and HS Code on the product product and product template
form views.

report_invoice:
adds to the invoice qweb template such that a section including the
fiscal device / KRA details is included at the bottom of the invoice
when the invoice is rendered as a pdf.

res_config_settings_view:
adds makes the proxy address field editable from the config settings.

account_move_view:
add a tab for the tremol device details and the qr code on the account
move form view. The KRA invoice number is also added as an optional
field on the account move tree view, and the invoice search view is
inherited to make this field searchable too.

(l10n_ke) account_tax_report_data, account_tax_template_data:
The tax report is defined for Kenya, and tax tags that link to the lines
on this tax report are defined on the existing taxes. This allows the
classification of the tax, between zero-rated and exempt, during the
serialisation.

closes odoo/odoo#106654

Task-id: 2950308
X-original-commit: b0be9e074a1ecf1f6de7a03c71df0c63868501e3
Signed-off-by: Josse Colpaert <jco@odoo.com>
Signed-off-by: Daniel Kosky (dako) <dako@odoo.com>
This commit is contained in:
dbkosky
2022-12-19 13:38:03 +01:00
committed by dbkosky
parent d4c865199e
commit b18caa7771
18 changed files with 1085 additions and 24 deletions
@@ -0,0 +1,228 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
import serial
import time
import struct
import json
from functools import reduce
from odoo import http
from odoo.addons.hw_drivers.iot_handlers.drivers.SerialBaseDriver import SerialDriver, SerialProtocol, serial_connection
from odoo.addons.hw_drivers.main import iot_devices
_logger = logging.getLogger(__name__)
TremolG03Protocol = SerialProtocol(
name='Tremol G03',
baudrate=115200,
bytesize=serial.EIGHTBITS,
stopbits=serial.STOPBITS_ONE,
parity=serial.PARITY_NONE,
timeout=3,
writeTimeout=0.2,
measureRegexp=None,
statusRegexp=None,
commandTerminator=b'',
commandDelay=0.4,
measureDelay=0.4,
newMeasureDelay=0.2,
measureCommand=b'',
emptyAnswerValid=False,
)
STX = 0x02
ETX = 0x0A
ACK = 0x06
NACK = 0x15
FD_ERRORS = {
0x30: 'OK',
0x32: 'Registers overflow',
0x33: 'Clock failure or incorrect date & time',
0x34: 'Opened fiscal receipt',
0x39: 'Incorrect password',
0x3b: '24 hours block - missing Z report',
0x3d: 'Interrupt power supply in fiscal receipt (one time until status is read)',
0x3e: 'Overflow EJ',
0x3f: 'Insufficient conditions',
}
COMMAND_ERRORS = {
0x30: 'OK',
0x31: 'Invalid command',
0x32: 'Illegal command',
0x33: 'Z daily report is not zero',
0x34: 'Syntax error',
0x35: 'Input registers orverflow',
0x36: 'Zero input registers',
0x37: 'Unavailable transaction for correction',
0x38: 'Insufficient amount on hand',
}
class TremolG03Driver(SerialDriver):
"""Driver for the Kenyan Tremol G03 fiscal device."""
_protocol = TremolG03Protocol
def __init__(self, identifier, device):
super().__init__(identifier, device)
self.device_type = 'fiscal_data_module'
self.message_number = 0
@classmethod
def get_default_device(cls):
fiscal_devices = list(filter(lambda d: iot_devices[d].device_type == 'fiscal_data_module', iot_devices))
return len(fiscal_devices) and iot_devices[fiscal_devices[0]]
@classmethod
def supported(cls, device):
"""Checks whether the device, which port info is passed as argument, is supported by the driver.
:param device: path to the device
:type device: str
:return: whether the device is supported by the driver
:rtype: bool
"""
protocol = cls._protocol
try:
protocol = cls._protocol
with serial_connection(device['identifier'], protocol) as connection:
connection.write(b'\x09')
time.sleep(protocol.commandDelay)
response = connection.read(1)
if response == b'\x40':
return True
except serial.serialutil.SerialTimeoutException:
pass
except Exception:
_logger.exception('Error while probing %s with protocol %s', device, protocol.name)
# ----------------
# HELPERS
# ----------------
@staticmethod
def generate_checksum(message):
""" Generate the checksum bytes for the bytes provided.
:param message: bytes representing the part of the message from which the checksum is calculated
:returns: two checksum bytes calculated from the message
This checksum is calculated as:
1) XOR of all bytes of the bytes
2) Conversion of the one XOR byte into the two bytes of the checksum by
adding 30h to each half-byte of the XOR
eg. to_check = \x12\x23\x34\x45\x56
XOR of all bytes in to_check = \x16
checksum generated as \x16 -> \x31 \x36
"""
xor = reduce(lambda a, b: a ^ b, message)
return bytes([(xor >> 4) + 0x30, (xor & 0xf) + 0x30])
# ----------------
# COMMUNICATION
# ----------------
def send(self, msgs):
""" Send and receive messages to/from the fiscal device over serial connection
Generate the wrapped message from the msgs and send them to the device.
The wrapping contains the <STX> (starting byte) <LEN> (length byte)
and <NBL> (message number byte) at the start and two <CS> (checksum
bytes), and the <ETX> line-feed byte at the end.
:param msgs: A list of byte strings representing the <CMD> and <DATA>
components of the serial message.
:return: A list of the responses (if any) from the device. If the
response is an ack, it wont be part of this list.
"""
with self._device_lock:
replies = []
for msg in msgs:
self.message_number += 1
core_message = struct.pack('BB%ds' % (len(msg)), len(msg) + 34, self.message_number + 32, msg)
request = struct.pack('B%ds2sB' % (len(core_message)), STX, core_message, self.generate_checksum(core_message), ETX)
time.sleep(self._protocol.commandDelay)
self._connection.write(request)
time.sleep(self._protocol.measureDelay)
response = self._connection.read_all()
if not response:
self.data['status'] = "no response"
_logger.error("Sent request: %s,\n Received no response", request)
self.abort_post()
break
if response[0] == ACK:
# In the case where either byte is not 0x30, there has been an error
if response[2] != 0x30 or response[3] != 0x30:
self.data['status'] = response[2:4].decode('cp1251')
_logger.error(
"Sent request: %s,\n Received fiscal device error: %s \n Received command error: %s",
request, FD_ERRORS.get(response[2], 'Unknown fiscal device error'),
COMMAND_ERRORS.get(response[3], 'Unknown command error'),
)
self.abort_post()
break
replies.append('')
elif response[0] == NACK:
self.data['status'] = "Received NACK"
_logger.error("Sent request: %s,\n Received NACK \x15", request)
self.abort_post()
break
elif response[0] == 0x02:
self.data['status'] = "ok"
size = response[1] - 35
reply = response[4:4 + size]
replies.append(reply.decode('cp1251'))
return {'replies': replies, 'status': self.data['status']}
def abort_post(self):
""" Cancel the posting of the invoice
In the event of an error, it is better to try to cancel the posting of
the invoice, since the state of the invoice on the device will remain
open otherwise, blocking further invoices being sent.
"""
self.message_number += 1
abort = struct.pack('BBB', 37, self.message_number + 32, 0x39)
request = struct.pack('B3s2sB', STX, abort, self.generate_checksum(abort), ETX)
time.sleep(self._protocol.commandDelay)
self._connection.write(request)
time.sleep(self._protocol.measureDelay)
response = self._connection.read_all()
if response and response[0] == 0x02:
self.data['status'] += "\n The invoice could not be cancelled."
else:
self.data['status'] += "\n The invoice was successfully cancelled"
class TremolG03Controller(http.Controller):
@http.route('/hw_proxy/l10n_ke_cu_send', type='http', auth='none', cors='*', csrf=False, save_session=False, methods=['POST'])
def l10n_ke_cu_send(self, messages, company_vat):
""" Posts the messages sent to this endpoint to the fiscal device connected to the server
:param messages: The messages (consisting of <CMD> and <DATA>) to
send to the fiscal device.
:returns: Dictionary containing a list of the responses from
fiscal device and status of the fiscal device.
"""
device = TremolG03Driver.get_default_device()
if device:
# First run the command to get the fiscal device numbers
device_numbers = device.send([b'\x60'])
# If the vat doesn't match, abort
if device_numbers['status'] != 'ok':
return device_numbers
serial_number, device_vat, _dummy = device_numbers['replies'][0].split(';')
if device_vat != company_vat:
return json.dumps({'status': 'The company vat number does not match that of the device'})
messages = json.loads(messages)
resp = json.dumps({**device.send([msg.encode('cp1251') for msg in messages]), 'serial_number': serial_number})
return resp
else:
return json.dumps({'status': 'The fiscal device is not connected to the proxy server'})
+1
View File
@@ -16,6 +16,7 @@ This provides a base chart of accounts and taxes template for use in Odoo.
'data/account.account.template.csv',
'data/l10n_ke_chart_data.xml',
'data/account_tax_group_data.xml',
'data/account_tax_report_data.xml',
'data/account_tax_template_data.xml',
'data/account_fiscal_position_template.xml',
'data/account_chart_template_configure_data.xml',
@@ -0,0 +1,206 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="tax_report_ke" model="account.report">
<field name="name">Tax Report</field>
<field name="root_report_id" ref="account.generic_tax_report"/>
<field name="country_id" ref="base.ke"/>
<field name="filter_fiscal_position" eval="True"/>
<field name="availability_condition">country</field>
<field name="column_ids">
<record id="tax_report_base_column" model="account.report.column">
<field name="name">Base</field>
<field name="expression_label">base</field>
</record>
<record id="tax_report_tax_column" model="account.report.column">
<field name="name">VAT</field>
<field name="expression_label">tax</field>
</record>
</field>
<field name="line_ids">
<record id="tax_report_line_general_rate_sales" model="account.report.line">
<field name="name">1. Taxable Sales (General Rate 16%)</field>
<field name="sequence">1</field>
<field name="code">box_1</field>
<field name="expression_ids">
<record id="tax_report_general_rate_sales_base_tag" model="account.report.expression">
<field name="label">base</field>
<field name="engine">tax_tags</field>
<field name="formula">16% Sales Base</field>
</record>
<record id="tax_report_general_rate_sales_tax_tag" model="account.report.expression">
<field name="label">tax</field>
<field name="engine">tax_tags</field>
<field name="formula">16% Sales Tax</field>
</record>
</field>
</record>
<record id="tax_report_line_other_rate_sales" model="account.report.line">
<field name="name">2. Taxable Sales (Other Rate 8%)</field>
<field name="sequence">2</field>
<field name="code">box_2</field>
<field name="expression_ids">
<record id="tax_report_other_rate_sales_base_tag" model="account.report.expression">
<field name="label">base</field>
<field name="engine">tax_tags</field>
<field name="formula">8% Sales Base</field>
</record>
<record id="tax_report_other_rate_sales_tax_tag" model="account.report.expression">
<field name="label">tax</field>
<field name="engine">tax_tags</field>
<field name="formula">8% Sales Tax</field>
</record>
</field>
</record>
<record id="tax_report_line_zero_rated_sales" model="account.report.line">
<field name="name">3. Sales (Zero Rated 0%)</field>
<field name="sequence">3</field>
<field name="code">box_3</field>
<field name="expression_ids">
<record id="tax_report_zero_rated_sales_base_tag" model="account.report.expression">
<field name="label">base</field>
<field name="engine">tax_tags</field>
<field name="formula">Zero Rated Sales Base</field>
</record>
</field>
</record>
<record id="tax_report_line_exempt_sales" model="account.report.line">
<field name="name">4. Sales (Exempt)</field>
<field name="sequence">4</field>
<field name="code">box_4</field>
<field name="expression_ids">
<record id="tax_report_exempt_sales_base_tag" model="account.report.expression">
<field name="label">base</field>
<field name="engine">tax_tags</field>
<field name="formula">Exempt Sales Base</field>
</record>
</field>
</record>
<record id="tax_report_line_total_sales" model="account.report.line">
<field name="name">5. Total Sales</field>
<field name="sequence">5</field>
<field name="code">box_5</field>
<field name="expression_ids">
<record id="tax_report_total_sales_base_tag" model="account.report.expression">
<field name="label">base</field>
<field name="engine">aggregation</field>
<field name="formula">box_1.base + box_2.base + box_3.base + box_4.base</field>
</record>
<record id="tax_report_total_sales_tax_tag" model="account.report.expression">
<field name="label">tax</field>
<field name="engine">aggregation</field>
<field name="formula">box_1.tax + box_2.tax</field>
</record>
</field>
</record>
<record id="tax_report_line_output_vat" model="account.report.line">
<field name="name">6. Total Output VAT</field>
<field name="sequence">6</field>
<field name="code">box_6</field>
<field name="expression_ids">
<record id="tax_report_output_vat_base_tag" model="account.report.expression">
<field name="label">base</field>
<field name="engine">aggregation</field>
<field name="formula">box_1.base + box_2.base + box_3.base</field>
</record>
<record id="tax_report_output_vat_tax_tag" model="account.report.expression">
<field name="label">tax</field>
<field name="engine">aggregation</field>
<field name="formula">box_1.tax + box_2.tax</field>
</record>
</field>
</record>
<record id="tax_report_line_general_rate_purchases" model="account.report.line">
<field name="name">7. Taxable Purchases (General Rate 16%)</field>
<field name="sequence">7</field>
<field name="code">box_7</field>
<field name="expression_ids">
<record id="tax_report_general_rate_purchases_base_tag" model="account.report.expression">
<field name="label">base</field>
<field name="engine">tax_tags</field>
<field name="formula">16% Purchases Base</field>
</record>
<record id="tax_report_general_rate_purchases_tax_tag" model="account.report.expression">
<field name="label">tax</field>
<field name="engine">tax_tags</field>
<field name="formula">16% Purchases Tax</field>
</record>
</field>
</record>
<record id="tax_report_line_other_rate_purchases" model="account.report.line">
<field name="name">8. Taxable Purchases (Other Rate 8%)</field>
<field name="sequence">8</field>
<field name="code">box_8</field>
<field name="expression_ids">
<record id="tax_report_other_rate_purchases_base_tag" model="account.report.expression">
<field name="label">base</field>
<field name="engine">tax_tags</field>
<field name="formula">8% purchases Base</field>
</record>
<record id="tax_report_other_rate_purchases_tax_tag" model="account.report.expression">
<field name="label">tax</field>
<field name="engine">tax_tags</field>
<field name="formula">8% Purchases Tax</field>
</record>
</field>
</record>
<record id="tax_report_line_zero_rated_purchases" model="account.report.line">
<field name="name">9. Purchases (Zero Rated 0%)</field>
<field name="sequence">9</field>
<field name="code">box_9</field>
<field name="expression_ids">
<record id="tax_report_zero_rated_purchases_base_tag" model="account.report.expression">
<field name="label">base</field>
<field name="engine">tax_tags</field>
<field name="formula">Zero Rated Purchases Base</field>
</record>
</field>
</record>
<record id="tax_report_line_exempt_purchases" model="account.report.line">
<field name="name">10. Purchases (Exempt)</field>
<field name="sequence">10</field>
<field name="code">box_10</field>
<field name="expression_ids">
<record id="tax_report_exempt_purchases_base_tag" model="account.report.expression">
<field name="label">base</field>
<field name="engine">tax_tags</field>
<field name="formula">Exempt Purchases Base</field>
</record>
</field>
</record>
<record id="tax_report_line_total_purchases" model="account.report.line">
<field name="name">11. Total Purchases</field>
<field name="sequence">11</field>
<field name="code">box_11</field>
<field name="expression_ids">
<record id="tax_report_total_purchases_base_tag" model="account.report.expression">
<field name="label">base</field>
<field name="engine">aggregation</field>
<field name="formula">box_7.base + box_8.base + box_9.base + box_10.base</field>
</record>
<record id="tax_report_total_purchases_tax_tag" model="account.report.expression">
<field name="label">tax</field>
<field name="engine">aggregation</field>
<field name="formula">box_7.tax + box_8.tax</field>
</record>
</field>
</record>
<record id="tax_report_line_input_vat" model="account.report.line">
<field name="name">12. Total Input VAT</field>
<field name="sequence">12</field>
<field name="code">box_12</field>
<field name="expression_ids">
<record id="tax_report_input_vat_base_tag" model="account.report.expression">
<field name="label">base</field>
<field name="engine">aggregation</field>
<field name="formula">box_7.base + box_8.base + box_9.base</field>
</record>
<record id="tax_report_input_vat_tax_tag" model="account.report.expression">
<field name="label">tax</field>
<field name="engine">aggregation</field>
<field name="formula">box_7.tax + box_8.tax</field>
</record>
</field>
</record>
</field>
</record>
</odoo>
+112 -24
View File
@@ -9,17 +9,27 @@
<field name="amount">16</field>
<field name="tax_group_id" ref="tax_group_16"/>
<field name="invoice_repartition_line_ids" eval="[(5, 0, 0),
(0,0, {'repartition_type': 'base'}),
(0,0, {
'repartition_type': 'base',
'plus_report_expression_ids': [ref('tax_report_general_rate_sales_base_tag')],
}),
(0,0, {
'factor_percent': 100,
'repartition_type': 'tax',
'account_id': ref('ke2200'),
'plus_report_expression_ids': [ref('tax_report_general_rate_sales_tax_tag')],
}),
]"/>
<field name="refund_repartition_line_ids" eval="[(5, 0, 0),
(0,0, {'repartition_type': 'base'}),
(0,0, {
'repartition_type': 'base',
'minus_report_expression_ids': [ref('tax_report_general_rate_sales_base_tag')],
}),
(0,0, {
'factor_percent': 100,
'repartition_type': 'tax',
'account_id': ref('ke2200'),
'minus_report_expression_ids': [ref('tax_report_general_rate_sales_tax_tag')],
}),
]"/>
</record>
@@ -32,17 +42,27 @@
<field name="amount">8</field>
<field name="tax_group_id" ref="tax_group_8"/>
<field name="invoice_repartition_line_ids" eval="[(5, 0, 0),
(0,0, {'repartition_type': 'base'}),
(0,0, {
'repartition_type': 'base',
'plus_report_expression_ids': [ref('tax_report_other_rate_sales_base_tag')],
}),
(0,0, {
'factor_percent': 100,
'repartition_type': 'tax',
'account_id': ref('ke2200'),
'plus_report_expression_ids': [ref('tax_report_other_rate_sales_tax_tag')],
}),
]"/>
<field name="refund_repartition_line_ids" eval="[(5, 0, 0),
(0,0, {'repartition_type': 'base'}),
(0,0, {
'repartition_type': 'base',
'minus_report_expression_ids': [ref('tax_report_other_rate_sales_base_tag')],
}),
(0,0, {
'factor_percent': 100,
'repartition_type': 'tax',
'account_id': ref('ke2200'),
'minus_report_expression_ids': [ref('tax_report_other_rate_sales_tax_tag')],
}),
]"/>
</record>
@@ -55,12 +75,24 @@
<field name="amount">0</field>
<field name="tax_group_id" ref="tax_group_0"/>
<field name="invoice_repartition_line_ids" eval="[(5, 0, 0),
(0,0, {'repartition_type': 'base'}),
(0,0, {'repartition_type': 'tax'}),
(0,0, {
'repartition_type': 'base',
'plus_report_expression_ids': [ref('tax_report_zero_rated_sales_base_tag')],
}),
(0,0, {
'factor_percent': 100,
'repartition_type': 'tax',
}),
]"/>
<field name="refund_repartition_line_ids" eval="[(5, 0, 0),
(0,0, {'repartition_type': 'base'}),
(0,0, {'repartition_type': 'tax'}),
(0,0, {
'repartition_type': 'base',
'minus_report_expression_ids': [ref('tax_report_zero_rated_sales_base_tag')],
}),
(0,0, {
'factor_percent': 100,
'repartition_type': 'tax',
}),
]"/>
</record>
<record id="STEX" model="account.tax.template">
@@ -72,12 +104,24 @@
<field name="amount">0</field>
<field name="tax_group_id" ref="tax_group_0"/>
<field name="invoice_repartition_line_ids" eval="[(5, 0, 0),
(0,0, {'repartition_type': 'base'}),
(0,0, {'repartition_type': 'tax'}),
(0,0, {
'repartition_type': 'base',
'plus_report_expression_ids': [ref('tax_report_exempt_sales_base_tag')],
}),
(0,0, {
'factor_percent': 100,
'repartition_type': 'tax',
}),
]"/>
<field name="refund_repartition_line_ids" eval="[(5, 0, 0),
(0,0, {'repartition_type': 'base'}),
(0,0, {'repartition_type': 'tax'}),
(0,0, {
'repartition_type': 'base',
'minus_report_expression_ids': [ref('tax_report_exempt_sales_base_tag')],
}),
(0,0, {
'factor_percent': 100,
'repartition_type': 'tax',
}),
]"/>
</record>
<record id="SWT3" model="account.tax.template">
@@ -273,17 +317,27 @@
<field name="amount">16</field>
<field name="tax_group_id" ref="tax_group_16"/>
<field name="invoice_repartition_line_ids" eval="[(5, 0, 0),
(0,0, {'repartition_type': 'base'}),
(0,0, {
'repartition_type': 'base',
'plus_report_expression_ids': [ref('tax_report_general_rate_purchases_base_tag')],
}),
(0,0, {
'factor_percent': 100,
'repartition_type': 'tax',
'account_id': ref('ke1110'),
'plus_report_expression_ids': [ref('tax_report_general_rate_purchases_tax_tag')],
}),
]"/>
<field name="refund_repartition_line_ids" eval="[(5, 0, 0),
(0,0, {'repartition_type': 'base'}),
(0,0, {
'repartition_type': 'base',
'minus_report_expression_ids': [ref('tax_report_general_rate_purchases_base_tag')],
}),
(0,0, {
'factor_percent': 100,
'repartition_type': 'tax',
'account_id': ref('ke1110'),
'minus_report_expression_ids': [ref('tax_report_general_rate_purchases_tax_tag')],
}),
]"/>
</record>
@@ -296,17 +350,27 @@
<field name="amount">8</field>
<field name="tax_group_id" ref="tax_group_8"/>
<field name="invoice_repartition_line_ids" eval="[(5, 0, 0),
(0,0, {'repartition_type': 'base'}),
(0,0, {
'repartition_type': 'base',
'plus_report_expression_ids': [ref('tax_report_other_rate_purchases_base_tag')],
}),
(0,0, {
'factor_percent': 100,
'repartition_type': 'tax',
'account_id': ref('ke1110'),
'plus_report_expression_ids': [ref('tax_report_other_rate_purchases_tax_tag')],
}),
]"/>
<field name="refund_repartition_line_ids" eval="[(5, 0, 0),
(0,0, {'repartition_type': 'base'}),
(0,0, {
'repartition_type': 'base',
'minus_report_expression_ids': [ref('tax_report_other_rate_purchases_base_tag')],
}),
(0,0, {
'factor_percent': 100,
'repartition_type': 'tax',
'account_id': ref('ke1110'),
'minus_report_expression_ids': [ref('tax_report_other_rate_purchases_tax_tag')],
}),
]"/>
</record>
@@ -319,12 +383,24 @@
<field name="amount">0</field>
<field name="tax_group_id" ref="tax_group_0"/>
<field name="invoice_repartition_line_ids" eval="[(5, 0, 0),
(0,0, {'repartition_type': 'base'}),
(0,0, {'repartition_type': 'tax'}),
(0,0, {
'repartition_type': 'base',
'plus_report_expression_ids': [ref('tax_report_zero_rated_purchases_base_tag')],
}),
(0,0, {
'factor_percent': 100,
'repartition_type': 'tax',
}),
]"/>
<field name="refund_repartition_line_ids" eval="[(5, 0, 0),
(0,0, {'repartition_type': 'base'}),
(0,0, {'repartition_type': 'tax'}),
(0,0, {
'repartition_type': 'base',
'minus_report_expression_ids': [ref('tax_report_zero_rated_purchases_base_tag')],
}),
(0,0, {
'factor_percent': 100,
'repartition_type': 'tax',
}),
]"/>
</record>
<record id="PTEX" model="account.tax.template">
@@ -336,12 +412,24 @@
<field name="amount">0</field>
<field name="tax_group_id" ref="tax_group_0"/>
<field name="invoice_repartition_line_ids" eval="[(5, 0, 0),
(0,0, {'repartition_type': 'base'}),
(0,0, {'repartition_type': 'tax'}),
(0,0, {
'repartition_type': 'base',
'plus_report_expression_ids': [ref('tax_report_exempt_purchases_base_tag')],
}),
(0,0, {
'factor_percent': 100,
'repartition_type': 'tax',
}),
]"/>
<field name="refund_repartition_line_ids" eval="[(5, 0, 0),
(0,0, {'repartition_type': 'base'}),
(0,0, {'repartition_type': 'tax'}),
(0,0, {
'repartition_type': 'base',
'minus_report_expression_ids': [ref('tax_report_exempt_purchases_base_tag')],
}),
(0,0, {
'factor_percent': 100,
'repartition_type': 'tax',
}),
]"/>
</record>
<record id="PWT3" model="account.tax.template">
+1
View File
@@ -0,0 +1 @@
from . import models
+27
View File
@@ -0,0 +1,27 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
{
'name': "Kenya Tremol Device EDI Integration",
'summary': """
Kenya Tremol Device EDI Integration
""",
'description': """
This module integrates with the Kenyan G03 Tremol control unit device to the KRA through TIMS.
""",
'author': 'Odoo',
'category': 'Accounting/Localizations/EDI',
'version': '1.0',
'license': 'LGPL-3',
'depends': ['l10n_ke'],
'data': [
'views/account_move_view.xml',
'views/product_view.xml',
'views/report_invoice.xml',
'views/res_config_settings_view.xml',
'views/res_partner_views.xml',
],
'assets': {
'web.assets_backend': [
'l10n_ke_edi_tremol/static/src/js/send_invoice.js',
],
},
}
@@ -0,0 +1,5 @@
from . import account_move
from . import product
from . import res_company
from . import res_config_settings
from . import res_partner
@@ -0,0 +1,228 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
import json
import re
from datetime import datetime
from odoo import models, fields, _
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class AccountMove(models.Model):
_inherit = 'account.move'
l10n_ke_cu_datetime = fields.Datetime(string='CU Signing Date and Time', copy=False)
l10n_ke_cu_serial_number = fields.Char(string='CU Serial Number', copy=False)
l10n_ke_cu_invoice_number = fields.Char(string='CU Invoice Number', copy=False)
l10n_ke_cu_qrcode = fields.Char(string='CU QR Code', copy=False)
# -------------------------------------------------------------------------
# HELPERS
# -------------------------------------------------------------------------
def _l10n_ke_fmt(self, string, length, ljust=True):
""" Function for common formatting behaviour
:param string: string to be formatted/encoded
:param length: integer length to justify (if enabled), and then truncate the string to
:param ljust: boolean representing whether the string should be justified
:returns: byte-string justified/truncated, with all non-alphanumeric characters removed
"""
if not string:
string = ''
return re.sub('[^A-Za-z0-9 ]+', '', str(string)).encode('cp1251').ljust(length if ljust else 0)[:length]
# -------------------------------------------------------------------------
# CHECKS
# -------------------------------------------------------------------------
def _l10n_ke_validate_move(self):
""" Returns list of errors related to misconfigurations
Find misconfigurations on the move, the lines of the move, and the
taxes on those lines that would result in rejection by the KRA.
"""
self.ensure_one()
errors = []
# The credit note should refer to the control unit number (receipt number) of the original
# invoice to which it relates.
if self.move_type == 'out_refund' and not self.reversed_entry_id.l10n_ke_cu_invoice_number:
errors.append(_("This credit note must reference the previous invoice, and this previous invoice must have already been submitted."))
for line in self.invoice_line_ids.filtered(lambda l: l.display_type == 'product'):
if not line.tax_ids or len(line.tax_ids) > 1:
errors.append(_("On line %s, you must select one and only one tax.", line.name))
else:
if line.tax_ids.amount == 0 and not (line.product_id and line.product_id.l10n_ke_hsn_code and line.product_id.l10n_ke_hsn_name):
errors.append(_("On line %s, a product with a HS Code and HS Name must be selected, since the tax is 0%% or exempt.", line.name))
for tax in self.invoice_line_ids.tax_ids:
if tax.amount not in (16, 8, 0):
errors.append(_("Tax '%s' is used, but only taxes of 16%%, 8%%, 0%% or Exempt can be sent. Please reconfigure or change the tax.", tax.name))
return errors
# -------------------------------------------------------------------------
# SERIALISERS
# -------------------------------------------------------------------------
def _l10n_ke_cu_open_invoice_message(self):
""" Serialise the required fields for opening an invoice
:returns: a list containing one byte-string representing the <CMD> and
<DATA> of the message sent to the fiscal device.
"""
headquarter_address = (self.commercial_partner_id.street or '') + (self.commercial_partner_id.street2 or '')
customer_address = (self.partner_id.street or '') + (self.partner_id.street2 or '')
postcode_and_city = (self.partner_id.zip or '') + '' + (self.partner_id.city or '')
invoice_elements = [
b'1', # Reserved - 1 symbol with value '1'
b' 0', # Reserved - 6 symbols with value ‘ 0’
b'0', # Reserved - 1 symbol with value '0'
b'1' if self.move_type == 'out_invoice' else b'A', # 1 symbol with value '1' (new invoice), 'A' (credit note), or '@' (debit note)
self._l10n_ke_fmt(self.commercial_partner_id.name, 30), # 30 symbols for Company name
self._l10n_ke_fmt(self.commercial_partner_id.vat, 14), # 14 Symbols for the client PIN number
self._l10n_ke_fmt(headquarter_address, 30), # 30 Symbols for customer headquarters
self._l10n_ke_fmt(customer_address, 30), # 30 Symbols for the address
self._l10n_ke_fmt(postcode_and_city, 30), # 30 symbols for the customer post code and city
self._l10n_ke_fmt('', 30), # 30 symbols for the exemption number
]
if self.move_type == 'out_refund':
invoice_elements.append(self._l10n_ke_fmt(self.reversed_entry_id.l10n_ke_cu_invoice_number, 19)), # 19 symbols for related invoice number
invoice_elements.append(re.sub('[^A-Za-z0-9 ]+', '', self.name)[-15:].ljust(15).encode('cp1251')) # 15 symbols for trader system invoice number
# Command: Open fiscal record (0x30)
return [b'\x30' + b';'.join(invoice_elements)]
def _l10n_ke_cu_lines_messages(self):
""" Serialise the data of each line on the invoice
This function transforms the lines in order to handle the differences
between the KRA expected data and the lines in odoo.
If a discount line (as a negative line) has been added to the invoice
lines, find a suitable line/lines to distribute the discount accross
:returns: List of byte-strings representing each command <CMD> and the
<DATA> of the line, which will be sent to the fiscal device
in order to add a line to the opened invoice.
"""
def is_discount_line(line):
return line.price_unit < 0.0
def is_candidate(discount_line, other_line):
""" If the of one line match those of the discount line, the discount can be distributed accross that line """
discount_taxes = discount_line.tax_ids.flatten_taxes_hierarchy()
other_line_taxes = other_line.tax_ids.flatten_taxes_hierarchy()
return set(discount_taxes.ids) == set(other_line_taxes.ids)
lines = self.invoice_line_ids.filtered(lambda l: l.display_type == 'product' and l.quantity and l.price_total)
# The device expects all monetary values in Kenyan Shillings
if self.currency_id == self.company_id.currency_id:
currency_rate = 1
else:
currency_rate = abs(self.invoice_line_ids[0].balance / self.invoice_line_ids[0].price_subtotal)
discount_dict = {line.id: line.discount for line in lines if line.price_total > 0}
for line in lines:
if not is_discount_line(line):
continue
# Search for non-discount lines
candidate_vals_list = [l for l in lines if not is_discount_line(l) and is_candidate(l, line)]
candidate_vals_list = sorted(candidate_vals_list, key=lambda x: x.price_unit * x.quantity, reverse=True)
line_to_discount = abs(line.price_unit * line.quantity)
for candidate in candidate_vals_list:
still_to_discount = abs(candidate.price_unit * candidate.quantity * (100.0 - discount_dict[candidate.id]) / 100.0)
if line_to_discount >= still_to_discount:
discount_dict[candidate.id] = 100.0
line_to_discount -= still_to_discount
else:
rest_to_discount = abs((line_to_discount / (candidate.price_unit * candidate.quantity)) * 100.0)
discount_dict[candidate.id] += rest_to_discount
break
vat_class = {16.0: 'A', 8.0: 'B'}
msgs = []
for line in self.invoice_line_ids.filtered(lambda l: l.display_type == 'product' and l.quantity and l.price_total > 0 and not discount_dict.get(l.id) >= 100):
# Here we use the original discount of the line, since it the distributed discount has not been applied in the price_total
price = round(line.price_total / line.quantity * 100 / (100 - line.discount), 2) * currency_rate
percentage = line.tax_ids[0].amount
# Letter to classify tax, 0% taxes are handled conditionally, as the tax can be zero-rated or exempt
letter = ''
if percentage in vat_class:
letter = vat_class[percentage]
else:
report_line_ids = line.tax_ids.invoice_repartition_line_ids.tag_ids._get_related_tax_report_expressions().report_line_id.ids
try:
exempt_report_line = self.env.ref('l10n_ke.tax_report_line_exempt_sales')
except ValueError:
raise UserError(_("Tax exempt report line cannot be found, please update the l10n_ke module."))
letter = 'E' if exempt_report_line.id in report_line_ids else 'C'
uom = line.product_uom_id and line.product_uom_id.name or ''
hscode = re.sub('[^0-9.]+', '', line.product_id.l10n_ke_hsn_code)[:10].ljust(10).encode('cp1251') if letter not in ('A', 'B') else b''.ljust(10)
hsname = re.sub('[^0-9.]+', '', line.product_id.l10n_ke_hsn_name)[:20].ljust(20).encode('cp1251') if letter not in ('A', 'B') else b''.ljust(20)
line_data = b';'.join([
self._l10n_ke_fmt(line.name, 36), # 36 symbols for the article's name
self._l10n_ke_fmt(letter, 1), # 1 symbol for article's vat class ('A', 'B', 'C', 'D', or 'E')
str(price)[:13].encode('cp1251'), # 1 to 13 symbols for article's price
self._l10n_ke_fmt(uom, 3), # 3 symbols for unit of measure
hscode, # 10 symbols for HS code in the format xxxx.xx.xx (can be empty)
hsname, # 20 symbols for the HS name (can be empty)
str(percentage).encode('cp1251')[:5] # up to 5 symbols for vat rate
])
# 1 to 10 symbols for quantity
line_data += b'*' + str(line.quantity).encode('cp1251')[:10]
if discount_dict.get(line.id):
# 1 to 7 symbols for percentage of discount/addition
discount_sign = b'-' if discount_dict[line.id] > 0 else b'+'
discount = discount_sign + str(abs(discount_dict[line.id])).encode('cp1251')[:6]
line_data += b',' + discount + b'%'
# Command: Sale of article (0x31)
msgs += [b'\x31' + line_data]
return msgs
def _l10n_ke_get_cu_messages(self):
self.ensure_one()
msgs = self._l10n_ke_cu_open_invoice_message()
msgs += self._l10n_ke_cu_lines_messages()
# Command: Close fiscal reciept (0x38)
msgs += [b'\x38']
# Command: Read date and time (0x68)
msgs += [b'\x68']
return msgs
# -------------------------------------------------------------------------
# POST COMMANDS / RECEIVE DATA
# -------------------------------------------------------------------------
def l10n_ke_action_cu_post(self):
self.ensure_one()
# Check the configuration of the invoice
errors = self._l10n_ke_validate_move()
if errors:
raise UserError(_("Invalid invoice configuration:\n\n%s") % '\n'.join(errors))
return {
'type': 'ir.actions.client',
'tag': 'post_send',
'params': {
'messages': json.dumps([m.decode('cp1251') for m in self._l10n_ke_get_cu_messages()]),
'move_id': self.id,
'proxy_address': self.company_id.l10n_ke_cu_proxy_address,
'company_vat': self.company_id.vat,
}
}
def l10n_ke_cu_response(self, response):
move = self.browse(response['move_id'])
replies = [msg for msg in response['replies']]
move.update({
'l10n_ke_cu_serial_number': response['serial_number'],
'l10n_ke_cu_invoice_number': replies[-2].split(';')[0],
'l10n_ke_cu_qrcode': replies[-2].split(';')[1].strip(),
'l10n_ke_cu_datetime': datetime.strptime(replies[-1], '%d-%m-%Y %H:%M'),
})
@@ -0,0 +1,32 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
class ProductTemplate(models.Model):
_inherit = "product.template"
l10n_ke_hsn_code = fields.Char(
string='HSN code',
help="Product code needed in case of not 16%. ",
)
l10n_ke_hsn_name = fields.Char(
string='HSN description',
help="Product code description needed in case of not 16%. ",
)
class ProductProduct(models.Model):
_inherit = "product.product"
l10n_ke_hsn_code = fields.Char(
string='HSN code',
related='product_tmpl_id.l10n_ke_hsn_code',
help="Product code needed in case of not 16%. ",
readonly=False,
)
l10n_ke_hsn_name = fields.Char(
string='HSN description',
related='product_tmpl_id.l10n_ke_hsn_name',
help="Product code description needed in case of not 16%. ",
readonly=False,
)
@@ -0,0 +1,14 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
class ResCompany(models.Model):
_inherit = 'res.company'
l10n_ke_cu_proxy_address = fields.Char(
default="http://localhost:8069",
string='Control Unit Proxy Address',
help='The address of the proxy server for the control unit.',
)
@@ -0,0 +1,10 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
l10n_ke_cu_proxy_address = fields.Char(related='company_id.l10n_ke_cu_proxy_address', readonly=False)
@@ -0,0 +1,16 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
class ResPartner(models.Model):
_inherit = 'res.partner'
l10n_ke_exemption_number = fields.Char(
string='Exemption Number',
help='The exemption number of the partner. Provided by the Kenyan government.',
)
def _commercial_fields(self):
return super()._commercial_fields() + ['l10n_ke_exemption_number']
@@ -0,0 +1,34 @@
odoo.define('l10n_ke_edi_tremol.action_post_send_invoice', function (require) {
const core = require('web.core');
const ajax = require('web.ajax');
const Dialog = require('web.Dialog');
var rpc = require('web.rpc');
var _t = core._t;
async function post_send(parent, {params}) {
const move_id = params.move_id;
await ajax.post(params.proxy_address + '/hw_proxy/l10n_ke_cu_send', params).then(function (res) {
const res_obj = JSON.parse(res);
if (res_obj.status != "ok") {
Dialog.alert(this, "Posting the invoice has failed, with the message: \n" + res_obj.status);
} else {
rpc.query({
model: 'account.move',
method: 'l10n_ke_cu_response',
args: [[], {'replies': res_obj.replies, 'serial_number': res_obj.serial_number, 'move_id': move_id}],
}).then(function () {
parent.services.action.doAction({
'type': 'ir.actions.client',
'tag': 'reload',
});
}, function () {
Dialog.alert(this, _t("Error trying to connect to Odoo. Check your internet connection"));
})
}
}, function () {
Dialog.alert(this, _t("Error trying to connect to the middleware. Is the middleware running?"));
})
}
core.action_registry.add('post_send', post_send);
return post_send;
});
@@ -0,0 +1,58 @@
<odoo>
<data>
<record id="l10n_ke_inherit_account_move_form" model="ir.ui.view">
<field name="name">l10n.ke.inherit.account.move.form</field>
<field name="model">account.move</field>
<field name="inherit_id" ref="account.view_move_form"/>
<field name="priority" eval="40"/>
<field name="arch" type="xml">
<xpath expr="//header/button[@name='action_post']" position="after">
<field name="l10n_ke_cu_qrcode" invisible="1"/>
<button name="l10n_ke_action_cu_post" type="object"
class="oe_highlight"
groups="account.group_account_manager"
string="Send Invoice To Device"
attrs="{'invisible': ['|', '|', '|', ('country_code', '!=', 'KE'), ('l10n_ke_cu_qrcode', '!=', False), ('state', '!=', 'posted'), ('move_type', 'not in', ['out_invoice', 'out_refund'])]}"/>
</xpath>
<xpath expr="//group[@id='header_right_group']" position="inside">
<field name="l10n_ke_cu_invoice_number" attrs="{'invisible': [('country_code', '!=', 'KE')]}" readonly="1"/>
</xpath>
<notebook position="inside">
<page string="Tremol GO3 Control Unit" attrs="{'invisible': [('country_code', '!=', 'KE')]}">
<group>
<group>
<field name="l10n_ke_cu_qrcode" widget="url" readonly="1"/>
<field name="l10n_ke_cu_serial_number" readonly="1"/>
<field name="l10n_ke_cu_datetime" readonly="1"/>
</group>
</group>
</page>
</notebook>
</field>
</record>
<record id="l10n_ke_inherit_account_move_tree_view" model="ir.ui.view">
<field name="name">l10n.ke.inherit.account.move.tree</field>
<field name="model">account.move</field>
<field name="inherit_id" ref="account.view_out_invoice_tree" />
<field name="arch" type="xml">
<field name="state" position="after">
<field name="l10n_ke_cu_invoice_number" optional="hide"/>
</field>
</field>
</record>
<record id="l10n_ke_inherit_account_move_search_view" model="ir.ui.view">
<field name="name">l10n.ke.inherit.account.move.search</field>
<field name="model">account.move</field>
<field name="inherit_id" ref="account.view_account_invoice_filter" />
<field name="arch" type="xml">
<xpath expr="//field[@name='journal_id']" position="after">
<field name="l10n_ke_cu_invoice_number" string="Kenya CU Invoice Number" filter_domain="[('l10n_ke_cu_invoice_number', 'ilike', self')]" />
</xpath>
</field>
</record>
</data>
</odoo>
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="l10n_ke_inherit_product_template_form_view" model="ir.ui.view">
<field name="name">l10n.ke.inherit.product.template.form.inherit</field>
<field name="model">product.template</field>
<field name="inherit_id" ref="account.product_template_form_view"/>
<field name="arch" type="xml">
<xpath expr="//page[@name='invoicing']//group[@name='accounting']" position="inside">
<group name="HS Code" string="HS Code" attrs="{'invisible': [('product_variant_count', '>', 1), ('is_product_variant', '=', False)]}">
<field name="l10n_ke_hsn_code"/>
<field name="l10n_ke_hsn_name"/>
</group>
</xpath>
</field>
</record>
<record id="l10n_ke_inherit_product_product_form_view" model="ir.ui.view">
<field name="name">l10n.ke.inherit.product.product.form</field>
<field name="model">product.product</field>
<field name="inherit_id" ref="product.product_variant_easy_edit_view"/>
<field name="arch" type="xml">
<xpath expr="//sheet" position="inside">
<group>
<group name="HS Code" string="HS Code">
<field name="l10n_ke_hsn_code"/>
<field name="l10n_ke_hsn_name"/>
</group>
</group>
</xpath>
</field>
</record>
</odoo>
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="l10n_ke_invoice" inherit_id="account.report_invoice_document">
<xpath expr="//div[@id='qrcode']" position="before">
<div t-if="o.country_code == 'KE'" id="l10n_ke_control_unit_information" style="page-break-inside:avoid;">
<b>Kenyan Control Unit Info</b>
<div class="row mt-4 mb-4">
<div class="col-auto col-3 mw-100 mb-2">
<p>
<b>Invoice Number: </b><br></br>
<span t-field="o.l10n_ke_cu_invoice_number"/>
</p>
<p>
<b>Serial Number: </b><br></br>
<span t-field="o.l10n_ke_cu_serial_number"/>
</p>
<p>
<b>Date and Time of Signing: </b><br></br>
<span t-field="o.l10n_ke_cu_datetime"/>
</p>
</div>
<div class="col-auto col-3 mw-100 mb-2">
<p t-if="o.l10n_ke_cu_qrcode">
<strong class="text-center">TIMS URL</strong><br/><br/>
<img style="display:block;" t-att-src="'/report/barcode/?barcode_type=%s&amp;value=%s&amp;width=%s&amp;height=%s' % ('QR', quote_plus(o.l10n_ke_cu_qrcode), 130, 130)" alt="QR Code"/>
</p>
</div>
</div>
</div>
</xpath>
</template>
</odoo>
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="res_config_settings_view_form" model="ir.ui.view">
<field name="name">l10n.ke.tremol.inherit.res.config.settings.form</field>
<field name="model">res.config.settings</field>
<field name="inherit_id" ref="account.res_config_settings_view_form"/>
<field name="arch" type="xml">
<xpath expr="//block[@id='account_vendor_bills']" position="after">
<div attrs="{'invisible':[('country_code', '!=', 'KE')]}">
<h2>Kenya TIMS Integration</h2>
<div class="row mt16 o_settings_container" id="l10n_ke_cu_details">
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_right_pane">
<span class="o_form_label">Tremol Device Settings</span>
<span class="fa fa-lg fa-building-o" title="Values set here are company-specific." aria-label="Values set here are company-specific." groups="base.group_multi_company" role="img"/>
<div class="text-muted">
The tremol device makes use of a proxy server, which can be running locally on your computer or on an IoT Box.
The proxy server must be on the same network as the fiscal device.
</div>
<div class="content-group">
<div class="row mt8">
<label for="l10n_ke_cu_proxy_address" class="col-lg-5 o_light_label"/>
<field name="l10n_ke_cu_proxy_address"/>
</div>
</div>
</div>
</div>
</div>
</div>
</xpath>
</field>
</record>
</odoo>
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="res_partner_view_form" model="ir.ui.view">
<field name="name">l10n.ke.tremol.inherit.res.partner.form</field>
<field name="model">res.partner</field>
<field name="inherit_id" ref="account.view_partner_property_form"/>
<field name="arch" type="xml">
<group name="accounting_entries" position="after">
<group string="Kenya Accounting Details" name="l10n_ke_details">
<field name="l10n_ke_exemption_number"/>
</group>
</group>
</field>
</record>
</odoo>