mirror of
https://github.com/bringout/oca-ocb-l10n_asia-pacific.git
synced 2026-04-26 02:02:05 +02:00
19.0 vanilla
This commit is contained in:
parent
7dc55599c6
commit
7f43bbbfcc
650 changed files with 45260 additions and 33436 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
149
odoo-bringout-oca-ocb-l10n_id/l10n_id/models/res_bank.py
Normal file
149
odoo-bringout-oca-ocb-l10n_id/l10n_id/models/res_bank.py
Normal 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'],
|
||||
})
|
||||
63
odoo-bringout-oca-ocb-l10n_id/l10n_id/models/template_id.py
Normal file
63
odoo-bringout-oca-ocb-l10n_id/l10n_id/models/template_id.py
Normal 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',
|
||||
},
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue