Integrasi Mekari Sign

Bismillah,
Assalamu'alaikum Warohmatullah Wabarokatuh,

Di artikel kali ini, insyaAllah saya akan memberikan sedikit tips bagaimana cara melakukan integrasi mekari sign.

Studi kasusnya adalah dengan saya men-generate dokumen PDF atau melampirkannya ke dalam odoo dengan memberikan atribut koordinat posisi tanda tangan digital, kemudian dikirim ke mekari menggunakan integrasi, ketika dokumen PDF selesai di tanda tangani, maka akan terupdate di odoo melalui url callback.

Untuk menentukan koordinat posisi tanda tangan agar tidak dilakukan manual, kita bisa menggunakan libary python PyMuPDF (fitz) yang insyaAllah akan dijelaskan di artikel terpisah.

Berikut langkah - langkahnya :

1. Pastikan sudah memiliki akun di mekari, jika belum silahkan lakukan registrasi di https://sandbox-account.mekari.com

2. Buat Config untuk menampung base url mekari, client id, client secret, authorized code, access token, expired token (optional), refresh token, endpoint global sign dan url callback, jangan lupa untuk munculkan di view configny.

Disini saya juga menambahkan fungsi get authorized code untuk mengisi field authorized code yang didapatkan dari link redirect di login portal mekari, fungis get token untuk melakukan mengisi field token dan mengupdate field expired token, serta fungsi refresh token untuk mengganti token yang sudah expire.

Untuk dokumentasi integrasi bisa dicek di https://documenter.getpostman.com/view/21582074/2s93K1oecc

from odoo import models, fields, api
import requests
import json
from datetime import date, datetime, time, timedelta
from odoo.exceptions import ValidationError, UserError

class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'

mekari_url = fields.Char(string="Mekari URL", config_parameter="aqn_mekari_ext.mekari_url")
mekari_client_id = fields.Char(string='Client ID', config_parameter="aqn_mekari_ext.mekari_client_id")
mekari_client_secret = fields.Char(string='Client Secret', config_parameter="aqn_mekari_ext.mekari_client_secret")
mekari_authorized_code = fields.Char(string="Authorized Code", config_parameter="aqn_mekari_ext.mekari_authorized_code")
mekari_access_token = fields.Char(string="Access Token", config_parameter="aqn_mekari_ext.mekari_access_token")
mekari_expired_token = fields.Datetime(string="Expired Token", config_parameter="aqn_mekari_ext.mekari_expired_token")
mekari_refresh_token = fields.Char(string="Refresh Token", config_parameter="aqn_mekari_ext.mekari_refresh_token")
mekari_endpoint_globalsign = fields.Char(string="Endpoint Global Sign", config_parameter="aqn_mekari_ext.mekari_endpoint_globalsign")
mekari_url_download_doc = fields.Char(string="URL Download Document", config_parameter="aqn_mekari_ext.mekari_url_download_doc")
mekari_url_callback = fields.Char(string="URL Callback Akad", config_parameter="aqn_mekari_ext.mekari_url_callback")
def get_mekari_config_api(self, param, check_exist=False):
params = {
'mekari_client_id': self.env['ir.config_parameter'].sudo().get_param('aqn_mekari_ext.mekari_client_id'),
'mekari_client_secret': self.env['ir.config_parameter'].sudo().get_param('aqn_mekari_ext.mekari_client_secret'),
'mekari_auth_code': self.env['ir.config_parameter'].sudo().get_param('aqn_mekari_ext.mekari_authorized_code'),
'mekari_url': self.env['ir.config_parameter'].sudo().get_param('aqn_mekari_ext.mekari_url'),
'mekari_refresh_token': self.env['ir.config_parameter'].sudo().get_param('aqn_mekari_ext.mekari_refresh_token'),
'mekari_access_token': self.env['ir.config_parameter'].sudo().get_param('aqn_mekari_ext.mekari_access_token'),
'mekari_endpoint_globalsign': self.env['ir.config_parameter'].sudo().get_param('aqn_mekari_ext.mekari_endpoint_globalsign'),
'mekari_url_download_doc': self.env['ir.config_parameter'].sudo().get_param('aqn_mekari_ext.mekari_url_download_doc'),
'mekari_url_callback': self.env['ir.config_parameter'].sudo().get_param('aqn_mekari_ext.mekari_url_callback'),
}

