diff --git a/pos_wechat/README.rst b/pos_wechat/README.rst new file mode 100644 index 0000000000..0cef0f1b91 --- /dev/null +++ b/pos_wechat/README.rst @@ -0,0 +1,77 @@ +.. image:: https://itpp.dev/images/infinity-readme.png + :alt: Tested and maintained by IT Projects Labs + :target: https://itpp.dev + +======================== + WeChat Payments in POS +======================== + +The module implements following payment workflows + +Quick Pay (micropay) +-------------------- + +* Cashier creates order and scan user's QR in user's WeChat 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 + +Native Payment (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 +========= + +Camera +------ + +If you don't have camera, you can executing following code in browser console to simulate scanning:: + + odoo.__DEBUG__.services['web.core'].bus.trigger('qr_scanned', '134579302432164181'); + +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 ``micropay_id`` reference to ``account.bank.statement.line`` + +Questions? +========== + +To get an assistance on this module contact us by email :arrow_right: help@itpp.dev + +Contributors +============ +* `Ivan Yelizariev `__ +* `Kolushov Alexandr `__ + +=================== + +Odoo Apps Store: https://apps.odoo.com/apps/modules/13.0/pos_wechat/ + + +Notifications on updates: `via Atom `_, `by Email `_ + +Tested on `Odoo 12.0 `_ diff --git a/pos_wechat/__init__.py b/pos_wechat/__init__.py new file mode 100644 index 0000000000..d7c7b9400f --- /dev/null +++ b/pos_wechat/__init__.py @@ -0,0 +1,3 @@ +# License MIT (https://opensource.org/licenses/MIT). +from . import models +from . import wizard diff --git a/pos_wechat/__manifest__.py b/pos_wechat/__manifest__.py new file mode 100644 index 0000000000..956b933473 --- /dev/null +++ b/pos_wechat/__manifest__.py @@ -0,0 +1,27 @@ +# Copyright 2018 Ivan Yelizariev +# License MIT (https://opensource.org/licenses/MIT). +{ + "name": """WeChat Payments in POS""", + "summary": """Support WeChat QR-based payments (scan and show)""", + "category": "Point of Sale", + "images": ["images/main.jpg"], + "version": "14.0.1.0.0", + "application": False, + "author": "IT-Projects LLC, Ivan Yelizariev", + "support": "help@itpp.dev", + "website": "https://github.com/itpp-labs/pos-addons#readme", + "license": "Other OSI approved licence", # MIT + "depends": [ + "wechat", + "pos_qr_scan", + "pos_qr_show", + "pos_qr_payments", + "pos_longpolling", + ], + "external_dependencies": {"python": [], "bin": []}, + "data": ["views/assets.xml", "wizard/pos_payment_views.xml"], + "demo": [], + "qweb": ["static/src/xml/pos.xml"], + "auto_install": False, + "installable": True, +} diff --git a/pos_wechat/doc/changelog.rst b/pos_wechat/doc/changelog.rst new file mode 100644 index 0000000000..9ee2b48b8e --- /dev/null +++ b/pos_wechat/doc/changelog.rst @@ -0,0 +1,4 @@ +`1.0.0` +------- + +- Init version diff --git a/pos_wechat/doc/index.rst b/pos_wechat/doc/index.rst new file mode 100644 index 0000000000..2f87a87aa6 --- /dev/null +++ b/pos_wechat/doc/index.rst @@ -0,0 +1,60 @@ +======================== + WeChat Payments in POS +======================== + + +Follow instructions of `WeChat API `__ module. + +Installation +============ + +* `Install `__ this module in a usual way + +Configuration +============= + +WeChat Journals +--------------- + +WeChat Journals are created automatically on first opening new 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 +===== + +Show QR to customer +------------------- + +* Start POS +* Create some Order +* Go to Payment screen +* Click on journal *Wechat Native Payment* +* RESULT: QR is shown on Screen and Customer Screen (when available) + +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 WeChat app +* Scan the QR +* Wait until customer authorise the payment in his WeChat app +* RESULT: Payment is proceeded. Use your WeChat 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 WeChat journal +* Depending on type of WeChat journal specify either **WeChat Order to refund** + or **Micropay to refund** diff --git a/pos_wechat/i18n/pos_wechat.pot b/pos_wechat/i18n/pos_wechat.pot new file mode 100644 index 0000000000..e82ec21b57 --- /dev/null +++ b/pos_wechat/i18n/pos_wechat.pot @@ -0,0 +1,82 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * pos_wechat +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 12.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: <>\n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: pos_wechat +#: model:ir.model.fields,field_description:pos_wechat.field_wechat_pos__display_name +msgid "Display Name" +msgstr "" + +#. module: pos_wechat +#: model:ir.model.fields,field_description:pos_wechat.field_wechat_pos__id +msgid "ID" +msgstr "" + +#. module: pos_wechat +#: model:ir.model.fields,field_description:pos_wechat.field_wechat_pos____last_update +msgid "Last Modified on" +msgstr "" + +#. module: pos_wechat +#: model:ir.model.fields,field_description:pos_wechat.field_pos_make_payment__micropay_id +msgid "Micropay to refund" +msgstr "" + +#. module: pos_wechat +#: model:ir.model,name:pos_wechat.model_pos_config +msgid "Point of Sale Configuration" +msgstr "" + +#. module: pos_wechat +#: model:ir.model,name:pos_wechat.model_pos_make_payment +msgid "Point of Sale Payment" +msgstr "" + +#. module: pos_wechat +#: model:ir.model.fields,field_description:pos_wechat.field_wechat_micropay__pos_id +#: model:ir.model.fields,field_description:pos_wechat.field_wechat_order__pos_id +#: model:ir.model.fields,field_description:pos_wechat.field_wechat_pos__pos_id +msgid "Pos" +msgstr "" + +#. module: pos_wechat +#: model:ir.model.fields,help:pos_wechat.field_pos_make_payment__journal_wechat +msgid "Register for WeChat payment" +msgstr "" + +#. module: pos_wechat +#: model:ir.model,name:pos_wechat.model_wechat_order +msgid "Unified Order" +msgstr "" + +#. module: pos_wechat +#: model:ir.model,name:pos_wechat.model_wechat_micropay +msgid "WeChat Micropay" +msgstr "" + +#. module: pos_wechat +#: model:ir.model.fields,field_description:pos_wechat.field_pos_make_payment__wechat_order_id +msgid "WeChat Order to refund" +msgstr "" + +#. module: pos_wechat +#: model:ir.model,name:pos_wechat.model_wechat_pos +msgid "WeChat POS" +msgstr "" + +#. module: pos_wechat +#: model:ir.model.fields,field_description:pos_wechat.field_pos_make_payment__journal_wechat +msgid "WeChat Payment" +msgstr "" + diff --git a/pos_wechat/images/main.jpg b/pos_wechat/images/main.jpg new file mode 100644 index 0000000000..e076baf2f0 Binary files /dev/null and b/pos_wechat/images/main.jpg differ diff --git a/pos_wechat/models/__init__.py b/pos_wechat/models/__init__.py new file mode 100644 index 0000000000..924296e36c --- /dev/null +++ b/pos_wechat/models/__init__.py @@ -0,0 +1,5 @@ +# License MIT (https://opensource.org/licenses/MIT). +from . import wechat_pos +from . import wechat_micropay +from . import wechat_order +from . import pos_config diff --git a/pos_wechat/models/pos_config.py b/pos_wechat/models/pos_config.py new file mode 100644 index 0000000000..564569f0de --- /dev/null +++ b/pos_wechat/models/pos_config.py @@ -0,0 +1,114 @@ +# Copyright 2018 Ivan Yelizariev +# License MIT (https://opensource.org/licenses/MIT). +from odoo import models + +MODULE = "pos_wechat" + + +class PosConfig(models.Model): + _inherit = "pos.config" + + def open_session_cb(self): + res = super(PosConfig, self).open_session_cb() + self.init_pos_wechat_journals() + return res + + def init_pos_wechat_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 + wechat_journal_active = journal_obj.search( + [("company_id", "=", user.company_id.id), ("wechat", "!=", False)] + ) + if wechat_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} + wechat_native_journal = self._create_wechat_journal( + dict( + sequence_name="Wechat Native Payment", + prefix="WNATIVE-- ", + journal_name="Wechat Native Payment", + code="WNATIVE", + wechat="native", + **options + ) + ) + micropay_journal = self._create_wechat_journal( + dict( + sequence_name="Wechat Micropay", + prefix="WMICRO- ", + journal_name="Wechat Micropay", + code="WMICRO", + wechat="micropay", + **options + ) + ) + if demo_is_on: + self.write( + { + "journal_ids": [ + (4, wechat_native_journal.id), + (4, micropay_journal.id), + ] + } + ) + + def _create_wechat_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 + } + ) + wechat_journal = self.env["account.journal"].create( + { + "name": vals["journal_name"], + "code": vals["code"], + "type": vals["type"], + "wechat": vals["wechat"], + "journal_user": True, + "sequence_id": new_sequence.id, + } + ) + self.env["ir.model.data"].create( + { + "name": "wechat_journal_" + str(wechat_journal.id), + "model": "account.journal", + "module": MODULE, + "res_id": int(wechat_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, wechat_journal.id)]}) + current_session = self.current_session_id + statement = [ + ( + 0, + 0, + { + "name": current_session.name, + "journal_id": wechat_journal.id, + "user_id": user.id, + "company_id": user.company_id.id, + }, + ) + ] + current_session.write({"statement_ids": statement}) + return wechat_journal diff --git a/pos_wechat/models/wechat_micropay.py b/pos_wechat/models/wechat_micropay.py new file mode 100644 index 0000000000..1b5e031f9c --- /dev/null +++ b/pos_wechat/models/wechat_micropay.py @@ -0,0 +1,54 @@ +# Copyright 2018 Ivan Yelizariev +# License MIT (https://opensource.org/licenses/MIT). +import json +import logging + +from odoo import api, models + +from odoo.addons.qr_payments.tools import odoo_async_call + +_logger = logging.getLogger(__name__) + + +class Micropay(models.Model): + + _inherit = ["wechat.pos", "wechat.micropay"] + _name = "wechat.micropay" + _description = "WeChat Micropay" + + @api.model + def _prepare_pos_create_from_qr(self, **kwargs): + body = self._body(kwargs["terminal_ref"]) + create_vals = {"pos_id": kwargs["pos_id"]} + kwargs.update(create_vals=create_vals) + args = (body,) + 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._on_micropay) + return "ok" + + @api.model + def _on_micropay(self, record): + record._send_pos_notification() + + def _prepare_message(self): + self.ensure_one() + result_json = json.loads(self.result_raw) + msg = { + "event": "payment_result", + "result_code": result_json["result_code"], + "order_ref": self.order_ref, + "total_fee": self.total_fee, + "journal_id": self.journal_id.id, + } + return msg diff --git a/pos_wechat/models/wechat_order.py b/pos_wechat/models/wechat_order.py new file mode 100644 index 0000000000..41488c4fb4 --- /dev/null +++ b/pos_wechat/models/wechat_order.py @@ -0,0 +1,37 @@ +# Copyright 2018 Ivan Yelizariev +# License MIT (https://opensource.org/licenses/MIT). +import json + +from odoo import api, models + + +class WeChatOrder(models.Model): + _inherit = ["wechat.order", "wechat.pos"] + _name = "wechat.order" + + def _prepare_message(self): + self.ensure_one() + result_json = json.loads(self.result_raw) + msg = { + "event": "payment_result", + "result_code": result_json["result_code"], + "order_ref": self.order_ref, + "total_fee": self.total_fee, + "journal_id": self.journal_id.id, + } + return msg + + def on_notification(self, data): + order = super(WeChatOrder, 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(WeChatOrder, self).create_qr(lines, **kwargs) diff --git a/pos_wechat/models/wechat_pos.py b/pos_wechat/models/wechat_pos.py new file mode 100644 index 0000000000..618a288575 --- /dev/null +++ b/pos_wechat/models/wechat_pos.py @@ -0,0 +1,20 @@ +# Copyright 2018 Ivan Yelizariev +# License MIT (https://opensource.org/licenses/MIT). +from odoo import fields, models + +CHANNEL_WECHAT = "wechat" + + +class WeChatPos(models.AbstractModel): + _name = "wechat.pos" + _description = "WeChat POS" + + pos_id = fields.Many2one("pos.config") + + 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_WECHAT, msg + ) diff --git a/pos_wechat/static/description/icon.png b/pos_wechat/static/description/icon.png new file mode 100644 index 0000000000..8a058284ed Binary files /dev/null and b/pos_wechat/static/description/icon.png differ diff --git a/pos_wechat/static/description/index.html b/pos_wechat/static/description/index.html new file mode 100644 index 0000000000..8c3fd090ae --- /dev/null +++ b/pos_wechat/static/description/index.html @@ -0,0 +1,113 @@ +
+
+
+

