diff --git a/alipay/README.rst b/alipay/README.rst new file mode 100644 index 0000000000..6d447b32dd --- /dev/null +++ b/alipay/README.rst @@ -0,0 +1,76 @@ +============ + Alipay API +============ + +Basic tools to integrate Odoo and Alipay. + +.. contents:: + :local: + +Payment methods +=============== + +Barcode Payment +--------------- +The merchant **scans** the QR code generated in buyer's Alipay Wallet, to charge the buyer and complete the payment. + +QR Code Payment +--------------- + +The merchant generates the QR code and **shows** to a buyer. The buyer will scan the QR code with Alipay Wallet and complete the payment process. + +Sound Wave Payment +------------------ + +The merchant processes the sound wave generated by Alipay Wallet, which identifies the buyer’s Alipay account, then charges the buyer and complete the payment. + +Alipay Documentation & tools +============================ + +Sandbox & Debugging +------------------- + +TODO + +Payments +-------- + +* https://docs.open.alipay.com/140 +* https://docs.open.alipay.com/api_1/ + +Debugging +========= + +Local Debug +----------- + +To debug UI, create *System Parameter* ``alipay.local_sandbox`` with value ``1``. All requests to alipay will return fake result without making a request. + +Credits +======= + +Contributors +------------ +* `Ivan Yelizariev `__ +* `Dinar Gabbasov `__ + +Sponsors +-------- +* `Sinomate `__ + +Maintainers +----------- +* `IT-Projects LLC `__ + +Further information +=================== + +Demo: http://runbot.it-projects.info/demo/misc-addons/11.0 + +HTML Description: https://apps.odoo.com/apps/modules/11.0/alipay/ + +Usage instructions: ``_ + +Changelog: ``_ + +Tested on Odoo 11.0 ee2b9fae3519c2494f34dacf15d0a3b5bd8fbd06 diff --git a/alipay/__init__.py b/alipay/__init__.py new file mode 100644 index 0000000000..ab2f6edd50 --- /dev/null +++ b/alipay/__init__.py @@ -0,0 +1,3 @@ +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +from . import models +from . import controllers diff --git a/alipay/__manifest__.py b/alipay/__manifest__.py new file mode 100644 index 0000000000..903c475c47 --- /dev/null +++ b/alipay/__manifest__.py @@ -0,0 +1,42 @@ +# Copyright 2018 Ivan Yelizariev +# Copyright 2018 Dinar Gabbasov +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +{ + "name": """Alipay API""", + "summary": """Technical module to integrate odoo with Alipay""", + "category": "Hidden", + # "live_test_url": "", + "images": ['images/alipay.png'], + "version": "11.0.1.0.0", + "application": False, + + "author": "IT-Projects LLC, Ivan Yelizariev", + "support": "apps@it-projects.info", + "website": "https://it-projects.info/team/yelizariev", + "license": "LGPL-3", + # "price": 9.00, + # "currency": "EUR", + + "depends": [ + 'product', + 'account', + 'qr_payments', + ], + "external_dependencies": {"python": [ + 'alipay', + ], "bin": []}, + "data": [ + "views/account_menuitem.xml", + "views/alipay_order_views.xml", + "views/alipay_refund_views.xml", + "views/account_journal_views.xml", + "data/ir_sequence_data.xml", + "data/module_data.xml", + "security/alipay_security.xml", + "security/ir.model.access.csv", + ], + "qweb": [], + + "auto_install": False, + "installable": True, +} diff --git a/alipay/controllers/__init__.py b/alipay/controllers/__init__.py new file mode 100644 index 0000000000..db97a3abef --- /dev/null +++ b/alipay/controllers/__init__.py @@ -0,0 +1,2 @@ +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +from . import alipay_controllers diff --git a/alipay/controllers/alipay_controllers.py b/alipay/controllers/alipay_controllers.py new file mode 100644 index 0000000000..d77adcddf7 --- /dev/null +++ b/alipay/controllers/alipay_controllers.py @@ -0,0 +1,109 @@ +# Copyright 2018 Ivan Yelizariev +# Copyright 2018 Dinar Gabbasov +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +from lxml import etree +import logging +import base64 +from odoo.exceptions import UserError + +from odoo import http, _ +from odoo.http import request +import requests + +_logger = logging.getLogger(__name__) +ALIPAY_NOTIFY_URL = '/alipay/callback' + + +class AlipayController(http.Controller): + + @http.route(ALIPAY_NOTIFY_URL, methods=['POST'], auth='public', type='http', csrf=False) + def alipay_callback(self): + xml_raw = request.httprequest.get_data().decode(request.httprequest.charset) + _logger.debug('/alipay/callback request data: %s\nheaders %s: ', xml_raw, request.httprequest.headers) + + # convert xml to object + xml = etree.fromstring(xml_raw) + data = {} + for child in xml: + data[child.tag] = child.text + + res = request.env['alipay.order'].sudo().on_notification(data) + + if res is not False: + return """""" + else: + return """""" + + @http.route('/alipay/miniprogram/authenticate', type='json', auth='public', csrf=False) + def authenticate(self, code, user_info, test_cr=False): + """ + :param code: After the user is permitted to log in on the Alipay mini-program, the callback content will + bring the code (five-minute validity period). The developer needs to send the code to the backend + of their server and use code in exchange for the session_key api. + The code is exchanged for the openid and session_key. + :param user_info: User information object, does not contain sensitive information such as openid + :return session_info: All information about session such as session_id, uid, etc. + """ + _logger.debug('/alipay/miniprogram/authenticate request: code - %s, user_info - %s', code, user_info) + openid, session_key = self.get_openid(code) + _logger.debug('Authenticate on Alipay server: openid - %s, session_key - %s', openid, session_key) + + if not openid or not session_key: + raise UserError(_('Unable to get data from Alipay server : openid - %s, session_key - %s') % (openid, session_key)) + + User = request.env['res.users'].sudo() + user = User.search([('openid', '=', openid)]) + if user: + user.write({ + 'alipay_session_key': session_key, + }) + else: + country = request.env['res.country'].search([('name', 'ilike', '%'+user_info.get('country')+'%')]) + name = user_info.get('nickName') + login = "alipay_%s" % openid + city = user_info.get('city') + + user = User.create({ + 'company_id': request.env.ref("base.main_company").id, + 'name': name, + 'image': base64.b64encode(requests.get(user_info.get('avatarUrl')).content) if user_info.get('avatarUrl') else None, + 'openid': openid, + 'alipay_session_key': session_key, + 'login': login, + 'country_id': country.id if country else None, + 'city': city, + 'groups_id': [(4, request.env.ref('alipay.group_miniprogram_user').id)] + }) + + if test_cr is False: + # A new cursor is used to authenticate the user and it cannot see the + # latest changes of current transaction. + # Therefore we need to make the commit. + + # In test mode, one special cursor is used for all transactions. + # So we don't need to make the commit. More over commit() shall not be used, + # because otherwise test changes are not rollbacked at the end of test + request.env.cr.commit() + + request.session.authenticate(request.db, user.login, user.alipay_session_key) + _logger.debug('Current user login: %s, id: %s', request.env.user.login, request.env.user.id) + session_info = request.env['ir.http'].session_info() + return session_info + + def get_openid(self, code): + """Get openid + + :param code: After the user is permitted to log in on the Alipay mini-program, the callback content will + bring the code (five-minute validity period). The developer needs to send the code to the backend + of their server and use code in exchange for the session_key api. + The code is exchanged for the openid and session_key. + :return openid: The Alipay user's unique ID + """ + url = request.env['ir.config_parameter'].sudo().get_openid_url(code) + response = requests.get(url) + response.raise_for_status() + value = response.json() + _logger.debug('get_openid function return parameters: %s', value) + openid = value.get('openid') + session_key = value.get('session_key') + return openid, session_key diff --git a/alipay/data/ir_sequence_data.xml b/alipay/data/ir_sequence_data.xml new file mode 100644 index 0000000000..b5099a5339 --- /dev/null +++ b/alipay/data/ir_sequence_data.xml @@ -0,0 +1,20 @@ + + + + + Alipay Order + alipay.order + WO- + 3 + + + + + Alipay Refund + alipay.refund + WR- + 3 + + + diff --git a/alipay/data/module_data.xml b/alipay/data/module_data.xml new file mode 100644 index 0000000000..7372d74fb5 --- /dev/null +++ b/alipay/data/module_data.xml @@ -0,0 +1,8 @@ + + + + Mini-program + Helps you manage your Alipay and main operations: create orders, payment, etc... + 60 + + diff --git a/alipay/doc/changelog.rst b/alipay/doc/changelog.rst new file mode 100644 index 0000000000..9ee2b48b8e --- /dev/null +++ b/alipay/doc/changelog.rst @@ -0,0 +1,4 @@ +`1.0.0` +------- + +- Init version diff --git a/alipay/doc/index.rst b/alipay/doc/index.rst new file mode 100644 index 0000000000..c9c5ffe3bf --- /dev/null +++ b/alipay/doc/index.rst @@ -0,0 +1,63 @@ +============ + Alipay API +============ + +.. contents:: + :local: + +Installation +============ + +* Install `alipay library`__:: + + pip install python-alipay-sdk + + # to update existing installation use + pip install -U python-alipay-sdk + +* Be sure that your server available for requests from outside world (i.e. it shall not be avaialble in local network only) + +Alipay APP +========== + +TODO + +Configuration +============= + +Credentials +----------- + +* `Activate Developer Mode `__ +* Open menu ``[[ Settings ]] >> Parameters >> System Parameters`` +* Create following parameters + + * ``alipay.app_id`` + * ``alipay.app_auth_code`` -- optional. Only for ISV (Third-party Service Provider) + * ``alipay.app_private_key_file`` -- path to file + * ``alipay.alipay_public_key_string`` -- content of public key file. Starts with ``-----BEGIN PUBLIC KEY-----`` + * ``alipay.app_auth_code`` + * ``alipay.app_auth_token`` + * ``alipay.notify_url`` -- optional. Use it if doesn't work automatiically. The url must be ``http(s)://YOUR_HOST/alipay/callback``. + + +Internal Numbers +---------------- + +If you get error ``invalid out_trade_no``, it means that you use the same +credentials in new database and odoo sends Alipay Order IDs that were previously +used in another system. To resolve this do as following: + +* Go to ``[[ Settings ]] >> Technical >> Sequence & Identifiers >> Sequences`` +* Find record *Alipay Order* or *Alipay Refund**, depending on which request has the problem +* Change either **Prefix**, **Suffix** or **Next Number** +* If you get the error again, try to increase **Next Number** + +Alipay tracking +--------------- +Alipay records (Orders, Refunds, etc.) can be found at ``[[ Invoicing ]] >> Configuration >> Alipay``. If you don't have that menu, you need to configure ``Show Full Accounting Features`` for your user first: + +* `Activate Developer Mode `__ +* Open menu ``[[ Settings ]] >> Users & Companies >> Users`` +* Open user you need +* Activate ``Show Full Accounting Features`` diff --git a/alipay/images/alipay.png b/alipay/images/alipay.png new file mode 100644 index 0000000000..1e3020ddac Binary files /dev/null and b/alipay/images/alipay.png differ diff --git a/alipay/models/__init__.py b/alipay/models/__init__.py new file mode 100644 index 0000000000..33511b8234 --- /dev/null +++ b/alipay/models/__init__.py @@ -0,0 +1,6 @@ +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +from . import alipay_order +from . import alipay_refund +from . import ir_config_parameter +from . import account_journal +from . import res_users diff --git a/alipay/models/account_journal.py b/alipay/models/account_journal.py new file mode 100644 index 0000000000..ec5cebca64 --- /dev/null +++ b/alipay/models/account_journal.py @@ -0,0 +1,12 @@ +# Copyright 2018 Ivan Yelizariev +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +from odoo import models, fields + + +class Journal(models.Model): + _inherit = 'account.journal' + + alipay = fields.Selection([ + ('scan', 'Scanning customer\'s QR'), + ('show', 'Showing QR to customer'), + ], string='Alipay Payment', help='Register for Alipay payment') diff --git a/alipay/models/alipay_order.py b/alipay/models/alipay_order.py new file mode 100644 index 0000000000..c979f77158 --- /dev/null +++ b/alipay/models/alipay_order.py @@ -0,0 +1,303 @@ +# Copyright 2018 Ivan Yelizariev +# Copyright 2018 Dinar Gabbasov +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +import logging +import json + +from odoo import models, fields, api +from odoo.tools.translate import _ + +_logger = logging.getLogger(__name__) + +try: + from alipay.exceptions import AliPayException +except ImportError as err: + _logger.debug(err) + + +PAYMENT_RESULT_NOTIFICATION_URL = 'alipay/callback' +SUCCESS = 'SUCCESS' + + +class AlipayOrder(models.Model): + """Records with order information and payment status. + + Can be used for different types of Payments. See description of trade_type field. """ + + _name = 'alipay.order' + _description = 'Unified Order' + _order = 'id desc' + + name = fields.Char('Name', readonly=True) + order_ref = fields.Char('Order Reference', readonly=True) + total_amount = fields.Float('Total Amount', help='Amount in currency units (not cents)', readonly=True) + discountable_amount = fields.Float('Discountable Amount', help='Amount in currency units (not cents)', readonly=True) + state = fields.Selection([ + ('draft', 'Unpaid'), + ('done', 'Paid'), + ('error', 'Error'), + ('refunded', 'Refunded (part of full amount)'), + ], string='State', default='draft') + # terminal_ref = fields.Char('Terminal Reference', help='e.g. POS Name', readonly=True) + debug = fields.Boolean('Sandbox', help="Payment was not made. It's only for testing purposes", readonly=True) + order_details_raw = fields.Text('Raw Order', readonly=True) + result_raw = fields.Text('Raw result', readonly=True) + notification_result_raw = fields.Text('Raw Notification result', readonly=True) + currency_id = fields.Many2one('res.currency', default=lambda self: self.env.user.company_id.currency_id) + notification_received = fields.Boolean(help='Set to true on receiving notifcation to avoid repeated processing', default=False) + journal_id = fields.Many2one('account.journal') + refund_fee = fields.Integer('Refund Amount', compute='_compute_refund_fee') + line_ids = fields.One2many('alipay.order.line', 'order_id') + refund_ids = fields.One2many('alipay.refund', 'order_id') + + @api.depends('refund_ids.refund_fee', 'refund_ids.state') + def _compute_refund_fee(self): + for r in self: + r.refund_fee = sum([ + ref.refund_fee + for ref in r.refund_ids + if ref.state == 'done' + ]) + + def _body(self): + """ Example of result: + + {"goods_detail": [ + { + "goods_id": "iphone6s_16G", + "wxpay_goods_id": "100 1", + "goods_name": "iPhone 6s 16G", + "goods_num": 1, + "price": 100, + "goods_category": "123456", + "body": "苹果手机", + }, + { + "goods_id": "iphone6s_3 2G", + "wxpay_goods_id": "100 2", + "goods_name": "iPhone 6s 32G", + "quantity": 1, + "price": 200, + "goods_category": "123789", + } + ]}""" + self.ensure_one() + rendered_lines = [] + order_body = [] + for line in self.line_ids: + name = line.name or line.product_id.name + body = name + if line.quantity_full != '1': + body = '%s %s' % (body, line.quantity_full) + order_body.append(body) + rline = { + 'goods_id': str(line.product_id.id), + 'goods_name': name, + 'goods_num': line.quantity, + 'price': line.get_fee(), + 'body': body + } + if line.category: + rline['category'] = line.category + + if line.wxpay_goods_ID: + rline['wxpay_goods_id'] = line.wxpay_goods_id + + rendered_lines.append(rline) + detail = {'goods_detail': rendered_lines} + order_body = '; '.join(order_body) + + return order_body, detail + + def _total_amount(self): + self.ensure_one() + total_amount = sum([ + line.get_amount() + for line in self.line_ids]) + return total_amount + + @api.model + def _create_from_qr(self, auth_code, total_amount, journal_id, subject, terminal_ref=None, create_vals=None, order_ref=None, **kwargs): + """ + :param product_category: is used to prepare "body" + :param total_amount: Specifies the amount to pay. The units are in currency units (not cents) + :param create_vals: extra args to pass on record creation + """ + debug = self.env['ir.config_parameter'].get_param('alipay.local_sandbox') == '1' + total_amount = total_amount + vals = { + 'journal_id': journal_id, + 'debug': debug, + 'terminal_ref': terminal_ref, + 'order_ref': order_ref, + 'total_amount': total_amount, + } + if create_vals: + vals.update(create_vals) + record = self.create(vals) + + if debug: + _logger.info('SANDBOX is activated. Request to apipay API servers are not sending') + # Dummy Data. Change it to try different scenarios + # Doc: https://docs.open.alipay.com/140/104626 + result_json = { + "code": "10003", + "msg": "订单创建成功支付处理中", + "trade_no": "2013112011001004330000121536", + "out_trade_no": record.name, + "buyer_user_id": "2088102122524333", + "buyer_logon_id": "159****5620", + "total_amount": "88.88" + } + if self.env.context.get('debug_alipay_response'): + result_json = self.env.context.get('debug_alipay_response') + else: + alipay = self.env['ir.config_parameter'].get_alipay_object() + # TODO: we probably have make cr.commit() before making request to + # be sure that we save data before sending request to avoid + # situation when order is sent to wechat server, but was not saved + # in our server for any reason + + result_json = alipay.api_alipay_trade_pay( + out_trade_no=record.name, + scene='bar_code', + auth_code=auth_code, + subject=subject, + total_amount=total_amount, + discountable_amount=kwargs.get('discountable_amount') + ) + + result_raw = json.dumps(result_json) + _logger.debug('result_raw: %s', result_raw) + # TODO state must depend on result_json['code'] + vals = { + 'result_raw': result_raw, + 'state': 'done', + } + record.write(vals) + return record + + @api.model + def create_qr(self, lines, subject, **kwargs): + try: + order, code_url = self._create_qr(lines, subject, **kwargs) + except AliPayException as e: + return { + 'error': _('Error on sending request to Alipay: %s') % e.response.text + } + return {'code_url': code_url} + + @api.model + def _create_qr(self, lines, subject, create_vals=None, total_amount=None, **kwargs): + """Native Payment + + :param lines: list of dictionary + :param total_amount: amount in currency (not cents) + """ + debug = self.env['ir.config_parameter'].get_param('alipay.local_sandbox') == '1' + vals = { + 'trade_type': 'NATIVE', + 'line_ids': [(0, 0, data) for data in lines], + 'order_ref': kwargs.get('order_ref'), + 'journal_id': kwargs.get('journal_id'), + 'debug': debug, + } + if create_vals: + vals.update(create_vals) + order = self.create(vals) + total_amount = total_amount or order._total_amount() + if debug: + _logger.info('SANDBOX is activated. Request to apipay API servers are not sending') + # Dummy Data. Change it to try different scenarios + # Doc: https://docs.open.alipay.com/api_1/alipay.trade.precreate + result_json = { + "code": "10000", + "msg": "Success", + "out_trade_no": order.name, + "qr_code": "https://qr.alipay.com/bavh4wjlxf12tper3a" + } + if self.env.context.get('debug_alipay_response'): + result_json = self.env.context.get('debug_alipay_response') + else: + alipay = self.env['ir.config_parameter'].get_alipay_object() + # TODO: we probably have make cr.commit() before making request to + # be sure that we save data before sending request to avoid + # situation when order is sent to wechat server, but was not saved + # in our server for any reason + + result_json = alipay.api_alipay_trade_precreate( + out_trade_no=order.name, + subject=subject, + total_amount=total_amount, + discountable_amount=kwargs.get('discountable_amount') + ) + + result_raw = json.dumps(result_json) + _logger.debug('result_raw: %s', result_raw) + vals = { + 'result_raw': result_raw, + 'total_amount': total_amount, + } + order.write(vals) + code_url = result_json['qr_code'] + return order, code_url + + def on_notification(self, data): + """ + return updated record + """ + # check signature + wpay = self.env['ir.config_parameter'].get_alipay_pay_object() + if not wpay.check_signature(data): + _logger.warning("Notification Signature is not valid:\n", data) + return False + + order_name = data.get('out_trade_no') + order = None + if order_name: + order = self.search([('name', '=', order_name)]) + if not order: + _logger.warning("Order %s from notification is not found", order.id) + return False + + # check for duplicates + if order.notification_received: + _logger.warning("Notifcation duplicate is received: %s", order) + return None + + vals = { + 'notification_result_raw': json.dumps(data), + 'notification_received': True, + } + if not (data['return_code'] == SUCCESS and data['result_code'] == SUCCESS): + vals['state'] = 'error' + + else: + vals['state'] = 'done' + + order.write(vals) + return order + + @api.model + def create(self, vals): + vals['name'] = self.env['ir.sequence'].next_by_code('alipay.order') + return super(AlipayOrder, self).create(vals) + + +class AlipayOrderLine(models.Model): + _name = 'alipay.order.line' + + name = fields.Char('Name', help="When empty, product's name is used") + description = fields.Char('Body') + product_id = fields.Many2one('product.product', required=True) + wxpay_goods_ID = fields.Char('Alipay Good ID') + price = fields.Monetary('Price', required=True, help='Price in currency units (not cents)') + currency_id = fields.Many2one('res.currency', related='order_id') + quantity = fields.Integer('Quantity', default=1, help='Quantity as Integer (Alipay limitation)') + quantity_full = fields.Char('Quantity Value', default='1') + category = fields.Char('Category') + order_id = fields.Many2one('alipay.order') + + def get_amount(self): + self.ensure_one() + return self.price or self.product_id.price diff --git a/alipay/models/alipay_refund.py b/alipay/models/alipay_refund.py new file mode 100644 index 0000000000..d5872f5107 --- /dev/null +++ b/alipay/models/alipay_refund.py @@ -0,0 +1,74 @@ +# Copyright 2018 Ivan Yelizariev +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +import logging +import json + +from odoo import models, fields, api + +_logger = logging.getLogger(__name__) + +SUCCESS = 'SUCCESS' + + +class AlipayRefund(models.Model): + """Records with refund information and payment status. + + Can be used for different types of Payments. See description of trade_type field. """ + + _name = 'alipay.refund' + _description = 'Unified Refund' + _order = 'id desc' + + name = fields.Char('Name', readonly=True) + refund_ref = fields.Char('Refund Reference', readonly=True) + refund_fee = fields.Integer('Refund Fee', help='Amount in cents', readonly=True) + state = fields.Selection([ + ('draft', 'Draft'), + ('done', 'Completed'), + ('error', 'Error'), + ], string='State', default='draft') + debug = fields.Boolean('Sandbox', help="Refund was not made. It's only for testing purposes", readonly=True) + refund_details_raw = fields.Text('Raw Refund', readonly=True) + result_raw = fields.Text('Raw result', readonly=True) + currency_id = fields.Many2one('res.currency', default=lambda self: self.env.user.company_id.currency_id) + order_id = fields.Many2one('alipay.order') + journal_id = fields.Many2one('account.journal') + + @api.model + def create(self, vals): + vals['name'] = self.env['ir.sequence'].next_by_code('alipay.refund') + return super(AlipayRefund, self).create(vals) + + def action_confirm(self): + self.ensure_one() + debug = self.env['ir.config_parameter'].get_param('alipay.local_sandbox') == '1' + alipay = self.env['ir.config_parameter'].get_alipay_object() + record = self.order_id + if debug: + _logger.info('SANDBOX is activated. Request to alipay servers is not sending') + # Dummy Data. Change it to try different scenarios + if self.env.context.get('debug_alipay_refund_response'): + result_raw = self.env.context.get('debug_alipay_order_response') + else: + result_raw = { + 'return_code': 'SUCCESS', + 'result_code': 'SUCCESS', + 'transaction_id': '12177525012014', + 'refund_id': '12312122222', + } + + else: + alipay = self.env['ir.config_parameter'].get_alipay_pay_object() + result_raw = alipay.refund.apply( + record.total_fee, + self.refund_fee, + self.name, + out_trade_no=record.name, + ) + + vals = { + 'result_raw': json.dumps(result_raw), + 'state': 'done', + } + self.write(vals) + record.state = 'refunded' diff --git a/alipay/models/ir_config_parameter.py b/alipay/models/ir_config_parameter.py new file mode 100644 index 0000000000..834288639a --- /dev/null +++ b/alipay/models/ir_config_parameter.py @@ -0,0 +1,74 @@ +# Copyright 2018 Ivan Yelizariev +# Copyright 2018 Dinar Gabbasov +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +import logging +from odoo import models, api +from ..controllers.alipay_controllers import ALIPAY_NOTIFY_URL + + +_logger = logging.getLogger(__name__) +try: + from alipay import AliPay, ISVAliPay +except ImportError as err: + _logger.debug(err) + + +class Param(models.Model): + + _inherit = 'ir.config_parameter' + + @api.model + def get_alipay_object(self): + sandbox = self.sudo().get_param('alipay.sandbox', '0') != '0' + if sandbox: + _logger.info('Sandbox Mode is used for Alipay API') + + app_id = self.sudo().get_param('alipay.app_id', '') + app_auth_code = self.sudo().get_param('alipay.app_auth_code', '') + + app_private_key_file = self.sudo().get_param('alipay.app_private_key_file', '') + alipay_public_key_string = self.sudo().get_param('alipay.alipay_public_key_string', DEFAULT_ALIPAY_PUBLIC_KEY) + + if self.env.context.get('app_private_key_string'): + # It's used in tests + app_private_key_string = self.env.context.get('app_private_key_string') + else: + with open(app_private_key_file, 'r') as f: + app_private_key_string = f.read() + + options = dict( + appid=app_id, + app_private_key_string=app_private_key_string, + alipay_public_key_string=alipay_public_key_string, + sign_type="RSA", + debug=sandbox, + ) + + notify_url = self.sudo().get_param('alipay.notify_url') + if not notify_url: + base = self.sudo().get_param('web.base.url') + notify_url = "{base}{path}".format( + base=base, + path=ALIPAY_NOTIFY_URL, + ) + + options['app_notify_url'] = notify_url + + if app_auth_code: + # ISV + options['app_auth_code'] = app_auth_code + _logger.debug('ISV mode is used for Alipay', options) + res = ISVAliPay(**options) + else: + res = AliPay(**options) + _logger.debug( + 'Alipay parameters: %s', options + ) + return res + + +# FROM https://global.alipay.com/service/declaration/8 +DEFAULT_ALIPAY_PUBLIC_KEY = """-----BEGIN PUBLIC KEY----- +MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDDI6d306Q8fIfCOaTXyiUeJHkrIvYISRcc73s3vF1ZT7XN8RNPwJxo8pWaJMmvyTn9N4HQ632qJBVHf8sxHi/fEsraprwCtzvzQETrNRwVxLO5jVmRGi60j8Ue1efIlzPXV9je9mkjzOmdssymZkh2QhUrCmZYI/FCEa3/cNMW0QIDAQAB + +-----END PUBLIC KEY-----""" diff --git a/alipay/models/res_users.py b/alipay/models/res_users.py new file mode 100644 index 0000000000..6a7064e53b --- /dev/null +++ b/alipay/models/res_users.py @@ -0,0 +1,24 @@ +# Copyright 2018 Dinar Gabbasov +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +import logging + +from odoo import models, api, fields +from odoo.exceptions import AccessDenied + +_logger = logging.getLogger(__name__) + + +class ResUsers(models.Model): + _inherit = 'res.users' + + openid = fields.Char('Openid') + alipay_session_key = fields.Char("Alipay session key") + + @api.model + def check_credentials(self, password): + try: + return super(ResUsers, self).check_credentials(password) + except AccessDenied: + res = self.sudo().search([('id', '=', self.env.uid), ('alipay_session_key', '=', password)]) + if not res: + raise diff --git a/alipay/security/alipay_security.xml b/alipay/security/alipay_security.xml new file mode 100644 index 0000000000..7c008f8811 --- /dev/null +++ b/alipay/security/alipay_security.xml @@ -0,0 +1,9 @@ + + + + + User + + + diff --git a/alipay/security/ir.model.access.csv b/alipay/security/ir.model.access.csv new file mode 100644 index 0000000000..5728addf2e --- /dev/null +++ b/alipay/security/ir.model.access.csv @@ -0,0 +1,8 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_alipay_order_line,access_alipay_order_line,model_alipay_order_line,base.group_user,1,0,0,0 +access_alipay_order,access_alipay_order,model_alipay_order,base.group_user,1,0,0,0 +access_alipay_refund,access_alipay_refund,model_alipay_refund,base.group_user,1,0,0,0 +access_product_product,access_product_product,product.model_product_product,alipay.group_miniprogram_user,1,0,0,0 +access_product_template,access_product_template,product.model_product_template,alipay.group_miniprogram_user,1,0,0,0 +access_product_attribute_value,access_product_attribute_value,product.model_product_attribute_value,alipay.group_miniprogram_user,1,0,0,0 +access_product_attribute_price,access_product_attribute_price,product.model_product_attribute_price,alipay.group_miniprogram_user,1,0,0,0 diff --git a/alipay/static/description/alipay_tracking.png b/alipay/static/description/alipay_tracking.png new file mode 100644 index 0000000000..a9df69c6bc Binary files /dev/null and b/alipay/static/description/alipay_tracking.png differ diff --git a/alipay/static/description/icon.png b/alipay/static/description/icon.png new file mode 100644 index 0000000000..b43a0a135f Binary files /dev/null and b/alipay/static/description/icon.png differ diff --git a/alipay/static/description/index.html b/alipay/static/description/index.html new file mode 100644 index 0000000000..f6bd34ecc4 --- /dev/null +++ b/alipay/static/description/index.html @@ -0,0 +1,98 @@ +
+
+
+

