Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions addons/point_of_sale/models/res_config_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ def _default_pos_config(self):
module_pos_stripe = fields.Boolean(string="Stripe Payment Terminal", help="The transactions are processed by Stripe. Set your Stripe credentials on the related payment method.")
module_pos_six = fields.Boolean(string="Six Payment Terminal", help="The transactions are processed by Six. Set the IP address of the terminal on the related payment method.")
module_pos_paytm = fields.Boolean(string="PayTM Payment Terminal", help="The transactions are processed by PayTM. Set your PayTM credentials on the related payment method.")
module_pos_mollie = fields.Boolean(string="Mollie Payment Terminal", help="The transactions are processed by Mollie. Set your Mollie credentials on the related payment method.")
module_pos_preparation_display = fields.Boolean(string="Preparation Display", help="Show orders on the preparation display screen.")
update_stock_quantities = fields.Selection(related="company_id.point_of_sale_update_stock_quantities", readonly=False)
account_default_pos_receivable_account_id = fields.Many2one(string='Default Account Receivable (PoS)', related='company_id.account_default_pos_receivable_account_id', readonly=False)
Expand Down
3 changes: 3 additions & 0 deletions addons/point_of_sale/views/res_config_settings_views.xml
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,9 @@
<setting title="The transactions are processed by PayTM. Set your PayTM credentials on the related payment method." string="PayTM" help="Accept payments with a PayTM payment terminal">
<field name="module_pos_paytm"/>
</setting>
<setting title="The transactions are processed by Mollie. Set your Mollie credentials on the related payment method." string="Mollie" help="Accept payments with a Mollie payment terminal">
<field name="module_pos_mollie"/>
</setting>
</block>

<block title="Connected Devices" id="pos_connected_devices_section">
Expand Down
5 changes: 5 additions & 0 deletions addons/pos_mollie/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.

from . import controllers
from . import models
29 changes: 29 additions & 0 deletions addons/pos_mollie/__manifest__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.

{
'name': 'POS Mollie',
'version': '1.0',
'category': 'Sales/Point of Sale',
'sequence': 6,
'summary': 'Integrate your POS with a Mollie payment terminal',
'description': """
Mollie terminal payments
=========================

This module enables customers to conveniently pay POS orders by utilizing the Mollie terminal and making payments through various cards.
""",

'author': 'Odoo S.A., Applix BV, Droggol Infotech Pvt. Ltd.',
'license': 'LGPL-3',
'depends': ['point_of_sale'],
'installable': True,
'data': [
'views/pos_payment_method_views.xml',
],
'assets': {
'point_of_sale._assets_pos': [
'pos_mollie/static/**/*',
],
},
}
4 changes: 4 additions & 0 deletions addons/pos_mollie/controllers/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.

from . import main
17 changes: 17 additions & 0 deletions addons/pos_mollie/controllers/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.

from odoo import http
from odoo.http import request


class PosMollieController(http.Controller):

@http.route('/pos_mollie/webhook/<int:payment_method_id>', type='http', methods=['POST'], auth='public', csrf=False)
def webhook(self, payment_method_id, **post):
if not post.get('id'):
return
payment_method_sudo = request.env['pos.payment.method'].sudo().browse(payment_method_id)
if payment_method_sudo.exists() and payment_method_sudo.use_payment_terminal == 'mollie':
payment_method_sudo._mollie_process_webhook(post)
return "OK" # send response to mark it as successful
4 changes: 4 additions & 0 deletions addons/pos_mollie/models/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.

from . import pos_payment_method
133 changes: 133 additions & 0 deletions addons/pos_mollie/models/pos_payment_method.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.

import logging
import requests

from werkzeug import urls

from odoo import fields, models

_logger = logging.getLogger(__name__)


class PosPaymentMethod(models.Model):
_inherit = 'pos.payment.method'

mollie_api_key = fields.Char(string="Mollie API key")
mollie_terminal_id = fields.Char(string="Mollie Terminal ID")

def _get_payment_terminal_selection(self):
return super()._get_payment_terminal_selection() + [('mollie', 'Mollie')]