if param == 'mekari_url':
if not params.get('mekari_url') and not check_exist:
raise ValidationError('Mekari URL belum diisi di Settings (Mekari API)')
elif param == 'mekari_client_id':
if not params.get('mekari_client_id') and not check_exist:
raise ValidationError('Client ID belum diisi di Settings (Mekari API)')
elif param == 'mekari_client_secret':
if not params.get('mekari_client_secret') and not check_exist:
raise ValidationError('Client Secret belum diisi di Settings (Mekari API)')

elif param == 'mekari_auth_code':
if not params.get('mekari_auth_code') and not check_exist:
raise ValidationError('Authorized Code belum diisi di Settings (Mekari API)')
elif param == 'mekari_access_token':
if not params.get('mekari_access_token') and not check_exist:
raise ValidationError('Access Token tidak ditemukan, silahkan lakukan get access token di Settings (Mekari API)')

elif param == 'mekari_refresh_token':
if not params.get('mekari_refresh_token') and not check_exist:
raise ValidationError('Refresh Token tidak ditemukan, silahkan lakukan get access token di Settings (Mekari API)')
elif param == 'mekari_endpoint_globalsign':
if not params.get('mekari_endpoint_globalsign') and not check_exist:
raise ValidationError('Endpoint Global Sign belum diisi di Settings (Mekari API)')
elif param == 'mekari_url_download_doc':
if not params.get('mekari_url_download_doc') and not check_exist:
raise ValidationError('URL Download Document belum diisi di Settings (Mekari API)')
elif param == 'mekari_url_callback':
if not params.get('mekari_url_callback') and not check_exist:
raise ValidationError('URL Callback belum diisi di Settings (Mekari API)')
if param:
return params.get(param)


def rpc_mekari_get_token(self):
mekari_url = self.get_mekari_config_api('mekari_url')
client_id = self.get_mekari_config_api('mekari_client_id')
client_secret = self.get_mekari_config_api('mekari_client_secret')
auth_code = self.get_mekari_config_api('mekari_auth_code')
access_token = self.get_mekari_config_api('mekari_access_token', True)

if access_token:
raise ValidationError('Access token sudah terisi, silahkan lakukan refresh token atau hapus Access Token')

base_endpoint = f'{mekari_url}/auth/oauth2/token'
headers = {
'Content-Type': 'application/json',
'Connection': 'keep-alive',
'Accept': 'application/json',
}
data = {
"client_id": f"{client_id}",
"client_secret": f"{client_secret}",
"grant_type": "authorization_code",
"code": f"{auth_code}"
}
response = requests.post(base_endpoint, data=json.dumps(data), headers=headers)
result = response.json()
if result.get('access_token'):
self.env['ir.config_parameter'].sudo().set_param('aqn_mekari_ext.mekari_access_token', result.get('access_token'))
self.env['ir.config_parameter'].sudo().set_param('aqn_mekari_ext.mekari_refresh_token', result.get('refresh_token'))
self.env['ir.config_parameter'].sudo().set_param('aqn_mekari_ext.mekari_expired_token', fields.Datetime.now() + timedelta(hours=1))
else:
raise ValidationError(str(result))
def rpc_mekari_refresh_token(self):
mekari_url = self.get_mekari_config_api('mekari_url')
client_id = self.get_mekari_config_api('mekari_client_id')
client_secret = self.get_mekari_config_api('mekari_client_secret')
refresh_token = self.get_mekari_config_api('mekari_refresh_token')

base_endpoint = f'{mekari_url}/auth/oauth2/token'
headers = {
'Content-Type': 'application/json',
'Connection': 'keep-alive',
'Accept': 'application/json',
}
data = {
"client_id": f"{client_id}",
"client_secret": f"{client_secret}",
"grant_type": "refresh_token",
"refresh_token": f"{refresh_token}"
}
response = requests.post(base_endpoint, data=json.dumps(data), headers=headers)
result = response.json()
if result.get('access_token'):
self.env['ir.config_parameter'].sudo().set_param('aqn_mekari_ext.mekari_access_token', result.get('access_token'))
self.env['ir.config_parameter'].sudo().set_param('aqn_mekari_ext.mekari_refresh_token', result.get('refresh_token'))
self.env['ir.config_parameter'].sudo().set_param('aqn_mekari_ext.mekari_expired_token', fields.Datetime.now() + timedelta(hours=1))
else:
raise ValidationError(str(result))
def rpc_mekari_get_authorized_code(self):
mekari_url = self.get_mekari_config_api('mekari_url')
client_id = self.get_mekari_config_api('mekari_client_id')
auth_code = self.get_mekari_config_api('mekari_auth_code', True)

if auth_code:
raise ValidationError('Authorized Code sudah terisi, silahkan lakukan get access token atau hapus Authorized Code')

return {
'type': 'ir.actions.act_url',
'url': f'{mekari_url}/auth?client_id={client_id}&response_type=code&scope=esign&lang=id',
'target': 'new',
}


<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<data>

<record id="res_config_settings_view_form_inherit_mekari" model="ir.ui.view">
<field name="name">res.config.settings.view.form.inherit.mekari</field>
<field name="model">res.config.settings</field>
<field name="inherit_id" ref="base_setup.res_config_settings_view_form"/>
<field name="arch" type="xml">
<xpath expr="//div[hasclass('settings')]" position="inside">
<div class="app_settings_block" data-string="Mekari API" string="Mekari API" data-key="aqn_mekari_ext">
<h2>Mekari API</h2>
<div class="row mt16 o_settings_container" id="mekari_setting_container">
<div class="col-12 col-lg-6 o_setting_box" id="mekari_configuration">
<div class="content-group">
<group>
<field name="mekari_url"/>
<field name="mekari_client_id"/>
<field name="mekari_client_secret"/>
<field name="mekari_authorized_code"/>
</group>
<button name="rpc_mekari_get_authorized_code" icon="fa-arrow-right" type="object" string="Get Authorized Code" class="btn-link"/>
<group>
<field name="mekari_access_token"/>
</group>
<button name="rpc_mekari_get_token" icon="fa-arrow-right" type="object" string="Get Access Token" class="btn-link"/>
<group>
<field name="mekari_expired_token" readonly="1"/>
<field name="mekari_refresh_token"/>
</group>
<button name="rpc_mekari_refresh_token" icon="fa-arrow-right" type="object" string="Refresh Token" class="btn-link"/>
<group>
<field name="mekari_endpoint_globalsign"/>
<field name="mekari_url_download_doc"/>
<field name="mekari_url_callback"/>
</group>
</div>
</div>
</div>
</div>
</xpath>
</field>

</record>
</data>
</odoo>




3. Buat object baru untuk menampung atribut koordinat posisi dari tanda tangan digitalnya di object yang akan kita kirim PDFnya ke mekari dalam bentuk field one2many

# -*- coding: utf-8 -*-

from odoo import models, fields, api

class AqnSignatureUsers(models.Model):
_name = 'aqn.signature.users'
_description = 'Signature Users'

order_id = fields.Many2one('sale.order', string="Sale Order")
page = fields.Integer(string="Page")
partner_id = fields.Many2one('res.partner', string="Partner")
is_meterai = fields.Boolean(string="Meterai")
pos_x = fields.Float(string="Position X (px)")
pos_y = fields.Float(string="Position Y (px)")
width = fields.Float(string="Width (px)", default=80)
height = fields.Float(string="Height (px)", default=80)
canvas_width = fields.Float(string="Canvas Width (px)", default=595)
canvas_height = fields.Float(string="Canvas Height (px)", default=842)


# -*- coding: utf-8 -*-

from odoo import models, fields, api
import base64
from odoo.tools import pdf
from odoo.exceptions import ValidationError,UserError
class aqn_akad(models.Model):
_inherit= 'sale.order'

akad_id= fields.Many2one(comodel_name='aqn.akad.template',compute='get_akad',store=True ,precompute=True,)
filename_akad=fields.Char()
sign_users_line = fields.One2many('aqn.signature.users', 'order_id', string="Signature Line")

<odoo>
<data>
<record id="sale_order_akad_form_view" model="ir.ui.view">
<field name="name">sale.order.akad.view.form</field>
<field name="model">sale.order</field>
<field name="inherit_id" ref="sale.view_order_form" />
<field name="arch" type="xml">
<page name="other_information" position="before">
<page name="partner_signature_page" string="Akad Esign">
<field name="sign_users_line">
<tree editable="bottom">
<field name="page"/>
<field name="partner_id"/>
<field name="is_meterai"/>
<field name="pos_x"/>
<field name="pos_y"/>
<field name="width"/>
<field name="height"/>
<field name="canvas_width"/>
<field name="canvas_height"/>
</tree>
</field>
</page>
</page>
</field>
</record>
</data>
</odoo>



4. Di object di object yang akan kita kirim PDFnya ke mekari tambahkan field mekari_doc_id, mekari_esign_token (untuk verifikasi callback) dan buat fungsi send file dan fungsi update attachment untuk mengupdate dokumen yang sudah ditanda tangani


class SaleOrder(models.Model):
_inherit = 'sale.order'

mekari_doc_id = fields.Char('Mekari Doc ID')
mekari_esign_token = fields.Char('Mekari Esign Token')


def remote_mekari_akad(self):
obj_config = self.env['res.config.settings']

expired_token = self.env['ir.config_parameter'].sudo().get_param('aqn_mekari_ext.mekari_expired_token')
if expired_token:
if expired_token < str(fields.Datetime.now()):
obj_config.rpc_mekari_refresh_token()

access_token = obj_config.get_mekari_config_api('mekari_access_token')
endpoint_global_sign = obj_config.get_mekari_config_api('mekari_endpoint_globalsign')

if not endpoint_global_sign:
raise ValidationError('Endpoint Global Sign belum diisi di Settings (Mekari API)')
base_endpoint = f'{endpoint_global_sign}'
headers = {
'Content-Type': 'application/json',
'Connection': 'keep-alive',
'Accept': 'application/json',
'Authorization': 'Bearer %s' % access_token
}
for rec in self:
dokumen = rec.akad_form.decode('utf-8')
url_callback = obj_config.get_mekari_config_api('mekari_url_callback')

token = str(uuid.uuid4())
url = f"{url_callback}?sale_id={rec.id}&token={token}"

annotations = []
signers = []
for sign in rec.sign_users_line:
if sign.is_meterai:
annotations.append({
"type_of": "meterai", #// optional default signature, (signature, meterai, initial, date_signed)
"meterai_provided": True,
"page": sign.page,
"position_x": sign.pos_x,
"position_y": sign.pos_y,
"element_width": sign.width,
"element_height": sign.height,
"canvas_width": sign.canvas_width,
"canvas_height": sign.canvas_height,
})
annotations.append({
"type_of": "signature", #// optional default signature, (signature, meterai, initial, date_signed)
"page": sign.page,
"position_x": sign.pos_x + sign.width,
"position_y": sign.pos_y,
"element_width": sign.width,
"element_height": sign.height,
"canvas_width": sign.canvas_width,
"canvas_height": sign.canvas_height,
})
# annotations.append({
# "type_of": "name", #// optional default signature, (signature, meterai, initial, date_signed)
# "page": sign.page,
# "position_x": sign.pos_x,
# "position_y": sign.pos_y + 50,
# "element_width": sign.width,
# "element_height": sign.height,
# "canvas_width": sign.canvas_width,
# "canvas_height": sign.canvas_height,
# # "align": "center"
# })
signers.append(
{
"name": sign.partner_id.name,
"email": sign.partner_id.email,
"annotations": annotations
}
)
annotations = []
raw_data = {
"doc": dokumen,
"filename": f"{rec.filename_akad}", # required
"signers": signers,
"signing_order": False, #// optional, default false
"callback_url": url #// optional
}