Alipay API

+

Basic tools to integrate Odoo and Alipay

+
+
+
+ +
+
+
+
+ If you don't know how Asia turned down cash with Alipay, check, for example, this video. +
+
+
+
+ +
+
+

Activate Developer Mode and Create following parameters

+
+ +
+
+
+ +
+
+

To debug UI, create alipay.local_sandbox with value 1

+
+ +
+
+
+ +
+
+

Alipay records (Orders, Refunds, etc.) can be found at Invoicing >> Configuration >> Alipay

+
+ +
+
+
+ +
+
+
+

Need our service?

+

Contact us by email or fill out request form

+ +
+
+
+
+ Tested on Odoo
11.0 community +
+
+ Tested on Odoo
11.0 enterprise +
+
+
+
+
diff --git a/alipay/static/description/parameters.png b/alipay/static/description/parameters.png new file mode 100644 index 0000000000..a8af3c77cf Binary files /dev/null and b/alipay/static/description/parameters.png differ diff --git a/alipay/static/description/sandbox.png b/alipay/static/description/sandbox.png new file mode 100644 index 0000000000..1ff3c4accb Binary files /dev/null and b/alipay/static/description/sandbox.png differ diff --git a/alipay/tests/__init__.py b/alipay/tests/__init__.py new file mode 100644 index 0000000000..dcdc89b21a --- /dev/null +++ b/alipay/tests/__init__.py @@ -0,0 +1,3 @@ +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +from . import test_alipay + diff --git a/alipay/tests/test_alipay.py b/alipay/tests/test_alipay.py new file mode 100644 index 0000000000..b167e6d33e --- /dev/null +++ b/alipay/tests/test_alipay.py @@ -0,0 +1,241 @@ +# Copyright 2018 Ivan Yelizariev +# Copyright 2018 Dinar Gabbasov +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +import logging +import json +import requests_mock +try: + from unittest.mock import patch +except ImportError: + from mock import patch +from odoo.tests.common import TransactionCase + + +_logger = logging.getLogger(__name__) +DUMMY_AUTH_CODE = '134579302432164181' +DUMMY_POS_ID = 1 +STATUS_SUCCESS = "10000" + + +class TestAlipayOrder(TransactionCase): + at_install = True + post_install = True + + def setUp(self): + super(TestAlipayOrder, self).setUp() + self.env['ir.config_parameter'].set_param('alipay.local_sandbox', '1') + self.requests_mock = requests_mock.Mocker(real_http=True) + self.requests_mock.start() + self.addCleanup(self.requests_mock.stop) + + context = dict( + app_private_key_string=DUMMY_RSA_KEY, + ) + + self.Config = self.env['ir.config_parameter'].with_context(context) + self.Order = self.env['alipay.order'].with_context(context) + self.Refund = self.env['alipay.refund'] + self.product1 = self.env['product.product'].create({ + 'name': 'Product1', + }) + self.product2 = self.env['product.product'].create({ + 'name': 'Product2', + }) + + self.lines = [ + { + "product_id": self.product1.id, + "name": "Product 1 Name", + "quantity": 1, + "price": 1, + "category": "123456", + "description": "翻译服务器错误", + }, + { + "product_id": self.product2.id, + "name": "Product 2 Name", + "quantity": 1, + "price": 2, + "category": "123456", + "description": "網路白目哈哈", + } + ] + + def test_alipay_object(self): + """check that alipay object is passing without errors""" + demo_user = self.env.ref('base.user_demo') + # ISV account + self.Config.set_param('alipay.app_auth_code', 'dummycode') + alipay = self.Config.sudo(demo_user).get_alipay_object() + self.assertTrue(alipay) + + # Normal account + self.Config.search([('key', '=', 'alipay.app_auth_code')]).unlink() + alipay = self.Config.sudo(demo_user).get_alipay_object() + self.assertTrue(alipay) + + def test_scan(self): + """Test payment workflow from server side. + + * Cashier scanned buyer's QR and upload it to odoo server, + odoo server sends information to alipay servers and wait for response with result. + + * Once user authorize the payment, odoo receives result syncroniosly from + previously sent request. + + * Odoo sends result to POS via longpolling. + + Due to limititation of testing framework, we use syncronios call for testing + + """ + + journal = self.env['account.journal'].search([('alipay', '=', 'scan')]) + + # make request with scanned qr code (auth_code) + order = self.Order._create_from_qr(**{ + 'auth_code': DUMMY_AUTH_CODE, + 'total_amount': 1, + 'journal_id': journal.id, + 'terminal_ref': 'POS/%s' % DUMMY_POS_ID, + 'order_ref': 'dummy out_trade_num', + 'subject': 'dummy subject', + }) + result_json = json.loads(order.result_raw) + self.assertTrue(result_json.get('trade_no'), "Wrong result_code. The patch doesn't work?") + + # CODE BELOW IS NOT CHECKED + def _test_show_payment(self): + """ Create QR, emulate payment, make refund """ + + order = self._create_order() + + # emulate notification + notification = { + 'return_code': 'SUCCESS', + 'result_code': 'SUCCESS', + 'out_trade_no': order.name, + } + handled = self.Order.on_notification(notification) + self.assertTrue(handled, 'Notification was not handled (error in checking for duplicates?)') + self.assertEqual(order.state, 'done', "Order's state is not changed after notification about update") + + # refund + post_result = { + 'secapi/pay/refund': { + 'trade_type': 'NATIVE', + 'result_code': 'SUCCESS', + }, + } + self._patch_post(post_result) + + refund_fee = 100 + refund_vals = { + 'order_id': order.id, + 'total_fee': order.total_fee, + 'refund_fee': refund_fee, + } + refund = self.Refund.create(refund_vals) + self.assertEqual(order.refund_fee, 0, "Order's refund ammout is not zero when refund is not confirmed") + refund.action_confirm() + self.assertEqual(refund.state, 'done', "Refund's state is not changed after refund is confirmed") + self.assertEqual(order.state, 'refunded', "Order's state is not changed after refund is confirmed") + self.assertEqual(order.refund_fee, refund_fee, "Order's refund amount is computed wrongly") + + refund = self.Refund.create(refund_vals) + refund.action_confirm() + self.assertEqual(order.refund_fee, 2 * refund_fee, "Order's refund amount is computed wrongly") + + def _patch_post(self, post_result): + + def post(url, data): + self.assertIn(url, post_result) + _logger.debug("Request data for %s: %s", url, data) + return post_result[url] + + # patch alipay + patcher = patch('alipay.pay.base.BaseAlipayPayAPI._post', wraps=post) + patcher.start() + self.addCleanup(patcher.stop) + + def _create_order(self): + post_result = { + 'pay/unifiedorder': { + 'code_url': 'weixin://wxpay/s/An4baqw', + 'trade_type': 'NATIVE', + 'result_code': 'SUCCESS', + }, + } + self._patch_post(post_result) + order, code_url = self.Order._create_qr(self.lines, total_fee=300) + self.assertEqual(order.state, 'draft', 'Just created order has wrong state') + return order + + def _test_notification_duplicates(self): + order = self._create_order() + + # simulate notification with failing request + notification = { + 'return_code': 'SUCCESS', + 'result_code': 'FAIL', + 'error_code': 'SYSTEMERR', + # 'transaction_id': '121775250120121775250120', + 'out_trade_no': order.name, + } + handled = self.Order.on_notification(notification) + self.assertTrue(handled, 'Notification was not handled (error in checking for duplicates?)') + handled = self.Order.on_notification(notification) + self.assertFalse(handled, 'Duplicate was not catched and handled as normal notificaiton') + + +DUMMY_RSA_KEY = """-----BEGIN RSA PRIVATE KEY----- +MIIJKgIBAAKCAgEAvVyLQYFxhQPqopCNmOj4WDn57USbvNFk78LEDf2b+ZZSMzn+ +ysXtTgTDf+W014FuwHn6UNajOVE1nL5FxJmLm26sfcZrtR9gZuUGsLx7Zi5C/C37 +Og2LYi6ncsK26hWbRvNKhyWuDnHDgdsj1JwZmXl32x83iiwIaq7wSRhnVeSOO4dX +NDfm1Dr0Gyfb4n3bNGb/KoRgo5s2TOms64BEZI1aHvHQXOGHaiP1YQBhXZsrFqmQ +UgX/D6aGDLhA+8/Yut8Bu3pICx+JQteG+Mb2TFOVPVHMDN5HluDr3ygnk2RCX3xg +eagsdiQD48oqnIA8nD8LfVdtHxAi9l2D/78ioqCI/hDT8t/Cr6ZYLFeC513dn4wK +5ULbNTKxKX6XKhZmUawKMQWkBBW/b9feWSJVXF/NkEVgUesXCvhXZ75tsWqlfAfO +12fwaYojZHiKH2Tzb3UDMi6qi0hWf/CmdHuyfyMHHe29SpOfQ6mxhNmpeqvUEopX +Urry71ZjXUFysZca+goYwI3sFN5LtsC537mYEU+kfUNWn50oO9BGj8i9r6SAPKzZ +tZ7oVe0CiRk5W4fhgE7HvMybP8HBIYXDVhJxVJAgVijkA45Gcfj/nkcWgwLJaZE3 +1lMOc33loXjy88OKQZOC5HoagnkG0rAOKBPzPy+92miSE3NwQMg9fDWHMr8CAwEA +AQKCAgEAt4E1Wjes4PBYs01OSv6JnEYi0zIHkkWBgW/HOp+oRYjNA+OR7MM+Irsv +EYRzadx+jXwnfati5iqyv8EML2d1CR2JfyGIQy+y5kPP5fnhw7XVKDkPGsUBbBY2 +I1palCJ4JZujf7CeKlVI11CcOm9Dx50U734i/n2JcokxRkSl73Db/Qg9E9eQk97F +rINF7Ql2IiQl5vf+Bs5lIsfY0SeuH5tz2EUSXNAZwFw0cNpDgMjcSsvrlfFFqc8A +XNc58k0LhJyUOzBXHKBlDid7Hx8AlBrzp0bbbSUDT02MhueM4qLoR0xq2bqFy78/ +HcJO5PbIxcm6wq60isPCfelF/9MkJbQxOjVIbRZi9LqPTn9N4wf6vniHEwesSE46 +axP9JRMjizQLL/JycwoAfvMF8NUxv5WcZHrh1d0MOyyrs1Scc4g6YNdqiqbu1npA +vIm8hw6vfxLdnt6oPaEyD+qDehXM0P9l8BGbGTeESXkkiwxTK2EyI4t5t7E77gv/ +PXKCoXLhoF2Vld3SV2Ywjlo85wcIMei7OBIl0vIxRmCdALaEiEpeaRZvBt4YUQgb +th6+SZ8E7VCJTiD2OqJhZETevoLOiZhPn5ylNyZlYQBdJ0O0f6ZxbXKL9uMNAXlq +n6EP8r6A6kn5np+oVTuYLyAJmtrFVy6XaQV3EHeKWObbPeTv7yECggEBAPTnMntU +KvozVvYlPSjoBE8qGQdElKBYEQGUgtqwNX9FAkt0WMf5q7tjFYdb+TN1ZqTp4ZFV +75kKBi3DRVgNvrmyELa+iKxajZjLjS5CfSwDW83v9KXXMuJvTyVz0FpsCGZ+Za3T +wXs9R9Ebbohpx5eti9lvuNYgyo3FCndhD66a+yGq2uujjm3YEpmn794LmcnEdUvJ +oTZ+jgllqlCTtk48r78xJL9wK/WpSWrM+pVFTpW04h0hxUsUTVo08bcSdg8H3w7n +avBwzmahcXaOEStcmZKjqeXaKb5k5b8JExKkjDQZt9ZFmKxGaRyofAJv1fGVNgXz +6lnUq40iHwIMd8kCggEBAMXxFKdDvmOAAB/H95R0jVZxhbclabAI2FpowaMmyruV +LgkhVCKJPuqaXBcKrtQfqyVDj2T8bRyRqlfdaEkk1LDYrqYkPGDaTjipCRPTn06n +YpuRet3vz2Iflf+Pp6XWrT+8HYfqM4qxITj0v0V+dAKokyoAt34dtYg9i26XmjHy +LUxawryzYuzcQ5orl6Fg4aTLm4gEamuD2+X/zscPG+sKRi7vC1eTg0fC5Y65F7Oh +NyBfwqkSc4D8Z0eGvGJ6D0pX73xzAe+7P08j+p5sGi6+yuZIe/vO+WtarYGxXaVY +ohoCAY+gCnlpcIxbOHY4NifsEWJeGCTPbRQizVCTKkcCggEBAL+OOvkmK3uKPqHH +HOBrIju9hNgfd1U3rQ2cWQGuxBlpI9NbDLpV+lJWvRckBHaQhJnHaizgl8kPgye2 +Tf4CukTLF7GotISDS6/QvvwI+5k6g0tAPg6dlWpxf+mefcDMMYHhqaxeLj8z/oF7 +wGgovPpRv0pyzZOHEIf1MCuSGs8K4BVEa3nWc2hNkrbnGYKHdmHQLaL68gMK2BRX +lfDyqKznYNveF405stiy8f78l5+8FyyX0CjTKluAZMSDFvGIGhnFoV4p+oZY5ch2 +zKXbl2hgRKrjItfrXa1ThDR5Z5a0aAm0eAu8Yh+V70+AJYdObHxKpnffglWDOC/r +GW/jyqkCggEBAL8pQET5S5lUOMp4mEWq+gSNxhFF9HepUyidGsSx5gCa5cazhUmF +OlnfkSg/jPAXVXW7dXSVw9pfYx9QGDLreuz/lkulmxn+OqTFupqHOccAKF8NdJd5 +zdJ5pqcU2VdzqAVxayOjrvs2bVtQIpi+stMOcnGSF6OYlYRpy4qWpretpsmirYcH +x3XwkukFSH71zXUVnbMScKQ8x9Wr4sqjcNbhKT6SZWXCdHqNYp0fbCByhYaidKBL +zXi4ShXtrWl9b97gZczOVQRs1Ytct+DfjbmvUMxtHC/nh0GCZSZnYIUawBJV9aP7 +b6IpjiQ+xJyHVOXhOjjBnpeOK03S/m3ecmkCggEAHmZdog2VKf+Ca9laBawIV4OT +UKSAjRqu6TqAU3XwMdAcvyicKwtbHHJZWBsqSvGqI5tfAMGh31l7qvY5uEYyn4EF +qqU7g+IaiXlVuQeh2ZsNGcIwwXT/6fYIwFL4jcvJClcYL9fdiM2gO00Yuxdqc+ao +X/xu8ATK9u6aI1isq2qi1qstV1wEoUN1Mv0SYHMlSdEUtf5dJLoLcco/UgTgQx0R +kt9Il6i2+9HdPwMpPkufXSuLzRtjMBzsCqRnGU0DKt5zcFjiI2pf8Up98GI94h4v +g8ejRoM8PiYWzG3qX2r6AxbVPZA1SqE7QuacTDF2y0eYwe8KXT2mwsYWSi/ufg== +-----END RSA PRIVATE KEY----- +""" diff --git a/alipay/views/account_journal_views.xml b/alipay/views/account_journal_views.xml new file mode 100644 index 0000000000..688f7139df --- /dev/null +++ b/alipay/views/account_journal_views.xml @@ -0,0 +1,17 @@ + + + + + + alipay.account_journal_form + account.journal + + + + + + + + + diff --git a/alipay/views/account_menuitem.xml b/alipay/views/account_menuitem.xml new file mode 100644 index 0000000000..d2bce852b6 --- /dev/null +++ b/alipay/views/account_menuitem.xml @@ -0,0 +1,9 @@ + + + + + + + + diff --git a/alipay/views/alipay_order_views.xml b/alipay/views/alipay_order_views.xml new file mode 100644 index 0000000000..d029d2d248 --- /dev/null +++ b/alipay/views/alipay_order_views.xml @@ -0,0 +1,77 @@ + + + + + + + + + alipay.order.form + alipay.order + +
+
+ +
+ +
+