def mollie_payment_request(self, data):
""" Creates payment records on mollie. This method will be called from POS.

:param dict data: payment details received from the POS.
:return Details of mollie payment record received from the mollie.
:rtype: dict
"""
self.ensure_one()
payment_payload = self._prepare_payment_payload(data)
return self._mollie_api_call('/payments', data=payment_payload, method='POST')

def _prepare_payment_payload(self, data):
""" Prepares the payload for the mollie api call for payment creation.

Learn more at: https://docs.mollie.com/reference/v2/payments-api/create-payment

:param dict data: payment details.
:return data in the format needed for the mollie payments.
:rtype: dict
"""
return {
'amount': {
'currency': data['currency'],
'value': f"{data['amount']:.2f}"
},
'description': data['description'],
'webhookUrl': self._get_mollie_webhook_url(data),
'method': 'pointofsale',
'terminalId': self.mollie_terminal_id,
'metadata': {
'pos_reference': data['pos_reference'],
'session_id':data['session_id'],
}
}

def _get_mollie_webhook_url(self, data):
""" Mollie sends payment status updates via webhook.

We have split this method in so we can override this in kiosk module.

:param dict data: payment details received from the POS.
:return webhook URL for mollie.
:rtype: str
"""
base_url = self.get_base_url()
return urls.url_join(base_url, f'/pos_mollie/webhook/{self.id}')

def _get_mollie_payment_status(self, transaction_id):
""" Fetch status of the mollie payment using transaction ID.

:param str transaction_id: ID of payment record of mollie.
:return details of mollie payment record.
:rtype: dict
"""
return self._mollie_api_call(f'/payments/{transaction_id}', method='GET')

def _mollie_process_webhook(self, webhook_data):
""" This method handles details received with mollie. This method called
when payment status changed for mollie payment record.

More details at: https://docs.mollie.com/overview/webhooks

:param str transaction_id: ID of payment record of mollie.
:return details of mollie payment record.
:rtype: dict
"""
self.ensure_one()
payment_status = self._get_mollie_payment_status(webhook_data.get('id'))
if payment_status and payment_status.get('status'):
mollie_session = self.env['pos.session'].browse(payment_status['metadata']['session_id'])
status_data = {
'id': payment_status['id'],
'status': payment_status.get('status'),
'config_id': mollie_session.config_id.id
}
self.env["bus.bus"].sudo()._sendone(mollie_session._get_bus_channel_name(), "MOLLIE_STATUS_UPDATE", status_data)

def _mollie_api_call(self, endpoint, data=None, method='POST'):
""" This is the main method responsible to call mollie API.

Learn about mollie authentication: https://docs.mollie.com/overview/authentication

:param str endpoint: endpoint for the API.
:param dict data: payload for the request.
:param str method: type of methid GET or POST.
:return response of the API call.
:rtype: dict
"""
self.ensure_one()
headers = {
'content-type': 'application/json',
"Authorization": f'Bearer {self.mollie_api_key}',
}

endpoint = f'/v2/{endpoint.strip("/")}'
url = urls.url_join('https://api.mollie.com/', endpoint)

_logger.info('Mollie POS Terminal CALL on: %s', url)

try:
response = requests.request(method, url, json=data, headers=headers, timeout=60)
response.raise_for_status()
except requests.exceptions.HTTPError:
error_details = response.json()
_logger.exception("MOLLIE-POS-ERROR \n %s", error_details)
return {'detail': error_details.get('detail')}
except requests.exceptions.RequestException as e:
msg = _('Unable to communicate with Mollie')
_logger.exception(f"MOLLIE-POS-ERROR {msg} {url} \n {e}")
return {'detail': msg}
return response.json()
Binary file added addons/pos_mollie/static/description/icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
176 changes: 176 additions & 0 deletions addons/pos_mollie/static/src/app/payment_mollie.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
/** @odoo-module */

import { _t } from "@web/core/l10n/translation";
import { PaymentInterface } from "@point_of_sale/app/payment/payment_interface";
import { ErrorPopup } from "@point_of_sale/app/errors/popups/error_popup";
import { ConfirmPopup } from "@point_of_sale/app/utils/confirm_popup/confirm_popup";