WeChat Payments in POS

+

Must-have feature in China and other countries!

+
+
+
+ +
+
+
+ +
+ If you don't know what WeChat is, check, for example, this funny video: https://www.youtube.com/watch?v=gysKE3POUv0 +
+ +
+
+
+ +
+
+

Show QR to buyer

+
+ +
+
+
+ +
+
+

Scan buyer's QR via device's camera

+
+ +
+
+
+ +
+
+

Scan buyer's QR via external device

+
+ +
+
+
+ +
+
+
+

Need a custom miniprogram for WeChat (微信小程序)?

+

Contact our partner in China

+ +
+
+
+ +
+
+
+

Need our service?

+

Contact us by email or fill out request form

+ +
+
+
+
+ Tested on Odoo
12.0 community +
+ +
+
+
+
diff --git a/pos_wechat/static/description/scan.png b/pos_wechat/static/description/scan.png new file mode 100644 index 0000000000..6d8bd0c304 Binary files /dev/null and b/pos_wechat/static/description/scan.png differ diff --git a/pos_wechat/static/description/scanner.jpg b/pos_wechat/static/description/scanner.jpg new file mode 100644 index 0000000000..137e44367d Binary files /dev/null and b/pos_wechat/static/description/scanner.jpg differ diff --git a/pos_wechat/static/description/show.png b/pos_wechat/static/description/show.png new file mode 100644 index 0000000000..d7cde2c940 Binary files /dev/null and b/pos_wechat/static/description/show.png differ diff --git a/pos_wechat/static/src/js/tour.js b/pos_wechat/static/src/js/tour.js new file mode 100644 index 0000000000..a59175974d --- /dev/null +++ b/pos_wechat/static/src/js/tour.js @@ -0,0 +1,91 @@ +/* - Copyright 2018 Ivan Yelizariev + License MIT (https://opensource.org/licenses/MIT). */ +/* 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_wechat.tour", function (require) { + "use strict"; + + var DUMMY_AUTH_CODE = "134579302432164181"; + var tour = require("web_tour.tour"); + var core = require("web.core"); + + function open_pos_neworder() { + return [ + tour.stepUtils.showAppsMenuItem(), + { + trigger: '.o_app[data-menu-xmlid="point_of_sale.menu_point_root"]', + content: + "Ready to launch your point of sale? Click here.", + position: "right", + edition: "community", + }, + { + trigger: '.o_app[data-menu-xmlid="point_of_sale.menu_point_root"]', + content: + "Ready to launch your point of sale? Click here.", + position: "bottom", + edition: "enterprise", + }, + { + trigger: ".o_pos_kanban button.oe_kanban_action_button", + content: + "

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_wechat/static/src/js/wechat_pay.js b/pos_wechat/static/src/js/wechat_pay.js new file mode 100644 index 0000000000..1327d4967f --- /dev/null +++ b/pos_wechat/static/src/js/wechat_pay.js @@ -0,0 +1,200 @@ +/* Copyright 2018 Ivan Yelizariev + Copyright 2019 Kolushov Alexandr + License MIT (https://opensource.org/licenses/MIT). */ +odoo.define("pos_wechat", 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 Backbone = window.Backbone; + + models.load_fields("account.journal", ["wechat"]); + + var Wechat = 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_is_integer: function (value) { + // Due to travis AssertionError: ('TypeError: undefined is not a function (evaluating "'Number.isInteger(code)')\n" + return ( + (Number.isInteger && Number.isInteger(value)) || + (typeof value === "number" && + isFinite(value) && + Math.floor(value) === value) + ); + }, + check_auth_code: function (value) { + // TODO: do we need to integrate this with barcode.nomenclature? + var code = Number(value); + if ( + code && + this.check_is_integer(code) && + code.length === 18 && + code[0] === 1 && + code[1] >= 0 && + code[1] <= 5 + ) { + return true; + } + return false; + }, + process_qr: function (auth_code) { + var order = this.pos.get_order(); + if (!order) { + return; + } + // TODO: block order for editing + this.micropay(auth_code, order); + }, + micropay: 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: "wechat.micropay", + method: "pos_create_from_qr", + kwargs: { + auth_code: auth_code, + pay_amount: order.get_due(), + order_ref: order.uid, + terminal_ref: terminal_ref, + journal_id: self.pos.micropay_journal.id, + pos_id: pos_id, + }, + }); + }; + + var current_send_number = 0; + return send_it().fail(function (error, e) { + if (self.pos.debug) { + console.log( + "Wechat", + self.pos.config.name, + "failed request #" + current_send_number + ":", + error.message + ); + } + self.pos.show_warning(); + }); + }, + }); + + var PosModelSuper = models.PosModel; + models.PosModel = models.PosModel.extend({ + initialize: function () { + var self = this; + PosModelSuper.prototype.initialize.apply(this, arguments); + this.wechat = new Wechat(this); + + this.bus.add_channel_callback("wechat", this.on_wechat, this); + this.ready.then(function () { + // Take out wechat micropay cashregister from cashregisters to avoid + // rendering in payment screent + self.micropay_journal = self.hide_cashregister(function (r) { + return r.wechat === "micropay"; + }); + }); + }, + scan_product: function (parsed_code) { + // TODO: do we need to make this optional? + var value = parsed_code.code; + if (this.wechat.check_auth_code(value)) { + this.wechat.process_qr(value); + return true; + } + return PosModelSuper.prototype.scan_product.apply(this, arguments); + }, + on_wechat: function (msg) { + this.add_qr_payment( + msg.order_ref, + msg.journal_id, + msg.total_fee / 100.0, + { + micropay_id: msg.micropay_id, + }, + // Auto validate payment + true + ); + }, + wechat_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: "wechat.order", + method: "create_qr", + kwargs: { + lines: lines, + 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.wechat === "native") { + this.pos.wechat_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.micropay_id = options.micropay_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.micropay_id = this.micropay_id; + return res; + }, + }); +}); diff --git a/pos_wechat/static/src/xml/pos.xml b/pos_wechat/static/src/xml/pos.xml new file mode 100644 index 0000000000..06fece8662 --- /dev/null +++ b/pos_wechat/static/src/xml/pos.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + diff --git a/pos_wechat/tests/__init__.py b/pos_wechat/tests/__init__.py new file mode 100644 index 0000000000..fc04225409 --- /dev/null +++ b/pos_wechat/tests/__init__.py @@ -0,0 +1,3 @@ +# License MIT (https://opensource.org/licenses/MIT). +from . import test_micropay +from . import test_wechat_order diff --git a/pos_wechat/tests/test_micropay.py b/pos_wechat/tests/test_micropay.py new file mode 100644 index 0000000000..b974072b14 --- /dev/null +++ b/pos_wechat/tests/test_micropay.py @@ -0,0 +1,80 @@ +# Copyright 2018 Ivan Yelizariev +# License MIT (https://opensource.org/licenses/MIT). +import logging + +from odoo.addons.point_of_sale.tests.common import TestPointOfSaleCommon + +try: + from unittest.mock import patch +except ImportError: + from mock import patch + + +_logger = logging.getLogger(__name__) +DUMMY_AUTH_CODE = "134579302432164181" +DUMMY_POS_ID = 1 + + +# TODO clean this up: no need to use HttpCase. Also some helpers are not used. +class TestMicropay(TestPointOfSaleCommon): + at_install = True + post_install = True + + def setUp(self): + super(TestMicropay, self).setUp() + + # create wechat journals + self.pos_config.init_pos_wechat_journals() + + # patch wechat + patcher = patch("wechatpy.pay.base.BaseWeChatPayAPI._post", wraps=self._post) + patcher.start() + self.addCleanup(patcher.stop) + + def _post(self, url, data): + MICROPAY_URL = "pay/micropay" + self.assertEqual(url, MICROPAY_URL) + _logger.debug("Request data for %s: %s", MICROPAY_URL, data) + + # see wechatpy/client/base.py::_handle_result for expected result + # format and tricks that are applied on original _post method + result = { + "return_code": "SUCCESS", + "result_code": "SUCCESS", + "openid": "123", + "total_fee": 123, + } + return result + + def test_micropay_backend(self): + """Test payment workflow from server side. + + * Cashier scanned buyer's QR and upload it to odoo server, + odoo server sends information to wechat 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([("wechat", "=", "micropay")]) + + # make request with scanned qr code (auth_code) + msg = self.env["wechat.micropay"].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, + "pay_amount": 1, + } + ) + self.assertEqual( + msg.get("result_code"), + "SUCCESS", + "Wrong result_code. The patch doesn't work?", + ) diff --git a/pos_wechat/tests/test_wechat_order.py b/pos_wechat/tests/test_wechat_order.py new file mode 100644 index 0000000000..dfd45fc42c --- /dev/null +++ b/pos_wechat/tests/test_wechat_order.py @@ -0,0 +1,173 @@ +# Copyright 2018 Ivan Yelizariev +# License MIT (https://opensource.org/licenses/MIT). +import logging + +from odoo.addons.point_of_sale.tests.common import TestPointOfSaleCommon + +try: + from unittest.mock import patch +except ImportError: + from mock import patch + + +_logger = logging.getLogger(__name__) +DUMMY_AUTH_CODE = "134579302432164181" +DUMMY_POS_ID = 1 + + +class TestWeChatOrder(TestPointOfSaleCommon): + at_install = True + post_install = True + + def setUp(self): + super(TestWeChatOrder, self).setUp() + # create wechat journals + self.pos_config.init_pos_wechat_journals() + + self.Order = self.env["wechat.order"] + self.Refund = self.env["wechat.refund"] + self.product1 = self.env["product.product"].create({"name": "Product1"}) + self.product2 = self.env["product.product"].create({"name": "Product2"}) + + 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 _create_pos_order(self): + def compute_tax(product, price, qty=1, taxes=None): + if taxes is None: + taxes = product.taxes_id.filtered( + lambda t: t.company_id.id == self.env.user.id + ) + currency = self.pos_config.pricelist_id.currency_id + res = taxes.compute_all(price, currency, qty, product=product) + untax = res["total_excluded"] + return untax, sum(tax.get("amount", 0.0) for tax in res["taxes"]) + + # I click on create a new session button + self.pos_config.open_session_cb() + + # I create a PoS order with 2 units of PCSC234 at 450 EUR + # and 3 units of PCSC349 at 300 EUR. + untax1, atax1 = compute_tax(self.product3, 450, 2) + untax2, atax2 = compute_tax(self.product4, 300, 3) + order = self.PosOrder.create( + { + "company_id": self.company_id, + "pricelist_id": self.partner1.property_product_pricelist.id, + "partner_id": self.partner1.id, + "lines": [ + ( + 0, + 0, + { + "name": "OL/0001", + "product_id": self.product3.id, + "price_unit": 450, + "discount": 0.0, + "qty": 2.0, + "tax_ids": [(6, 0, self.product3.taxes_id.ids)], + "price_subtotal": untax1, + "price_subtotal_incl": untax1 + atax1, + }, + ), + ( + 0, + 0, + { + "name": "OL/0002", + "product_id": self.product4.id, + "price_unit": 300, + "discount": 0.0, + "qty": 3.0, + "tax_ids": [(6, 0, self.product4.taxes_id.ids)], + "price_subtotal": untax2, + "price_subtotal_incl": untax2 + atax2, + }, + ), + ], + "amount_tax": atax1 + atax2, + "amount_total": untax1 + untax2 + atax1 + atax2, + "amount_paid": 0, + "amount_return": 0, + } + ) + return order + + def _create_wechat_order(self): + post_result = { + "pay/unifiedorder": { + "code_url": "weixin://wxpay/s/An4baqw", + "trade_type": "NATIVE", + "result_code": "SUCCESS", + } + } + self.lines = [ + { + "product_id": self.product1.id, + "name": "Product 1 Name", + "quantity": 2, + "price": 450, + "category": "123456", + "description": "翻译服务器错误", + }, + { + "product_id": self.product2.id, + "name": "Product 2 Name", + "quantity": 3, + "price": 300, + "category": "123456", + "description": "網路白目哈哈", + }, + ] + 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_refund(self): + # Order are not really equal because I'm lazy + # Just imagine that they are correspond each other + order = self._create_pos_order() + wechat_order = self._create_wechat_order() + + order.wechat_order_id = wechat_order.id + + # patch refund api request + post_result = { + "secapi/pay/refund": {"trade_type": "NATIVE", "result_code": "SUCCESS"} + } + self._patch_post(post_result) + + # I create a refund + refund_action = order.refund() + refund = self.PosOrder.browse(refund_action["res_id"]) + + wechat_journal = self.env["account.journal"].search([("wechat", "=", "native")]) + + 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": wechat_journal.id, + "wechat_order_id": wechat_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( + wechat_order.state, + "refunded", + "Wechat Order state is not changed after making refund payment", + ) diff --git a/pos_wechat/views/assets.xml b/pos_wechat/views/assets.xml new file mode 100644 index 0000000000..9a894999f1 --- /dev/null +++ b/pos_wechat/views/assets.xml @@ -0,0 +1,14 @@ + + + + +