19.0 vanilla

This commit is contained in:
Ernad Husremovic 2026-03-09 09:31:34 +01:00
parent c5006a6999
commit 80293571e7
420 changed files with 21812 additions and 44297 deletions

View file

@ -2,6 +2,7 @@
Saudi Arabia POS Localization
===========================================================
## Installation
@ -12,38 +13,15 @@ pip install odoo-bringout-oca-ocb-l10n_sa_pos
## Dependencies
This addon depends on:
- l10n_gcc_pos
- l10n_sa
## Manifest Information
- **Name**: Saudi Arabia - Point of Sale
- **Version**: N/A
- **Category**: Accounting/Localizations/Point of Sale
- **License**: LGPL-3
- **Installable**: False
## Source
Based on [OCA/OCB](https://github.com/OCA/OCB) branch 16.0, addon `l10n_sa_pos`.
- Repository: https://github.com/OCA/OCB
- Branch: 19.0
- Path: addons/l10n_sa_pos
## License
This package maintains the original LGPL-3 license from the upstream Odoo project.
## Documentation
- Overview: doc/OVERVIEW.md
- Architecture: doc/ARCHITECTURE.md
- Models: doc/MODELS.md
- Controllers: doc/CONTROLLERS.md
- Wizards: doc/WIZARDS.md
- Reports: doc/REPORTS.md
- Security: doc/SECURITY.md
- Install: doc/INSTALL.md
- Usage: doc/USAGE.md
- Configuration: doc/CONFIGURATION.md
- Dependencies: doc/DEPENDENCIES.md
- Troubleshooting: doc/TROUBLESHOOTING.md
- FAQ: doc/FAQ.md
This package preserves the original LGPL-3 license.

View file

@ -2,23 +2,28 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
{
'name': 'Saudi Arabia - Point of Sale',
'author': 'Odoo S.A.',
'category': 'Accounting/Localizations/Point of Sale',
'icon': '/l10n_sa/static/description/icon.png',
'description': """
Saudi Arabia POS Localization
===========================================================
""",
'author': 'Odoo S.A.',
'license': 'LGPL-3',
'depends': [
'l10n_gcc_pos',
'l10n_sa',
],
'assets': {
'point_of_sale.assets': [
'point_of_sale._assets_pos': [
'web/static/lib/zxing-library/zxing-library.js',
'l10n_sa_pos/static/src/js/models.js',
'l10n_sa_pos/static/src/xml/OrderReceipt.xml',
'l10n_sa_pos/static/src/css/pos_receipt.css',
'l10n_sa_pos/static/src/**/*',
],
'web.assets_tests': [
'l10n_sa_pos/static/tests/tours/**/*',
],
'web.assets_unit_tests': [
'l10n_sa_pos/static/src/app/utils/qr.js',
'l10n_sa_pos/static/tests/unit/**/*',
]
},
'auto_install': True,

View file

@ -0,0 +1,130 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * l10n_sa_pos
#
# Weblate <noreply-mt-weblate@weblate.org>, 2025.
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 19.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-12-30 19:09+0000\n"
"PO-Revision-Date: 2025-11-17 03:15+0000\n"
"Last-Translator: Weblate <noreply-mt-weblate@weblate.org>\n"
"Language-Team: Arabic <https://translate.odoo.com/projects/odoo-19-l10n/"
"l10n_sa_pos/ar/>\n"
"Language: ar\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=6; plural=n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 "
"&& n%100<=10 ? 3 : n%100>=11 ? 4 : 5;\n"
"X-Generator: Weblate 5.12.2\n"
#. module: l10n_sa_pos
#. odoo-javascript
#: code:addons/l10n_sa_pos/static/src/app/add_zatca_refund_reason_popup/add_zatca_refund_reason_popup.xml:0
msgid "Additional Refund Information"
msgstr ""
#. module: l10n_sa_pos
#: model:ir.model.fields.selection,name:l10n_sa_pos.selection__pos_order__l10n_sa_reason__br-ksa-17-reason-3
msgid ""
"Amendment of the supply value which is pre-agreed upon between the supplier "
"and consumer"
msgstr "تعديل قيمة التوريد المتفق عليها مسبقًا بين المورد والمستهلك"
#. module: l10n_sa_pos
#: model:ir.model.fields.selection,name:l10n_sa_pos.selection__pos_order__l10n_sa_reason__br-ksa-17-reason-1
msgid ""
"Cancellation or suspension of the supplies after its occurrence either "
"wholly or partially"
msgstr "إلغاء أو تعليق التوريدات بعد حدوثها سواء كليًا أو جزئيًا"
#. module: l10n_sa_pos
#: model:ir.model.fields,field_description:l10n_sa_pos.field_pos_config__display_name
#: model:ir.model.fields,field_description:l10n_sa_pos.field_pos_order__display_name
msgid "Display Name"
msgstr "اسم العرض"
#. module: l10n_sa_pos
#: model:ir.model.fields,field_description:l10n_sa_pos.field_pos_config__id
#: model:ir.model.fields,field_description:l10n_sa_pos.field_pos_order__id
msgid "ID"
msgstr "المُعرف"
#. module: l10n_sa_pos
#: model:ir.model.fields.selection,name:l10n_sa_pos.selection__pos_order__l10n_sa_reason__br-ksa-17-reason-5
msgid "In case of change in Seller's or Buyer's information"
msgstr "في حالة تغيير بيانات البائع أو المشتري"
#. module: l10n_sa_pos
#: model:ir.model.fields.selection,name:l10n_sa_pos.selection__pos_order__l10n_sa_reason__br-ksa-17-reason-2
msgid ""
"In case of essential change or amendment in the supply, which leads to the "
"change of the VAT due"
msgstr ""
"في حالة حدوث تغيير أو تعديل جوهري في التوريد، مما يؤدي إلى تغيير ضريبة "
"القيمة المضافة المستحقة"
#. module: l10n_sa_pos
#: model:ir.model.fields.selection,name:l10n_sa_pos.selection__pos_order__l10n_sa_reason__br-ksa-17-reason-4
msgid "In case of goods or services refund"
msgstr "في حالة رد سلع أو خدمات"
#. module: l10n_sa_pos
#: model:ir.model.fields,field_description:l10n_sa_pos.field_pos_order__l10n_sa_reason_value
msgid "L10N Sa Reason Value"
msgstr ""
#. module: l10n_sa_pos
#. odoo-javascript
#: code:addons/l10n_sa_pos/static/src/app/add_zatca_refund_reason_popup/add_zatca_refund_reason_popup.xml:0
msgid "Ok"
msgstr "موافق"
#. module: l10n_sa_pos
#: model:ir.model,name:l10n_sa_pos.model_pos_config
msgid "Point of Sale Configuration"
msgstr "تهيئة نقطة البيع"
#. module: l10n_sa_pos
#: model:ir.model,name:l10n_sa_pos.model_pos_order
msgid "Point of Sale Orders"
msgstr "طلبات نقطة البيع"
#. module: l10n_sa_pos
#. odoo-javascript
#: code:addons/l10n_sa_pos/static/src/overrides/components/order_receipt/order_receipt.xml:0
msgid "Reason:"
msgstr "السبب:"
#. module: l10n_sa_pos
#. odoo-javascript
#: code:addons/l10n_sa_pos/static/src/overrides/components/order_receipt/order_receipt.xml:0
msgid "Reference:"
msgstr "المرجع:"
#. module: l10n_sa_pos
#. odoo-python
#: code:addons/l10n_sa_pos/models/pos_order.py:0
msgid ""
"You cannot create a consolidated invoice for POS orders with different ZATCA "
"refund reasons."
msgstr ""
#. module: l10n_sa_pos
#. odoo-python
#: code:addons/l10n_sa_pos/models/pos_config.py:0
msgid "You have to set a country in your company setting."
msgstr ""
#. module: l10n_sa_pos
#: model:ir.model.fields,field_description:l10n_sa_pos.field_pos_order__l10n_sa_reason
msgid "ZATCA Reason"
msgstr "سبب هيئة الزكاة والضريبة والجمارك"
#. module: l10n_sa_pos
#. odoo-javascript
#: code:addons/l10n_sa_pos/static/src/app/add_zatca_refund_reason_popup/add_zatca_refund_reason_popup.xml:0
msgid "ZATCA Refund Reason:"
msgstr ""

View file

@ -0,0 +1,123 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * l10n_sa_pos
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 19.0+e\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-12-30 19:09+0000\n"
"PO-Revision-Date: 2025-12-30 19:09+0000\n"
"Last-Translator: \n"
"Language-Team: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: \n"
#. module: l10n_sa_pos
#. odoo-javascript
#: code:addons/l10n_sa_pos/static/src/app/add_zatca_refund_reason_popup/add_zatca_refund_reason_popup.xml:0
msgid "Additional Refund Information"
msgstr ""
#. module: l10n_sa_pos
#: model:ir.model.fields.selection,name:l10n_sa_pos.selection__pos_order__l10n_sa_reason__br-ksa-17-reason-3
msgid ""
"Amendment of the supply value which is pre-agreed upon between the supplier "
"and consumer"
msgstr ""
#. module: l10n_sa_pos
#: model:ir.model.fields.selection,name:l10n_sa_pos.selection__pos_order__l10n_sa_reason__br-ksa-17-reason-1
msgid ""
"Cancellation or suspension of the supplies after its occurrence either "
"wholly or partially"
msgstr ""
#. module: l10n_sa_pos
#: model:ir.model.fields,field_description:l10n_sa_pos.field_pos_config__display_name
#: model:ir.model.fields,field_description:l10n_sa_pos.field_pos_order__display_name
msgid "Display Name"
msgstr ""
#. module: l10n_sa_pos
#: model:ir.model.fields,field_description:l10n_sa_pos.field_pos_config__id
#: model:ir.model.fields,field_description:l10n_sa_pos.field_pos_order__id
msgid "ID"
msgstr ""
#. module: l10n_sa_pos
#: model:ir.model.fields.selection,name:l10n_sa_pos.selection__pos_order__l10n_sa_reason__br-ksa-17-reason-5
msgid "In case of change in Seller's or Buyer's information"
msgstr ""
#. module: l10n_sa_pos
#: model:ir.model.fields.selection,name:l10n_sa_pos.selection__pos_order__l10n_sa_reason__br-ksa-17-reason-2
msgid ""
"In case of essential change or amendment in the supply, which leads to the "
"change of the VAT due"
msgstr ""
#. module: l10n_sa_pos
#: model:ir.model.fields.selection,name:l10n_sa_pos.selection__pos_order__l10n_sa_reason__br-ksa-17-reason-4
msgid "In case of goods or services refund"
msgstr ""
#. module: l10n_sa_pos
#: model:ir.model.fields,field_description:l10n_sa_pos.field_pos_order__l10n_sa_reason_value
msgid "L10N Sa Reason Value"
msgstr ""
#. module: l10n_sa_pos
#. odoo-javascript
#: code:addons/l10n_sa_pos/static/src/app/add_zatca_refund_reason_popup/add_zatca_refund_reason_popup.xml:0
msgid "Ok"
msgstr ""
#. module: l10n_sa_pos
#: model:ir.model,name:l10n_sa_pos.model_pos_config
msgid "Point of Sale Configuration"
msgstr ""
#. module: l10n_sa_pos
#: model:ir.model,name:l10n_sa_pos.model_pos_order
msgid "Point of Sale Orders"
msgstr ""
#. module: l10n_sa_pos
#. odoo-javascript
#: code:addons/l10n_sa_pos/static/src/overrides/components/order_receipt/order_receipt.xml:0
msgid "Reason:"
msgstr ""
#. module: l10n_sa_pos
#. odoo-javascript
#: code:addons/l10n_sa_pos/static/src/overrides/components/order_receipt/order_receipt.xml:0
msgid "Reference:"
msgstr ""
#. module: l10n_sa_pos
#. odoo-python
#: code:addons/l10n_sa_pos/models/pos_order.py:0
msgid ""
"You cannot create a consolidated invoice for POS orders with different ZATCA"
" refund reasons."
msgstr ""
#. module: l10n_sa_pos
#. odoo-python
#: code:addons/l10n_sa_pos/models/pos_config.py:0
msgid "You have to set a country in your company setting."
msgstr ""
#. module: l10n_sa_pos
#: model:ir.model.fields,field_description:l10n_sa_pos.field_pos_order__l10n_sa_reason
msgid "ZATCA Reason"
msgstr ""
#. module: l10n_sa_pos
#. odoo-javascript
#: code:addons/l10n_sa_pos/static/src/app/add_zatca_refund_reason_popup/add_zatca_refund_reason_popup.xml:0
msgid "ZATCA Refund Reason:"
msgstr ""

View file

@ -1,4 +1,3 @@
# -*- encoding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import pos_order

View file

@ -1,15 +1,28 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
from odoo import api, models
from odoo.exceptions import UserError
from odoo.tools.translate import _
class pos_config(models.Model):
class PosConfig(models.Model):
_inherit = 'pos.config'
def open_ui(self):
for config in self:
if not config.company_id.country_id:
raise UserError(_("You have to set a country in your company setting."))
return super(pos_config, self).open_ui()
return super().open_ui()
@api.model
def _load_pos_data_read(self, records, config):
data = super()._load_pos_data_read(records, config)
if data and self.env.company.country_id.code == 'SA':
l10n_sa_reason_field = self.env['ir.model.fields']._get('account.move', 'l10n_sa_reason')
data[0]['_zatca_refund_reasons'] = [
{'value': refund_reason.value, 'name': refund_reason.name}
for refund_reason in l10n_sa_reason_field.selection_ids
]
return data

View file

@ -1,14 +1,34 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
from odoo import _, api, fields, models
from odoo.addons.l10n_sa.models.account_move import ADJUSTMENT_REASONS
from odoo.exceptions import UserError
class POSOrder(models.Model):
class PosOrder(models.Model):
_inherit = 'pos.order'
l10n_sa_reason = fields.Selection(string="ZATCA Reason", selection=ADJUSTMENT_REASONS)
l10n_sa_reason_value = fields.Char(compute='_compute_l10n_sa_reason_value')
def _prepare_invoice_vals(self):
vals = super()._prepare_invoice_vals()
if self.company_id.country_id.code == 'SA':
vals.update({'l10n_sa_confirmation_datetime': self.date_order})
mapped_reasons = self.mapped('l10n_sa_reason')
if len(set(mapped_reasons)) > 1:
raise UserError(_(
"You cannot create a consolidated invoice for POS orders with different"
" ZATCA refund reasons."
))
confirmation_datetime = self.date_order if len(self) == 1 else fields.Datetime.now()
vals.update({
'l10n_sa_confirmation_datetime': confirmation_datetime,
'l10n_sa_reason': mapped_reasons[0] if mapped_reasons else False,
})
return vals
@api.depends("l10n_sa_reason")
def _compute_l10n_sa_reason_value(self):
for record in self:
record.l10n_sa_reason_value = dict(self._fields['l10n_sa_reason']._description_selection(self.env)).get(record.l10n_sa_reason)

View file

@ -0,0 +1,19 @@
import { Component, useState } from "@odoo/owl";
import { Dialog } from "@web/core/dialog/dialog";
import { usePos } from "@point_of_sale/app/hooks/pos_hook";
export class AddZatcaRefundReasonPopup extends Component {
static template = "l10n_sa_pos.AddZatcaRefundReasonPopup";
static components = { Dialog };
setup() {
this.pos = usePos();
this.state = useState({
l10n_sa_reason: this.props.order.l10n_sa_reason || "BR-KSA-17-reason-4",
});
}
confirm() {
this.props.getPayload(this.state);
this.props.close();
}
}

View file

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="l10n_sa_pos.AddZatcaRefundReasonPopup">
<Dialog title.translate="Additional Refund Information">
<div class="mb-3">
<label for="zatca_refund_reason" class="form-label">ZATCA Refund Reason: </label>
<select class="detail form-select" id="zatca_refund_reason" name="l10n_sa_reason" t-model="state.l10n_sa_reason">
<t t-foreach="pos.config._zatca_refund_reasons" t-as="l10n_sa_reason" t-key="l10n_sa_reason.value">
<option t-att-value="l10n_sa_reason.value"
t-att-selected="l10n_sa_reason.value === state.l10n_sa_reason ? 'selected' : undefined">
<t t-out="l10n_sa_reason.name"/>
</option>
</t>
</select>
</div>
<t t-set-slot="footer">
<button class="btn btn-primary o-default-button" t-on-click="confirm">Ok</button>
</t>
</Dialog>
</t>
</templates>

View file

@ -0,0 +1,37 @@
import { formatDateTime } from "@web/core/l10n/dates";
export function computeSAQRCode(name, vat, date_isostring, amount_total, amount_tax) {
/* Generate the qr code for Saudi e-invoicing. Specs are available at the following link at page 23
https://zatca.gov.sa/ar/E-Invoicing/SystemsDevelopers/Documents/20210528_ZATCA_Electronic_Invoice_Security_Features_Implementation_Standards_vShared.pdf
*/
const ksa_timestamp = formatDateTime(date_isostring, {
tz: "Asia/Riyadh",
format: "MM/dd/yyyy, HH:mm:ss",
});
const seller_name_enc = _compute_qr_code_field(1, name);
const company_vat_enc = _compute_qr_code_field(2, vat);
const timestamp_enc = _compute_qr_code_field(3, ksa_timestamp);
const invoice_total_enc = _compute_qr_code_field(4, amount_total.toString());
const total_vat_enc = _compute_qr_code_field(5, amount_tax.toString());
const str_to_encode = seller_name_enc.concat(
company_vat_enc,
timestamp_enc,
invoice_total_enc,
total_vat_enc
);
let binary = "";
for (let i = 0; i < str_to_encode.length; i++) {
binary += String.fromCharCode(str_to_encode[i]);
}
return btoa(binary);
}
function _compute_qr_code_field(tag, field) {
const textEncoder = new TextEncoder();
const name_byte_array = Array.from(textEncoder.encode(field));
const name_tag_encoding = [tag];
const name_length_encoding = [name_byte_array.length];
return name_tag_encoding.concat(name_length_encoding, name_byte_array);
}

View file

@ -1,64 +0,0 @@
odoo.define('l10n_sa_pos.pos', function (require) {
"use strict";
var { Order } = require('point_of_sale.models');
var Registries = require('point_of_sale.Registries');
const PosL10nSAOrder = (Order) => class PosL10nSAOrder extends Order {
export_for_printing() {
var result = super.export_for_printing(...arguments);
if (this.pos.company.country && this.pos.company.country.code === 'SA') {
result.is_settlement = this.is_settlement();
if (!result.is_settlement) {
const codeWriter = new window.ZXing.BrowserQRCodeSvgWriter()
let qr_values = this.compute_sa_qr_code(result.company.name, result.company.vat, result.date.isostring, result.total_with_tax, result.total_tax);
let qr_code_svg = new XMLSerializer().serializeToString(codeWriter.write(qr_values, 150, 150));
result.qr_code = "data:image/svg+xml;base64," + window.btoa(qr_code_svg);
}
}
return result;
}
/**
* If the order is empty (there are no products)
* and all "pay_later" payments are negative,
* we are settling a customer's account.
* If the module pos_settle_due is not installed,
* the function always returns false (since "pay_later" doesn't exist)
* @returns {boolean} true if the current order is a settlement, else false
*/
is_settlement() {
return this.is_empty() &&
!!this.paymentlines.filter(paymentline => paymentline.payment_method.type === "pay_later" && paymentline.amount < 0).length;
}
compute_sa_qr_code(name, vat, date_isostring, amount_total, amount_tax) {
/* Generate the qr code for Saudi e-invoicing. Specs are available at the following link at page 23
https://zatca.gov.sa/ar/E-Invoicing/SystemsDevelopers/Documents/20210528_ZATCA_Electronic_Invoice_Security_Features_Implementation_Standards_vShared.pdf
*/
const seller_name_enc = this._compute_qr_code_field(1, name);
const company_vat_enc = this._compute_qr_code_field(2, vat);
const timestamp_enc = this._compute_qr_code_field(3, date_isostring);
const invoice_total_enc = this._compute_qr_code_field(4, amount_total.toString());
const total_vat_enc = this._compute_qr_code_field(5, amount_tax.toString());
const str_to_encode = seller_name_enc.concat(company_vat_enc, timestamp_enc, invoice_total_enc, total_vat_enc);
let binary = '';
for (let i = 0; i < str_to_encode.length; i++) {
binary += String.fromCharCode(str_to_encode[i]);
}
return btoa(binary);
}
_compute_qr_code_field(tag, field) {
const textEncoder = new TextEncoder();
const name_byte_array = Array.from(textEncoder.encode(field));
const name_tag_encoding = [tag];
const name_length_encoding = [name_byte_array.length];
return name_tag_encoding.concat(name_length_encoding, name_byte_array);
}
}
Registries.Model.extend(Order, PosL10nSAOrder);
});

View file

@ -0,0 +1,72 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-inherit="point_of_sale.ReceiptHeader" t-inherit-mode="extension">
<xpath expr="//img[hasclass('pos-receipt-logo')]" position="after">
<t t-if="order.isSACompany() and !order.isSettlement()">
<t t-set="asQrCode" t-value="order.generateQrcode()"/>
<img t-if="asQrCode" id="qrcode" t-att-src="asQrCode" class="pos-receipt-qrcode"/>
<br/>
</t>
</xpath>
<t t-set="show_title" position="after">
<t t-set="show_title" t-if="order.isSACompany()" t-value="order.state !== 'draft'"/>
</t>
<t t-set="show_simplified_title" position="after">
<t t-set="show_simplified_title" t-if="order.isSACompany()" t-value="!order.isSettlement() and order.isSimplified"/>
</t>
</t>
<t t-inherit="point_of_sale.OrderReceipt" t-inherit-mode="extension">
<span name="subtotal_span_dual" position="before">
<span name="subtotal_span_dual_sa" t-elif="order.isSACompany() and order.config_id.l10n_gcc_dual_language_receipt" class="text-nowrap mw-100" t-translation="off">
Subtotal / المبلغ الخاضع للضريبة
<br/>
(غير شامل ضريبة القيمة المضافة)
</span>
</span>
<span name="total_span_dual" position="before">
<span name="total_span_dual_sa" t-elif="order.isSACompany() and order.config_id.l10n_gcc_dual_language_receipt" class="label-total" t-translation="off">
Total / إجمالي قيمة الفاتورة
<br/>
(شامل ضريبة القيمة المضافة)
</span>
</span>
<xpath expr="//div[@t-if='order.company.point_of_sale_use_ticket_qr_code and order.finalized']//div[hasclass('w-100')]" position="inside">
<div name="order_reference" t-if="order.isSACompany()">
<t t-if="!order.config_id.l10n_gcc_dual_language_receipt">
<span>Reference:</span><t t-out="order.pos_reference"/>
</t>
<t t-else="">
<t t-out="order.pos_reference"/><span t-translation="off"> :المرجع</span><br/>
<span t-translation="off">Reference: </span><t t-out="order.pos_reference"/>
</t>
</div>
<div name="order_reason" t-if="order.isSACompany() and order.isRefund and order.l10n_sa_reason_value">
<t t-if="!order.config_id.l10n_gcc_dual_language_receipt">
<span>Reason:</span><br/>
</t>
<t t-else="">
<span t-translation="off"> :سبب إصدار الإشعار من الأسباب التالية</span><br/>
<span t-translation="off">Reason: </span><br/>
</t>
<span t-out="order.l10n_sa_reason_value"/>
</div>
</xpath>
<xpath expr="//t[@t-if='company.street']/.." position="attributes">
<attribute name="t-if">!order.isSACompany()</attribute>
</xpath>
<xpath expr="//t[@t-if='company.street']/.." position="after">
<div name="custom_address_block" t-else="">
<t t-if="company.street" t-out="company.street"/>
<t t-if="company.street2" t-out="', ' + company.street2"/>
<t t-if="company.street or company.street2"><br/></t>
<t t-if="company.city" t-out="company.city"/>
<t t-if="company.state_id?.code" t-out="', ' + company.state_id.code"/>
<t t-if="company.zip" t-out="', ' + company.zip"/>
<t t-if="company.city or company.state_id?.code or company.zip"><br/></t>
<t t-if="company.country_id" t-out="company.country_id?.name"/>
</div>
</xpath>
</t>
</templates>

View file

@ -0,0 +1,19 @@
import { AddZatcaRefundReasonPopup } from "@l10n_sa_pos/app/add_zatca_refund_reason_popup/add_zatca_refund_reason_popup";
import { TicketScreen } from "@point_of_sale/app/screens/ticket_screen/ticket_screen";
import { makeAwaitable } from "@point_of_sale/app/utils/make_awaitable_dialog";
import { patch } from "@web/core/utils/patch";
patch(TicketScreen.prototype, {
async addAdditionalRefundInfo(order, destinationOrder) {
if (this.pos.company.country_id.code === "SA") {
const payload = await makeAwaitable(this.dialog, AddZatcaRefundReasonPopup, {
order: destinationOrder,
});
if (payload) {
destinationOrder.l10n_sa_reason = payload.l10n_sa_reason;
destinationOrder.to_invoice = true;
}
}
return super.addAdditionalRefundInfo(...arguments);
},
});

View file

@ -0,0 +1,36 @@
import { PosOrder } from "@point_of_sale/app/models/pos_order";
import { patch } from "@web/core/utils/patch";
import { computeSAQRCode } from "@l10n_sa_pos/app/utils/qr";
patch(PosOrder.prototype, {
isSACompany() {
return this.company.country_id?.code === "SA";
},
generateQrcode() {
if (this.isSACompany()) {
if (!this.isSettlement()) {
const company = this.company;
const codeWriter = new window.ZXing.BrowserQRCodeSvgWriter();
const qr_values = this.compute_sa_qr_code(
company.name,
company.vat,
this.date_order,
this.priceIncl,
this.amountTaxes
);
const qr_code_svg = new XMLSerializer().serializeToString(
codeWriter.write(qr_values, 200, 200)
);
return "data:image/svg+xml;base64," + window.btoa(qr_code_svg);
}
}
return false;
},
compute_sa_qr_code(name, vat, date_isostring, amount_total, amount_tax) {
return computeSAQRCode(name, vat, date_isostring, amount_total, amount_tax);
},
get isSimplified() {
return !this?.partner_id?.is_company && this.company_id.country_id?.code === "SA";
},
});

View file

@ -1,23 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-inherit="point_of_sale.OrderReceipt" t-inherit-mode="extension" owl="1">
<xpath expr="//img[hasclass('pos-receipt-logo')]" position="after">
<t t-if="receipt.is_gcc_country and !receipt.is_settlement">
<img t-if="receipt.qr_code" id="qrcode" t-att-src="receipt.qr_code" class="pos-receipt-qrcode"/>
<br/>
</t>
</xpath>
<xpath expr="//span[@id='title_english']" position="replace">
<t t-if="!receipt.is_settlement">
<span id="title_english">Simplified Tax Invoice</span>
</t>
</xpath>
<xpath expr="//span[@id='title_arabic']" position="replace">
<t t-if="!receipt.is_settlement">
<span id="title_arabic">فاتورة ضريبية مبسطة</span>
</t>
</xpath>
</t>
</templates>

View file

@ -0,0 +1,21 @@
import * as Dialog from "@point_of_sale/../tests/generic_helpers/dialog_util";
import * as Chrome from "@point_of_sale/../tests/pos/tours/utils/chrome_util";
import * as ProductScreen from "@point_of_sale/../tests/pos/tours/utils/product_screen_util";
import * as PaymentScreen from "@point_of_sale/../tests/pos/tours/utils/payment_screen_util";
import { registry } from "@web/core/registry";
registry.category("web_tour.tours").add("test_sa_qr_is_shown", {
steps: () =>
[
Chrome.startPoS(),
Dialog.confirm("Open Register"),
ProductScreen.addOrderline("Small Shelf", "1"),
ProductScreen.clickPayButton(),
PaymentScreen.clickPaymentMethod("Cash"),
PaymentScreen.clickValidate(),
{
trigger: "#qrcode.pos-receipt-qrcode",
content: "QR code should be visible on the receipt.",
},
].flat(),
});

View file

@ -0,0 +1,16 @@
import { describe, expect, test } from "@odoo/hoot";
import { computeSAQRCode } from "@l10n_sa_pos/app/utils/qr";
const { DateTime } = luxon;
describe("SA QR Code", () => {
test("check QR format", () => {
const date = DateTime.fromISO("2025-03-07T10:15:17");
const qrEncoded = computeSAQRCode("SA Company", "123456789012345", date, 100.0, 0);
const expected =
"AQpTQSBDb21wYW55Ag8xMjM0NTY3ODkwMTIzNDUDFDAzLzA3LzIwMjUsIDEyOjE1OjE3BAMxMDAFATA=";
expect(qrEncoded).toBe(expected, {
message: `QR code mismatch: expected "${expected}", got "${qrEncoded}", make sure the timezone is respected`,
});
});
});

View file

@ -0,0 +1,3 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import test_sa_pos

View file

@ -0,0 +1,100 @@
from odoo.addons.account.tests.common import AccountTestInvoicingCommon
from odoo.addons.point_of_sale.tests.common import TestPoSCommon
from odoo.addons.point_of_sale.tests.test_generic_localization import TestGenericLocalization
from odoo.tests import tagged
from odoo.addons.point_of_sale.tests.test_frontend import TestPointOfSaleHttpCommon
from odoo.addons.account_edi.tests.common import AccountEdiTestCommon
@tagged('post_install', '-at_install', 'post_install_l10n')
class TestGenericSA(TestGenericLocalization):
@classmethod
@AccountTestInvoicingCommon.setup_country('sa')
def setUpClass(cls):
super().setUpClass()
if cls.env['ir.module.module']._get('l10n_sa_edi').state == 'installed':
cls.skipTest(cls, "l10n_sa_edi should not be installed")
cls.main_pos_config.company_id.name = 'Generic SA'
cls.company.write({
'email': 'info@company.saexample.com',
'phone': '+966 51 234 5678',
'street2': 'Testomania',
'vat': '311111111111113',
'state_id': cls.env['res.country.state'].create({
'name': 'Riyadh',
'code': 'RYA',
'country_id': cls.company.country_id.id
}),
'street': 'Al Amir Mohammed Bin Abdul Aziz Street',
'city': 'المدينة المنورة',
'zip': '42317',
})
@tagged('post_install_l10n', 'post_install', '-at_install')
class TestUi(TestPointOfSaleHttpCommon):
@classmethod
@AccountEdiTestCommon.setup_edi_format('l10n_sa_edi.edi_sa_zatca')
@AccountEdiTestCommon.setup_country('sa')
def setUpClass(cls):
super().setUpClass()
# Setup company
cls.company.write({
'name': 'SA Company Test',
'email': 'info@company.saexample.com',
'phone': '+966 51 234 5678',
'street2': 'Testomania',
'vat': '311111111111113',
'state_id': cls.env['res.country.state'].create({
'name': 'Riyadh',
'code': 'RYA',
'country_id': cls.company.country_id.id
}),
'street': 'Al Amir Mohammed Bin Abdul Aziz Street',
'city': 'المدينة المنورة',
'zip': '42317',
})
def test_sa_qr_is_shown(self):
"""
Tests that the Saudi Arabia's timezone is applied on the QR code generated at the
end of an order.
"""
if self.env['ir.module.module']._get('l10n_sa_edi').state == 'installed':
self.skipTest("The needed configuration for e-invoices is not available")
self.main_pos_config.with_user(self.pos_admin).open_ui()
self.start_tour("/pos/ui?config_id=%d" % self.main_pos_config.id, 'test_sa_qr_is_shown', login="pos_admin")
@tagged('post_install_l10n', 'post_install', '-at_install')
class TestSaPosInvoice(TestPoSCommon, TestPointOfSaleHttpCommon):
def test_consolidate_invoices_for_same_customer(self):
"""
Test that consolidated invoicing for multiple POS orders of the same customer
succeeds and sets the confirmation datetime for a Saudi Arabia company.
"""
self.config = self.basic_config
self.env.company.country_id = self.env.ref('base.sa')
self.open_new_session()
pos_orders = sum(self._create_orders([
{
'pos_order_lines_ui_args': [(self.product_a, 1)],
'customer': self.customer,
'is_invoiced': False,
}
for _ in range(2)
]).values(), self.env['pos.order'])
self.env['pos.make.invoice'].create({"consolidated_billing": True}).with_context({
"active_ids": pos_orders.ids
}).action_create_invoices()
invoice = pos_orders.account_move
self.assertTrue(invoice, "A consolidated invoice should have been created")
self.assertTrue(
invoice.l10n_sa_confirmation_datetime,
"The consolidated invoice should have l10n_sa_confirmation_datetime set"
)

View file

@ -1,13 +1,15 @@
[project]
name = "odoo-bringout-oca-ocb-l10n_sa_pos"
version = "16.0.0"
description = "Saudi Arabia - Point of Sale - Odoo addon"
description = "Saudi Arabia - Point of Sale -
Odoo addon
"
authors = [
{ name = "Ernad Husremovic", email = "hernad@bring.out.ba" }
]
dependencies = [
"odoo-bringout-oca-ocb-l10n_gcc_pos>=16.0.0",
"odoo-bringout-oca-ocb-l10n_sa>=16.0.0",
"odoo-bringout-oca-ocb-l10n_gcc_pos>=19.0.0",
"odoo-bringout-oca-ocb-l10n_sa>=19.0.0",
"requests>=2.25.1"
]
readme = "README.md"
@ -17,7 +19,7 @@ classifiers = [
"Intended Audience :: Developers",
"License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Topic :: Office/Business",
]