+
+ + + + + + + + + + +
+
+
+
+ + + alipay.order.list + alipay.order + + + + + + + + + + + alipay.order.search + alipay.order + + + + + + + + + + + + Alipay Order + alipay.order + form + tree,form + + + + +
diff --git a/alipay/views/alipay_refund_views.xml b/alipay/views/alipay_refund_views.xml new file mode 100644 index 0000000000..898b070ba9 --- /dev/null +++ b/alipay/views/alipay_refund_views.xml @@ -0,0 +1,71 @@ + + + + + alipay.refund.form + alipay.refund + +
+
+ +
+ +
+

+
+ + + + + + + + + + + +
+
+
+
+ + + alipay.refund.list + alipay.refund + + + + + + + + + + + alipay.refund.search + alipay.refund + + + + + + + + + + + + Alipay Refund + alipay.refund + form + tree,form + + + + +
diff --git a/payment_wechat/README.rst b/payment_wechat/README.rst new file mode 100644 index 0000000000..01f146ce1e --- /dev/null +++ b/payment_wechat/README.rst @@ -0,0 +1,52 @@ +.. image:: https://img.shields.io/badge/license-LGPL--3-blue.png + :target: https://www.gnu.org/licenses/lgpl + :alt: License: LGPL-3 + +================= + WeChat payments +================= + +Technical module to integrate WeChat payments with odoo POS, eCommerce or backend. As in WeChat QR codes are used, addional modules are required to show QR code in POS or eCommerce. Following methods are supported: + +* TODO User scans QR and authorise payment +* TODO User opens eCommerce website via WeChat's browser, fills the cart and is redirected to WeChat App UI to authorise the payment + +Note, that this module doesn't implement *Quick Pay* method, i.e. the one where buyer shows QR code and vendor scans. + +Credits +======= + +Contributors +------------ +* `Ivan Yelizariev `__ + +Sponsors +-------- +* `IT-Projects LLC `__ + +Maintainers +----------- +* `IT-Projects LLC `__ + + To get a guaranteed support + you are kindly requested to purchase the module + at `odoo apps store `__. + + Thank you for understanding! + + `IT-Projects Team `__ + +Further information +=================== + +Demo: http://runbot.it-projects.info/demo/misc-addons/11.0 + +HTML Description: https://apps.odoo.com/apps/modules/11.0/payment_wechat/ + +Usage instructions: ``_ + +Changelog: ``_ + +Notifications on updates: `via Atom `_, `by Email `_ + +Tested on Odoo 11.0 4d0a1330e05bd688265bea14df4ad12838f9f2d7 diff --git a/payment_wechat/__init__.py b/payment_wechat/__init__.py new file mode 100644 index 0000000000..f7209b1710 --- /dev/null +++ b/payment_wechat/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import controllers diff --git a/payment_wechat/__manifest__.py b/payment_wechat/__manifest__.py new file mode 100644 index 0000000000..d7a7415571 --- /dev/null +++ b/payment_wechat/__manifest__.py @@ -0,0 +1,37 @@ +# Copyright 2018 Ivan Yelizariev +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +{ + "name": """WeChat payments""", + "summary": """The most popular Chinese payment method""", + "category": "Accounting", + # "live_test_url": "", + "images": [], + "version": "11.0.1.0.0", + "application": False, + + "author": "IT-Projects LLC, Ivan Yelizariev", + "support": "apps@it-projects.info", + "website": "https://it-projects.info/team/yelizariev", + "license": "LGPL-3", + # "price": 9.00, + # "currency": "EUR", + + "depends": [ + ], + "external_dependencies": {"python": [], "bin": []}, + "data": [ + ], + "demo": [ + "demo/w_p_demo.xml", + ], + "qweb": [ + ], + + "post_load": None, + "pre_init_hook": None, + "post_init_hook": None, + "uninstall_hook": None, + + "auto_install": False, + "installable": False, +} diff --git a/payment_wechat/controllers/__init__.py b/payment_wechat/controllers/__init__.py new file mode 100644 index 0000000000..15a22e1505 --- /dev/null +++ b/payment_wechat/controllers/__init__.py @@ -0,0 +1 @@ +from . import p_w_controllers diff --git a/payment_wechat/controllers/p_w_controllers.py b/payment_wechat/controllers/p_w_controllers.py new file mode 100644 index 0000000000..0191296e26 --- /dev/null +++ b/payment_wechat/controllers/p_w_controllers.py @@ -0,0 +1,146 @@ +from __future__ import unicode_literals +import time +import random +import logging +import requests +import odoo +import json +from odoo.http import request + +_logger = logging.getLogger(__name__) + +try: + from odoo.addons.bus.controllers.main import BusController +except ImportError: + _logger.error('pos_multi_session_sync inconsisten with odoo version') + BusController = object + + +class Controller(BusController): + + @odoo.http.route('/wechat/getsignkey', type="json", auth="public") + def getSignKey(self, message): + data = {} + data['mch_id'] = request.env['ir.config_parameter'].get_param('wechat.mchId') + wcc = request.env['wechat.config'] + data['nonce_str'] = (wcc.getRandomNumberGeneration(message))[:32] + data['sign'] = (str(time.time()).replace('.', '') + + '{0:010}'.format(random.randint(1, 9999999999)) + + '{0:010}'.format(random.randint(1, 9999999999)))[:32] + post = wcc.makeXmlPost(data) + print(post) + url = "https://api.mch.weixin.qq.com/sandboxnew/pay/getsignkey" + r1 = requests.post(url, data=post) + print(r1) + print(r1.status_code) + print(r1.headers) + print(r1.headers['content-type']) + print(r1.iter_content) + print(len(r1.text)) + print(len(r1.content)) + # print(r1.mch_id) + # print(r1.sandbox_signkey) + message = {} + message['resp1'] = r1.text + return message + + @odoo.http.route('/wechat/test', type="json", auth="public") + def testAccessToken(self, message): + wcc = request.env['wechat.config'] + if not wcc: + wcc = wcc.create({ + 'token_validity': 7000, + 'access_token': 'test' + }) + wcc.getAccessToken() + + @odoo.http.route('/wechat/payment_commence', type="json", auth="public") + def micropay(self, message): + # data = message['data'] + # data['order_id'] = '{0:06}'.format(message['data']['order_id']) + # data['cashier_id'] = '{0:05}'.format(message['data']['cashier_id']) + # data['session_id'] = '{0:05}'.format(message['data']['session_id']) + data = {} + data['auth_code'] = message['data']['auth_code'] + data['appid'] = request.env['ir.config_parameter'].get_param('wechat.appId') + data['mch_id'] = request.env['ir.config_parameter'].get_param('wechat.mchId') + data['body'] = message['data']['order_short'] + + data['out_trade_no'] = (str(time.time()).replace('.', '') \ + + '{0:010}'.format(random.randint(1, 9999999999)) \ + + '{0:010}'.format(random.randint(1, 9999999999)))[:32] + wcc = request.env['wechat.config'] + if not wcc: + wcc = wcc.create({ + 'token_validity': 1, + 'access_token': '' + }) + data['total_fee'] = message['data']['total_fee'] + data['spbill_create_ip'] = wcc.getIpList()[0] + print(wcc.getIpList()) + # data['auth_code'] = message['data']['auth_code'] + # + # device_info = + # sign_type = + # detail = + # attach = + # fee_type = + # goods_tag = + # limit_pay = + # scene_info = + # + data['nonce_str'] = (wcc.getRandomNumberGeneration(message))[:32] + data['sign'] = (wcc.getRandomNumberGeneration(message))[:32] + + post = wcc.makeXmlPost(data) + print(post) + r1 = requests.post("https://api.mch.weixin.qq.com/sandboxnew/pay/micropay", data=post) + print(r1) + print(r1.status_code) + print(r1.headers) + print(r1.headers['content-type']) + print(r1.encoding) + print(len(r1.text)) + print(len(r1.content)) + message = {} + message['resp1'] = r1 + message['resp_text1'] = r1.text + message['resp_cont1'] = r1.content + # message['encode_text1'] = r1.text.encode('iso-8859-1').decode('utf-8') + # print(r1.text.encode('utf-8')) + time.sleep(5) + # return request.redirect('/wechat/payment_query') + # + # @odoo.http.route('/wechat/payment_query', type="json", auth="public") + # def queryOrderApi(self, message): + data_qa = {} + data_qa['appid'] = data['appid'] + data_qa['mch_id'] = data['mch_id'] + data_qa['out_trade_no'] = data['out_trade_no'] + data_qa['nonce_str'] = data['nonce_str'] + data_qa['sign'] = data['sign'] + if hasattr(data, 'sign_type'): + data_qa['sign_type'] = data['sign_type'] + + post = wcc.makeXmlPost(data_qa) + print(post) + r2 = requests.post("https://api.mch.weixin.qq.com/sandboxnew/pay/orderquery", data=post) + print(r2) + print(r2.status_code) + print(r2.headers) + print(r2.headers['content-type']) + print(r2.encoding) + print(len(r2.text)) + print(len(r2.content)) + message['resp2'] = r2 + message['resp_text2'] = r2.text + message['resp_cont2'] = r2.content + # message['encode_text2'] = r2.text.encode('iso-8859-1').decode('utf-8') + # with open('txt.txt', 'w+') as fil: + # fil.write(r1.text, r2.text) + # print(r2.text.encode('utf-8')) + # for each_unicode_character in r2.text.encode('utf-8').decode('utf-8'): + # print(each_unicode_character) + # print(message['encode_text1']) + # print(message['encode_text2']) + return message diff --git a/payment_wechat/demo/w_p_demo.xml b/payment_wechat/demo/w_p_demo.xml new file mode 100644 index 0000000000..af509e251f --- /dev/null +++ b/payment_wechat/demo/w_p_demo.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/payment_wechat/doc/changelog.rst b/payment_wechat/doc/changelog.rst new file mode 100644 index 0000000000..9ee2b48b8e --- /dev/null +++ b/payment_wechat/doc/changelog.rst @@ -0,0 +1,4 @@ +`1.0.0` +------- + +- Init version diff --git a/payment_wechat/doc/index.rst b/payment_wechat/doc/index.rst new file mode 100644 index 0000000000..85a4a4a079 --- /dev/null +++ b/payment_wechat/doc/index.rst @@ -0,0 +1,12 @@ +================= + WeChat payments +================= + +Follow instructions of `WeChat API `__. + +Usage +===== + +Following instruction covers backend usage only. For POS and eCommerce use instructions of corresponding modules. + +* open menu TODO diff --git a/payment_wechat/models/__init__.py b/payment_wechat/models/__init__.py new file mode 100644 index 0000000000..f84001a85e --- /dev/null +++ b/payment_wechat/models/__init__.py @@ -0,0 +1 @@ +from . import wechat_models diff --git a/payment_wechat/models/wechat_models.py b/payment_wechat/models/wechat_models.py new file mode 100644 index 0000000000..754fb11a8e --- /dev/null +++ b/payment_wechat/models/wechat_models.py @@ -0,0 +1,73 @@ +from __future__ import absolute_import, unicode_literals +from odoo import fields, models, api +from odoo.http import request +import json +import hashlib +import time +import requests + + +class AccountJournal(models.Model): + _inherit = "account.journal" + + wechat_payment = fields.Boolean(string='Allow WeChat payments', default=False, + help="Check this box if this account allows pay via WeChat") + + +# class PosOrder(models.Model): +# _inherit = "pos.order" +# +# auth_code = fields.Integer(string='Code obtained from customers QR or BarCode', default=0) + + +class WechatConfiguration(models.Model): + _name = "wechat.config" + + # auth_code = fields.Integer(string='Code obtained from customers QR or BarCode', default=0) + access_token = fields.Char(string='access_token') + token_validity = fields.Float(string='validity time') + + @api.multi + def getAccessToken(self): + print('inside getAccessToken!!!!!!!!!!!', self.token_validity < time.time()) + if not self.token_validity: + self.createVals() + if self.token_validity < time.time(): + appId = request.env['ir.config_parameter'].get_param('wechat.appId') + appSecret = request.env['ir.config_parameter'].get_param('wechat.appSecret') + url = "https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=%s&secret=%s" % ( + appId, appSecret) + response = requests.get(url) + access_token = json.loads(response.text)['access_token'] + self.write({'token_validity': time.time() + 7000, 'access_token': access_token}) + else: + access_token = self.access_token + return access_token + + def getIpList(self): + token = self.getAccessToken() + url = "https://api.wechat.com/cgi-bin/getcallbackip?access_token=%s" % token + response = requests.get(url) + return json.loads(response.text)['ip_list'] + + def sortData(self, message): + arrA = [] + data = message['data'] + for key in data: + if data[key]: + arrA.append(str(key) + '=' + str(data[key])) + arrA.sort() + return arrA + + def getRandomNumberGeneration(self, message): + data = self.sortData(message) + strA = ' & '.join(data) + return hashlib.sha256(strA.encode('utf-8')).hexdigest().upper() + + def makeXmlPost(self, data): + xml_str = [''] + for key in sorted(data): + if data[key]: + xml_str.append('<' + str(key) + '>' + str(data[key]) + '') + xml_str.append('') + return '\n'.join(xml_str) diff --git a/payment_wechat/static/description/icon.png b/payment_wechat/static/description/icon.png new file mode 100644 index 0000000000..b43a0a135f Binary files /dev/null and b/payment_wechat/static/description/icon.png differ diff --git a/payment_wechat/views/views.xml b/payment_wechat/views/views.xml new file mode 100644 index 0000000000..ae8a1f6e24 --- /dev/null +++ b/payment_wechat/views/views.xml @@ -0,0 +1,15 @@ + + + + account.journal.form + account.journal + + + + + + + + + + diff --git a/pos_alipay/README.rst b/pos_alipay/README.rst new file mode 100644 index 0000000000..187816180c --- /dev/null +++ b/pos_alipay/README.rst @@ -0,0 +1,90 @@ +======================== + Alipay Payments in POS +======================== + +The module implements following payment workflows + +Barcode Payment +--------------- + +* Cashier creates order and scan user's QR in user's Alipay mobile app + + * scanning can be done via Mobile Phone camera (``pos_mobile`` module is recommended) + * scanning can be done via usb scanner + * scanning can be done via usb scanner attached to PosBox + +* User's receives order information and authorise fund transferring +* Cashier gets payment confirmation in POS + +QR Code Payment +--------------- + +* Cashier clicks a button to get one-time url and shows it to Buyer as a QR Code + + * QR can be shown in POS + * QR can be shown in Mobile POS (``pos_mobile`` module is recommended) + * QR can be shown in Customer screen + +* Buyer scans to finish the transaction. +* Cashier gets payment confirmation in POS + +Debugging +========= + +Scanning +-------- + +If you don't have camera or scanner, you can executing following code in browser console to simulate scanning:: + + odoo.__DEBUG__.services['web.core'].bus.trigger('qr_scanned', '28763443825664394'); + +Customer Screen +--------------- + +To emulate Customer screen do as following: + +* run another odoo on a different port, say ``9069``, workers 1, extra *server wide modules*, i.e. use ``--workers=1 --load=web,hw_proxy,hw_posbox_homepage,hw_screen`` +* open page at your browser: http://localhost:9069/point_of_sale/display -- you must see message ``POSBox Client display`` +* at POS' Settings activate ``[x] PosBox``, activate ``[x] Customer Display`` and set **IP Address** to ``localhost:9069`` +* Now just open POS + +Roadmap +======= + +* TODO: In sake of UX, we need to add ``alipay_order_id`` reference to ``account.bank.statement.line`` + +Credits +======= + +Contributors +------------ +* `Kolushov Alexandr `__ + +Sponsors +-------- +* `IT-Projects LLC `__ + +Maintainers +----------- +* `IT-Projects LLC `__ + + To get a guaranteed support you are kindly requested to purchase the module at `odoo apps store `__. + + Thank you for understanding! + + `IT-Projects Team `__ + +Further information +=================== + +Demo: http://runbot.it-projects.info/demo/pos_addons/11.0 + +HTML Description: https://apps.odoo.com/apps/modules/11.0/pos_payment_alipay/ + +Usage instructions: ``_ + +Changelog: ``_ + +Notifications on updates: `via Atom `_, `by Email `_ + +Tested on Odoo 11.0 ee2b9fae3519c2494f34dacf15d0a3b5bd8fbd06 diff --git a/pos_alipay/__init__.py b/pos_alipay/__init__.py new file mode 100644 index 0000000000..599cf993a7 --- /dev/null +++ b/pos_alipay/__init__.py @@ -0,0 +1,3 @@ +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +from . import models +from . import wizard diff --git a/pos_alipay/__manifest__.py b/pos_alipay/__manifest__.py new file mode 100644 index 0000000000..b844b345c3 --- /dev/null +++ b/pos_alipay/__manifest__.py @@ -0,0 +1,41 @@ +# Copyright 2018 Ivan Yelizariev +# Copyright 2018 Dinar Gabbasov +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +{ + "name": """Alipay Payments in POS""", + "summary": """Support payment by scanning user's QR""", + "category": "Point of Sale", + # "live_test_url": "", + "images": ['images/pos_alipay.png'], + "version": "11.0.1.0.0", + "application": False, + + "author": "IT-Projects LLC, Kolushov Alexandr", + "support": "apps@it-projects.info", + "website": "https://it-projects.info/team/KolushovAlexandr", + "license": "LGPL-3", + # "price": 9.00, + # "currency": "EUR", + + "depends": [ + "alipay", + "pos_qr_scan", + "pos_qr_show", + "pos_qr_payments", + "pos_longpolling", + ], + "external_dependencies": {"python": [], "bin": []}, + "data": [ + "views/assets.xml", + "wizard/pos_payment_views.xml", + "security/alipay_security.xml", + ], + "demo": [ + ], + "qweb": [ + "static/src/xml/pos.xml", + ], + + "auto_install": False, + "installable": True, +} diff --git a/pos_alipay/doc/changelog.rst b/pos_alipay/doc/changelog.rst new file mode 100644 index 0000000000..9ee2b48b8e --- /dev/null +++ b/pos_alipay/doc/changelog.rst @@ -0,0 +1,4 @@ +`1.0.0` +------- + +- Init version diff --git a/pos_alipay/doc/index.rst b/pos_alipay/doc/index.rst new file mode 100644 index 0000000000..2ffc9b278e --- /dev/null +++ b/pos_alipay/doc/index.rst @@ -0,0 +1,49 @@ +======================== + Alipay Payments in POS +======================== + +Follow instructions of `Alipay API `__ module. + +Installation +============ + +* `Install `__ this module in a usual way + +Configuration +============= + +Alipay Journals +--------------- + +Alipay Journals are created automatically on first opening POS session. + +* In demo installation: they are availabe in POS immediatly +* In non-demo installation: add Journals to **Payment Methods** in *Point of + Sale*'s Settings, then close existing session if any and open again + +Usage +===== + +Scanning customer's QR +---------------------- + +* Start POS +* Create some Order +* Click ``[Scan QR Code]`` or use QR Scanner device attached to PosBox or the device you use (computer, tablet, phone) +* Ask customer to prepare QR in Alipay app +* Scan the QR +* Wait until customer authorise the payment in his Alipay app +* RESULT: Payment is proceeded. Use your Alipay Seller control panel to see balance update. + +Refunds +------- + +* Make Refund Order via backend as usual: + + * Go to ``[[ Point of Sale ]] >> Orders >> Orders`` + * Open product to be refuned + * Click button ``[Return Products]`` + +* In Refund Order click ``[Payment]`` +* In **Payment Mode** specify a Alipay journal +* Specify **Alipay Order to refund** diff --git a/pos_alipay/images/pos_alipay.png b/pos_alipay/images/pos_alipay.png new file mode 100644 index 0000000000..09f0dba8ba Binary files /dev/null and b/pos_alipay/images/pos_alipay.png differ diff --git a/pos_alipay/models/__init__.py b/pos_alipay/models/__init__.py new file mode 100644 index 0000000000..f64ec3672c --- /dev/null +++ b/pos_alipay/models/__init__.py @@ -0,0 +1,4 @@ +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +from . import alipay_pos +from . import alipay_order +from . import pos_config diff --git a/pos_alipay/models/alipay_order.py b/pos_alipay/models/alipay_order.py new file mode 100644 index 0000000000..420593ab53 --- /dev/null +++ b/pos_alipay/models/alipay_order.py @@ -0,0 +1,65 @@ +# Copyright 2018 Ivan Yelizariev +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +import json +from odoo import models, api +from odoo.addons.qr_payments.tools import odoo_async_call + + +class AlipayOrder(models.Model): + _inherit = ['alipay.order', 'alipay.pos'] + _name = 'alipay.order' + + @api.multi + def _prepare_message(self): + self.ensure_one() + result_json = json.loads(self.result_raw) + msg = { + 'event': 'payment_result', + 'code': result_json['code'], + 'order_ref': self.order_ref, + 'total_amount': self.total_amount, + 'journal_id': self.journal_id.id, + } + return msg + + def on_notification(self, data): + order = super(AlipayOrder, self).on_notification(data) + if order and order.pos_id: + order._send_pos_notification() + return order + + @api.model + def create_qr(self, lines, **kwargs): + pos_id = kwargs.get('pos_id') + if pos_id: + if 'create_vals' not in kwargs: + kwargs['create_vals'] = {} + kwargs['create_vals']['pos_id'] = pos_id + return super(AlipayOrder, self).create_qr(lines, **kwargs) + + @api.model + def _prepare_pos_create_from_qr(self, **kwargs): + create_vals = { + 'pos_id': kwargs['pos_id'], + } + kwargs.update(create_vals=create_vals) + args = () + return args, kwargs + + @api.model + def pos_create_from_qr_sync(self, **kwargs): + args, kwargs = self._prepare_pos_create_from_qr(**kwargs) + record = self._create_from_qr(*args, **kwargs) + return record._prepare_message() + + @api.model + def pos_create_from_qr(self, **kwargs): + """Async method. Result is sent via longpolling""" + args, kwargs = self._prepare_pos_create_from_qr(**kwargs) + odoo_async_call(self._create_from_qr, args, kwargs, + callback=self._send_pos_notification_callback) + return 'ok' + + @api.model + def _send_pos_notification_callback(self, record): + record._send_pos_notification() diff --git a/pos_alipay/models/alipay_pos.py b/pos_alipay/models/alipay_pos.py new file mode 100644 index 0000000000..49b76b9a52 --- /dev/null +++ b/pos_alipay/models/alipay_pos.py @@ -0,0 +1,22 @@ +# Copyright 2018 Ivan Yelizariev +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +from odoo import models, api, fields +CHANNEL_ALIPAY = 'alipay' + + +class AlipayPos(models.AbstractModel): + _name = 'alipay.pos' + + pos_id = fields.Many2one('pos.config') + + @api.multi + def _send_pos_notification(self): + self.ensure_one() + msg = self._prepare_message() + assert self.pos_id, "The record has empty value of pos_id field" + return self.env['pos.config']._send_to_channel_by_id( + self._cr.dbname, + self.pos_id.id, + CHANNEL_ALIPAY, + msg, + ) diff --git a/pos_alipay/models/pos_config.py b/pos_alipay/models/pos_config.py new file mode 100644 index 0000000000..e872a6eaa4 --- /dev/null +++ b/pos_alipay/models/pos_config.py @@ -0,0 +1,105 @@ +# Copyright 2018 Ivan Yelizariev +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +from odoo import models, api + + +MODULE = 'pos_alipay' + + +class PosConfig(models.Model): + _inherit = 'pos.config' + + @api.multi + def open_session_cb(self): + res = super(PosConfig, self).open_session_cb() + self.init_pos_alipay_journals() + return res + + def init_pos_alipay_journals(self): + """Init demo Journals for current company""" + # Multi-company is not primary task for this module, but I copied this + # code from pos_debt_notebook, so why not + journal_obj = self.env['account.journal'] + user = self.env.user + alipay_journal_active = journal_obj.search([ + ('company_id', '=', user.company_id.id), + ('alipay', '!=', False), + ]) + if alipay_journal_active: + return + + demo_is_on = self.env['ir.module.module'].search([('name', '=', MODULE)]).demo + + options = { + 'noupdate': True, + 'type': 'cash', + 'write_statement': demo_is_on, + } + alipay_show_journal = self._create_alipay_journal(dict( + sequence_name='Alipay Payments by Showing QR', + prefix='ALISHOW-- ', + journal_name='Alipay Payments by Showing QR', + code='ALISHOW', + alipay='show', + **options + )) + alipay_scan_journal = self._create_alipay_journal(dict( + sequence_name='Alipay Payments by Scanning QR', + prefix='ALISCAN- ', + journal_name='Alipay Payments by Scanning QR', + code='ALISCAN', + alipay='scan', + **options + )) + if demo_is_on: + self.write({ + 'journal_ids': [ + (4, alipay_show_journal.id), + (4, alipay_scan_journal.id), + ], + }) + + def _create_alipay_journal(self, vals): + user = self.env.user + new_sequence = self.env['ir.sequence'].create({ + 'name': vals['sequence_name'] + str(user.company_id.id), + 'padding': 3, + 'prefix': vals['prefix'] + str(user.company_id.id), + }) + self.env['ir.model.data'].create({ + 'name': 'journal_sequence' + str(new_sequence.id), + 'model': 'ir.sequence', + 'module': MODULE, + 'res_id': new_sequence.id, + 'noupdate': True, # If it's False, target record (res_id) will be removed while module update + }) + alipay_journal = self.env['account.journal'].create({ + 'name': vals['journal_name'], + 'code': vals['code'], + 'type': vals['type'], + 'alipay': vals['alipay'], + 'journal_user': True, + 'sequence_id': new_sequence.id, + }) + self.env['ir.model.data'].create({ + 'name': 'alipay_journal_' + str(alipay_journal.id), + 'model': 'account.journal', + 'module': MODULE, + 'res_id': int(alipay_journal.id), + 'noupdate': True, # If it's False, target record (res_id) will be removed while module update + }) + if vals['write_statement']: + self.write({ + 'journal_ids': [(4, alipay_journal.id)], + }) + current_session = self.current_session_id + statement = [(0, 0, { + 'name': current_session.name, + 'journal_id': alipay_journal.id, + 'user_id': user.id, + 'company_id': user.company_id.id + })] + current_session.write({ + 'statement_ids': statement, + }) + return alipay_journal diff --git a/pos_alipay/security/alipay_security.xml b/pos_alipay/security/alipay_security.xml new file mode 100644 index 0000000000..ce2698632b --- /dev/null +++ b/pos_alipay/security/alipay_security.xml @@ -0,0 +1,16 @@ + + + + + + Mini-Program: mini-program user: read POS products only + + [('sale_ok', '=', True), ('available_in_pos', '=', True)] + + + + + + + diff --git a/pos_alipay/static/description/icon.png b/pos_alipay/static/description/icon.png new file mode 100644 index 0000000000..8a058284ed Binary files /dev/null and b/pos_alipay/static/description/icon.png differ diff --git a/pos_alipay/static/description/index.html b/pos_alipay/static/description/index.html new file mode 100644 index 0000000000..63740febae --- /dev/null +++ b/pos_alipay/static/description/index.html @@ -0,0 +1,100 @@ +
+
+
+

