19.0 vanilla

This commit is contained in:
Ernad Husremovic 2026-03-09 09:31:21 +01:00
parent 7dc55599c6
commit 7f43bbbfcc
650 changed files with 45260 additions and 33436 deletions

View file

@ -1,3 +1,6 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import account_move
from . import template_id
from . import res_bank
from . import qris_transaction

View file

@ -1,36 +1,137 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
from odoo.tools.misc import formatLang
from odoo import fields, models, _
class AccountMove(models.Model):
_inherit = "account.move"
_inherit = 'account.move'
l10n_id_qris_transaction_ids = fields.Many2many('l10n_id.qris.transaction', groups='account.group_account_invoice')
def _generate_qr_code(self, silent_errors=False):
"""
Adds information about which invoice is triggering the creation of the QR-Code, so that we can link both together.
"""
# EXTENDS account
return super(
AccountMove,
self.with_context(qris_model="account.move", qris_model_id=str(self.id)),
)._generate_qr_code(silent_errors)
def _l10n_id_cron_update_payment_status(self):
"""
This cron will:
- Get all invoices that are not paid, and have details about QRIS qr codes.
- For each invoices, get information about the payment state of the QR using the API.
- If the QR is not paid and it has been more than 30m, we discard that qr id (no longer valid)
- If it is paid, we will register the payment on the invoices.
"""
invoices = self.search([
('payment_state', '=', 'not_paid'),
('l10n_id_qris_transaction_ids', '!=', False)
])
return invoices._l10n_id_update_payment_status()
def action_l10n_id_update_payment_status(self):
"""
This action will:
- Get all invoices that are not paid, and have details about QRIS qr codes.
- For each invoices, get information about the payment state of the QR using the API.
- If the QR is not paid and it has been more than 30m, we discard that qr id (no longer valid)
- If it is paid, we will register the payment on the invoices.
"""
invoices = self.filtered_domain([
('payment_state', '=', 'not_paid'),
('l10n_id_qris_transaction_ids', '!=', False)
])
return invoices._l10n_id_update_payment_status()
def _l10n_id_update_payment_status(self):
""" Starts by fetching the QR statuses for the invoices in self, then update said invoices based on the statuses """
qr_statuses = self._l10n_id_get_qris_qr_statuses()
return self._l10n_id_process_invoices(qr_statuses)
def _l10n_id_get_qris_qr_statuses(self):
"""
Query the API in order to get updated information on the status of each QR codes linked to the invoices in self.
If the QR has been paid, only the paid information is returned.
:return: a list with the format:
{
invoice: {
'paid': True,
'qr_statuses': [],
},
invoice: {
'paid': False,
'qr_statuses': [],
}
}
"""
result = {}
for invoice in self:
result[invoice.id] = invoice.l10n_id_qris_transaction_ids._l10n_id_get_qris_qr_statuses()
return result
def _l10n_id_process_invoices(self, invoices_statuses):
"""
Receives the list of invoices and their statuses, and update them using it.
For paid invoices we will register the payment and log a note, while for unpaid ones we will discard expired
QR data and keep the non-expired ones for the next run.
"""
paid_invoices = self.env['account.move']
paid_messages = {}
for invoice in self:
statuses = invoices_statuses.get(invoice.id)
# Paid invoice: we simply prepare a message to notify of the payment with details if possible.
if statuses['paid']:
paid_status = statuses['qr_statuses'][0]
if 'qris_payment_customername' in paid_status and 'qris_payment_methodby' in paid_status:
message = _(
"This invoice was paid by %(customer)s using QRIS with the payment method %(method)s.",
customer=paid_status['qris_payment_customername'],
method=paid_status['qris_payment_methodby'],
)
else:
message = _("This invoice was paid using QRIS.")
paid_invoices |= invoice
paid_messages[invoice.id] = message
# Update paid invoices
if paid_invoices:
paid_invoices._message_log_batch(bodies=paid_messages)
# Finally, register the payment:
return self.env['account.payment.register'].with_context(
active_model='account.move', active_ids=paid_invoices.ids
).create({'group_payment': False}).action_create_payments()
def _compute_tax_totals(self):
""" OVERRIDE
For invoices based on ID company as of January 2025, there is a separate tax base computation for nun-luxury goods.
For invoices based on ID company as of January 2025, there is a separate tax base computation for non-luxury goods.
Tax base is supposed to be 11/12 of original while tax amount is increased from 11% to 12% hence effectively
maintaining 11% tax amount.
We change tax totals section to display adjusted base amount on invoice PDF for special non-luxury goods tax group.
"""
super()._compute_tax_totals()
non_luxury_tax_group = self.env.ref('l10n_id.l10n_id_tax_group_non_luxury_goods', raise_if_not_found=False)
if not non_luxury_tax_group:
return
for move in self.filtered(lambda m: m.is_sale_document()):
if move.invoice_date and move.invoice_date < fields.Date.to_date('2025-01-01'):
# invoice might be coming from different companies, each tax group with unique XML ID
non_luxury_tax_group = self.env['account.chart.template'].with_company(move.company_id.id).ref("l10n_id_tax_group_non_luxury_goods", raise_if_not_found=False)
if not non_luxury_tax_group or move.invoice_date and move.invoice_date < fields.Date.to_date('2025-01-01'):
continue
for subtotal_group in move.tax_totals['groups_by_subtotal'].values():
for group in subtotal_group:
if group['tax_group_id'] == non_luxury_tax_group.id:
dpp = group['tax_group_base_amount'] * (11 / 12)
# adding (DPP) information to make it clearer for users why the number is different from the Untaxed Amount
group.update({
'tax_group_base_amount': dpp,
'formatted_tax_group_base_amount': formatLang(self.env, dpp, currency_obj=move.currency_id),
'tax_group_name': group['tax_group_name'] + ' (on DPP)',
# for every tax group component with non-luxury tax group, we adjust the base amount and adjust the display to
# show base amount
change_tax_base = False
for subtotal in move.tax_totals["subtotals"]:
for tax_group in subtotal["tax_groups"]:
if tax_group["id"] == non_luxury_tax_group.id:
tax_group.update({
"display_base_amount": tax_group["display_base_amount"] * (11 / 12),
"display_base_amount_currency": tax_group["display_base_amount_currency"] * (11 / 12),
"group_name": tax_group["group_name"] + " (on DPP)",
})
move.tax_totals['display_tax_base'] = True
change_tax_base = True
if change_tax_base:
move.tax_totals["same_tax_base"] = False

View file

@ -0,0 +1,83 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from datetime import timedelta
from odoo import api, fields, models, _
from odoo.exceptions import ValidationError
class L10n_IdQrisTransaction(models.Model):
"""QRIS Transaction
General table to store a certian unique transaction with QRIS details attached
"""
_name = 'l10n_id.qris.transaction'
_description = "Record of QRIS transactions"
model = fields.Char(string="Model") # payment in respond to which model
model_id = fields.Char(string="Model ID") # id/uuid
# Fields that store the QRIS details coming from API request
qris_invoice_id = fields.Char(readonly=True)
qris_amount = fields.Integer(readonly=True)
qris_content = fields.Char(readonly=True)
qris_creation_datetime = fields.Datetime(readonly=True)
bank_id = fields.Many2one("res.partner.bank", help="Bank used to generate the current QRIS transaction")
paid = fields.Boolean(help="Payment Status of QRIS")
def _get_supported_models(self):
return ['account.move']
@api.constrains('model')
def _constraint_model(self):
# only allow supported models
if self.model not in self._get_supported_models():
raise ValidationError(_("QRIS capability is not extended to model %s yet!", self.model))
def _get_record(self):
""" Get the backend invoice record that the qris transaction is handling
To be overriden in other modules"""
self.ensure_one()
if self.model != 'account.move':
return
return self.env['account.move'].browse(int(self.model_id)).exists()
@api.model
def _get_latest_transaction(self, model, model_id):
""" Find latest transaction associated to the model and model_id """
return self.search([('model', '=', model), ('model_id', '=', model_id)], order='qris_creation_datetime desc', limit=1)
def _l10n_id_get_qris_qr_statuses(self):
""" Fetch the result of the transaction
:param invoice_bank_id (Model <res.partner.bank>): bank (with QRIS configuration)
:returns tuple(bool, dict): paid/unpaid status and status_response from QRIS
"""
# storing all failure transactions in case final result is unpaid
unpaid_status_data = []
# Looping to make requests is far from ideal, but we have no choices as they don't allow getting multiple QR result at once.
# Ensure to loop in reverse and check from the most recent QR code.
for transaction in self.sorted(lambda t: t.qris_creation_datetime):
status_response = self.sudo().bank_id._l10n_id_qris_fetch_status(transaction)
if status_response['data'].get('qris_status') == 'paid':
transaction.paid = True
return {
'paid': True,
'qr_statuses': [status_response['data']]
}
else:
unpaid_status_data.append(status_response['data'])
return {
'paid': False,
'qr_statuses': unpaid_status_data
}
@api.autovacuum
def _gc_remove_pointless_qris_transactions(self):
""" Removes unpaid transactions that have been for more than 35 minutes.
These can no longer be paid and status will no longer change
"""
time_limit = fields.Datetime.now() - timedelta(seconds=2100)
transactions = self.env['l10n_id.qris.transaction'].search([('qris_creation_datetime', '<=', time_limit), ('paid', '=', False)])
transactions.unlink()

View file

@ -0,0 +1,149 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import datetime
import requests
import pytz
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
from odoo.tools.urls import urljoin
QRIS_TIMEOUT = 35 # They say that the time to get a response vary between 6 to 30s
def _l10n_id_make_qris_request(endpoint, params):
""" Make an API request to QRIS, using the given path and params. """
url = urljoin('https://qris.online/restapi/qris/', endpoint)
try:
response = requests.get(url, params=params, timeout=QRIS_TIMEOUT)
response.raise_for_status()
response = response.json()
except requests.exceptions.HTTPError as err:
raise ValidationError(_("Communication with QRIS failed. QRIS returned with the following error: %s", err))
except (requests.RequestException, ValueError):
raise ValidationError(_("Could not establish a connection to the QRIS API."))
return response
class ResPartnerBank(models.Model):
_inherit = "res.partner.bank"
l10n_id_qris_api_key = fields.Char("QRIS API Key", groups="base.group_system")
l10n_id_qris_mid = fields.Char("QRIS Merchant ID", groups="base.group_system")
@api.model
def _get_available_qr_methods(self):
# EXTENDS account
rslt = super()._get_available_qr_methods()
rslt.append(('id_qr', _("QRIS"), 40))
return rslt
def _get_error_messages_for_qr(self, qr_method, debtor_partner, currency):
# EXTENDS account
if qr_method == 'id_qr':
if self.country_code != 'ID':
return _("You cannot generate a QRIS QR code with a bank account that is not in Indonesia.")
if currency.name not in ['IDR']:
return _("You cannot generate a QRIS QR code with a currency other than IDR")
if not (self.sudo().l10n_id_qris_api_key and self.sudo().l10n_id_qris_mid):
return _("To use QRIS QR code, Please setup the QRIS API Key and Merchant ID on the bank's configuration")
return None
return super()._get_error_messages_for_qr(qr_method, debtor_partner, currency)
def _check_for_qr_code_errors(self, qr_method, amount, currency, debtor_partner, free_communication, structured_communication):
# EXTENDS account
if qr_method == 'id_qr':
if not amount:
return _("The amount must be set to generate a QR code.")
return super()._check_for_qr_code_errors(qr_method, amount, currency, debtor_partner, free_communication, structured_communication)
def _get_qr_vals(self, qr_method, amount, currency, debtor_partner, free_communication, structured_communication):
""" Getting content for the QR through calling QRIS API and storing the QRIS transaction as a record"""
# EXTENDS account
if qr_method == "id_qr":
model = self.env.context.get('qris_model')
model_id = self.env.context.get('qris_model_id')
# qris_trx is to help us fetch the backend record associated to the model and model_id.
# we are using model and model_id instead of model.browse(id) because while executing this method
# not all backend records are created already. For example, pos.order record isn't created until
# payment is completed on the PoS interace.
qris_trx = self.env['l10n_id.qris.transaction']._get_latest_transaction(model, model_id)
# QRIS codes are valid for 30 minutes. To leave some margin, we will return the same QR code we already
# generated if the invoice is re-accessed before 25m. Otherwise, a new QR code is generated
# Additionally, we want to check that it's requesting for the same amount as it's possible to change
# amount in apps like PoS.
if qris_trx and qris_trx.qris_amount == int(amount):
now = fields.Datetime.now()
latest_qr_date = qris_trx.qris_creation_datetime
if (now - latest_qr_date).total_seconds() < 1500:
return qris_trx['qris_content']
params = {
"do": "create-invoice",
"apikey": self.sudo().l10n_id_qris_api_key,
"mID": self.sudo().l10n_id_qris_mid,
"cliTrxNumber": free_communication or structured_communication,
"cliTrxAmount": int(amount)
}
response = _l10n_id_make_qris_request('show_qris.php', params)
if response.get("status") == "failed":
raise ValidationError(response.get("data"))
data = response.get('data')
# create a new transaction line while also converting the qris_request_date to UTC time
if model and model_id:
new_trx = self.env['l10n_id.qris.transaction'].create({
'model': model,
'model_id': model_id,
'qris_invoice_id': data.get('qris_invoiceid'),
'qris_amount': int(amount),
# Since the QRIS response is always returned with "Asia/Jakarta" timezone which is UTC+07:00
'qris_creation_datetime': fields.Datetime.to_datetime(data.get('qris_request_date')) - datetime.timedelta(hours=7),
'qris_content': data.get('qris_content'),
'bank_id': self.id
})
# Search the backend record and attach the qris transaction to the record if it exists.
trx_record = new_trx._get_record()
if trx_record:
trx_record.l10n_id_qris_transaction_ids |= new_trx
return data.get('qris_content')
return super()._get_qr_vals(qr_method, amount, currency, debtor_partner, free_communication, structured_communication)
def _get_qr_code_generation_params(self, qr_method, amount, currency, debtor_partner, free_communication, structured_communication):
# EXTENDS account
if qr_method == 'id_qr':
if not self.env.context.get('is_online_qr'):
return {}
return {
'barcode_type': 'QR',
'quiet': 0,
'width': 120,
'height': 120,
'value': self._get_qr_vals(qr_method, amount, currency, debtor_partner, free_communication, structured_communication),
}
return super()._get_qr_code_generation_params(qr_method, amount, currency, debtor_partner, free_communication, structured_communication)
def _l10n_id_qris_fetch_status(self, qr_data):
"""
using self and the given data, fetches the status of a specific QR code generated by QRIS
Expected values in the qr_data dict are:
- invoice_id returned when generating a QR code
- the amount present in the qr code
- the datetime at which the QR code was generated
"""
return _l10n_id_make_qris_request('checkpaid_qris.php', {
'do': 'checkStatus',
'apikey': self.l10n_id_qris_api_key,
'mID': self.l10n_id_qris_mid,
'invid': qr_data['qris_invoice_id'],
'trxvalue': qr_data['qris_amount'],
'trxdate': qr_data['qris_creation_datetime'],
})

View file

@ -0,0 +1,63 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
from odoo.addons.account.models.chart_template import template
class AccountChartTemplate(models.AbstractModel):
_inherit = 'account.chart.template'
@template('id')
def _get_id_template_data(self):
return {
'property_account_receivable_id': 'l10n_id_11210010',
'property_account_payable_id': 'l10n_id_21100010',
'property_stock_valuation_account_id': 'l10n_id_11300180',
'code_digits': '8',
}
@template('id', 'res.company')
def _get_id_res_company(self):
return {
self.env.company.id: {
'anglo_saxon_accounting': True,
'account_fiscal_country_id': 'base.id',
'bank_account_code_prefix': '1112',
'cash_account_code_prefix': '1111',
'transfer_account_code_prefix': '1999999',
'default_cash_difference_income_account_id': 'l10n_id_99900002',
'default_cash_difference_expense_account_id': 'l10n_id_99900001',
'account_default_pos_receivable_account_id': 'l10n_id_11210011',
'income_currency_exchange_account_id': 'l10n_id_81100030',
'expense_currency_exchange_account_id': 'l10n_id_91100020',
'account_journal_early_pay_discount_loss_account_id': 'l10n_id_99900003',
'account_journal_early_pay_discount_gain_account_id': 'l10n_id_99900004',
'account_sale_tax_id': 'tax_ST4',
'account_purchase_tax_id': 'tax_PT4',
'expense_account_id': 'l10n_id_51000010',
'income_account_id': 'l10n_id_41000010',
'account_stock_journal_id': 'inventory_valuation',
'account_stock_valuation_id': 'l10n_id_11300180',
'deferred_expense_account_id': 'l10n_id_11210040',
'deferred_revenue_account_id': 'l10n_id_28110030',
},
}
@template('id', 'account.account')
def _get_id_account_account(self):
return {
'l10n_id_11300180': {
'account_stock_expense_id': 'l10n_id_51000020',
'account_stock_variation_id': 'l10n_id_42500010',
},
}
@template('id', 'account.journal')
def _get_id_account_journal(self):
return {
'bank': {'default_account_id': 'l10n_id_11120001'},
'cash': {
'name': self.env._("Cash"),
'type': 'cash',
'default_account_id': 'l10n_id_11110001',
},
}