response = requests.post(base_endpoint, data=json.dumps(raw_data), headers=headers)
result = response.json()
print (result,'=================')
if result:
if result.get('data'):
if result.get('data').get('id'):
rec.mekari_doc_id = result.get('data').get('id')
rec.mekari_esign_token = token
if result.get('error') == True:
raise ValidationError(str(result))


def rpc_update_akad_form(self, sale_id=False, token=False):

obj_config = self.env['res.config.settings']

expired_token = self.env['ir.config_parameter'].sudo().get_param('aqn_mekari_ext.mekari_expired_token')
if expired_token:
if expired_token < str(fields.Datetime.now()):
obj_config.rpc_mekari_refresh_token()

access_token = obj_config.get_mekari_config_api('mekari_access_token')

if sale_id:
order = self.env['sale.order'].browse(sale_id)

if order.mekari_esign_token != token:
return {
'error': True,
'message': f'Token tidak sesuai',
'data': {}
}

url_doc = obj_config.get_mekari_config_api('mekari_url_download_doc')

# url = f'https://sandbox-api.mekari.com/v2/esign/v1/documents/{order.mekari_doc_id}/download'
url = f"{url_doc}/{order.mekari_doc_id}/download"

headers = {
'Content-Type': 'application/json',
'Connection': 'keep-alive',
'Accept': 'application/json',
'Authorization': 'Bearer %s' % access_token
}
response = requests.get(url, headers=headers)

try:
return response.json()
except Exception as e:
pdf = base64.b64encode(response.content).decode('utf-8')
filename = order.filename_akad
order.akad_form = False
order.akad_form = pdf
order.filename_akad = filename
order.is_completed = True
return {
'error': False,
'message': f'File {order.filename_akad} Berhasil diupdate di SO {order.name}',
'data': {}
}
return {
'error': True,
'message': f'SO dengan ID {sale_id} tidak ditemukan',
'data': {}
}

5. munculkan di view object-nya

<odoo>
<data>
<record id="view_order_form_inherit_so" model="ir.ui.view">
<field name="name">sale.order.inherit</field>
<field name="model">sale.order</field>
<field name="inherit_id" ref="sale.view_order_form"/>
<field name="arch" type="xml">
<button name="action_quotation_send" position="before">
<button name="remote_mekari_akad" type="object" class="btn-primary" string="Send Akad Form" attrs="{'invisible':['|',('is_akad','=',False),('state','!=','sale')]}" required="1"/>
</button>
<field name="payment_term_id" position="after">
<field name="mekari_doc_id" attrs="{'invisible':[('mekari_doc_id','=',False)]}" readonly="1"/>
<field name="mekari_esign_token" invisible="1"/>
</field>
</field>
</record>

</data>
</odoo>


6. Buat controller untuk url callback yang akan dipanggil ketika dokumen yang kita kirim sudah ditandatangani semua

# -*- coding: utf-8 -*-
from odoo import http

class AqnMekariExt(http.Controller):
@http.route('/mekari_callback_esign', auth='public', type="http", csrf=False)
def index(self, **kw):
so = http.request.env['sale.order']
if kw.get('sale_id'):
result = so.sudo().rpc_update_akad_form(int(kw.get('sale_id')), kw.get('token'))
return str(result)

Terima kasih, semoga bermanfaat.
Kahfi
Wassalamu'alaikum Warohmatullah Wabarokatuh











Digital Signature