Alipay Payments in POS

+

Connecting tool for Alipay and POS in Odoo

+
+
+
+ +
+
+
+
+ If you don't know how Asia turned down cash with Alipay, check, for example, this video. +
+
+
+
+ +
+
+

Show QR code to buyer

+
+ +
+
+
+ + +
+
+

Scan buyer's QR code via device's camera

+
+ +
+
+
+ +
+
+

Scan buyer's QR code via external device

+
+ +
+
+
+ + +
+
+
+

Need our service?

+

Contact us by email or fill out request form

+ +
+
+
+
+ Tested on Odoo
11.0 community +
+
+ Tested on Odoo
11.0 enterprise +
+
+
+
+
diff --git a/pos_alipay/static/description/pos_1.png b/pos_alipay/static/description/pos_1.png new file mode 100644 index 0000000000..401cb0cab7 Binary files /dev/null and b/pos_alipay/static/description/pos_1.png differ diff --git a/pos_alipay/static/description/pos_2.png b/pos_alipay/static/description/pos_2.png new file mode 100644 index 0000000000..553b411a5e Binary files /dev/null and b/pos_alipay/static/description/pos_2.png differ diff --git a/pos_alipay/static/description/pos_3.png b/pos_alipay/static/description/pos_3.png new file mode 100644 index 0000000000..9c9cd10abb Binary files /dev/null and b/pos_alipay/static/description/pos_3.png differ diff --git a/pos_alipay/static/src/js/alipay.js b/pos_alipay/static/src/js/alipay.js new file mode 100644 index 0000000000..fe2d76515d --- /dev/null +++ b/pos_alipay/static/src/js/alipay.js @@ -0,0 +1,189 @@ +/* Copyright 2018 Ivan Yelizariev + License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). */ +odoo.define('pos_alipay', function(require){ + "use strict"; + + require('pos_qr_scan'); + require('pos_qr_show'); + var rpc = require('web.rpc'); + var core = require('web.core'); + var models = require('point_of_sale.models'); + + var _t = core._t; + + models.load_fields('account.journal', ['alipay']); + + var exports = {}; + + var PosModelSuper = models.PosModel; + models.PosModel = models.PosModel.extend({ + initialize: function(){ + var self = this; + PosModelSuper.prototype.initialize.apply(this, arguments); + this.alipay = new exports.Alipay(this); + + this.bus.add_channel_callback( + "alipay", + this.on_alipay, + this); + this.ready.then(function(){ + // take out alipay scan cashregister from cashregisters to avoid + // rendering in payment screent + self.scan_journal = self.hide_cashregister(function(r){ + return r.alipay === 'scan'; + }); + }); + + }, + scan_product: function(parsed_code){ + // TODO: do we need to make this optional? + var value = parsed_code.code; + if (this.alipay.check_auth_code(value)){ + this.alipay.process_qr(value); + return true; + } + return PosModelSuper.prototype.scan_product.apply(this, arguments); + }, + on_alipay: function(msg){ + this.add_qr_payment( + msg.order_ref, + msg.journal_id, + msg.total_fee / 100.0, + { + scan_id: msg.scan_id + }, + // auto validate payment + true + ); + }, + alipay_qr_payment: function(order, creg){ + /* send request asynchronously */ + var self = this; + + var pos = this; + var terminal_ref = 'POS/' + pos.config.name; + var pos_id = pos.config.id; + + var lines = order.orderlines.map(function(r){ + return { + // always use 1 because quantity is taken into account in price field + quantity: 1, + quantity_full: r.get_quantity(), + price: r.get_price_with_tax(), + product_id: r.get_product().id, + }; + }); + + // Send without repeating on failure + return rpc.query({ + model: 'alipay.order', + method: 'create_qr', + kwargs: { + 'lines': lines, + 'subject': order.name, + 'order_ref': order.uid, + 'pay_amount': order.get_due(), + 'terminal_ref': terminal_ref, + 'pos_id': pos_id, + 'journal_id': creg.journal.id, + }, + }).then(function(data){ + if (data.code_url){ + self.on_payment_qr(order, data.code_url); + } else if (data.error) { + self.show_warning(data.error); + } else { + self.show_warning('Unknown error'); + } + }); + }, + }); + + + var OrderSuper = models.Order; + models.Order = models.Order.extend({ + add_paymentline: function(cashregister){ + if (cashregister.journal.alipay === 'show'){ + this.pos.alipay_qr_payment(this, cashregister); + return; + } + return OrderSuper.prototype.add_paymentline.apply(this, arguments); + }, + }); + + var PaymentlineSuper = models.Paymentline; + models.Paymentline = models.Paymentline.extend({ + initialize: function(attributes, options){ + PaymentlineSuper.prototype.initialize.apply(this, arguments); + this.scan_id = options.scan_id; + }, + // TODO: do we need to extend init_from_JSON too ? + export_as_JSON: function(){ + var res = PaymentlineSuper.prototype.export_as_JSON.apply(this, arguments); + res.scan_id = this.scan_id; + return res; + }, + }); + + exports.Alipay = window.Backbone.Model.extend({ + initialize: function(pos){ + var self = this; + this.pos = pos; + core.bus.on('qr_scanned', this, function(value){ + if (self.check_auth_code(value)){ + self.process_qr(value); + } + }); + }, + check_auth_code: function(code) { + // TODO: do we need to integrate this with barcode.nomenclature? + var beginning = code.substring(0, 2); + if (code && Number.isInteger(Number(code)) && + 16 <= code.length && code.length <= 24 && + 25 <= Number(beginning) && Number(beginning) <= 30) { + return true; + } + return false; + }, + process_qr: function(auth_code){ + var order = this.pos.get_order(); + if (!order){ + return; + } + // TODO: block order for editing + this.scan(auth_code, order); + }, + scan: function(auth_code, order){ + /* send request asynchronously */ + var self = this; + + var terminal_ref = 'POS/' + self.pos.config.name; + var pos_id = self.pos.config.id; + + var send_it = function () { + return rpc.query({ + model: 'alipay.order', + method: 'pos_create_from_qr', + kwargs: { + 'auth_code': auth_code, + 'total_amount': order.get_due(), + 'order_ref': order.uid, + 'subject': order.name, + 'terminal_ref': terminal_ref, + 'journal_id': self.pos.scan_journal.id, + 'pos_id': pos_id, + }, + }); + }; + + var current_send_number = 0; + return send_it().fail(function (error, e) { + if (self.pos.debug){ + console.log('Alipay', self.pos.config.name, 'failed request #'+current_send_number+':', error.message); + } + self.pos.show_warning(); + }); + }, + }); + return exports; +}); diff --git a/pos_alipay/static/src/js/tour.js b/pos_alipay/static/src/js/tour.js new file mode 100644 index 0000000000..52b9c14758 --- /dev/null +++ b/pos_alipay/static/src/js/tour.js @@ -0,0 +1,67 @@ +/*- Copyright 2018 Ivan Yelizariev + License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). */ +/* This file is not used until we make a CI tool, that can run it. Normal CI cannot use longpolling. + See https://github.com/odoo/odoo/commit/673f4aa4a77161dc58e0e1bf97e8f713b1e88491 + */ +odoo.define('pos_alipay.tour', function (require) { + "use strict"; + + var DUMMY_AUTH_CODE = '134579302432164181'; + var tour = require("web_tour.tour"); + var core = require('web.core'); + var _t = core._t; + + function open_pos_neworder() { + return [{ + trigger: '.o_app[data-menu-xmlid="point_of_sale.menu_point_root"], .oe_menu_toggler[data-menu-xmlid="point_of_sale.menu_point_root"]', + content: _t("Ready to launch your point of sale? Click here."), + position: 'bottom', + }, { + trigger: ".o_pos_kanban button.oe_kanban_action_button", + content: _t("

Click to start the point of sale interface. It runs on tablets, laptops, or industrial hardware.

Once the session launched, the system continues to run without an internet connection.

"), + position: "bottom" + }, { + content: "Switch to table or make dummy action", + trigger: '.table:not(.oe_invisible .neworder-button), .order-button.selected', + position: "bottom" + }, { + content: 'waiting for loading to finish', + trigger: '.order-button.neworder-button', + }]; + } + + function add_product_to_order(product_name) { + return [{ + content: 'buy ' + product_name, + trigger: '.product-list .product-name:contains("' + product_name + '")', + }, { + content: 'the ' + product_name + ' have been added to the order', + trigger: '.order .product-name:contains("' + product_name + '")', + }]; + } + + var steps = []; + steps = steps.concat(open_pos_neworder()); + steps = steps.concat(add_product_to_order('Miscellaneous')); + // simulate qr scanning + steps = steps.concat([{ + content: "Make dummy action and trigger scanning event", + trigger: '.order-button.selected', + run: function(){ + core.bus.trigger('qr_scanned', DUMMY_AUTH_CODE); + } + }]); + // wait until order is proceeded + steps = steps.concat([{ + content: "Screen is changed to payment screen", + trigger: '.button_next', + run: function(){ + // no need to click on the button + } + },{ + content: "Screen is changed to receipt or products screen (depends on settings)", + trigger: '.button_print,.order-button', + }]); + tour.register('tour_pos_debt_notebook', { test: true, url: '/web' }, steps); + +}); diff --git a/pos_alipay/static/src/xml/pos.xml b/pos_alipay/static/src/xml/pos.xml new file mode 100644 index 0000000000..7515e4b5e5 --- /dev/null +++ b/pos_alipay/static/src/xml/pos.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + diff --git a/pos_alipay/tests/__init__.py b/pos_alipay/tests/__init__.py new file mode 100644 index 0000000000..75fcc9c72b --- /dev/null +++ b/pos_alipay/tests/__init__.py @@ -0,0 +1,2 @@ +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +from . import test_alipay diff --git a/pos_alipay/tests/test_alipay.py b/pos_alipay/tests/test_alipay.py new file mode 100644 index 0000000000..437694ca44 --- /dev/null +++ b/pos_alipay/tests/test_alipay.py @@ -0,0 +1,139 @@ +# Copyright 2018 Ivan Yelizariev +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +import logging + +from odoo.addons.point_of_sale.tests.common import TestPointOfSaleCommon +from odoo.addons.alipay.tests.test_alipay import DUMMY_RSA_KEY + + +_logger = logging.getLogger(__name__) +DUMMY_AUTH_CODE = '134579302432164181' +DUMMY_POS_ID = 1 + + +class TestAlipayOrder(TestPointOfSaleCommon): + at_install = True + post_install = True + + def setUp(self): + super(TestAlipayOrder, self).setUp() + self.env['ir.config_parameter'].set_param('alipay.local_sandbox', '1') + + # create alipay journals + self.pos_config.init_pos_alipay_journals() + context = dict( + app_private_key_string=DUMMY_RSA_KEY, + ) + + self.Config = self.env['ir.config_parameter'].with_context(context) + self.Order = self.env['alipay.order'].with_context(context) + self.Refund = self.env['alipay.refund'].with_context(context) + self.PosMakePayment = self.PosMakePayment.with_context(context) + self.product1 = self.env['product.product'].create({ + 'name': 'Product1', + }) + self.product2 = self.env['product.product'].create({ + 'name': 'Product2', + }) + + def _create_pos_order(self): + # I create a new PoS order with 2 lines + order = self.PosOrder.create({ + 'company_id': self.company_id, + 'partner_id': self.partner1.id, + 'pricelist_id': self.partner1.property_product_pricelist.id, + 'lines': [(0, 0, { + 'name': "OL/0001", + 'product_id': self.product3.id, + 'price_unit': 450, + 'discount': 5.0, + 'qty': 2.0, + 'tax_ids': [(6, 0, self.product3.taxes_id.ids)], + }), (0, 0, { + 'name': "OL/0002", + 'product_id': self.product4.id, + 'price_unit': 300, + 'discount': 5.0, + 'qty': 3.0, + 'tax_ids': [(6, 0, self.product4.taxes_id.ids)], + })] + }) + return order + + def _create_alipay_order(self): + self.lines = [ + { + "product_id": self.product1.id, + "name": "Product 1 Name", + "quantity": 2, + "price": 4.50, + "category": "123456", + "description": "翻译服务器错误", + }, + { + "product_id": self.product2.id, + "name": "Product 2 Name", + "quantity": 3, + "price": 3.0, + "category": "123456", + "description": "網路白目哈哈", + } + ] + order, code_url = self.Order._create_qr(self.lines, "Test Order", total_amount=3.0) + self.assertEqual(order.state, 'draft', 'Just created order has wrong state') + return order + + def test_refund(self): + # Order are not really equal because I'm lazy + # Just imagine that they are correspond each other + order = self._create_pos_order() + alipay_order = self._create_alipay_order() + order.alipay_order_id = alipay_order.id + + # I create a refund + refund_action = order.refund() + refund = self.PosOrder.browse(refund_action['res_id']) + + alipay_journal = self.env['account.journal'].search([('alipay', '=', 'show')]) + + payment_context = {"active_ids": refund.ids, "active_id": refund.id} + refund_payment = self.PosMakePayment.with_context(**payment_context).create({ + 'amount': refund.amount_total, + 'journal_id': alipay_journal.id, + 'alipay_order_id': alipay_order.id, + }) + + # I click on the validate button to register the payment. + refund_payment.with_context(**payment_context).check() + + self.assertEqual(refund.state, 'paid', "The refund is not marked as paid") + + self.assertEqual(alipay_order.state, 'refunded', "Alipay Order state is not changed after making refund payment") + + def test_scan(self): + """Test payment workflow from server side. + + * Cashier scanned buyer's QR and upload it to odoo server, + odoo server sends information to alipay servers and wait for response with result. + + * Once user authorize the payment, odoo receives result syncroniosly from + previously sent request. + + * Odoo sends result to POS via longpolling. + + Due to limititation of testing framework, we use syncronios call for testing + + """ + + journal = self.env['account.journal'].search([('alipay', '=', 'scan')]) + + # make request with scanned qr code (auth_code) + msg = self.env['alipay.order'].pos_create_from_qr_sync(**{ + 'auth_code': DUMMY_AUTH_CODE, + 'terminal_ref': 'POS/%s' % DUMMY_POS_ID, + 'pos_id': DUMMY_POS_ID, + 'journal_id': journal.id, + 'total_amount': 1, + 'subject': 'Order #1' + }) + self.assertEqual(msg.get('code'), '10003', "Wrong result code. The patch doesn't work?") diff --git a/pos_alipay/views/assets.xml b/pos_alipay/views/assets.xml new file mode 100644 index 0000000000..0a28c54aed --- /dev/null +++ b/pos_alipay/views/assets.xml @@ -0,0 +1,11 @@ + + + + + + diff --git a/pos_alipay/wizard/__init__.py b/pos_alipay/wizard/__init__.py new file mode 100644 index 0000000000..c38a86c4c2 --- /dev/null +++ b/pos_alipay/wizard/__init__.py @@ -0,0 +1,2 @@ +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +from . import pos_payment diff --git a/pos_alipay/wizard/pos_payment.py b/pos_alipay/wizard/pos_payment.py new file mode 100644 index 0000000000..94d6df17e6 --- /dev/null +++ b/pos_alipay/wizard/pos_payment.py @@ -0,0 +1,33 @@ +# Copyright 2018 Ivan Yelizariev +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +from odoo import models, fields, api + + +class PosMakePayment(models.TransientModel): + _inherit = 'pos.make.payment' + + journal_alipay = fields.Selection(related='journal_id.alipay') + alipay_order_id = fields.Many2one('alipay.order', string='Alipay Order to refund') + + def check(self): + res = super(PosMakePayment, self).check() + record = self.alipay_order_id + if record and self.amount < 0: + refund_amount = self.amount + refund_vals = { + 'order_id': self.alipay_order_id.id, + 'total_amount': record.total_amount, + 'refund_amount': refund_amount, + 'journal_id': self.journal_id.id, + } + refund = self.env['alipay.refund'].create(refund_vals) + refund.action_confirm() + return res + + @api.onchange('order_ref', 'journal_id') + def update_alipay_order(self): + if self.journal_alipay: + self.alipay_order_id = self.env['alipay.order'].search([ + ('order_ref', '=', self.order_ref), + ('journal_id', '=', self.journal_id.id) + ])[:1] diff --git a/pos_alipay/wizard/pos_payment_views.xml b/pos_alipay/wizard/pos_payment_views.xml new file mode 100644 index 0000000000..7cb7d2070b --- /dev/null +++ b/pos_alipay/wizard/pos_payment_views.xml @@ -0,0 +1,16 @@ + + + + + pos.make.payment.form + pos.make.payment + + + + + + + + + diff --git a/pos_mobile/static/src/js/chrome.js b/pos_mobile/static/src/js/chrome.js index c4f8762d8e..c1805c805d 100644 --- a/pos_mobile/static/src/js/chrome.js +++ b/pos_mobile/static/src/js/chrome.js @@ -59,7 +59,7 @@ odoo.define('pos_mobile.chrome', function (require) { var payment_method = $(".payment-screen .paymentmethods-container"); payment_method.detach(); - $('.payment-screen .paymentlines-container').after(payment_method); + $('.payment-screen .payment-numpad').before(payment_method); // element before the closing button in top header $($('.pos-rightheader .oe_status')[0]).css({'margin-right': '70px'}); diff --git a/pos_mobile/static/src/js/screens.js b/pos_mobile/static/src/js/screens.js index 2f89aa481d..e14896984a 100644 --- a/pos_mobile/static/src/js/screens.js +++ b/pos_mobile/static/src/js/screens.js @@ -281,7 +281,7 @@ odoo.define('pos_mobile.screens', function (require) { this._super(); var payment_method = $(".payment-screen .paymentmethods-container"); payment_method.detach(); - $('.payment-screen .paymentlines-container').after(payment_method); + $('.payment-screen .payment-numpad').before(payment_method); } }); diff --git a/pos_payment/README.rst b/pos_payment/README.rst new file mode 100644 index 0000000000..21347261bc --- /dev/null +++ b/pos_payment/README.rst @@ -0,0 +1,47 @@ +.. image:: https://img.shields.io/badge/license-LGPL--3-blue.png + :target: https://www.gnu.org/licenses/lgpl + :alt: License: LGPL-3 + +========================== + Payment Acquirers in POS +========================== + +Accept online payments in POS. It works only with invoices created from POS Order. + +Credits +======= + +Contributors +------------ +* `Ivan Yelizariev `__ + +Sponsors +-------- +* `IT-Projects LLC `__ + +Maintainers +----------- +* `IT-Projects LLC `__ + + To get a guaranteed support + you are kindly requested to purchase the module + at `odoo apps store `__. + + Thank you for understanding! + + `IT-Projects Team `__ + +Further information +=================== + +Demo: http://runbot.it-projects.info/demo/pos-addons/11.0 + +HTML Description: https://apps.odoo.com/apps/modules/11.0/payment_wechat/ + +Usage instructions: ``_ + +Changelog: ``_ + +Notifications on updates: `via Atom `_, `by Email `_ + +Tested on Odoo 11.0 4d0a1330e05bd688265bea14df4ad12838f9f2d7 diff --git a/pos_payment/__init__.py b/pos_payment/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/pos_payment/__manifest__.py b/pos_payment/__manifest__.py new file mode 100644 index 0000000000..3470bc82e3 --- /dev/null +++ b/pos_payment/__manifest__.py @@ -0,0 +1,42 @@ +# Copyright 2018 Ivan Yelizariev +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +{ + "name": """Payment Acquirers in POS""", + "summary": """Accept online payments in POS""", + "category": "Point of Sale", + # "live_test_url": "", + "images": [], + "version": "11.0.1.0.0", + "application": False, + + "author": "IT-Projects LLC, Ivan Yelizariev", + "support": "apps@it-projects.info", + "website": "https://it-projects.info/team/yelizariev", + "license": "LGPL-3", + # "price": 9.00, + # "currency": "EUR", + + "depends": [ + "{DEPENDENCY1}", + "{DEPENDENCY2}", + ], + "external_dependencies": {"python": [], "bin": []}, + "data": [ + "{FILE1}.xml", + "{FILE2}.xml", + ], + "demo": [ + "demo/{DEMOFILE1}.xml", + ], + "qweb": [ + "static/src/xml/{QWEBFILE1}.xml", + ], + + "post_load": None, + "pre_init_hook": None, + "post_init_hook": None, + "uninstall_hook": None, + + "auto_install": False, + "installable": False, +} diff --git a/pos_payment/doc/changelog.rst b/pos_payment/doc/changelog.rst new file mode 100644 index 0000000000..9ee2b48b8e --- /dev/null +++ b/pos_payment/doc/changelog.rst @@ -0,0 +1,4 @@ +`1.0.0` +------- + +- Init version diff --git a/pos_payment/doc/index.rst b/pos_payment/doc/index.rst new file mode 100644 index 0000000000..e444a16d12 --- /dev/null +++ b/pos_payment/doc/index.rst @@ -0,0 +1,30 @@ +========================== + Payment Acquirers in POS +========================== + +Installation +============ +* `Install `__ this module in a usual way +* `Activate longpolling `__ + +Configuration +============= + +TODO + +{Instruction how to configure the module before start to use it} + +* `Activate Developer Mode `__ +* Open menu ``[[ {Menu} ]] >> {Submenu} >> {Subsubmenu}`` +* Click ``[{Button Name}]`` + +Usage +===== + +TODO + +{Instruction for daily usage. It should describe how to check that module works. What shall user do and what would user get.} + +* Open menu ``[[ {Menu} ]]>> {Submenu} >> {Subsubmenu}`` +* Click ``[{Button Name}]`` +* RESULT: {what user gets, how the modules changes default behaviour} diff --git a/pos_payment/static/description/icon.png b/pos_payment/static/description/icon.png new file mode 100644 index 0000000000..8a058284ed Binary files /dev/null and b/pos_payment/static/description/icon.png differ diff --git a/pos_wechat_miniprogram/README.rst b/pos_wechat_miniprogram/README.rst new file mode 100644 index 0000000000..210aa7aff8 --- /dev/null +++ b/pos_wechat_miniprogram/README.rst @@ -0,0 +1,39 @@ +========================== + POS: WeChat Mini-program +========================== + +Integrate POS with WeChat mini-program + +Credits +======= + +Contributors +------------ +* `Dinar Gabbasov `__ + +Sponsors +-------- +* `Sinomate `__ + +Maintainers +----------- +* `IT-Projects LLC `__ + + To get a guaranteed support you are kindly requested to purchase the module at `odoo apps store `__. + + Thank you for understanding! + + `IT-Projects Team `__ + +Further information +=================== + +Demo: http://runbot.it-projects.info/demo/pos_addons/11.0 + +HTML Description: https://apps.odoo.com/apps/modules/11.0/pos_wechat_miniprogram/ + +Usage instructions: ``_ + +Changelog: ``_ + +Tested on Odoo 11.0 ee2b9fae3519c2494f34dacf15d0a3b5bd8fbd06 diff --git a/pos_wechat_miniprogram/__init__.py b/pos_wechat_miniprogram/__init__.py new file mode 100644 index 0000000000..de95592500 --- /dev/null +++ b/pos_wechat_miniprogram/__init__.py @@ -0,0 +1 @@ +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). diff --git a/pos_wechat_miniprogram/__manifest__.py b/pos_wechat_miniprogram/__manifest__.py new file mode 100644 index 0000000000..bf8990dee9 --- /dev/null +++ b/pos_wechat_miniprogram/__manifest__.py @@ -0,0 +1,35 @@ +# Copyright 2018 Dinar Gabbasov +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +{ + "name": """POS: WeChat Mini-program""", + "summary": """Integrate POS with WeChat mini-program""", + "category": "Point of Sale", + # "live_test_url": "", + "images": ["images/pos_wechat_miniprogram.jpg"], + "version": "11.0.1.0.0", + "application": False, + + "author": "IT-Projects LLC, Dinar Gabbasov", + "support": "apps@it-projects.info", + "website": "https://it-projects.info/team/GabbasovDinar", + "license": "LGPL-3", + # "price": 9.00, + # "currency": "EUR", + + "depends": [ + "wechat_miniprogram", + "pos_wechat", + ], + "external_dependencies": {"python": [], "bin": []}, + "data": [ + "security/wechat_security.xml", + "security/ir.model.access.csv", + ], + "demo": [ + ], + "qweb": [ + ], + + "auto_install": False, + "installable": True, +} diff --git a/pos_wechat_miniprogram/doc/changelog.rst b/pos_wechat_miniprogram/doc/changelog.rst new file mode 100644 index 0000000000..9ee2b48b8e --- /dev/null +++ b/pos_wechat_miniprogram/doc/changelog.rst @@ -0,0 +1,4 @@ +`1.0.0` +------- + +- Init version diff --git a/pos_wechat_miniprogram/doc/index.rst b/pos_wechat_miniprogram/doc/index.rst new file mode 100644 index 0000000000..75f011226a --- /dev/null +++ b/pos_wechat_miniprogram/doc/index.rst @@ -0,0 +1,10 @@ +========================== + POS: WeChat Mini-program +========================== + +Follow instructions of `WeChat API `__ module. + +Installation +============ + +* `Install `__ this module in a usual way diff --git a/pos_wechat_miniprogram/images/pos_wechat_miniprogram.jpg b/pos_wechat_miniprogram/images/pos_wechat_miniprogram.jpg new file mode 100644 index 0000000000..76dff2fa12 Binary files /dev/null and b/pos_wechat_miniprogram/images/pos_wechat_miniprogram.jpg differ diff --git a/pos_wechat_miniprogram/security/ir.model.access.csv b/pos_wechat_miniprogram/security/ir.model.access.csv new file mode 100644 index 0000000000..ddecde45a8 --- /dev/null +++ b/pos_wechat_miniprogram/security/ir.model.access.csv @@ -0,0 +1,2 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_pos_category,access_pos_category,point_of_sale.model_pos_category,wechat_miniprogram.group_miniprogram_user,1,0,0,0 diff --git a/pos_wechat_miniprogram/security/wechat_security.xml b/pos_wechat_miniprogram/security/wechat_security.xml new file mode 100644 index 0000000000..39f9cc9927 --- /dev/null +++ b/pos_wechat_miniprogram/security/wechat_security.xml @@ -0,0 +1,16 @@ + + + + + + Mini-Program: mini-program user: read POS products only + + [('sale_ok', '=', True), ('available_in_pos', '=', True)] + + + + + + + diff --git a/pos_wechat_miniprogram/static/description/icon.png b/pos_wechat_miniprogram/static/description/icon.png new file mode 100644 index 0000000000..8a058284ed Binary files /dev/null and b/pos_wechat_miniprogram/static/description/icon.png differ diff --git a/qcloud_sms/Dockerfile b/qcloud_sms/Dockerfile new file mode 100644 index 0000000000..a40fdc8db8 --- /dev/null +++ b/qcloud_sms/Dockerfile @@ -0,0 +1,9 @@ +# Use this docker for development +FROM itprojectsllc/install-odoo:11.0-dev + +USER root + +RUN pip install qcloudsms_py +RUN pip install phonenumbers + +USER odoo diff --git a/qcloud_sms/README.rst b/qcloud_sms/README.rst new file mode 100644 index 0000000000..6184d9d08c --- /dev/null +++ b/qcloud_sms/README.rst @@ -0,0 +1,91 @@ +======================= + Tencent Cloud SMS API +======================= + +Basic tools to integrate Odoo and Tencent Cloud SMS. + +.. contents:: + :local: + +Tencent SMS service +=================== + +Tencent Cloud SMS currently supports the following operations: + +Domestic SMS +------------ + +* Single text message +* Specify a template to send a text message +* Send group/mass message +* Specify template to send group/mass text messages +* Pull SMS receipt and SMS reply status + +> `Note` SMS pull function need to contact tencent cloud SMS technical support (QQ:3012203387)opened permissions, large customers can use this feature batch pull, other customers do not recommend using. + +International SMS +----------------- + +* Single text message +* Specify a template to send a text message +* Send group/mass message +* Specify template to send group/mass text messages +* Pull SMS receipt and SMS reply status + +> `Note` Overseas SMS and domestic SMS use the same interface, just replace the corresponding country code and mobile phone number. Each time you request the mobile phone number from the group sending interface, all mobile phone numbers must be domestic or international only + +Voice Notification +------------------ + +* Send a voice verification code +* Send a voice announcement +* Upload a voice file +* Send a voice announcement by voice file fid +* Specify a template to send a voice notification class + +Developing preparation +====================== + +Get SDK AppID and AppKey +------------------------ + +Cloud SMS app SDK 'AppID' and 'AppKey' are available in `SMS console `__ + +Application signature +--------------------- + +A complete SMS consists of two parts: SMS `signature` and SMS text. The SMS `signature` must be applied and reviewed in the corresponding service module of the `SMS console `__. + +Application template +-------------------- + +The template for text or voice message must be applied and reviewed in the corresponding service module of the `SMS console `__ + +Credits +======= + +Contributors +------------ +* `Dinar Gabbasov `__ + +Sponsors +-------- +* `Sinomate `__ + +Maintainers +----------- +* `IT-Projects LLC `__ + + +Further information +=================== + +Demo: http://runbot.it-projects.info/demo/misc-addons/11.0 + +HTML Description: https://apps.odoo.com/apps/modules/11.0/wechat/ + +Usage instructions: ``_ + +Changelog: ``_ + +Tested on Odoo 11.0 ee2b9fae3519c2494f34dacf15d0a3b5bd8fbd06 diff --git a/qcloud_sms/__init__.py b/qcloud_sms/__init__.py new file mode 100644 index 0000000000..92325983cf --- /dev/null +++ b/qcloud_sms/__init__.py @@ -0,0 +1,2 @@ +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +from . import models diff --git a/qcloud_sms/__manifest__.py b/qcloud_sms/__manifest__.py new file mode 100644 index 0000000000..ec86c36200 --- /dev/null +++ b/qcloud_sms/__manifest__.py @@ -0,0 +1,34 @@ +# Copyright 2018 Dinar Gabbasov +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +{ + "name": """Tencent Cloud SMS API""", + "summary": """Technical module to intergrate odoo with Tencent Cloud SMS""", + "category": "Hidden", + # "live_test_url": "", + "images": ["images/qcloud_sms1.jpg"], + "version": "11.0.1.0.0", + "application": False, + + "author": "IT-Projects LLC, Dinar Gabbasov", + "support": "apps@it-projects.info", + "website": "https://it-projects.info/team/GabbasovDinar", + "license": "LGPL-3", + # "price": 9.00, + # "currency": "EUR", + + "depends": [ + 'base', + ], + "external_dependencies": {"python": [ + 'qcloudsms_py', + 'phonenumbers', + ], "bin": []}, + "data": [ + "security/ir.model.access.csv", + "views/res_config.xml", + ], + "qweb": [], + + "auto_install": False, + "installable": True, +} diff --git a/qcloud_sms/doc/changelog.rst b/qcloud_sms/doc/changelog.rst new file mode 100644 index 0000000000..9ee2b48b8e --- /dev/null +++ b/qcloud_sms/doc/changelog.rst @@ -0,0 +1,4 @@ +`1.0.0` +------- + +- Init version diff --git a/qcloud_sms/doc/index.rst b/qcloud_sms/doc/index.rst new file mode 100644 index 0000000000..33450431d6 --- /dev/null +++ b/qcloud_sms/doc/index.rst @@ -0,0 +1,59 @@ +======================= + Tencent Cloud SMS API +======================= + +.. contents:: + :local: + +Installation +============ + +* Install `qcloudsms_py library `__:: + + pip install qcloudsms_py + + # to update existing installation use + pip install -U qcloudsms_py + + +Tencent Cloud SMS API +===================== + +TODO + +Configuration +============= + +Credentials +----------- + +* `Activate Developer Mode `__ +* Open menu ``[[ Settings ]] >> Parameters >> System Parameters`` +* Create following parameters + + * ``qcloudsms.app_id`` + * ``qcloudsms.app_key`` + + +SMS Tracking +------------ +ALL SMS messages can be found at ``[[ Settings ]] >> Tencent Cloud SMS >> Messages``. If you don't have that menu, you need to `Activate Developer Mode `__ + +SMS Templates +------------- +* `Activate Developer Mode `__ +* Open menu ``[[ Settings ]] >> Tencent Cloud SMS >> Templates`` +* Click on ``[Create]`` +* Specify ``Name`` and ``SMS Type`` +* If necessary, specify other fields +* Click on ``[Save]`` + +SMS Templates params +-------------------- + +``domestic_template_params`` or ``international_template_params`` - the array of template values. The parameters must be separated by commas. If the template has no parameters, leave it empty. + +For example: + +If the template has the following format: +``Your login verification code is {1}, which is valid for {2} minutes.`` and the params have the following values: ``['123456', '5']``, after sending an SMS we receive the message: ``Your login verification code is 123456, which is valid for 5 minutes.`` diff --git a/qcloud_sms/images/qcloud_sms1.jpg b/qcloud_sms/images/qcloud_sms1.jpg new file mode 100644 index 0000000000..5b22182af9 Binary files /dev/null and b/qcloud_sms/images/qcloud_sms1.jpg differ diff --git a/qcloud_sms/models/__init__.py b/qcloud_sms/models/__init__.py new file mode 100644 index 0000000000..9260073a5a --- /dev/null +++ b/qcloud_sms/models/__init__.py @@ -0,0 +1,3 @@ +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +from . import qcloud_sms +from . import ir_config_parameter diff --git a/qcloud_sms/models/ir_config_parameter.py b/qcloud_sms/models/ir_config_parameter.py new file mode 100644 index 0000000000..9673ba9426 --- /dev/null +++ b/qcloud_sms/models/ir_config_parameter.py @@ -0,0 +1,27 @@ +# Copyright 2018 Dinar Gabbasov +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +import logging + +from odoo import models, api + + +_logger = logging.getLogger(__name__) + +try: + from qcloudsms_py import QcloudSms +except ImportError as err: + _logger.debug(err) + + +class Param(models.Model): + + _inherit = 'ir.config_parameter' + + @api.model + def get_qcloud_sms_object(self): + _logger.debug( + 'Tencent Cloud SMS Credentials: app_id=%s, app_key=%s', + self.get_param('qcloudsms.app_id', ''), + '%s...' % self.get_param('qcloudsms.app_key', '')[:5] + ) + return QcloudSms(self.get_param('qcloudsms.app_id', ''), self.get_param('qcloudsms.app_key', '')) diff --git a/qcloud_sms/models/qcloud_sms.py b/qcloud_sms/models/qcloud_sms.py new file mode 100644 index 0000000000..d1476ab25e --- /dev/null +++ b/qcloud_sms/models/qcloud_sms.py @@ -0,0 +1,395 @@ +# Copyright 2018 Dinar Gabbasov +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +import logging + +from odoo import models, fields, api, _ +from odoo.exceptions import UserError + +_logger = logging.getLogger(__name__) + +try: + from qcloudsms_py.httpclient import HTTPError + import phonenumbers +except ImportError as err: + _logger.debug(err) + + +class QCloudSMS(models.Model): + """Records with information about SMS messages.""" + + _name = 'qcloud.sms' + _description = 'SMS Messages' + _order = 'id desc' + + STATE_SELECTION = [ + ('draft', 'Draft'), + ('sent', 'Sent'), + ('done', 'Delivered'), + ('error', 'Error'), + ] + + partner_ids = fields.Many2many('res.partner', string='Partner') + send_datetime = fields.Datetime(string='Sent', readonly=True, help='Date and Time of sending the message', + default=fields.Datetime.now) + message = fields.Text(string='Message') + state = fields.Selection(STATE_SELECTION, string='Status', readonly=True, default='draft', + help='Status of the SMS message') + template_id = fields.Many2one('qcloud.sms.template', 'SMS Template') + sms_type = fields.Selection([ + (0, 'Normal'), + (1, 'Marketing') + ], string='SMS Type', default=0, help='Type of SMS message') + + def _get_country(self, partner): + if 'country_id' in partner: + return partner.country_id + return self.env.user.company_id.country_id + + def _sms_sanitization(self, partner, number=None): + number = number or partner.mobile + if number: + country = self._get_country(partner) + country_code = country.code if country else None + try: + phone_nbr = phonenumbers.parse(number, region=country_code, keep_raw_input=True) + except phonenumbers.phonenumberutil.NumberParseException as e: + raise UserError(_('Unable to parse %s:\n%s') % (number, e)) + + if not phonenumbers.is_possible_number(phone_nbr) or not phonenumbers.is_valid_number(phone_nbr): + raise UserError(_('Invalid number %s: probably incorrect prefix') % number) + return phone_nbr + else: + raise UserError(_("Mobile phone number not specified for Partner: %s(id: %s)", partner.name, partner.id)) + + @api.model + def send_message(self, message, partner_id, sms_type=None, **kwargs): + """ + Send single SMS message. + + :param message: SMS message content + :param partner_id: id of partner to whom the message will be sent + :param sms_type: SMS message type, 0 - normal SMS, 1 - marketing SMS + :param kwargs: not required parameters, extend - extend field, default is empty string, + ext - ext field, content will be returned by server as it is, url - custom url + """ + try: + result = self._send_message(message, partner_id, sms_type, **kwargs) + except HTTPError as e: + return { + 'error': _('Error on sending SMS: %s') % e.response.text + } + _logger.debug('Send message JSON result: %s', result) + return result + + @api.model + def _send_message(self, message, partner_id, sms_type, **kwargs): + partner = self.env['res.partner'].browse(partner_id) + vals = { + 'message': message, + 'partner_ids': partner, + 'sms_type': sms_type, + } + + # create new record + sms = self.create(vals) + + # get SMS object + qcloudsms = self.env['ir.config_parameter'].sudo().get_qcloud_sms_object() + ssender = qcloudsms.SmsSingleSender() + + try: + phone_obj = self._sms_sanitization(partner) + except UserError as e: + sms.write({ + 'state': 'error' + }) + _logger.debug(e) + raise + + country_code = phone_obj.country_code + national_number = phone_obj.national_number + _logger.debug("Country code: %s, Mobile number: %s", country_code, national_number) + + extend_field = kwargs.get('extend_field') or "" + url = kwargs.get('url') or None + + result = ssender.send(sms.sms_type, country_code, national_number, + message, extend=extend_field, ext=sms.id, url=url) + + state = 'sent' if result.get('result') == 0 else 'error' + sms.write({ + 'state': state + }) + result['sms_id'] = sms.id + return result + + @api.model + def send_group_message(self, message, partner_ids, sms_type=None, **kwargs): + """ + Send a SMS messages to multiple partners at once. + + :param message: SMS message content + :param partner_ids: ids of partners to whom the message will be sent + :param sms_type: SMS message type, 0 - normal SMS, 1 - marketing SMS + :param kwargs: not required parameters, extend - extend field, default is empty string, + ext - ext field, content will be returned by server as it is, url - custom url + """ + try: + result = self._send_group_message(message, partner_ids, sms_type, **kwargs) + except HTTPError as e: + return { + 'error': _('Error on sending SMS: %s') % e.response.text + } + _logger.debug('Send group message JSON result: %s', result) + return result + + @api.model + def _send_group_message(self, message, partner_ids, sms_type, **kwargs): + partners = self.env['res.partner'].browse(partner_ids) + + vals = { + 'message': message, + 'partner_ids': partners, + 'sms_type': sms_type, + } + + # create new record + sms = self.create(vals) + + # get SMS object + qcloudsms = self.env['ir.config_parameter'].sudo().get_qcloud_sms_object() + msender = qcloudsms.SmsMultiSender() + + try: + phone_obj_list = map(self._sms_sanitization, partners) + except UserError as e: + sms.state = 'error' + _logger.debug(e) + + country_code_list = list(map(lambda x: x.country_code, phone_obj_list)) + country_code = list(set(country_code_list)) + + if len(country_code) > 1: + raise UserError(_('The country code must be the same for all phone numbers')) + + country_code = country_code[0] + national_number_list = list(map(lambda x: x.national_number, phone_obj_list)) + + _logger.debug("Country code: %s, Mobile numbers: %s", country_code, national_number_list) + + extend_field = kwargs.get('extend_field') or "" + url = kwargs.get('url') or None + + result = msender.send(sms.sms_type, country_code, national_number_list, + message, extend=extend_field, ext=sms.id, url=url) + + state = 'sent' if result.get('result') == 0 else 'error' + sms.write({ + 'state': state + }) + result['sms_id'] = sms.id + return result + + +class QCloudSMSTemplate(models.Model): + """Templates of SMS messages.""" + + _name = 'qcloud.sms.template' + _description = 'SMS Templates' + _order = 'id desc' + + name = fields.Char(string='Name', help='The template name') + + domestic_sms_template_ID = fields.Integer( + string='Domestic SMS Template ID', + help='SMS Template ID is the Tencent Cloud SMS template (the specific content of the SMS message to be sent).' + ) + domestic_template_params = fields.Text( + string="Domestic Template parameters", + help="Parameters must be separated by commas. If the template has no parameters, leave it empty." + ) + domestic_sms_sign = fields.Char( + string='Domestic SMS Signature', + help='SMS Signature is the Tencent Cloud SMS signature (an identifier added before the message body for ' + 'identification of the company or business.).' + ) + international_sms_template_ID = fields.Integer( + string='International SMS Template ID', + help='SMS Template ID is the Tencent Cloud SMS template (the specific content of the SMS message to be sent).' + ) + international_template_params = fields.Text( + string="International Template parameters", + help="Parameters must be separated by commas. If the template has no parameters, leave it empty." + ) + international_sms_sign = fields.Char( + string='International SMS Signature ID', + help='SMS Signature is the Tencent Cloud SMS signature (an identifier added before the message body for ' + 'identification of the company or business.).' + ) + + @api.multi + def _get_sms_params_by_country_code(self, code): + self.ensure_one() + res = {} + # China Country Code 86 (use domestics templates) + if code == '86': + res['template_ID'] = self.domestic_sms_template_ID or '' + params = self.domestic_template_params or '' + res['sign'] = self.domestic_sms_sign or '' + else: + res['template_ID'] = self.international_sms_template_ID or '' + params = self.international_template_params or '' + res['sign'] = self.international_sms_sign or '' + + res['template_params'] = params.split(',') + + return res + + @api.model + def send_template_message(self, partner_id, template_id, **kwargs): + """ + Send single SMS message with template paramters. + + :param partner_id: id of partner to whom the message will be sent + :param template_id: id of template + :param kwargs: not required parameters, extend - extend field, default is empty string, + ext - ext field, content will be returned by server as it is, url - custom url + """ + try: + template = self.browse(template_id) + result = template._send_template_message(partner_id, **kwargs) + except HTTPError as e: + return { + 'error': _('Error on sending Template SMS: %s') % e.response.text + } + _logger.debug('Send template message JSON result: %s', result) + return result + + @api.multi + def _send_template_message(self, partner_id, **kwargs): + self.ensure_one() + partner = self.env['res.partner'].browse(partner_id) + + vals = { + 'partner_ids': partner, + 'template_id': self.id, + } + + # create new sms record + sms_model = self.env['qcloud.sms'] + sms_record = sms_model.create(vals) + + # get SMS object + qcloudsms = self.env['ir.config_parameter'].sudo().get_qcloud_sms_object() + ssender = qcloudsms.SmsSingleSender() + + try: + phone_obj = sms_model._sms_sanitization(partner) + except UserError as e: + sms_record.write({ + 'state': 'error' + }) + _logger.debug(e) + raise + + country_code = phone_obj.country_code + national_number = phone_obj.national_number + + _logger.debug("Country code: %s, Mobile number: %s", country_code, national_number) + + params = self._get_sms_params_by_country_code(country_code) + + _logger.debug("SMS params: %s", params) + + extend_field = kwargs.get('extend_field') or '' + url = kwargs.get('url') or None + + result = ssender.send_with_param(country_code, national_number, params.get('template_ID'), + params.get('template_params'), sign=params.get('sign'), extend=extend_field, + ext=sms_record.id, url=url) + + state = 'sent' if result.get('result') == 0 else 'error' + sms_record.write({ + 'state': state + }) + + result['sms_id'] = sms_record.id + return result + + @api.model + def send_template_group_message(self, partner_ids, template_id, **kwargs): + """ + Send a SMS messages with template parameters to multiple + partners at once. + + :param partner_ids: ids of partners to whom the message will be sent + :param template_id: id of template + :param kwargs: not required parameters, extend - extend field, default is empty string, + ext - ext field, content will be returned by server as it is, url - custom url + """ + try: + template = self.browse(template_id) + result = template._send_template_group_message(partner_ids, **kwargs) + except HTTPError as e: + return { + 'error': _('Error on sending Template SMS: %s') % e.response.text + } + _logger.debug('Send template group message JSON result: %s', result) + return result + + @api.multi + def _send_template_group_message(self, partner_ids, **kwargs): + self.ensure_one() + partners = self.env['res.partner'].browse(partner_ids) + + vals = { + 'partner_ids': partners, + 'template_id': self.id, + } + + # create new sms record + sms_model = self.env['qcloud.sms'] + sms_record = sms_model.create(vals) + + # get SMS object + qcloudsms = self.env['ir.config_parameter'].sudo().get_qcloud_sms_object() + msender = qcloudsms.SmsMultiSender() + + try: + phone_obj_list = map(sms_model._sms_sanitization, partners) + except UserError as e: + sms_record.write({ + 'state': 'error' + }) + _logger.debug(e) + raise + + country_code_list = list(map(lambda x: x.country_code, phone_obj_list)) + country_code = list(set(country_code_list)) + + if len(country_code) > 1: + raise UserError(_('The country code must be the same for all phone numbers')) + + country_code = country_code[0] + national_number_list = list(map(lambda x: x.national_number, phone_obj_list)) + + _logger.debug("Country code: %s, Mobile numbers: %s", country_code, national_number_list) + + params = self._get_sms_params_by_country_code(country_code) + + _logger.debug("SMS params: %s", params) + + extend_field = kwargs.get('extend_field') or '' + url = kwargs.get('url') or None + + result = msender.send_with_param(country_code, national_number_list, params.get('template_ID'), + params.get('template_params'), sign=params.get('sign'), extend=extend_field, + ext=sms_record.id, url=url) + + state = 'sent' if result.get('result') == 0 else 'error' + sms_record.write({ + 'state': state + }) + + result['sms_id'] = sms_record.id + return result diff --git a/qcloud_sms/security/ir.model.access.csv b/qcloud_sms/security/ir.model.access.csv new file mode 100644 index 0000000000..5a0ed2b4e1 --- /dev/null +++ b/qcloud_sms/security/ir.model.access.csv @@ -0,0 +1,3 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_qcloud_sms,access_qcloud_sms,model_qcloud_sms,base.group_user,1,1,1,0 +access_qcloud_sms_template,access_qcloud_sms_template,model_qcloud_sms_template,base.group_user,1,1,1,0 diff --git a/qcloud_sms/static/description/icon.png b/qcloud_sms/static/description/icon.png new file mode 100644 index 0000000000..b43a0a135f Binary files /dev/null and b/qcloud_sms/static/description/icon.png differ diff --git a/qcloud_sms/tests/__init__.py b/qcloud_sms/tests/__init__.py new file mode 100644 index 0000000000..6c9d6322ef --- /dev/null +++ b/qcloud_sms/tests/__init__.py @@ -0,0 +1,2 @@ +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +from . import test_qcloud_sms diff --git a/qcloud_sms/tests/test_qcloud_sms.py b/qcloud_sms/tests/test_qcloud_sms.py new file mode 100644 index 0000000000..33737a8417 --- /dev/null +++ b/qcloud_sms/tests/test_qcloud_sms.py @@ -0,0 +1,85 @@ +# Copyright 2018 Dinar Gabbasov +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +import logging + +try: + from unittest.mock import patch +except ImportError: + from mock import patch + +from odoo.tests.common import HttpCase +from odoo import api + + +_logger = logging.getLogger(__name__) + + +class TestQCloudSMS(HttpCase): + at_install = True + post_install = True + + def setUp(self): + super(TestQCloudSMS, self).setUp() + self.phantom_env = api.Environment(self.registry.test_cr, self.uid, {}) + self.Message = self.phantom_env['qcloud.sms'] + self.partner_1 = self.phantom_env.ref('base.res_partner_1') + self.partner_2 = self.phantom_env.ref('base.res_partner_2') + self.partner_1.write({ + 'mobile': '+1234567890' + }) + self.partner_2.write({ + 'mobile': '+1987654320' + }) + self.Template = self.phantom_env['qcloud.sms.template'].create({ + 'name': 'Test Template', + 'international_sms_template_ID': 123456, + 'international_template_params': '1,2,3,4,5', + 'international_sms_sign': 'Test LLC' + }) + # add patch for phonenumbers + patcher_possible_number = patch('phonenumbers.is_possible_number', wraps=lambda *args: True) + patcher_possible_number.start() + self.addCleanup(patcher_possible_number.stop) + + patcher_valid_number = patch('phonenumbers.is_valid_number', wraps=lambda *args: True) + patcher_valid_number.start() + self.addCleanup(patcher_valid_number.stop) + + # add patch for qcloudsms_py request + response_json = { + "result": 0, + "errmsg": "OK", + "ext": "", + "fee": 1, + "sid": "xxxxxxx" + } + self._patch_post_requests(response_json) + + def _patch_post_requests(self, response_json): + + def api_request(req, httpclient=None): + _logger.debug("Request data: req - %s, httpclient - %s", req, httpclient) + return response_json + + patcher = patch('qcloudsms_py.util.api_request', wraps=api_request) + patcher.start() + self.addCleanup(patcher.stop) + + def test_send_message(self): + message = "Your login verification code is 1234, which is valid for 2 minutes." \ + "If you are not using our service, ignore the message." + response = self.Message.send_message(message, self.partner_1.id) + self.assertEquals(response.get('result'), 0, 'Could not send message') + + def test_send_group_message(self): + message = "Discount for all products 50%!" + response = self.Message.send_group_message(message, [self.partner_1.id, self.partner_2.id], sms_type=1) + self.assertEquals(response.get('result'), 0, 'Could not send group message') + + def test_send_template_message(self): + response = self.Template.send_template_message(self.partner_1.id, self.Template.id) + self.assertEquals(response.get('result'), 0, 'Could not send template message') + + def test_send_template_group_message(self): + response = self.Template.send_template_group_message([self.partner_1.id, self.partner_2.id], self.Template.id) + self.assertEquals(response.get('result'), 0, 'Could not send template group message') diff --git a/qcloud_sms/views/res_config.xml b/qcloud_sms/views/res_config.xml new file mode 100644 index 0000000000..fa80b3986e --- /dev/null +++ b/qcloud_sms/views/res_config.xml @@ -0,0 +1,91 @@ + + + + qcloud.sms.tree + qcloud.sms + + + + + + + + + + + qcloud.sms.form + qcloud.sms + +
+
+ +
+ + + + + + + + + + + +
+
+
+ + + qcloud.sms.template.tree + qcloud.sms.template + + + + + + + + + qcloud.sms.template.form + qcloud.sms.template + +
+ + + + + + + + + + + + + + + +
+
+
+ + + Messages + ir.actions.act_window + qcloud.sms + form + tree,form + + + + Templates + ir.actions.act_window + qcloud.sms.template + form + tree,form + + + + + +
diff --git a/qr_payments/tools/async.py b/qr_payments/tools/async.py new file mode 100644 index 0000000000..53513886a1 --- /dev/null +++ b/qr_payments/tools/async.py @@ -0,0 +1,39 @@ +# Copyright 2018 Ivan Yelizariev +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +import threading + + +from odoo import api + +__all__ = ['odoo_async_call'] + + +def odoo_async_call(target, args, kwargs, callback=None): + t = threading.Thread(target=odoo_wrapper, args=(target, args, kwargs, callback)) + t.start() + return t + + +# TODO: is there more elegant way? +def odoo_wrapper(target, args, kwargs, callback): + self = get_self(target) + with api.Environment.manage(), self.pool.cursor() as cr: + result = call_with_new_cr(cr, target, args, kwargs) + if callback: + call_with_new_cr(cr, callback, (result,)) + + +def get_self(method): + try: + # python 3 + return method.__self__ + except: + # python 2 + return method.im_self + + +def call_with_new_cr(cr, method, args=None, kwargs=None): + method_name = method.__name__ + self = get_self(method) + self = self.with_env(self.env(cr=cr)) + return getattr(self, method_name)(*(args or ()), **(kwargs or {})) diff --git a/requirements.txt b/requirements.txt index 6e9ee4d695..7992746adb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,5 @@ wechatpy[cryptography] requests-mock python-alipay-sdk +qcloudsms_py +phonenumbers diff --git a/wechat_miniprogram/README.rst b/wechat_miniprogram/README.rst new file mode 100644 index 0000000000..bdaa298c1d --- /dev/null +++ b/wechat_miniprogram/README.rst @@ -0,0 +1,194 @@ +========================= + WeChat mini-program API +========================= + +Basic tools to integrate Odoo and WeChat mini-program. + +.. contents:: + :local: + +Payment method +============== + +**Mini program** uses Official Account Payment method. For more information, please see the `WeChat Pay Interface Document `__ + +Developing mini-program +======================= + +Authentication +-------------- + +To authenticate a user from the mini-program, you must send a request with a code and user information of the mini-program to the server. To receive the code and send the request, you must use ``wx.login`` provided by mini-program API. Odoo will create a user if one does not exist and assign session_id which has to be sent via a cookie on each RPC request.:: + + function AuthenticateUser(code) { + return new Promise(function(resolve, reject) { + var userInfo = app.globalData.userInfo; + code = code || false; + function do_request() { + var options = { + url: 'https://ODOO_HOST/wechat/miniprogram/authenticate', + header: { + 'Content-Type': 'application/json' + }, + success: function(res) { + // save session_id + var data = res.data.result; + if (data.session_id) { + wx.setStorage({ + key: 'session_id', + data: data.session_id, + success: function() { + resolve(data); + } + }) + } else { + reject(res); + } + }, + fail: function(res) { + reject(res); + } + }; + + var params = { + context: { + }, + code: code, + user_info: userInfo + }; + + // send request to Odoo server + wxJsonRpc(params, options); + } + + if (code) { + do_request(); + } else { + wx.login({ + success: function(data) { + code = data.code; + do_request(); + } + }) + } + }); + } + +RPC calls +--------- + +RPC request from mini-program:: + + function odooRpc(params, options) { + return new Promise(function(resolve, reject){ + options = options || { + }; + function do_request(session_id) { + options.url = 'https://ODOO_HOST/web/dataset/call_kw'; + options.header = { + 'Content-Type': 'applications/json', + 'X-Openerp-Session-Id': session_id + }; + options.success = function(res) { + var data = res.data.result; + resolve(data); + }; + options.fail = function(res) { + reject(res); + }; + wxJsonRpc(params, options); + } + wx.getStorage({ + key: 'session_id', + success: function(res) { + if (res.data) { + do_request(res.data); + } else { + AuthenticateUser().then(function(data){ + do_request(data.session_id); + }); + } + }, + fail: function() { + AuthenticateUser().then(function(data){ + do_request(data.session_id); + }); + }, + }); + }); + } + + function wxJsonRpc(params, options) { + var data = { + "jsonrpc": "2.0", + "method": "call", + "params": params, + "id": Math.floor(Math.random() * 1000 * 1000 * 1000), + } + options.data = JSON.stringify(data); + options.dataType = 'json'; + options.method = 'POST'; + // send request to Odoo server + wx.request(options); + } + + +**Example:** +Load Products from Odoo Server:: + + var params = { + models: 'product.product', + method: 'search_read', + args: [ + ], + context: { + }, + kwargs: { + domain: [['sale_ok','=',true],['available_in_pos','=',true]], + fields: ['display_name', 'list_price', 'lst_price', 'standard_price', 'categ_id', 'pos_categ_id', 'taxes_id', + 'barcode', 'default_code', 'to_weight', 'uom_id', 'description_sale', 'description', + 'product_tmpl_id','tracking'], + } + } + + odooRpc(params).then(function(res) { + console.log(res); + }); + +**Result:** list of Products + +Sandbox & Debugging of mini-program +=================================== + +* API Debug Console https://open.wechat.com/cgi-bin/newreadtemplate?t=overseas_open/docs/oa/basic-info/debug-console +* Creating Test Accounts https://admin.wechat.com/debug/cgi-bin/sandbox?t=sandbox/login + + * You will get ``sub_appid`` and ``sub_appsecret`` values for work with mini-programs + +Credits +======= + +Contributors +------------ +* `Dinar Gabbasov `__ + +Sponsors +-------- +* `Sinomate `__ + +Maintainers +----------- +* `IT-Projects LLC `__ + +Further information +=================== + +Demo: http://runbot.it-projects.info/demo/misc-addons/11.0 + +HTML Description: https://apps.odoo.com/apps/modules/11.0/wechat_miniprogram/ + +Usage instructions: ``_ + +Changelog: ``_ + +Tested on Odoo 11.0 ee2b9fae3519c2494f34dacf15d0a3b5bd8fbd06 diff --git a/wechat_miniprogram/__init__.py b/wechat_miniprogram/__init__.py new file mode 100644 index 0000000000..ab2f6edd50 --- /dev/null +++ b/wechat_miniprogram/__init__.py @@ -0,0 +1,3 @@ +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +from . import models +from . import controllers diff --git a/wechat_miniprogram/__manifest__.py b/wechat_miniprogram/__manifest__.py new file mode 100644 index 0000000000..4318974f32 --- /dev/null +++ b/wechat_miniprogram/__manifest__.py @@ -0,0 +1,32 @@ +# Copyright 2018 Dinar Gabbasov +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +{ + "name": """WeChat mini-program API""", + "summary": """Technical module to intergrate odoo with WeChat mini-program""", + "category": "Hidden", + # "live_test_url": "", + "images": ["images/wechat_miniprogram.jpg"], + "version": "11.0.1.0.0", + "application": False, + + "author": "IT-Projects LLC, Dinar Gabbasov", + "support": "apps@it-projects.info", + "website": "https://it-projects.info/team/GabbasovDinar", + "license": "LGPL-3", + # "price": 9.00, + # "currency": "EUR", + + "depends": [ + 'wechat', + ], + "external_dependencies": {"python": [], "bin": []}, + "data": [ + "data/module_data.xml", + "security/wechat_security.xml", + "security/ir.model.access.csv", + ], + "qweb": [], + + "auto_install": False, + "installable": True, +} diff --git a/wechat_miniprogram/controllers/__init__.py b/wechat_miniprogram/controllers/__init__.py new file mode 100644 index 0000000000..7951449de5 --- /dev/null +++ b/wechat_miniprogram/controllers/__init__.py @@ -0,0 +1,2 @@ +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +from . import wechat_controllers diff --git a/wechat_miniprogram/controllers/wechat_controllers.py b/wechat_miniprogram/controllers/wechat_controllers.py new file mode 100644 index 0000000000..ec5e9baf41 --- /dev/null +++ b/wechat_miniprogram/controllers/wechat_controllers.py @@ -0,0 +1,88 @@ +# Copyright 2018 Dinar Gabbasov +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +import logging +import base64 +from odoo.exceptions import UserError + +from odoo import http, _ +from odoo.http import request +import requests + +_logger = logging.getLogger(__name__) + + +class WechatMiniProgramController(http.Controller): + + @http.route('/wechat/miniprogram/authenticate', type='json', auth='public', csrf=False) + def authenticate(self, code, user_info, test_cr=False): + """ + :param code: After the user is permitted to log in on the WeChat mini-program, the callback content will + bring the code (five-minute validity period). The developer needs to send the code to the backend + of their server and use code in exchange for the session_key api. + The code is exchanged for the openid and session_key. + :param user_info: User information object, does not contain sensitive information such as openid + :return session_info: All information about session such as session_id, uid, etc. + """ + _logger.debug('/wechat/miniprogram/authenticate request: code - %s, user_info - %s', code, user_info) + openid, session_key = self.get_openid(code) + _logger.debug('Authenticate on WeChat server: openid - %s, session_key - %s', openid, session_key) + + if not openid or not session_key: + raise UserError(_('Unable to get data from WeChat server : openid - %s, session_key - %s') % (openid, session_key)) + + User = request.env['res.users'].sudo() + user = User.search([('openid', '=', openid)]) + if user: + user.write({ + 'wechat_session_key': session_key, + }) + else: + country = request.env['res.country'].search([('name', 'like', '%'+user_info.get('country')+'%')], limit=1) + name = user_info.get('nickName') + login = "wechat_%s" % openid + city = user_info.get('city') + + user = User.create({ + 'company_id': request.env.ref("base.main_company").id, + 'name': name, + 'image': base64.b64encode(requests.get(user_info.get('avatarUrl')).content) if user_info.get('avatarUrl') else None, + 'openid': openid, + 'wechat_session_key': session_key, + 'login': login, + 'country_id': country.id if country else None, + 'city': city, + 'groups_id': [(4, request.env.ref('wechat_miniprogram.group_miniprogram_user').id)] + }) + + if test_cr is False: + # A new cursor is used to authenticate the user and it cannot see the + # latest changes of current transaction. + # Therefore we need to make the commit. + + # In test mode, one special cursor is used for all transactions. + # So we don't need to make the commit. More over commit() shall not be used, + # because otherwise test changes are not rollbacked at the end of test + request.env.cr.commit() + + request.session.authenticate(request.db, user.login, user.wechat_session_key) + _logger.debug('Current user login: %s, id: %s', request.env.user.login, request.env.user.id) + session_info = request.env['ir.http'].session_info() + return session_info + + def get_openid(self, code): + """Get openid + + :param code: After the user is permitted to log in on the WeChat mini-program, the callback content will + bring the code (five-minute validity period). The developer needs to send the code to the backend + of their server and use code in exchange for the session_key api. + The code is exchanged for the openid and session_key. + :return openid: The WeChat user's unique ID + """ + url = request.env['ir.config_parameter'].sudo().get_openid_url(code) + response = requests.get(url) + response.raise_for_status() + value = response.json() + _logger.debug('get_openid function return parameters: %s', value) + openid = value.get('openid') + session_key = value.get('session_key') + return openid, session_key diff --git a/wechat_miniprogram/data/module_data.xml b/wechat_miniprogram/data/module_data.xml new file mode 100644 index 0000000000..a8d9ebe379 --- /dev/null +++ b/wechat_miniprogram/data/module_data.xml @@ -0,0 +1,8 @@ + + + + Mini-program + Helps you manage your Wechat and main operations: create orders, payment, etc... + 60 + + diff --git a/wechat_miniprogram/doc/changelog.rst b/wechat_miniprogram/doc/changelog.rst new file mode 100644 index 0000000000..9ee2b48b8e --- /dev/null +++ b/wechat_miniprogram/doc/changelog.rst @@ -0,0 +1,4 @@ +`1.0.0` +------- + +- Init version diff --git a/wechat_miniprogram/doc/index.rst b/wechat_miniprogram/doc/index.rst new file mode 100644 index 0000000000..0512519c14 --- /dev/null +++ b/wechat_miniprogram/doc/index.rst @@ -0,0 +1,27 @@ +========================= + WeChat mini-program API +========================= + +Follow instructions of `WeChat API `__ module. + +Installation +============ + +Multi database +-------------- + +If you have several databases, you need to check that all requests are sent to the desired database. The user authentication request from the Mini-program does not contain session cookies. So, if Odoo cannot determine which database to use, it will return a 404 error (Page not found). +In order for the requests to send to the desired database, you need to configure `dbfilter `__. + +Configuration +============= + +Credentials +----------- + +* `Activate Developer Mode `__ +* Open menu ``[[ Settings ]] >> Parameters >> System Parameters`` +* Create following parameters + + * ``wechat.miniprogram_app_id`` + * ``wechat.miniprogram_app_secret`` diff --git a/wechat_miniprogram/images/wechat_miniprogram.jpg b/wechat_miniprogram/images/wechat_miniprogram.jpg new file mode 100644 index 0000000000..f212197398 Binary files /dev/null and b/wechat_miniprogram/images/wechat_miniprogram.jpg differ diff --git a/wechat_miniprogram/models/__init__.py b/wechat_miniprogram/models/__init__.py new file mode 100644 index 0000000000..4f4ec4bc2a --- /dev/null +++ b/wechat_miniprogram/models/__init__.py @@ -0,0 +1,4 @@ +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +from . import wechat_order +from . import ir_config_parameter +from . import res_users diff --git a/wechat_miniprogram/models/ir_config_parameter.py b/wechat_miniprogram/models/ir_config_parameter.py new file mode 100644 index 0000000000..4d0d4081be --- /dev/null +++ b/wechat_miniprogram/models/ir_config_parameter.py @@ -0,0 +1,52 @@ +# Copyright 2018 Dinar Gabbasov +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +import logging +import werkzeug.urls + +from odoo import models, api + + +_logger = logging.getLogger(__name__) + +try: + from wechatpy import WeChatPay +except ImportError as err: + _logger.debug(err) + + +class Param(models.Model): + + _inherit = 'ir.config_parameter' + + @api.model + def get_wechat_miniprogram_pay_object(self): + sandbox = self.sudo().get_param('wechat.sandbox', '0') != '0' + if sandbox: + _logger.info('Sandbox Mode is used for WeChat API') + _logger.debug('WeChat Credentials: miniprogram_app_id=%s, miniprogram_app_secret=%s, mch_id=%s, sub_mch_id=%s, sandbox mode is %s', + self.sudo().get_param('wechat.miniprogram_app_id', ''), + '%s...' % self.sudo().get_param('wechat.miniprogram_app_secret', '')[:5], + self.sudo().get_param('wechat.mch_id', ''), + self.sudo().get_param('wechat.sub_mch_id', ''), + sandbox + ) + return WeChatPay( + self.sudo().get_param('wechat.app_id', ''), + self.sudo().get_param('wechat.app_secret', ''), + self.sudo().get_param('wechat.mch_id', ''), + sub_mch_id=self.sudo().get_param('wechat.sub_mch_id', ''), + sandbox=sandbox, + sub_appid=self.sudo().get_param('wechat.miniprogram_app_id', '') + ) + + def get_openid_url(self, code): + base_url = 'https://api.weixin.qq.com/sns/jscode2session' + param = { + 'appid': self.sudo().get_param('wechat.miniprogram_app_id', ''), + 'secret': self.sudo().get_param('wechat.miniprogram_app_secret', ''), + 'js_code': code, + 'grant_type': 'authorization_code' + } + url = '%s?%s' % (base_url, werkzeug.urls.url_encode(param)) + _logger.debug('openid url: %s', url) + return url diff --git a/wechat_miniprogram/models/res_users.py b/wechat_miniprogram/models/res_users.py new file mode 100644 index 0000000000..a18c998739 --- /dev/null +++ b/wechat_miniprogram/models/res_users.py @@ -0,0 +1,24 @@ +# Copyright 2018 Dinar Gabbasov +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +import logging + +from odoo import models, api, fields +from odoo.exceptions import AccessDenied + +_logger = logging.getLogger(__name__) + + +class ResUsers(models.Model): + _inherit = 'res.users' + + openid = fields.Char('Openid') + wechat_session_key = fields.Char("WeChat session key") + + @api.model + def check_credentials(self, password): + try: + return super(ResUsers, self).check_credentials(password) + except AccessDenied: + res = self.sudo().search([('id', '=', self.env.uid), ('wechat_session_key', '=', password)]) + if not res: + raise diff --git a/wechat_miniprogram/models/wechat_order.py b/wechat_miniprogram/models/wechat_order.py new file mode 100644 index 0000000000..188327baf2 --- /dev/null +++ b/wechat_miniprogram/models/wechat_order.py @@ -0,0 +1,88 @@ +# Copyright 2018 Dinar Gabbasov +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +import logging +import json + +from odoo import models, api + +_logger = logging.getLogger(__name__) + + +class WeChatOrder(models.Model): + _inherit = 'wechat.order' + + @api.model + def create_jsapi_order(self, lines, create_vals): + return self._create_jsapi_order(lines, create_vals) + + @api.model + def _create_jsapi_order(self, lines, create_vals): + """JSAPI Payment + + :param openid: The WeChat user's unique ID + :param lines: list of dictionary + :param create_vals: User order information + :returns order_id: Current order id + result_json: Payments data for WeChat + """ + debug = self.env['ir.config_parameter'].sudo().get_param('wechat.local_sandbox') == '1' + + vals = { + 'trade_type': 'NATIVE', + 'line_ids': [(0, 0, data) for data in lines], + 'debug': debug, + } + + if create_vals: + vals.update(create_vals) + + order = self.sudo().create(vals) + total_fee = order._total_fee() + body, detail = order._body() + mpay = self.env['ir.config_parameter'].get_wechat_miniprogram_pay_object() + openid = self.env.user.openid + if debug: + _logger.info('SANDBOX is activated. Request to wechat servers is not sending') + # Dummy Data. Change it to try different scenarios + result_raw = { + 'return_code': 'SUCCESS', + 'result_code': 'SUCCESS', + 'openid': '123', + 'timeStamp': '1414561699', + 'nonceStr': '5K8264ILTKCH16CQ2502SI8ZNMTM67VS', + 'package': 'prepay_id=123456789', + 'signType': 'MD5', + 'prepay_id': '123', + 'paySign': 'C380BEC2BFD727A4B6845133519F3AD6', + } + if self.env.context.get('debug_wechat_order_response'): + result_raw = self.env.context.get('debug_wechat_order_response') + else: + _logger.debug('Unified order:\n total_fee: %s\n body: %s\n, detail: \n %s', + total_fee, body, detail) + result_raw = mpay.order.create( + trade_type='JSAPI', + body=body, + total_fee=total_fee, + notify_url=self._notify_url(), + out_trade_no=order.name, + detail=detail, + # TODO fee_type=record.currency_id.name + sub_user_id=openid + ) + _logger.debug('JSAPI Order result_raw: %s', result_raw) + + result_json = mpay.jsapi.get_jsapi_params( + prepay_id=result_raw.get('prepay_id'), + nonce_str=result_raw.get('nonce_str') + ) + + vals = { + 'result_raw': json.dumps(result_raw), + 'total_fee': total_fee, + } + order.sudo().write(vals) + return { + "order_id": order.id, + "data": result_json + } diff --git a/wechat_miniprogram/security/ir.model.access.csv b/wechat_miniprogram/security/ir.model.access.csv new file mode 100644 index 0000000000..3b94eae5d9 --- /dev/null +++ b/wechat_miniprogram/security/ir.model.access.csv @@ -0,0 +1,5 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_product_product,access_product_product,product.model_product_product,wechat_miniprogram.group_miniprogram_user,1,0,0,0 +access_product_template,access_product_template,product.model_product_template,wechat_miniprogram.group_miniprogram_user,1,0,0,0 +access_product_attribute_value,access_product_attribute_value,product.model_product_attribute_value,wechat_miniprogram.group_miniprogram_user,1,0,0,0 +access_product_attribute_price,access_product_attribute_price,product.model_product_attribute_price,wechat_miniprogram.group_miniprogram_user,1,0,0,0 diff --git a/wechat_miniprogram/security/wechat_security.xml b/wechat_miniprogram/security/wechat_security.xml new file mode 100644 index 0000000000..63c913c467 --- /dev/null +++ b/wechat_miniprogram/security/wechat_security.xml @@ -0,0 +1,9 @@ + + + + + User + + + diff --git a/wechat_miniprogram/static/description/icon.png b/wechat_miniprogram/static/description/icon.png new file mode 100644 index 0000000000..b43a0a135f Binary files /dev/null and b/wechat_miniprogram/static/description/icon.png differ diff --git a/wechat_miniprogram/tests/__init__.py b/wechat_miniprogram/tests/__init__.py new file mode 100644 index 0000000000..e3a260c6aa --- /dev/null +++ b/wechat_miniprogram/tests/__init__.py @@ -0,0 +1,3 @@ +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +from . import test_wechat_order + diff --git a/wechat_miniprogram/tests/test_wechat_order.py b/wechat_miniprogram/tests/test_wechat_order.py new file mode 100644 index 0000000000..4dc51971d8 --- /dev/null +++ b/wechat_miniprogram/tests/test_wechat_order.py @@ -0,0 +1,161 @@ +# Copyright 2018 Dinar Gabbasov +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +import logging +import json +import requests_mock +import requests +try: + from unittest.mock import patch +except ImportError: + from mock import patch +from odoo.tests.common import HttpCase, HOST, PORT +from odoo import api + + +_logger = logging.getLogger(__name__) + + +class TestWeChatOrder(HttpCase): + at_install = True + post_install = True + + def setUp(self): + super(TestWeChatOrder, self).setUp() + self.requests_mock = requests_mock.Mocker(real_http=True) + self.requests_mock.start() + self.addCleanup(self.requests_mock.stop) + self.phantom_env = api.Environment(self.registry.test_cr, self.uid, {}) + + patcher = patch('wechatpy.WeChatPay.check_signature', wraps=lambda *args: True) + patcher.start() + self.addCleanup(patcher.stop) + + self.Order = self.phantom_env['wechat.order'] + self.product1 = self.phantom_env['product.product'].create({ + 'name': 'Product1', + }) + self.product2 = self.phantom_env['product.product'].create({ + 'name': 'Product2', + }) + + self.lines = [ + { + "product_id": self.product1.id, + "name": "Product 1 Name", + "quantity": 1, + "price": 1, + "category": "123456", + "description": "翻译服务器错误", + }, + { + "product_id": self.product2.id, + "name": "Product 2 Name", + "quantity": 1, + "price": 2, + "category": "123456", + "description": "網路白目哈哈", + } + ] + + def _patch_post(self, post_result): + + def post(url, data): + self.assertIn(url, post_result) + _logger.debug("Request data for %s: %s", url, data) + return post_result[url] + + # patch wechat + patcher = patch('wechatpy.pay.base.BaseWeChatPayAPI._post', wraps=post) + patcher.start() + self.addCleanup(patcher.stop) + + def _patch_get_requests(self, url, response_json): + self.requests_mock.register_uri('GET', url, json=response_json) + + def _create_jsapi_order(self, create_vals, lines): + post_result = { + 'pay/unifiedorder': { + 'trade_type': 'JSAPI', + 'result_code': 'SUCCESS', + 'prepay_id': 'qweqweqwesadsd2113', + 'nonce_str': 'wsdasd12312eaqsd21q3' + } + } + self._patch_post(post_result) + res = self.phantom_env['wechat.order'].create_jsapi_order(lines, create_vals) + order = self.Order.browse(res.get('order_id')) + self.assertEqual(order.state, 'draft', 'Just created order has wrong state') + return res + + def url_open_json(self, url, code, user_info, timeout=10): + headers = { + 'Content-Type': 'application/json' + } + params = { + "context": {}, + "code": code, + "user_info": user_info, + "test_cr": True + } + data = { + "jsonrpc": "2.0", + "method": "call", + "params": params, + "id": None + } + json_data = json.dumps(data) + if url.startswith('/'): + url = "http://%s:%s%s" % (HOST, PORT, url) + # Don't need to use self.opener.post because we need to make a request without session cookies. + # (Mini-Program don't have session data) + return requests.post(url, data=json_data, timeout=timeout, headers=headers) + + def _authenticate_miniprogram_user(self, code, user_info): + response_json = { + 'openid': 'qwepo21pei90salje12', + 'session_key': 'qweafjqwhoieuojeqwe4213', + } + url = "/wechat/miniprogram/authenticate" + base_url = 'https://api.weixin.qq.com/sns/jscode2session' + self._patch_get_requests(base_url, response_json) + res = self.url_open_json(url, code, user_info, 60) + self.assertEqual(res.status_code, 200) + return res.json() + + def test_JSAPI_payment(self): + # fake values for a test + create_vals = {} + + res = self._create_jsapi_order(create_vals=create_vals, lines=self.lines) + + data = res.get('data') + order_id = res.get('order_id') + order = self.Order.browse(order_id) + + self.assertIn('timeStamp', data, 'JSAPI payment: "timeStamp" not found in data') + self.assertIn('nonceStr', data, 'JSAPI payment: "nonceStr" not found in data') + self.assertIn('package', data, 'JSAPI payment: "package" not found in data') + self.assertIn('signType', data, 'JSAPI payment: "signType" not found in data') + self.assertIn('paySign', data, 'JSAPI payment: "paySign" not found in data') + + # simulate notification + notification = { + 'return_code': 'SUCCESS', + 'result_code': 'SUCCESS', + 'out_trade_no': order.name, + } + + handled = self.Order.on_notification(notification) + self.assertTrue(handled, 'Notification was not handled (error in checking for duplicates?)') + self.assertEqual(order.state, 'done', "Order's state is not changed after notification about update") + + def test_authenticate_miniprogram_user(self): + # fake values for a test + code = "ksdjfwopeiq120jkahs" + user_info = { + 'country': 'Russia', + 'nickName': 'Test User', + 'city': 'Moscow', + } + session_info = self._authenticate_miniprogram_user(code, user_info).get('result') + self.assertIn('session_id', session_info, 'Authenticate user: "session_id" not found in data')