export class PaymentMollie extends PaymentInterface {
/**
* @override
*/
setup() {
super.setup(...arguments);
this.paymentLineResolvers = {};
}

/**
* @override
*/
send_payment_request(cid) {
super.send_payment_request(cid);
return this._make_mollie_payment(cid);
}

/**
* @override
*
* At the moment, Mollie POS payments are no cancellable from the API call.
* It can be only cancelled from the terminal itself. If you cancel the
* transaction from the terminal, we get webhook call for status update and
* `handleMollieStatusResponse` will handle cancellation.
*
* But in case you want to cancel from odoo pos (e.g terminal switched off during transaction)
* We show the dialog to guide user in correct direction.
*/
async send_payment_cancel(order, cid) {
const { confirmed } = await this.env.services.popup.add(ConfirmPopup, {
title: _t('Cancel mollie payment'),
body: _t('First cancel transaction on POS device. Only use force cancel if that fails'),
confirmText: _t('Force Cancel'),
cancelText: _t('Discard')
});

if (confirmed) {
super.send_payment_cancel(order, cid);
const paymentLine = this.get_pending_mollie_line();
paymentLine.set_payment_status('retry');
return true;
}
}

/**
* Call odoo backend to create payment request on mollie.
*/
_make_mollie_payment(cid) {
let order = this.pos.get_order();
if (order.selected_paymentline.amount < 0) {
this._show_error(_t("Cannot process transactions with negative amount."));
return Promise.resolve();
}

let payment_data = this._prepare_payment_data(cid);
return this.env.services.orm.silent
.call('pos.payment.method', 'mollie_payment_request', [
[this.payment_method.id],
payment_data
])
.then((response_data) => {return this._mollie_handle_response(response_data);})
.catch(this._handle_odoo_connection_failure.bind(this));
}

/**
* Call odoo backend to create payment request on mollie.
*/
_prepare_payment_data(cid) {
const order = this.pos.get_order();
const line = order.paymentlines.find((paymentLine) => paymentLine.cid === cid);
return {
'description': order.name,
'pos_reference': order.uid,
'currency': this.pos.currency.name,
'amount': line.amount,
'session_id': this.pos.pos_session.id,
'config_id': this.pos.pos_session.config_id[0],
}
}

_handle_odoo_connection_failure(data = {}) {
const line = this.get_pending_mollie_line();
if (line) {
line.set_payment_status("retry");
}
this._show_error(
_t("Could not connect to the Odoo server, please check your internet connection and try again.")
);
return Promise.reject(data);
}

/**
* This method handles the response that comes from Mollie
* when we first make a request to pay.
*/
_mollie_handle_response(response) {

const line = this.get_pending_mollie_line();

if (response.status != 'open') {
this._show_error(response.detail);
line.set_payment_status('retry');
return Promise.resolve();
}
if (response.id) {
line.transaction_id = response.id;
}
line.set_payment_status('waitingCard');
return this.waitForPaymentConfirmation();

}

waitForPaymentConfirmation() {
return new Promise((resolve) => {
this.paymentLineResolvers[this.get_pending_mollie_line().cid] = resolve;
});
}

/**
* This method is called from pos_bus when the payment
* confirmation from Mollie is received via the webhook.
*/
async handleMollieStatusResponse(response) {

const line = this.get_pending_mollie_line();
if (!response) {
this._handle_odoo_connection_failure();
return;
}

if (line.transaction_id != response.id) {
return;
}

if (response.status == 'paid') {
this._resolvePaymentStatus(true);
} else if (['expired', 'canceled', 'failed'].includes(response.status)) {
this._resolvePaymentStatus(false);
}
}

_resolvePaymentStatus(state) {
const line = this.get_pending_mollie_line();
const resolver = this.paymentLineResolvers?.[line.cid];
if (resolver) {
resolver(state);
} else {
line.handle_payment_response(state);
}
}

// --------------------
// HELPER FUNCTIONS
// --------------------

get_pending_mollie_line() {
return this.pos.getPendingPaymentLine("mollie");
}

_show_error(msg, title) {
if (!title) {
title = _t("Mollie Error");
}
this.env.services.popup.add(ErrorPopup, {
title: title,
body: msg,
});
}
}
Loading