[ADD] pos_online_payment, *: add online payments
*: point_of_sale, pos_adyen, pos_hr, pos_mercury, pos_restaurant Adds online payments in Point of Sale using the payment module. The pos_online_payment module is meant to be used by shops for paying online instead of using a physical terminal and by restaurants for paying an order either from the paying station or in the self-order process (smartphone used to order and pay online). The module depends only on point_of_sale (and account_payment to support online payments). The online payments support for self-order will be added in another commit. The feature can be used by configuring a special pos.payment.method for one or many pos.config. Operation: The frontend session user can enter one or several online payment method lines (with or without other payment methods). When he validates the order, if the online payment amounts are valid, a popup with a QR code is displayed on the frontend screen and on the customer display. This QR code allows the customer to open the payment page to pay online for the order. The amount to pay for an order on the payment portal is the remaining unpaid amount or a lower amount if several online payments lines are used. Therefore, at any time, the customer will be able to pay the amount of the order that is unpaid, without requiring a staff operation to update the amount to pay. When an online payment is done for a POS order, the generated account.payment is linked to the POS order through a pos.payment that is created with the online payment method configured for the pos.config, and the POS order paid process is triggered (it is the responsibility of the server to finalize the validation of POS orders that have at least one online payment, and not the responsibility of the POS session web client). If for any reason, there is no longer any online payment method configured for the pos.config of the order, a new one can be created automatically and exceptionnaly used to link the done payment with the POS order. This guarantees that any online payment saved by the payment module will be linked to the POS order. The session web client is notified of successful online payments through web socket communication, to make the cashier screen automatically and quickly react to payments. If several online payments are used for a single POS order, they are processed one after each other. Working principles: - The POS session web client is not allowed to save online payments on the server. Only the Odoo server, which handles the payment, can save online payments for a POS order. - The POS session is only responsible for saving the order (especially other payment methods) and the next online payment amount to pay (in case of several online payments) on the server before the online payments are made by the customer, and for displaying the online payment status and the link (QR code) to open the payment page. That means that modifications that are made after the order is fully paid are not taken into account by the server. - Before validating an order, the POS session checks with the server if any online payment has been made for that POS order. If the user wasn't aware of one, the validation process is aborted, notifying him. - The sensitive online payments data is received by the frontend user with RPC to the server when validating an order, cancelling an online payment or receiving a notification through the POS bus (web socket communication). This data is used to update the cashier data for the specific order. - No sensitive information is sent through the POS bus (web socket communication). Indeed, the bus communication is only protected by the name of the channel, and there is no mecanism to prevent several attempts. Therefore, no sensitive information is sent through it, only a notification to invite the local browser to do a safe RPC to the server to check the new state of the order. - Online payments for a POS order cannot be modified or removed when they succeeded. Checks ensure to never allow an online payment to make the total paid amount greater than the total amount of the POS order. - If the frontend user clicks on the "Cancel" button of the online payment popup, the order can potentially still be paid online. Indeed, the order will be removed from the server only if it was added only for the online payment flow, and any order that is not paid can be paid on the online payment portal (if the customer has the web link). This behavior ensures that in a shop, starting an online payment and then cancelling it doesn't prevent the user to close the POS session. And it ensures to don't delete a draft order that is used for other flows, like in a restaurant or when there are trusted POS configs. Payment portal features: - The payment portal pages of a POS order are protected with its access_token. - If the amount of the order has changed when the customer clicks to pay, an error is fired to notify him and give him the choice to continue or abandon the payment. - An exit_route argument (an URL) can be passed to allow the customer to exit the payment portal at any time (with a button), which could be used for self-order flow. - A button to go back to the payment page is displayed when an error occurs during a payment transaction, which allows the customer to retry a payment without needing to scan again the QR code. - If the customer is not logged in in the payment portal (most frequent case), it is considered as the public user partner for the payment flow to don't give him privileged access to sensitive data like payment tokens. - The accounting process of online payments for POS orders is made in the same way that the pos.payment.method that have split_transactions to True. - The POS customer of a POS order has no link with the customer making an online payment for that POS order. - The payment portal is made using the pos.order id and not the pos_reference because the latter is not guaranteed to be unique. Some tests have been written, especially one that simulates a whole online payment flow starting from nothing, creating an order, requesting an online payment, faking this online payment by doing web requests to the payment portal, then finally closing the POS session to check that there is no accounting issue. The design of the payment portal is temporary. closes odoo/odoo#123237 Task-id: 3276404 Related: odoo/enterprise#41776 Signed-off-by: Joseph Caburnay (jcb) <jcb@odoo.com>
This commit is contained in:
@@ -148,23 +148,29 @@ class PosOrder(models.Model):
|
||||
pos_order = pos_order.with_company(pos_order.company_id)
|
||||
self = self.with_company(pos_order.company_id)
|
||||
self._process_payment_lines(order, pos_order, pos_session, draft)
|
||||
return pos_order._process_saved_order(draft)
|
||||
|
||||
def _process_saved_order(self, draft):
|
||||
self.ensure_one()
|
||||
if not draft:
|
||||
try:
|
||||
pos_order.action_pos_order_paid()
|
||||
self.action_pos_order_paid()
|
||||
except psycopg2.DatabaseError:
|
||||
# do not hide transactional errors, the order(s) won't be saved!
|
||||
raise
|
||||
except Exception as e:
|
||||
_logger.error('Could not fully process the POS Order: %s', tools.ustr(e))
|
||||
pos_order._create_order_picking()
|
||||
pos_order._compute_total_cost_in_real_time()
|
||||
self._create_order_picking()
|
||||
self._compute_total_cost_in_real_time()
|
||||
|
||||
if pos_order.to_invoice and pos_order.state == 'paid':
|
||||
pos_order._generate_pos_order_invoice()
|
||||
if self.to_invoice and self.state == 'paid':
|
||||
self._generate_pos_order_invoice()
|
||||
|
||||
return pos_order.id
|
||||
return self.id
|
||||
|
||||
def _clean_payment_lines(self):
|
||||
self.ensure_one()
|
||||
self.payment_ids.unlink()
|
||||
|
||||
def _process_payment_lines(self, pos_order, order, pos_session, draft):
|
||||
"""Create account.bank.statement.lines from the dictionary given to the parent function.
|
||||
@@ -182,8 +188,7 @@ class PosOrder(models.Model):
|
||||
"""
|
||||
prec_acc = order.currency_id.decimal_places
|
||||
|
||||
order_bank_statement_lines= self.env['pos.payment'].search([('pos_order_id', '=', order.id)])
|
||||
order_bank_statement_lines.unlink()
|
||||
order._clean_payment_lines()
|
||||
for payments in pos_order['statement_ids']:
|
||||
order.add_payment(self._payment_fields(order, payments[2]))
|
||||
|
||||
@@ -494,7 +499,10 @@ class PosOrder(models.Model):
|
||||
return float_is_zero(self._get_rounded_amount(self.amount_total) - self.amount_paid, precision_rounding=self.currency_id.rounding)
|
||||
|
||||
def _get_rounded_amount(self, amount):
|
||||
if self.config_id.cash_rounding:
|
||||
# TODO: add support for mix of cash and non-cash payments when both cash_rounding and only_round_cash_method are True
|
||||
if self.config_id.cash_rounding \
|
||||
and (not self.config_id.only_round_cash_method \
|
||||
or any(p.payment_method_id.is_cash_count for p in self.payment_ids)):
|
||||
amount = float_round(amount, precision_rounding=self.config_id.rounding_method.rounding, rounding_method=self.config_id.rounding_method.rounding_method)
|
||||
currency = self.currency_id
|
||||
return currency.round(amount) if currency else amount
|
||||
|
||||
@@ -1761,7 +1761,6 @@ class PosSession(models.Model):
|
||||
def _ensure_access_token(self):
|
||||
# Code taken from addons/portal/models/portal_mixin.py
|
||||
if not self.access_token:
|
||||
# we use a `write` to force the cache clearing otherwise `return self.access_token` will return False
|
||||
self.sudo().write({'access_token': secrets.token_hex(16)})
|
||||
return self.access_token
|
||||
|
||||
|
||||
+67
-63
@@ -54,7 +54,7 @@
|
||||
|
||||
<!-- Orderlines -->
|
||||
<div class="pos-customer_products" name="Products list">
|
||||
<t t-call="point_of_sale.CustomerFacingDisplayOrderLines" />
|
||||
<t t-call="point_of_sale.CustomerFacingDisplayMainContainer" />
|
||||
</div>
|
||||
|
||||
<div class="pos-payment_info" t-attf-style="background-image: #{backgroundImageCSSValue};">
|
||||
@@ -87,71 +87,11 @@
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-name="point_of_sale.CustomerFacingDisplayOrderLines" owl="1">
|
||||
<t t-name="point_of_sale.CustomerFacingDisplayMainContainer" owl="1">
|
||||
<div class="pos_orderlines">
|
||||
<t t-if="order">
|
||||
<t t-if="order.get_current_screen_data().name != 'ReceiptScreen'">
|
||||
<t t-if="order.get_orderlines().length > 0">
|
||||
<div class="pos-order_top_bar">
|
||||
<span>Your Order</span>
|
||||
</div>
|
||||
<div class="pos_orderlines_list" t-att-class="{ 'backend_product_screen': order.get_current_screen_data().name === 'ProductScreen' }">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th colspan="2" class="pos-product-th">
|
||||
<span>Product</span>
|
||||
</th>
|
||||
<th>Quantity</th>
|
||||
<th>Price</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr t-foreach="order.get_orderlines()" t-key="orderline.id" t-as="orderline" class="pos_orderlines_item" t-att-class="{ 'selected_orderline': orderline.id === order.get_selected_orderline().id }">
|
||||
<td class="product_img">
|
||||
<img t-attf-src="#{productImages[orderline.product.id]}" />
|
||||
</td>
|
||||
<td class="product_text_info">
|
||||
<ul>
|
||||
<li t-esc="orderline.get_full_product_name()" />
|
||||
<t t-if="orderline.get_discount_str() !== '0'">
|
||||
<li class="product_details">
|
||||
<i class="fa fa-tag" role="img" aria-label="Discount" title="Discount" />
|
||||
<t t-esc="orderline.get_discount_str()" />% <span class="gray">discount</span>
|
||||
</li>
|
||||
</t>
|
||||
<t t-if="orderline.get_customer_note()">
|
||||
<li class="product_details">
|
||||
<i class="fa fa-sticky-note" role="img" aria-label="Customer Note" title="Customer Note" />
|
||||
<span class="gray" t-esc="orderline.get_customer_note()" />
|
||||
</li>
|
||||
</t>
|
||||
</ul>
|
||||
</td>
|
||||
<td class="product_quantity gray">
|
||||
<span t-esc="orderline.get_quantity_str()" />
|
||||
<span t-esc="orderline.get_unit().name" />
|
||||
</td>
|
||||
<td>
|
||||
<div t-esc="pos.env.utils.formatCurrency(orderline.get_display_price())" />
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<script type="text/javascript">
|
||||
document.addEventListener("DOMContentLoaded", function() {
|
||||
fixScrollingIfNecessary();
|
||||
});
|
||||
</script>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<div class="order-empty">
|
||||
<i class="fa fa-shopping-cart" role="img" aria-label="Shopping cart"
|
||||
title="Shopping cart" />
|
||||
<h1>This order is empty</h1>
|
||||
</div>
|
||||
</t>
|
||||
<t t-call="point_of_sale.CustomerFacingDisplayOrderLines"/>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<div class="pos-thank-you-message">
|
||||
@@ -162,6 +102,70 @@
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-name="point_of_sale.CustomerFacingDisplayOrderLines" owl="1">
|
||||
<t t-if="order.get_orderlines().length > 0">
|
||||
<div class="pos-order_top_bar">
|
||||
<span>Your Order</span>
|
||||
</div>
|
||||
<div class="pos_orderlines_list" t-att-class="{ 'backend_product_screen': order.get_current_screen_data().name === 'ProductScreen' }">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th colspan="2" class="pos-product-th">
|
||||
<span>Product</span>
|
||||
</th>
|
||||
<th>Quantity</th>
|
||||
<th>Price</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr t-foreach="order.get_orderlines()" t-key="orderline.id" t-as="orderline" class="pos_orderlines_item" t-att-class="{ 'selected_orderline': orderline.id === order.get_selected_orderline().id }">
|
||||
<td class="product_img">
|
||||
<img t-attf-src="#{productImages[orderline.product.id]}" />
|
||||
</td>
|
||||
<td class="product_text_info">
|
||||
<ul>
|
||||
<li t-esc="orderline.get_full_product_name()" />
|
||||
<t t-if="orderline.get_discount_str() !== '0'">
|
||||
<li class="product_details">
|
||||
<i class="fa fa-tag" role="img" aria-label="Discount" title="Discount" />
|
||||
<t t-esc="orderline.get_discount_str()" />% <span class="gray">discount</span>
|
||||
</li>
|
||||
</t>
|
||||
<t t-if="orderline.get_customer_note()">
|
||||
<li class="product_details">
|
||||
<i class="fa fa-sticky-note" role="img" aria-label="Customer Note" title="Customer Note" />
|
||||
<span class="gray" t-esc="orderline.get_customer_note()" />
|
||||
</li>
|
||||
</t>
|
||||
</ul>
|
||||
</td>
|
||||
<td class="product_quantity gray">
|
||||
<span t-esc="orderline.get_quantity_str()" />
|
||||
<span t-esc="orderline.get_unit().name" />
|
||||
</td>
|
||||
<td>
|
||||
<div t-esc="pos.env.utils.formatCurrency(orderline.get_display_price())" />
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<script type="text/javascript">
|
||||
document.addEventListener("DOMContentLoaded", function() {
|
||||
fixScrollingIfNecessary();
|
||||
});
|
||||
</script>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<div class="order-empty">
|
||||
<i class="fa fa-shopping-cart" role="img" aria-label="Shopping cart"
|
||||
title="Shopping cart" />
|
||||
<h1>This order is empty</h1>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
<t t-name="point_of_sale.CustomerFacingDisplayPaymentLines" owl="1">
|
||||
<div class="pos-paymentlines">
|
||||
<t t-if="order">
|
||||
|
||||
@@ -270,26 +270,32 @@ export class PaymentScreen extends Component {
|
||||
}
|
||||
|
||||
// 3. Post process.
|
||||
if (syncOrderResult.length && this.currentOrder.wait_for_push_order()) {
|
||||
const postPushResult = await this._postPushOrderResolve(
|
||||
this.currentOrder,
|
||||
syncOrderResult.map((res) => res.id)
|
||||
);
|
||||
if (!postPushResult) {
|
||||
this.popup.add(ErrorPopup, {
|
||||
title: this.env._t("Error: no internet connection."),
|
||||
body: this.env._t(
|
||||
"Some, if not all, post-processing after syncing order failed."
|
||||
),
|
||||
});
|
||||
}
|
||||
if (
|
||||
syncOrderResult &&
|
||||
syncOrderResult.length > 0 &&
|
||||
this.currentOrder.wait_for_push_order()
|
||||
) {
|
||||
await this.postPushOrderResolve(syncOrderResult.map((res) => res.id));
|
||||
}
|
||||
|
||||
this.afterOrderValidation(!!syncOrderResult && syncOrderResult.length > 0);
|
||||
}
|
||||
async postPushOrderResolve(ordersServerId) {
|
||||
const postPushResult = await this._postPushOrderResolve(this.currentOrder, ordersServerId);
|
||||
if (!postPushResult) {
|
||||
this.popup.add(ErrorPopup, {
|
||||
title: this.env._t("Error: no internet connection."),
|
||||
body: this.env._t("Some, if not all, post-processing after syncing order failed."),
|
||||
});
|
||||
}
|
||||
}
|
||||
async afterOrderValidation(suggestToSync = true) {
|
||||
// Remove the order from the local storage so that when we refresh the page, the order
|
||||
// won't be there
|
||||
this.pos.db.remove_unpaid_order(this.currentOrder);
|
||||
|
||||
// Ask the user to sync the remaining unsynced orders.
|
||||
if (syncOrderResult && this.pos.db.get_orders().length) {
|
||||
if (suggestToSync && this.pos.db.get_orders().length) {
|
||||
const { confirmed } = await this.popup.add(ConfirmPopup, {
|
||||
title: this.env._t("Remaining unsynced orders"),
|
||||
body: this.env._t("There are unsynced orders. Do you want to sync these orders?"),
|
||||
@@ -301,6 +307,8 @@ export class PaymentScreen extends Component {
|
||||
this.pos.push_orders();
|
||||
}
|
||||
}
|
||||
// Always show the next screen regardless of error since pos has to
|
||||
// continue working even offline.
|
||||
this.pos.showScreen(this.nextScreen);
|
||||
}
|
||||
get nextScreen() {
|
||||
|
||||
@@ -1431,7 +1431,10 @@ export class Order extends PosModel {
|
||||
});
|
||||
paymentLines = [];
|
||||
this.paymentlines.forEach((item) => {
|
||||
return paymentLines.push([0, 0, item.export_as_JSON()]);
|
||||
const itemAsJson = item.export_as_JSON();
|
||||
if (itemAsJson) {
|
||||
return paymentLines.push([0, 0, itemAsJson]);
|
||||
}
|
||||
});
|
||||
var json = {
|
||||
name: this.get_name(),
|
||||
@@ -2607,17 +2610,19 @@ export class Order extends PosModel {
|
||||
}
|
||||
_get_qr_code_data() {
|
||||
if (this.pos.company.point_of_sale_use_ticket_qr_code) {
|
||||
const codeWriter = new window.ZXing.BrowserQRCodeSvgWriter();
|
||||
// Use the unique access token to ensure the authenticity of the request. Use the order reference as a second check just in case.
|
||||
const address = `${this.pos.base_url}/pos/ticket/validate?access_token=${this.access_token}`;
|
||||
const qr_code_svg = new XMLSerializer().serializeToString(
|
||||
codeWriter.write(address, 150, 150)
|
||||
return this._make_qr_code_data(
|
||||
`${this.pos.base_url}/pos/ticket/validate?access_token=${this.access_token}`
|
||||
);
|
||||
return "data:image/svg+xml;base64," + window.btoa(qr_code_svg);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
_make_qr_code_data(url) {
|
||||
const codeWriter = new window.ZXing.BrowserQRCodeSvgWriter();
|
||||
const qr_code_svg = new XMLSerializer().serializeToString(codeWriter.write(url, 150, 150));
|
||||
return "data:image/svg+xml;base64," + window.btoa(qr_code_svg);
|
||||
}
|
||||
/**
|
||||
* Returns a random 5 digits alphanumeric code
|
||||
* @returns {string}
|
||||
|
||||
@@ -25,7 +25,6 @@ PaymentScreen.check.validateButtonIsHighlighted(false);
|
||||
PaymentScreen.do.pressNumpad("Backspace Backspace");
|
||||
PaymentScreen.do.fillPaymentLineAmountMobile("Cash", "0");
|
||||
PaymentScreen.check.selectedPaymentlineHas("Cash", "0.00");
|
||||
PaymentScreen.do.pressNumpad("Backspace");
|
||||
PaymentScreen.do.clickPaymentlineDelButton("Cash", "0", true);
|
||||
PaymentScreen.check.emptyPaymentlines("52.8");
|
||||
|
||||
@@ -190,16 +189,16 @@ Chrome.do.clickMenuButton();
|
||||
Chrome.do.clickTicketButton();
|
||||
TicketScreen.do.clickNewTicket();
|
||||
|
||||
ProductScreen.exec.addOrderline('Product Test 1.2', '1');
|
||||
ProductScreen.exec.addOrderline("Product Test 1.2", "1");
|
||||
ProductScreen.do.clickPayButton();
|
||||
|
||||
PaymentScreen.check.totalIs('1.00');
|
||||
PaymentScreen.do.clickPaymentMethod('Cash');
|
||||
PaymentScreen.do.pressNumpad('2');
|
||||
PaymentScreen.do.fillPaymentLineAmountMobile('Cash', '2');
|
||||
PaymentScreen.check.totalIs("1.00");
|
||||
PaymentScreen.do.clickPaymentMethod("Cash");
|
||||
PaymentScreen.do.pressNumpad("2");
|
||||
PaymentScreen.do.fillPaymentLineAmountMobile("Cash", "2");
|
||||
|
||||
PaymentScreen.check.remainingIs('0.0');
|
||||
PaymentScreen.check.changeIs('1.0');
|
||||
PaymentScreen.check.remainingIs("0.0");
|
||||
PaymentScreen.check.changeIs("1.0");
|
||||
|
||||
registry
|
||||
.category("web_tour.tours")
|
||||
@@ -209,20 +208,20 @@ startSteps();
|
||||
|
||||
ProductScreen.do.confirmOpeningPopup();
|
||||
ProductScreen.do.clickHomeCategory();
|
||||
ProductScreen.exec.addOrderline('Product Test 40', '1');
|
||||
ProductScreen.exec.addOrderline("Product Test 40", "1");
|
||||
ProductScreen.do.clickPartnerButton();
|
||||
ProductScreen.do.clickCustomer('Nicole Ford');
|
||||
ProductScreen.do.clickCustomer("Nicole Ford");
|
||||
ProductScreen.do.clickPayButton();
|
||||
|
||||
PaymentScreen.check.totalIs('40.00');
|
||||
PaymentScreen.do.clickPaymentMethod('Bank');
|
||||
PaymentScreen.do.pressNumpad('3 8');
|
||||
PaymentScreen.do.fillPaymentLineAmountMobile('Bank', '38');
|
||||
PaymentScreen.check.remainingIs('2.0');
|
||||
PaymentScreen.do.clickPaymentMethod('Cash');
|
||||
PaymentScreen.check.totalIs("40.00");
|
||||
PaymentScreen.do.clickPaymentMethod("Bank");
|
||||
PaymentScreen.do.pressNumpad("3 8");
|
||||
PaymentScreen.do.fillPaymentLineAmountMobile("Bank", "38");
|
||||
PaymentScreen.check.remainingIs("2.0");
|
||||
PaymentScreen.do.clickPaymentMethod("Cash");
|
||||
|
||||
PaymentScreen.check.remainingIs('0.0');
|
||||
PaymentScreen.check.changeIs('0.0');
|
||||
PaymentScreen.check.remainingIs("0.0");
|
||||
PaymentScreen.check.changeIs("0.0");
|
||||
|
||||
PaymentScreen.do.clickInvoiceButton();
|
||||
PaymentScreen.do.clickValidate();
|
||||
@@ -230,29 +229,30 @@ ReceiptScreen.check.receiptIsThere();
|
||||
ReceiptScreen.do.clickNextOrder();
|
||||
|
||||
ProductScreen.do.clickHomeCategory();
|
||||
ProductScreen.exec.addOrderline('Product Test 41', '1');
|
||||
ProductScreen.exec.addOrderline("Product Test 41", "1");
|
||||
ProductScreen.do.clickPartnerButton();
|
||||
ProductScreen.do.clickCustomer('Nicole Ford');
|
||||
ProductScreen.do.clickCustomer("Nicole Ford");
|
||||
ProductScreen.do.clickPayButton();
|
||||
|
||||
PaymentScreen.check.totalIs('41.00');
|
||||
PaymentScreen.do.clickPaymentMethod('Bank');
|
||||
PaymentScreen.do.pressNumpad('3 8');
|
||||
PaymentScreen.do.fillPaymentLineAmountMobile('Bank', '38');
|
||||
PaymentScreen.check.remainingIs('3.0');
|
||||
PaymentScreen.do.clickPaymentMethod('Cash');
|
||||
PaymentScreen.check.totalIs("41.00");
|
||||
PaymentScreen.do.clickPaymentMethod("Bank");
|
||||
PaymentScreen.do.pressNumpad("3 8");
|
||||
PaymentScreen.do.fillPaymentLineAmountMobile("Bank", "38");
|
||||
PaymentScreen.check.remainingIs("3.0");
|
||||
PaymentScreen.do.clickPaymentMethod("Cash");
|
||||
|
||||
PaymentScreen.check.remainingIs('0.0');
|
||||
PaymentScreen.check.changeIs('0.0');
|
||||
PaymentScreen.check.remainingIs("0.0");
|
||||
PaymentScreen.check.changeIs("0.0");
|
||||
|
||||
PaymentScreen.do.clickInvoiceButton();
|
||||
PaymentScreen.do.clickValidate();
|
||||
ReceiptScreen.check.receiptIsThere();
|
||||
|
||||
|
||||
registry
|
||||
.category("web_tour.tours")
|
||||
.add("PaymentScreenRoundingHalfUpCashAndBank", { test: true, url: "/pos/ui", steps: getSteps() });
|
||||
registry.category("web_tour.tours").add("PaymentScreenRoundingHalfUpCashAndBank", {
|
||||
test: true,
|
||||
url: "/pos/ui",
|
||||
steps: getSteps(),
|
||||
});
|
||||
|
||||
startSteps();
|
||||
|
||||
|
||||
@@ -27,7 +27,25 @@ class Do {
|
||||
{
|
||||
content: "Click on the menu button",
|
||||
trigger: ".menu-button",
|
||||
}
|
||||
},
|
||||
];
|
||||
}
|
||||
closeSession() {
|
||||
return [
|
||||
...this.clickMenuButton(),
|
||||
{
|
||||
content: "click on the close session menu button",
|
||||
trigger: ".close-button",
|
||||
},
|
||||
{
|
||||
content: "click on the close session popup button",
|
||||
trigger: ".close-pos-popup .footer .button.highlight",
|
||||
},
|
||||
{
|
||||
content: "check that the session is closed without error",
|
||||
trigger: ".o_web_client",
|
||||
isCheck: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,20 +17,25 @@ class Do {
|
||||
* @param {String} name payment method
|
||||
* @param {String} amount
|
||||
*/
|
||||
clickPaymentlineDelButton(name, amount, mobile=false) {
|
||||
if (mobile) {
|
||||
return [
|
||||
{
|
||||
content: `delete ${name} paymentline with ${amount} amount`,
|
||||
trigger: `.paymentlines .paymentline .payment-infos:contains("${name}") ~ .delete-button`,
|
||||
mobile: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
clickPaymentlineDelButton(name, amount, mobile = false) {
|
||||
return [
|
||||
{
|
||||
content: `delete ${name} paymentline with ${amount} amount`,
|
||||
trigger: `.paymentlines .paymentline .payment-infos:contains("${name}") ~ .delete-button`,
|
||||
trigger: `.paymentlines .paymentline .payment-infos:contains("${name}"):has(.payment-amount:contains("${amount}")) ~ .delete-button`,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the paymentline having the given payment method name and amount.
|
||||
* @param {String} name payment method
|
||||
* @param {String} amount
|
||||
*/
|
||||
clickPaymentline(name, amount) {
|
||||
return [
|
||||
{
|
||||
content: `click ${name} paymentline with ${amount} amount`,
|
||||
trigger: `.paymentlines .paymentline .payment-infos:contains("${name}"):has(.payment-amount:contains("${amount}"))`,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -69,8 +74,8 @@ class Do {
|
||||
* fast inputs. Fast inputs is the case in tours. This method is only for the
|
||||
* desktop environment. The mobile environment doesn't work exactly the same way
|
||||
* so we have to call fillPaymentLineAmountMobile to have the same behaviour.
|
||||
*
|
||||
* e.g. :
|
||||
*
|
||||
* e.g. :
|
||||
* PaymentScreen.do.enterPaymentLineAmount("Cash", "70");
|
||||
* PaymentScreen.check.remainingIs("2.0");
|
||||
* PaymentScreen.do.pressNumpad("0"); <- desktop: add a 0
|
||||
@@ -98,7 +103,7 @@ class Do {
|
||||
mobile: false,
|
||||
};
|
||||
}
|
||||
return keys.split(' ').map(generateStep);
|
||||
return keys.split(" ").map(generateStep);
|
||||
}
|
||||
|
||||
clickBack() {
|
||||
@@ -119,10 +124,10 @@ class Do {
|
||||
}
|
||||
|
||||
enterPaymentLineAmount(lineName, keys) {
|
||||
const numpadKeys = keys.split('').join(' ');
|
||||
const numpadKeys = keys.split("").join(" ");
|
||||
return [
|
||||
...this.pressNumpad(numpadKeys),
|
||||
...this.fillPaymentLineAmountMobile(lineName, keys)
|
||||
...this.fillPaymentLineAmountMobile(lineName, keys),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -143,7 +148,7 @@ class Do {
|
||||
content: "click confirm button",
|
||||
trigger: ".popup .footer .confirm",
|
||||
mobile: true,
|
||||
}
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
<field name="receivable_account_id" groups="account.group_account_readonly" attrs="{'invisible': [('split_transactions', '=', True)]}" placeholder="Leave empty to use the default account from the company setting" />
|
||||
<field name="company_id" readonly="1" groups="base.group_multi_company" />
|
||||
</group>
|
||||
<group attrs="{'invisible': ['|', ('hide_use_payment_terminal', '=', False), ('type', 'in', ['cash', 'pay_later'])]}">
|
||||
<group attrs="{'invisible': ['|', ('hide_use_payment_terminal', '=', False), ('type', '!=', 'bank')]}">
|
||||
<div colspan="2">
|
||||
<h2>Tips:</h2>
|
||||
<p>
|
||||
|
||||
@@ -14,7 +14,9 @@ patch(Payment.prototype, "pos_adyen.Payment", {
|
||||
//@override
|
||||
export_as_JSON() {
|
||||
const json = this._super(...arguments);
|
||||
json.terminal_service_id = this.terminalServiceId;
|
||||
if (json) {
|
||||
json.terminal_service_id = this.terminalServiceId;
|
||||
}
|
||||
return json;
|
||||
},
|
||||
//@override
|
||||
|
||||
@@ -4,7 +4,7 @@ import { PaymentScreen } from "@point_of_sale/app/screens/payment_screen/payment
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
|
||||
patch(PaymentScreen.prototype, "pos_hr.PaymentScreen", {
|
||||
async _finalizeValidation() {
|
||||
async validateOrder(isForceValidate) {
|
||||
this.currentOrder.cashier = this.pos.get_cashier();
|
||||
await this._super(...arguments);
|
||||
},
|
||||
|
||||
@@ -103,7 +103,11 @@ patch(Payment.prototype, "pos_mercury.Payment", {
|
||||
this.set_credit_card_name();
|
||||
},
|
||||
export_as_JSON() {
|
||||
return Object.assign(this._super(...arguments), {
|
||||
const result = this._super(...arguments);
|
||||
if (!result) {
|
||||
return result;
|
||||
}
|
||||
return Object.assign(result, {
|
||||
paid: this.paid,
|
||||
mercury_card_number: this.mercury_card_number,
|
||||
mercury_card_brand: this.mercury_card_brand,
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,27 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
|
||||
{
|
||||
'name': 'Point of Sale online payment',
|
||||
'depends': ['point_of_sale', 'account_payment'],
|
||||
'data': [
|
||||
'views/payment_transaction_views.xml',
|
||||
'views/pos_payment_views.xml',
|
||||
'views/pos_payment_method_views.xml',
|
||||
'views/payment_portal_templates.xml',
|
||||
'views/account_payment_views.xml',
|
||||
],
|
||||
'auto_install': True,
|
||||
'installable': True,
|
||||
'assets': {
|
||||
'point_of_sale.assets_prod': [
|
||||
'pos_online_payment/static/src/app/**/*',
|
||||
'pos_online_payment/static/src/css/**/*',
|
||||
],
|
||||
'web.assets_tests': [
|
||||
'pos_online_payment/static/tests/tours/**/*',
|
||||
],
|
||||
},
|
||||
'license': 'LGPL-3',
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import payment_portal
|
||||
@@ -0,0 +1,291 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from werkzeug.urls import url_encode
|
||||
|
||||
from odoo import _, http, tools
|
||||
from odoo.http import request
|
||||
from odoo.exceptions import AccessError, ValidationError, UserError
|
||||
from odoo.addons.payment.controllers.post_processing import PaymentPostProcessing
|
||||
from odoo.addons.payment.controllers import portal as payment_portal
|
||||
|
||||
|
||||
class PaymentPortal(payment_portal.PaymentPortal):
|
||||
|
||||
def _check_order_access(self, pos_order_id, access_token):
|
||||
try:
|
||||
order_sudo = self._document_check_access(
|
||||
'pos.order', pos_order_id, access_token)
|
||||
except:
|
||||
raise AccessError(
|
||||
_("The provided order or access token is invalid."))
|
||||
|
||||
if order_sudo.state == "cancel":
|
||||
raise ValidationError(_("The order has been canceled."))
|
||||
return order_sudo
|
||||
|
||||
@staticmethod
|
||||
def _ensure_session_open(pos_order_sudo):
|
||||
if pos_order_sudo.session_id.state != 'opened':
|
||||
raise AccessError(_("The POS session is not opened."))
|
||||
|
||||
def _get_partner_sudo(self, user_sudo):
|
||||
partner_sudo = user_sudo.partner_id
|
||||
if not partner_sudo and user_sudo._is_public():
|
||||
partner_sudo = self.env.ref('base.public_user')
|
||||
return partner_sudo
|
||||
|
||||
def _redirect_login(self):
|
||||
return request.redirect('/web/login?' + url_encode({'redirect': request.httprequest.full_path}))
|
||||
|
||||
@staticmethod
|
||||
def _get_amount_to_pay(order_to_pay_sudo):
|
||||
if order_to_pay_sudo.state in ('paid', 'done', 'invoiced'):
|
||||
return 0.0
|
||||
amount = order_to_pay_sudo._get_checked_next_online_payment_amount()
|
||||
if amount and PaymentPortal._is_valid_amount(amount, order_to_pay_sudo.currency_id):
|
||||
return amount
|
||||
else:
|
||||
return order_to_pay_sudo.get_amount_unpaid()
|
||||
|
||||
@staticmethod
|
||||
def _is_valid_amount(amount, currency):
|
||||
return isinstance(amount, float) and tools.float_compare(amount, 0.0, precision_rounding=currency.rounding) > 0
|
||||
|
||||
def _get_allowed_providers_sudo(self, pos_order_sudo, partner_id, amount_to_pay):
|
||||
payment_method = pos_order_sudo.online_payment_method_id
|
||||
if not payment_method:
|
||||
raise UserError(_("There is no online payment method configured for this Point of Sale order."))
|
||||
compatible_providers_sudo = request.env['payment.provider'].sudo()._get_compatible_providers(
|
||||
pos_order_sudo.company_id.id, partner_id, amount_to_pay, currency_id=pos_order_sudo.currency_id.id)
|
||||
# In sudo mode to read the fields of providers and partner (if not logged in)
|
||||
# Return the payment providers configured in the pos.payment.method that are compatible for the payment API
|
||||
return compatible_providers_sudo & payment_method._get_online_payment_providers(pos_order_sudo.config_id.id, error_if_invalid=False)
|
||||
|
||||
@staticmethod
|
||||
def _get_exit_route_arg(exit_route):
|
||||
return '&' + url_encode({'exit_route': exit_route}) if exit_route else ''
|
||||
|
||||
@staticmethod
|
||||
def _get_pay_route(pos_order_id, access_token, exit_route=None):
|
||||
exit_route_arg = PaymentPortal._get_exit_route_arg(exit_route)
|
||||
return f'/pos/pay/{pos_order_id}?access_token={access_token}{exit_route_arg}'
|
||||
|
||||
@staticmethod
|
||||
def _get_landing_route(pos_order_id, access_token, exit_route_arg=None, tx_id=None):
|
||||
tx_id_arg = f'&tx_id={tx_id}' if tx_id else ''
|
||||
return f'/pos/pay/confirmation/{pos_order_id}?access_token={access_token}{exit_route_arg}{tx_id_arg}'
|
||||
|
||||
@http.route('/pos/pay/<int:pos_order_id>', type='http', methods=['GET'], auth='public', website=True, sitemap=False)
|
||||
def pos_order_pay(self, pos_order_id, access_token=None, exit_route=None):
|
||||
""" Behaves like payment.PaymentPortal.payment_pay but for POS online payment.
|
||||
|
||||
:param int pos_order_id: The POS order to pay, as a `pos.order` id
|
||||
:param str access_token: The access token used to verify the user
|
||||
:param str exit_route: The URL to open to leave the POS online payment flow
|
||||
|
||||
:return: The rendered payment form
|
||||
:rtype: str
|
||||
:raise: AccessError if the provided order or access token is invalid
|
||||
:raise: ValidationError if data on the server prevents the payment
|
||||
"""
|
||||
pos_order_sudo = self._check_order_access(pos_order_id, access_token)
|
||||
self._ensure_session_open(pos_order_sudo)
|
||||
|
||||
user_sudo = request.env.user
|
||||
logged_in = not user_sudo._is_public()
|
||||
partner_sudo = self._get_partner_sudo(user_sudo)
|
||||
if not partner_sudo:
|
||||
return self._redirect_login()
|
||||
|
||||
kwargs = {
|
||||
'pos_order_id': pos_order_sudo.id,
|
||||
}
|
||||
exit_route_arg = self._get_exit_route_arg(exit_route)
|
||||
rendering_context = {
|
||||
**kwargs,
|
||||
'exit_route': exit_route,
|
||||
'reference_prefix': request.env['payment.transaction'].sudo()._compute_reference_prefix(provider_code=None, separator='-', **kwargs),
|
||||
'partner_id': partner_sudo.id,
|
||||
'access_token': access_token,
|
||||
'transaction_route': f'/pos/pay/transaction/{pos_order_sudo.id}?access_token={access_token}{exit_route_arg}',
|
||||
'landing_route': self._get_landing_route(pos_order_sudo.id, access_token, exit_route_arg=exit_route_arg),
|
||||
**self._get_custom_rendering_context_values(**kwargs),
|
||||
}
|
||||
|
||||
currency_id = pos_order_sudo.currency_id
|
||||
|
||||
if not currency_id.active:
|
||||
rendering_context['currency'] = False
|
||||
return self._render_pay(rendering_context)
|
||||
rendering_context['currency'] = currency_id
|
||||
|
||||
amount_to_pay = self._get_amount_to_pay(pos_order_sudo)
|
||||
if not self._is_valid_amount(amount_to_pay, currency_id):
|
||||
rendering_context['amount'] = False
|
||||
return self._render_pay(rendering_context)
|
||||
rendering_context['amount'] = amount_to_pay
|
||||
|
||||
providers_sudo = self._get_allowed_providers_sudo(pos_order_sudo, partner_sudo.id, amount_to_pay)
|
||||
|
||||
# Compute the fees taken by providers supporting the feature
|
||||
fees_by_provider = {
|
||||
provider_sudo: provider_sudo._compute_fees(
|
||||
amount_to_pay, currency_id, partner_sudo.country_id)
|
||||
for provider_sudo in providers_sudo.filtered('fees_active')
|
||||
}
|
||||
|
||||
if logged_in:
|
||||
tokens_sudo = request.env['payment.token']._get_available_tokens(
|
||||
providers_sudo.ids, partner_sudo.id)
|
||||
show_tokenize_input = self._compute_show_tokenize_input_mapping(
|
||||
providers_sudo, **kwargs)
|
||||
else:
|
||||
tokens_sudo = False
|
||||
show_tokenize_input = dict.fromkeys(providers_sudo.ids, False)
|
||||
|
||||
rendering_context.update({
|
||||
'providers': providers_sudo,
|
||||
'tokens': tokens_sudo,
|
||||
'fees_by_provider': fees_by_provider,
|
||||
'show_tokenize_input': show_tokenize_input,
|
||||
**self._get_custom_rendering_context_values(**kwargs),
|
||||
})
|
||||
return self._render_pay(rendering_context)
|
||||
|
||||
def _render_pay(self, rendering_context):
|
||||
return request.render('pos_online_payment.pay', rendering_context)
|
||||
|
||||
@http.route('/pos/pay/transaction/<int:pos_order_id>', type='json', auth='public', website=True, sitemap=False)
|
||||
def pos_order_pay_transaction(self, pos_order_id, access_token=None, **kwargs):
|
||||
""" Behaves like payment.PaymentPortal.payment_transaction but for POS online payment.
|
||||
|
||||
:param int pos_order_id: The POS order to pay, as a `pos.order` id
|
||||
:param str access_token: The access token used to verify the user
|
||||
:param str exit_route: The URL to open to leave the POS online payment flow
|
||||
:param dict kwargs: Data from payment module
|
||||
|
||||
:return: The mandatory values for the processing of the transaction
|
||||
:rtype: dict
|
||||
:raise: AccessError if the provided order or access token is invalid
|
||||
:raise: ValidationError if data on the server prevents the payment
|
||||
:raise: UserError if data provided by the user is invalid/missing
|
||||
"""
|
||||
pos_order_sudo = self._check_order_access(pos_order_id, access_token)
|
||||
self._ensure_session_open(pos_order_sudo)
|
||||
exit_route = request.httprequest.args.get('exit_route')
|
||||
user_sudo = request.env.user
|
||||
logged_in = not user_sudo._is_public()
|
||||
partner_sudo = self._get_partner_sudo(user_sudo)
|
||||
if not partner_sudo:
|
||||
return self._redirect_login()
|
||||
|
||||
if kwargs.get('is_validation'):
|
||||
raise UserError(
|
||||
_("A validation payment cannot be used for a Point of Sale online payment."))
|
||||
|
||||
if 'partner_id' in kwargs and kwargs['partner_id'] != partner_sudo.id:
|
||||
raise UserError(
|
||||
_("The provided partner_id is different than expected."))
|
||||
|
||||
# Don't allow passing arbitrary create values and avoid tokenization for
|
||||
# the public user.
|
||||
kwargs['custom_create_values'] = {
|
||||
'pos_order_id': pos_order_sudo.id
|
||||
}
|
||||
if not logged_in:
|
||||
if kwargs.get('tokenization_requested') or kwargs.get('flow') == 'token':
|
||||
raise UserError(
|
||||
_("Tokenization is not available for logged off customers."))
|
||||
kwargs['custom_create_values']['tokenize'] = False
|
||||
|
||||
currency_id = pos_order_sudo.currency_id
|
||||
if not currency_id.active:
|
||||
raise ValidationError(_("The currency is invalid."))
|
||||
# Ignore the currency provided by the customer
|
||||
kwargs['currency_id'] = currency_id.id
|
||||
|
||||
amount_to_pay = self._get_amount_to_pay(pos_order_sudo)
|
||||
if not self._is_valid_amount(amount_to_pay, currency_id):
|
||||
raise ValidationError(_("There is nothing to pay for this order."))
|
||||
if tools.float_compare(kwargs['amount'], amount_to_pay, precision_rounding=currency_id.rounding) != 0:
|
||||
raise ValidationError(
|
||||
_("The amount to pay has changed. Please refresh the page."))
|
||||
|
||||
payment_option_id = kwargs.get('payment_option_id', None)
|
||||
if not payment_option_id:
|
||||
raise UserError(_("A payment option must be specified."))
|
||||
flow = kwargs.get('flow')
|
||||
if not (flow and flow in ['redirect', 'direct', 'token']):
|
||||
raise UserError(_("The payment should either be direct, with redirection, or made by a token."))
|
||||
providers_sudo = self._get_allowed_providers_sudo(pos_order_sudo, partner_sudo.id, amount_to_pay)
|
||||
if flow == 'token':
|
||||
tokens_sudo = request.env['payment.token']._get_available_tokens(
|
||||
providers_sudo.ids, partner_sudo.id)
|
||||
if payment_option_id not in tokens_sudo.ids:
|
||||
raise UserError(_("The payment token is invalid."))
|
||||
else:
|
||||
if payment_option_id not in providers_sudo.ids:
|
||||
raise UserError(_("The payment provider is invalid."))
|
||||
|
||||
kwargs['reference_prefix'] = None # Computed with pos_order_id
|
||||
kwargs.pop('pos_order_id', None) # _create_transaction kwargs keys must be different than custom_create_values keys
|
||||
|
||||
tx_sudo = self._create_transaction(**kwargs)
|
||||
tx_sudo.landing_route = self._get_landing_route(pos_order_sudo.id, access_token, exit_route_arg=self._get_exit_route_arg(exit_route), tx_id=tx_sudo.id)
|
||||
|
||||
return tx_sudo._get_processing_values()
|
||||
|
||||
@http.route('/pos/pay/confirmation/<int:pos_order_id>', type='http', methods=['GET'], auth='public', website=True, sitemap=False)
|
||||
def pos_order_pay_confirmation(self, pos_order_id, tx_id=None, access_token=None, exit_route=None, **kwargs):
|
||||
""" Behaves like payment.PaymentPortal.payment_confirm but for POS online payment.
|
||||
|
||||
:param int pos_order_id: The POS order to confirm, as a `pos.order` id
|
||||
:param str tx_id: The transaction to confirm, as a `payment.transaction` id
|
||||
:param str access_token: The access token used to verify the user
|
||||
:param str exit_route: The URL to open to leave the POS online payment flow
|
||||
:param dict kwargs: Data from payment module
|
||||
|
||||
:return: The rendered confirmation page
|
||||
:rtype: str
|
||||
:raise: AccessError if the provided order or access token is invalid
|
||||
"""
|
||||
tx_id = self._cast_as_int(tx_id)
|
||||
rendering_context = {
|
||||
'state': 'error',
|
||||
'exit_route': exit_route,
|
||||
'pay_route': self._get_pay_route(pos_order_id, access_token, exit_route)
|
||||
}
|
||||
if not tx_id or not pos_order_id:
|
||||
return self._render_pay_confirmation(rendering_context)
|
||||
|
||||
pos_order_sudo = self._check_order_access(pos_order_id, access_token)
|
||||
|
||||
tx_sudo = request.env['payment.transaction'].sudo().search([('id', '=', tx_id)])
|
||||
if tx_sudo.pos_order_id.id != pos_order_sudo.id:
|
||||
return self._render_pay_confirmation(rendering_context)
|
||||
|
||||
rendering_context.update(
|
||||
pos_order_id=pos_order_sudo.id,
|
||||
order_reference=pos_order_sudo.pos_reference,
|
||||
tx_reference=tx_sudo.reference,
|
||||
amount=tx_sudo.amount,
|
||||
currency=tx_sudo.currency_id,
|
||||
provider_name=tx_sudo.provider_id.name,
|
||||
tx=tx_sudo, # for the payment.transaction_status template
|
||||
)
|
||||
|
||||
# Stop monitoring the transaction now that it reached a final state.
|
||||
PaymentPostProcessing.remove_transactions(tx_sudo)
|
||||
|
||||
if tx_sudo.state not in ('authorized', 'done'):
|
||||
rendering_context['state'] = 'tx_error'
|
||||
return self._render_pay_confirmation(rendering_context)
|
||||
|
||||
tx_sudo._process_pos_online_payment()
|
||||
|
||||
rendering_context['state'] = 'success'
|
||||
return self._render_pay_confirmation(rendering_context)
|
||||
|
||||
def _render_pay_confirmation(self, rendering_context):
|
||||
return request.render('pos_online_payment.pay_confirmation', rendering_context)
|
||||
@@ -0,0 +1,10 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import pos_payment_method
|
||||
from . import pos_payment
|
||||
from . import account_payment
|
||||
from . import payment_transaction
|
||||
from . import pos_config
|
||||
from . import pos_order
|
||||
from . import pos_session
|
||||
@@ -0,0 +1,25 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import _, fields, models
|
||||
|
||||
|
||||
class AccountPayment(models.Model):
|
||||
_inherit = 'account.payment'
|
||||
|
||||
pos_order_id = fields.Many2one('pos.order', string='POS Order', help='The Point of Sale order linked to this payment', readonly=True)
|
||||
|
||||
def action_view_pos_order(self):
|
||||
""" Return the action for the view of the pos order linked to the payment.
|
||||
"""
|
||||
self.ensure_one()
|
||||
|
||||
action = {
|
||||
'name': _("POS Order"),
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'pos.order',
|
||||
'target': 'current',
|
||||
'res_id': self.pos_order_id.id,
|
||||
'view_mode': 'form'
|
||||
}
|
||||
return action
|
||||
@@ -0,0 +1,85 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import _, api, fields, models, tools
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
|
||||
class PaymentTransaction(models.Model):
|
||||
_inherit = 'payment.transaction'
|
||||
|
||||
pos_order_id = fields.Many2one('pos.order', string='POS Order', help='The Point of Sale order linked to the payment transaction', readonly=True)
|
||||
|
||||
@api.model
|
||||
def _compute_reference_prefix(self, provider_code, separator, **values):
|
||||
""" Override of payment to compute the reference prefix based on POS-specific values.
|
||||
|
||||
:return: The computed reference prefix if POS order id is found, the one of `super` otherwise
|
||||
:rtype: str
|
||||
"""
|
||||
pos_order_id = values.get('pos_order_id')
|
||||
if pos_order_id:
|
||||
pos_order = self.env['pos.order'].sudo().browse(pos_order_id).exists()
|
||||
if pos_order:
|
||||
return pos_order.pos_reference
|
||||
return super()._compute_reference_prefix(provider_code, separator, **values)
|
||||
|
||||
def _set_authorized(self, state_message=None, **kwargs):
|
||||
""" Override of payment to process POS online payments automatically. """
|
||||
super()._set_authorized(state_message=state_message, **kwargs)
|
||||
self._process_pos_online_payment()
|
||||
|
||||
def _reconcile_after_done(self):
|
||||
""" Override of payment to process POS online payments automatically. """
|
||||
super()._reconcile_after_done()
|
||||
self._process_pos_online_payment()
|
||||
|
||||
def _process_pos_online_payment(self):
|
||||
for tx in self:
|
||||
if tx and tx.pos_order_id and tx.state in ('authorized', 'done') and not tx.payment_id.pos_order_id:
|
||||
pos_order = tx.pos_order_id
|
||||
if tools.float_compare(tx.amount, 0.0, precision_rounding=pos_order.currency_id.rounding) <= 0:
|
||||
raise ValidationError(_('The payment transaction (%d) has a negative amount.', tx.id))
|
||||
|
||||
if not tx.payment_id: # the payment could already have been created by account_payment module
|
||||
tx._create_payment()
|
||||
if not tx.payment_id:
|
||||
raise ValidationError(_('The POS online payment (tx.id=%d) could not be saved correctly', tx.id))
|
||||
|
||||
payment_method = pos_order.online_payment_method_id
|
||||
if not payment_method:
|
||||
pos_config = pos_order.config_id
|
||||
payment_method = self.env['pos.payment.method'].sudo()._get_or_create_online_payment_method(pos_config.company_id.id, pos_config.id)
|
||||
if not payment_method:
|
||||
raise ValidationError(_('The POS online payment (tx.id=%d) could not be saved correctly because the online payment method could not be found', tx.id))
|
||||
|
||||
pos_order.add_payment({
|
||||
'amount': tx.amount,
|
||||
'payment_date': tx.last_state_change,
|
||||
'payment_method_id': payment_method.id,
|
||||
'online_account_payment_id': tx.payment_id.id,
|
||||
'pos_order_id': pos_order.id,
|
||||
})
|
||||
tx.payment_id.update({
|
||||
'pos_payment_method_id': payment_method.id,
|
||||
'pos_order_id': pos_order.id,
|
||||
'pos_session_id': pos_order.session_id.id,
|
||||
})
|
||||
if pos_order.state == 'draft' and pos_order._is_pos_order_paid():
|
||||
pos_order._process_saved_order(False)
|
||||
pos_order._send_online_payments_notification_via_bus()
|
||||
|
||||
def action_view_pos_order(self):
|
||||
""" Return the action for the view of the pos order linked to the transaction.
|
||||
"""
|
||||
self.ensure_one()
|
||||
|
||||
action = {
|
||||
'name': _("POS Order"),
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'pos.order',
|
||||
'target': 'current',
|
||||
'res_id': self.pos_order_id.id,
|
||||
'view_mode': 'form'
|
||||
}
|
||||
return action
|
||||
@@ -0,0 +1,26 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import models, api, _
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
|
||||
class PosConfig(models.Model):
|
||||
_inherit = 'pos.config'
|
||||
|
||||
@api.constrains('payment_method_ids')
|
||||
def _check_online_payment_methods(self):
|
||||
""" Checks the journal currency with _get_online_payment_providers(..., error_if_invalid=True)"""
|
||||
for config in self:
|
||||
opm_amount = 0
|
||||
for pm in config.payment_method_ids:
|
||||
if pm.is_online_payment:
|
||||
opm_amount += 1
|
||||
if opm_amount > 1:
|
||||
raise ValidationError(_("A POS config cannot have more than one online payment method."))
|
||||
if not pm._get_online_payment_providers(config.id, error_if_invalid=True):
|
||||
raise ValidationError(_("To use an online payment method in a POS config, it must have at least one published payment provider supporting the currency of that POS config."))
|
||||
|
||||
def _get_cashier_online_payment_method(self):
|
||||
self.ensure_one()
|
||||
return self.payment_method_ids.filtered('is_online_payment')[:1]
|
||||
@@ -0,0 +1,77 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import models, fields, api, tools
|
||||
|
||||
|
||||
class PosOrder(models.Model):
|
||||
_inherit = 'pos.order'
|
||||
|
||||
online_payment_method_id = fields.Many2one('pos.payment.method', compute="_compute_online_payment_method_id")
|
||||
next_online_payment_amount = fields.Float(string='Next online payment amount to pay', states={'draft': [('readonly', False)]}, readonly=True, digits=0, required=False) # unlimited precision
|
||||
|
||||
@api.depends('config_id.payment_method_ids')
|
||||
def _compute_online_payment_method_id(self):
|
||||
for order in self:
|
||||
order.online_payment_method_id = order.config_id._get_cashier_online_payment_method()
|
||||
|
||||
def get_amount_unpaid(self):
|
||||
self.ensure_one()
|
||||
return self.currency_id.round(self._get_rounded_amount(self.amount_total) - self.amount_paid)
|
||||
|
||||
def _clean_payment_lines(self):
|
||||
self.ensure_one()
|
||||
order_payments = self.env['pos.payment'].search(['&', ('pos_order_id', '=', self.id), ('online_account_payment_id', '=', False)])
|
||||
order_payments.unlink()
|
||||
|
||||
def get_and_set_online_payments_data(self, next_online_payment_amount=False):
|
||||
""" Allows to update the amount to pay for the next online payment and
|
||||
get online payments already made and how much remains to be paid.
|
||||
If next_online_payment_amount is different than False, updates the
|
||||
next online payment amount, otherwise, the next online payment amount
|
||||
is unchanged.
|
||||
If next_online_payment_amount is 0 and the order has no successful
|
||||
online payment, is in draft state, is not a restaurant order and the
|
||||
pos.config has no trusted config, then the order is deleted from the
|
||||
database, because it was probably added for the online payment flow.
|
||||
"""
|
||||
self.ensure_one()
|
||||
is_paid = self.state in ('paid', 'done', 'invoiced')
|
||||
if is_paid:
|
||||
return {
|
||||
'id': self.id,
|
||||
'paid_order': self._export_for_ui(self)
|
||||
}
|
||||
|
||||
online_payments = self.sudo().env['pos.payment'].search_read(domain=['&', ('pos_order_id', '=', self.id), ('online_account_payment_id', '!=', False)], fields=['payment_method_id', 'amount'], load=False)
|
||||
return_data = {
|
||||
'id': self.id,
|
||||
'online_payments': online_payments,
|
||||
'amount_unpaid': self.get_amount_unpaid(),
|
||||
}
|
||||
if not isinstance(next_online_payment_amount, bool):
|
||||
if tools.float_is_zero(next_online_payment_amount, precision_rounding=self.currency_id.rounding) and len(online_payments) == 0 and self.state == 'draft' and not self.config_id.module_pos_restaurant and len(self.config_id.trusted_config_ids) == 0:
|
||||
self.sudo()._clean_payment_lines() # Needed to delete the order
|
||||
self.sudo().unlink()
|
||||
return_data['deleted'] = True
|
||||
elif self._check_next_online_payment_amount(next_online_payment_amount):
|
||||
self.next_online_payment_amount = next_online_payment_amount
|
||||
|
||||
return return_data
|
||||
|
||||
def _send_online_payments_notification_via_bus(self):
|
||||
self.ensure_one()
|
||||
# The bus communication is only protected by the name of the channel.
|
||||
# Therefore, no sensitive information is sent through it, only a
|
||||
# notification to invite the local browser to do a safe RPC to
|
||||
# the server to check the new state of the order.
|
||||
self.env['bus.bus']._sendone(self.session_id._get_bus_channel_name(), 'ONLINE_PAYMENTS_NOTIFICATION', {'id': self.id})
|
||||
|
||||
def _check_next_online_payment_amount(self, amount):
|
||||
self.ensure_one()
|
||||
return tools.float_compare(amount, 0.0, precision_rounding=self.currency_id.rounding) >= 0 and tools.float_compare(amount, self.get_amount_unpaid(), precision_rounding=self.currency_id.rounding) <= 0
|
||||
|
||||
def _get_checked_next_online_payment_amount(self):
|
||||
self.ensure_one()
|
||||
amount = self.next_online_payment_amount
|
||||
return amount if self._check_next_online_payment_amount(amount) else False
|
||||
@@ -0,0 +1,57 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import logging
|
||||
from odoo import api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PosPayment(models.Model):
|
||||
_inherit = 'pos.payment'
|
||||
|
||||
online_account_payment_id = fields.Many2one('account.payment', string='Online accounting payment', readonly=True) # One2one
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
online_account_payments_by_pm = {}
|
||||
for vals in vals_list:
|
||||
pm_id = vals['payment_method_id']
|
||||
if pm_id not in online_account_payments_by_pm:
|
||||
online_account_payments_by_pm[pm_id] = set()
|
||||
online_account_payments_by_pm[pm_id].add(vals.get('online_account_payment_id'))
|
||||
|
||||
opms_read_id = self.env['pos.payment.method'].search_read(['&', ('id', 'in', list(online_account_payments_by_pm.keys())), ('is_online_payment', '=', True)], ["id"])
|
||||
opms_id = {opm_read_id['id'] for opm_read_id in opms_read_id}
|
||||
online_account_payments_to_check_id = set()
|
||||
|
||||
for pm_id, oaps_id in online_account_payments_by_pm.items():
|
||||
if pm_id in opms_id:
|
||||
if None in oaps_id:
|
||||
raise UserError("Cannot create a POS online payment without an accounting payment.")
|
||||
else:
|
||||
online_account_payments_to_check_id.update(oaps_id)
|
||||
elif any(oaps_id):
|
||||
raise UserError("Cannot create a POS payment with a not online payment method and an online accounting payment.")
|
||||
|
||||
if online_account_payments_to_check_id:
|
||||
valid_oap_amount = self.env['account.payment'].search_count([('id', 'in', list(online_account_payments_to_check_id))])
|
||||
if valid_oap_amount != len(online_account_payments_to_check_id):
|
||||
raise UserError("Cannot create a POS online payment without an accounting payment.")
|
||||
|
||||
return super().create(vals_list)
|
||||
|
||||
def write(self, vals):
|
||||
if vals.keys() & ('amount', 'payment_date', 'payment_method_id', 'online_account_payment_id', 'pos_order_id') and any(payment.online_account_payment_id or payment.payment_method_id.is_online_payment for payment in self):
|
||||
raise UserError("Cannot edit a POS online payment essential data.")
|
||||
return super().write(vals)
|
||||
|
||||
@api.constrains('payment_method_id')
|
||||
def _check_payment_method_id(self):
|
||||
bypass_check_payments = self.filtered('payment_method_id.is_online_payment')
|
||||
if any(payment.payment_method_id != payment.pos_order_id.online_payment_method_id for payment in bypass_check_payments):
|
||||
# An online payment must always be saved for the POS, even if the online payment method is no longer configured/allowed in the pos.config, because in any case it is saved by account_payment and payment modules.
|
||||
_logger.warning("Allow to save a POS online payment with an unexpected online payment method")
|
||||
|
||||
super(PosPayment, self - bypass_check_payments)._check_payment_method_id()
|
||||
@@ -0,0 +1,118 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
|
||||
class PosPaymentMethod(models.Model):
|
||||
_inherit = "pos.payment.method"
|
||||
|
||||
is_online_payment = fields.Boolean(string="Online Payment", help="Use this payment method for online payments (payments made on a web page with online payment providers)", default=False)
|
||||
online_payment_provider_ids = fields.Many2many('payment.provider', string="Allowed Providers", domain="[('is_published', '=', True), ('state', 'in', ['enabled', 'test'])]")
|
||||
has_an_online_payment_provider = fields.Boolean(compute='_compute_has_an_online_payment_provider', readonly=True)
|
||||
type = fields.Selection(selection_add=[('online', 'Online')])
|
||||
|
||||
@api.depends('is_online_payment')
|
||||
def _compute_type(self):
|
||||
opm = self.filtered('is_online_payment')
|
||||
if opm:
|
||||
opm.type = 'online'
|
||||
|
||||
super(PosPaymentMethod, self - opm)._compute_type()
|
||||
|
||||
def _get_online_payment_providers(self, pos_config_id=False, error_if_invalid=True):
|
||||
self.ensure_one()
|
||||
providers_sudo = self.sudo().online_payment_provider_ids
|
||||
if not providers_sudo: # Empty = all published providers
|
||||
providers_sudo = self.sudo().env['payment.provider'].search([('is_published', '=', True), ('state', 'in', ['enabled', 'test'])])
|
||||
|
||||
if not pos_config_id:
|
||||
return providers_sudo
|
||||
|
||||
config_currency = self.sudo().env['pos.config'].browse(pos_config_id).currency_id
|
||||
valid_providers = providers_sudo.filtered(lambda p: not p.journal_id.currency_id or p.journal_id.currency_id == config_currency)
|
||||
if error_if_invalid and len(providers_sudo) != len(valid_providers):
|
||||
raise ValidationError(_("All payment providers configured for an online payment method must use the same currency as the Sales Journal, or the company currency if that is not set, of the POS config."))
|
||||
return valid_providers
|
||||
|
||||
@api.depends('is_online_payment', 'online_payment_provider_ids')
|
||||
def _compute_has_an_online_payment_provider(self):
|
||||
for pm in self:
|
||||
if pm.is_online_payment:
|
||||
pm.has_an_online_payment_provider = bool(pm._get_online_payment_providers())
|
||||
else:
|
||||
pm.has_an_online_payment_provider = False
|
||||
|
||||
def _is_write_forbidden(self, fields):
|
||||
return super(PosPaymentMethod, self)._is_write_forbidden(fields - {'online_payment_provider_ids'})
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
for vals in vals_list:
|
||||
if vals.get('is_online_payment', False):
|
||||
self._force_online_payment_values(vals)
|
||||
return super().create(vals_list)
|
||||
|
||||
def write(self, vals):
|
||||
if 'is_online_payment' in vals:
|
||||
if vals['is_online_payment']:
|
||||
self._force_online_payment_values(vals)
|
||||
return super().write(vals)
|
||||
|
||||
opm = self.filtered('is_online_payment')
|
||||
not_opm = self - opm
|
||||
|
||||
res = True
|
||||
if opm:
|
||||
forced_vals = vals.copy()
|
||||
self._force_online_payment_values(forced_vals, True)
|
||||
res = super(PosPaymentMethod, opm).write(forced_vals) and res
|
||||
if not_opm:
|
||||
res = super(PosPaymentMethod, not_opm).write(vals) and res
|
||||
|
||||
return res
|
||||
|
||||
@staticmethod
|
||||
def _force_online_payment_values(vals, if_present=False):
|
||||
if 'type' in vals:
|
||||
vals['type'] = 'online'
|
||||
|
||||
disabled_fields_name = ('split_transactions', 'receivable_account_id', 'outstanding_account_id', 'journal_id', 'is_cash_count', 'use_payment_terminal')
|
||||
if if_present:
|
||||
for name in disabled_fields_name:
|
||||
if name in vals:
|
||||
vals[name] = False
|
||||
else:
|
||||
for name in disabled_fields_name:
|
||||
vals[name] = False
|
||||
|
||||
def _get_payment_terminal_selection(self):
|
||||
return super(PosPaymentMethod, self)._get_payment_terminal_selection() if not self.is_online_payment else []
|
||||
|
||||
@api.depends('type')
|
||||
def _compute_hide_use_payment_terminal(self):
|
||||
opm = self.filtered(lambda pm: pm.type == 'online')
|
||||
if opm:
|
||||
opm.hide_use_payment_terminal = True
|
||||
super(PosPaymentMethod, self - opm)._compute_hide_use_payment_terminal()
|
||||
|
||||
@api.model
|
||||
def _get_or_create_online_payment_method(self, company_id, pos_config_id):
|
||||
""" Get the first online payment method compatible with the provided pos.config.
|
||||
If there isn't any, try to find an existing one in the same company and return it without adding the pos.config to it.
|
||||
If there is not, create a new one for the company and return it without adding the pos.config to it.
|
||||
"""
|
||||
# Parameters are ids instead of a pos.config record because this method can be called from a web controller or internally
|
||||
payment_method_id = self.env['pos.payment.method'].search([('is_online_payment', '=', True), ('company_id', '=', company_id), ('config_ids', 'in', pos_config_id)], limit=1).exists()
|
||||
if not payment_method_id:
|
||||
payment_method_id = self.env['pos.payment.method'].search([('is_online_payment', '=', True), ('company_id', '=', company_id)], limit=1).exists()
|
||||
if not payment_method_id:
|
||||
payment_method_id = self.env['pos.payment.method'].create({
|
||||
'name': 'Online Payment',
|
||||
'is_online_payment': True,
|
||||
'company_id': company_id,
|
||||
})
|
||||
if not payment_method_id:
|
||||
raise ValidationError('Could not create an online payment method (company_id=%d, pos_config_id=%d)' % (company_id, pos_config_id))
|
||||
return payment_method_id
|
||||
@@ -0,0 +1,76 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from collections import defaultdict
|
||||
from odoo import models, tools, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class PosSession(models.Model):
|
||||
_inherit = 'pos.session'
|
||||
|
||||
def _loader_params_pos_payment_method(self):
|
||||
result = super()._loader_params_pos_payment_method()
|
||||
result['search_params']['fields'].append('is_online_payment')
|
||||
return result
|
||||
|
||||
def _accumulate_amounts(self, data):
|
||||
data = super()._accumulate_amounts(data)
|
||||
amounts = lambda: {'amount': 0.0, 'amount_converted': 0.0}
|
||||
|
||||
split_receivables_online = defaultdict(amounts)
|
||||
currency_rounding = self.currency_id.rounding
|
||||
for order in self.order_ids:
|
||||
for payment in order.payment_ids:
|
||||
amount = payment.amount
|
||||
if tools.float_is_zero(amount, precision_rounding=currency_rounding):
|
||||
continue
|
||||
date = payment.payment_date
|
||||
payment_method = payment.payment_method_id
|
||||
payment_type = payment_method.type
|
||||
|
||||
if payment_type == 'online':
|
||||
split_receivables_online[payment] = self._update_amounts(split_receivables_online[payment], {'amount': amount}, date)
|
||||
|
||||
data.update({'split_receivables_online': split_receivables_online,})
|
||||
return data
|
||||
|
||||
def _create_bank_payment_moves(self, data):
|
||||
data = super()._create_bank_payment_moves(data)
|
||||
|
||||
split_receivables_online = data.get('split_receivables_online')
|
||||
MoveLine = data.get('MoveLine')
|
||||
|
||||
online_payment_to_receivable_lines = {}
|
||||
|
||||
for payment, amounts in split_receivables_online.items():
|
||||
split_receivable_line = MoveLine.create(self._get_split_receivable_op_vals(payment, amounts['amount'], amounts['amount_converted']))
|
||||
account_payment = payment.online_account_payment_id
|
||||
payment_receivable_line = account_payment.move_id.line_ids.filtered(lambda line: line.account_id == account_payment.destination_account_id)
|
||||
online_payment_to_receivable_lines[payment] = split_receivable_line | payment_receivable_line
|
||||
|
||||
data['online_payment_to_receivable_lines'] = online_payment_to_receivable_lines
|
||||
return data
|
||||
|
||||
def _get_split_receivable_op_vals(self, payment, amount, amount_converted):
|
||||
partner = payment.online_account_payment_id.partner_id
|
||||
accounting_partner = self.env["res.partner"]._find_accounting_partner(partner)
|
||||
if not accounting_partner:
|
||||
raise UserError(_("The partner of the POS online payment (id=%d) could not be found") % (payment.id))
|
||||
partial_vals = {
|
||||
'account_id': accounting_partner.property_account_receivable_id.id,
|
||||
'move_id': self.move_id.id,
|
||||
'partner_id': accounting_partner.id,
|
||||
'name': '%s - %s (%s)' % (self.name, payment.payment_method_id.name, payment.online_account_payment_id.payment_method_line_id.payment_provider_id.name),
|
||||
}
|
||||
return self._debit_amounts(partial_vals, amount, amount_converted)
|
||||
|
||||
def _reconcile_account_move_lines(self, data):
|
||||
data = super()._reconcile_account_move_lines(data)
|
||||
online_payment_to_receivable_lines = data.get('online_payment_to_receivable_lines')
|
||||
|
||||
for payment, lines in online_payment_to_receivable_lines.items():
|
||||
if payment.online_account_payment_id.partner_id.property_account_receivable_id.reconcile:
|
||||
lines.filtered(lambda line: not line.reconciled).reconcile()
|
||||
|
||||
return data
|
||||
@@ -0,0 +1,22 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
import { PosBus } from "@point_of_sale/app/bus/pos_bus_service";
|
||||
|
||||
patch(PosBus.prototype, "pos_online_payment.PosBus", {
|
||||
//@override
|
||||
dispatch(message) {
|
||||
this._super(...arguments);
|
||||
|
||||
if (message.type === "ONLINE_PAYMENTS_NOTIFICATION") {
|
||||
// The bus communication is only protected by the name of the channel.
|
||||
// Therefore, no sensitive information is sent through it, only a
|
||||
// notification to invite the local browser to do a safe RPC to
|
||||
// the server to check the new state of the order.
|
||||
const currentOrder = this.pos.get_order();
|
||||
if (currentOrder && currentOrder.server_id === message.payload.id) {
|
||||
currentOrder.update_online_payments_data_with_server(this.orm, false);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
|
||||
<t t-name="pos_online_payment.CustomerFacingDisplayHead" t-inherit="point_of_sale.CustomerFacingDisplayHead" t-inherit-mode="extension" owl="1">
|
||||
<xpath expr="//div[@class='resources']" position="inside">
|
||||
<link rel="stylesheet" type="text/css" href="/pos_online_payment/static/src/css/customer_facing_display.css" />
|
||||
</xpath>
|
||||
</t>
|
||||
|
||||
<t t-name="pos_online_payment.CustomerFacingDisplayMainContainer" t-inherit="point_of_sale.CustomerFacingDisplayMainContainer" t-inherit-mode="extension" owl="1">
|
||||
<xpath expr="//t[@t-call='point_of_sale.CustomerFacingDisplayOrderLines']" position="replace">
|
||||
<t t-if="order.get_current_screen_data().name === 'PaymentScreen' and order.uiState.PaymentScreen?.onlinePaymentData">
|
||||
<div class="online-payment">
|
||||
<div class="instructions">
|
||||
<p>Please scan the QR code to open the payment page</p>
|
||||
<div class="spacer"/>
|
||||
<div class="qr-code" alt="QR Code to pay" t-attf-style="background-image: url('{{order.uiState.PaymentScreen.onlinePaymentData.qrCode}}');" />
|
||||
<div class="spacer"/>
|
||||
</div>
|
||||
<div class="info">
|
||||
<div>
|
||||
<span>Amount: </span>
|
||||
<span class="amount" t-esc="pos.env.utils.formatCurrency(order.uiState.PaymentScreen.onlinePaymentData.amount)" />
|
||||
</div>
|
||||
<div>
|
||||
<span>Order reference: </span>
|
||||
<span t-esc="order.name" />
|
||||
</div>
|
||||
<div>
|
||||
<span>Order id: </span>
|
||||
<span t-esc="order.server_id" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<t t-call="point_of_sale.CustomerFacingDisplayOrderLines" />
|
||||
</t>
|
||||
</xpath>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -0,0 +1,263 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { PaymentScreen } from "@point_of_sale/app/screens/payment_screen/payment_screen";
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
import { OnlinePaymentPopup } from "@pos_online_payment/app/utils/online_payment_popup/online_payment_popup";
|
||||
import { ConfirmPopup } from "@point_of_sale/app/utils/confirm_popup/confirm_popup";
|
||||
import { ErrorPopup } from "@point_of_sale/app/errors/popups/error_popup";
|
||||
import { floatIsZero } from "@web/core/utils/numbers";
|
||||
import { sprintf } from "@web/core/utils/strings";
|
||||
|
||||
patch(PaymentScreen.prototype, "pos_online_payment.PaymentScreen", {
|
||||
getRemainingOnlinePaymentLines() {
|
||||
return this.paymentLines.filter(
|
||||
(line) => line.payment_method.is_online_payment && line.get_payment_status() !== "done"
|
||||
);
|
||||
},
|
||||
checkRemainingOnlinePaymentLines(unpaidAmount) {
|
||||
const remainingLines = this.getRemainingOnlinePaymentLines();
|
||||
let remainingAmount = 0;
|
||||
let amount = 0;
|
||||
for (const line of remainingLines) {
|
||||
amount = line.get_amount();
|
||||
if (amount <= 0) {
|
||||
this.popup.add(ErrorPopup, {
|
||||
title: this.env._t("Invalid online payment"),
|
||||
body: sprintf(this.env._t("Online payments cannot have a negative amount (%s: %s)."), line.payment_method.name, this.env.utils.formatCurrency(amount)),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
remainingAmount += amount;
|
||||
}
|
||||
if (!floatIsZero(unpaidAmount - remainingAmount, this.pos.currency.decimal_places)) {
|
||||
this.popup.add(ErrorPopup, {
|
||||
title: this.env._t("Invalid online payments"),
|
||||
body: sprintf(this.env._t("The total amount of remaining online payments to execute (%s) doesn't correspond to the remaining unpaid amount of the order (%s)."), this.env.utils.formatCurrency(remainingAmount), this.env.utils.formatCurrency(unpaidAmount)),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
//@override
|
||||
async _isOrderValid(isForceValidate) {
|
||||
if (!await this._super(...arguments)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!this.payment_methods_from_config.some((pm) => pm.is_online_payment)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this.currentOrder.finalized) {
|
||||
this.afterOrderValidation(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
const onlinePaymentLines = this.getRemainingOnlinePaymentLines();
|
||||
if (onlinePaymentLines.length > 0) {
|
||||
// Send the order to the server everytime before the online payments process to
|
||||
// allow the server to get the data for online payments and link the successful
|
||||
// online payments to the order.
|
||||
// The validation process will be done by the server directly after a successful
|
||||
// online payment that makes the order fully paid.
|
||||
this.currentOrder.initialize_validation_date();
|
||||
this.currentOrder.save_to_db();
|
||||
this.pos.addOrderToUpdateSet();
|
||||
|
||||
try {
|
||||
await this.pos.sendDraftToServer();
|
||||
} catch (error) {
|
||||
// Code from _finalizeValidation():
|
||||
if (error.code == 700 || error.code == 701) {
|
||||
this.error = true;
|
||||
}
|
||||
|
||||
if ("code" in error) {
|
||||
// We started putting `code` in the rejected object for invoicing error.
|
||||
// We can continue with that convention such that when the error has `code`,
|
||||
// then it is an error when invoicing. Besides, _handlePushOrderError was
|
||||
// introduce to handle invoicing error logic.
|
||||
await this._handlePushOrderError(error);
|
||||
}
|
||||
this.showSaveOrderOnServerErrorPopup();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!this.currentOrder.server_id) {
|
||||
this.showSaveOrderOnServerErrorPopup();
|
||||
return false;
|
||||
}
|
||||
|
||||
const qrCodeData = this.currentOrder._get_online_payment_qr_code_data();
|
||||
if (!qrCodeData || qrCodeData.length == 0) {
|
||||
this.cancelOnlinePayment(this.currentOrder);
|
||||
this.popup.add(ErrorPopup, {
|
||||
title: this.env._t("Online payment unavailable"),
|
||||
body: this.env._t("The QR Code for paying could not be generated."),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
let prevOnlinePaymentLine = null;
|
||||
let lastOrderServerOPData = null;
|
||||
for (const onlinePaymentLine of onlinePaymentLines) {
|
||||
const onlinePaymentLineAmount = onlinePaymentLine.get_amount();
|
||||
// The local state is not aware if the online payment has already been done.
|
||||
lastOrderServerOPData = await this.currentOrder.update_online_payments_data_with_server(this.pos.orm, onlinePaymentLineAmount);
|
||||
if (!lastOrderServerOPData) {
|
||||
this.popup.add(ErrorPopup, {
|
||||
title: this.env._t("Online payment unavailable"),
|
||||
body: this.env._t("There is a problem with the server. The order online payment status cannot be retrieved."),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
if (!lastOrderServerOPData.is_paid) {
|
||||
if (lastOrderServerOPData.modified_payment_lines) {
|
||||
this.cancelOnlinePayment(this.currentOrder);
|
||||
this.showModifiedOnlinePaymentsPopup();
|
||||
return false;
|
||||
}
|
||||
if ((prevOnlinePaymentLine && prevOnlinePaymentLine.get_payment_status() !== "done") || !this.checkRemainingOnlinePaymentLines(lastOrderServerOPData.amount_unpaid)) {
|
||||
this.cancelOnlinePayment(this.currentOrder);
|
||||
return false;
|
||||
}
|
||||
|
||||
onlinePaymentLine.set_payment_status("waiting");
|
||||
this.currentOrder.select_paymentline(onlinePaymentLine);
|
||||
lastOrderServerOPData = await this.showOnlinePaymentQrCode(qrCodeData, onlinePaymentLineAmount);
|
||||
if (onlinePaymentLine.get_payment_status() === "waiting") {
|
||||
onlinePaymentLine.set_payment_status(undefined);
|
||||
}
|
||||
prevOnlinePaymentLine = onlinePaymentLine;
|
||||
}
|
||||
}
|
||||
|
||||
if (!lastOrderServerOPData || !lastOrderServerOPData.is_paid) {
|
||||
lastOrderServerOPData = await this.currentOrder.update_online_payments_data_with_server(this.pos.orm, 0);
|
||||
}
|
||||
if (!lastOrderServerOPData || !lastOrderServerOPData.is_paid) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await this.afterPaidOrderSavedOnServer(lastOrderServerOPData.paid_order);
|
||||
return false; // Cancel normal flow because the current order is already saved on the server.
|
||||
} else if (this.currentOrder.server_id) {
|
||||
const orderServerOPData = await this.currentOrder.update_online_payments_data_with_server(this.pos.orm, 0);
|
||||
if (!orderServerOPData) {
|
||||
const { confirmed } = await this.popup.add(ConfirmPopup, {
|
||||
title: this.env._t("Online payment unavailable"),
|
||||
body: this.env._t("There is a problem with the server. The order online payment status cannot be retrieved. Are you sure there is no online payment for this order ?"),
|
||||
confirmText: this.env._t("Yes"),
|
||||
});
|
||||
if (!confirmed) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (orderServerOPData.is_paid) {
|
||||
await this.afterPaidOrderSavedOnServer(orderServerOPData.paid_order);
|
||||
return false; // Cancel normal flow because the current order is already saved on the server.
|
||||
}
|
||||
if (orderServerOPData.modified_payment_lines) {
|
||||
this.showModifiedOnlinePaymentsPopup();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
cancelOnlinePayment(order) {
|
||||
// Remove the draft order from the server if there is no done online payment
|
||||
order.update_online_payments_data_with_server(this.pos.orm, 0);
|
||||
},
|
||||
showSaveOrderOnServerErrorPopup() {
|
||||
this.popup.add(ErrorPopup, {
|
||||
title: this.env._t("Online payment unavailable"),
|
||||
body: this.env._t("There is a problem with the server. The order cannot be saved and therefore the online payment is not possible."),
|
||||
});
|
||||
},
|
||||
showModifiedOnlinePaymentsPopup() {
|
||||
this.popup.add(ErrorPopup, {
|
||||
title: this.env._t("Updated online payments"),
|
||||
body: this.env._t("There are online payments that were missing in your view."),
|
||||
});
|
||||
},
|
||||
async showOnlinePaymentQrCode(qrCodeData, amount) {
|
||||
if (!this.currentOrder.uiState.PaymentScreen) {
|
||||
this.currentOrder.uiState.PaymentScreen = {};
|
||||
}
|
||||
this.currentOrder.uiState.PaymentScreen.onlinePaymentData = {
|
||||
amount: amount,
|
||||
qrCode: qrCodeData,
|
||||
order: this.currentOrder,
|
||||
};
|
||||
|
||||
const { confirmed, payload: orderServerOPData } = await this.popup.add(OnlinePaymentPopup, this.currentOrder.uiState.PaymentScreen.onlinePaymentData);
|
||||
|
||||
if (this.currentOrder.uiState.PaymentScreen) {
|
||||
delete this.currentOrder.uiState.PaymentScreen.onlinePaymentData;
|
||||
if (Object.keys(this.currentOrder.uiState.PaymentScreen).length === 0) {
|
||||
delete this.currentOrder.uiState.PaymentScreen;
|
||||
}
|
||||
}
|
||||
|
||||
return confirmed ? orderServerOPData : null;
|
||||
},
|
||||
async afterPaidOrderSavedOnServer(orderJSON) {
|
||||
if (!orderJSON) {
|
||||
this.popup.add(ErrorPopup, {
|
||||
title: this.env._t("Server error"),
|
||||
body: this.env._t("The saved order could not be retrieved."),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Update the local order with the data from the server, because it's the server
|
||||
// that is responsible for saving the final state of an order when there is an
|
||||
// online payment in it.
|
||||
// This prevents the case where the cashier changes the payment lines after the
|
||||
// order is paid with an online payment and the server saves the order as paid.
|
||||
// Without that update, the payment lines printed on the receipt ticket would
|
||||
// be invalid.
|
||||
const isInvoiceRequested = this.currentOrder.is_to_invoice();
|
||||
const orderJSONInArray = [orderJSON];
|
||||
await this.pos._loadMissingProducts(orderJSONInArray);
|
||||
await this.pos._loadMissingPartners(orderJSONInArray);
|
||||
const updatedOrder = this.pos.createReactiveOrder(orderJSON);
|
||||
if (!updatedOrder || this.currentOrder.server_id !== updatedOrder.backendId) {
|
||||
this.popup.add(ErrorPopup, {
|
||||
title: this.env._t("Order saving issue"),
|
||||
body: this.env._t("The order has not been saved correctly on the server."),
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.pos.orders.add(updatedOrder);
|
||||
const oldLocalOrder = this.currentOrder;
|
||||
this.pos.set_order(updatedOrder);
|
||||
this.pos.removeOrder(oldLocalOrder, false);
|
||||
this.pos.validated_orders_name_server_id_map[this.currentOrder.name] = this.currentOrder.id;
|
||||
|
||||
// Now, do practically the normal flow
|
||||
if ((this.currentOrder.is_paid_with_cash() || this.currentOrder.get_change()) && this.pos.config.iface_cashdrawer) {
|
||||
this.hardwareProxy.printer.openCashbox();
|
||||
}
|
||||
|
||||
this.currentOrder.finalized = true;
|
||||
|
||||
if (isInvoiceRequested) {
|
||||
if (!this.currentOrder.account_move) {
|
||||
this.popup.add(ErrorPopup, {
|
||||
title: this.env._t("Invoice could not be generated"),
|
||||
body: this.env._t("The invoice could not be generated."),
|
||||
});
|
||||
} else {
|
||||
await this.report.download("account.account_invoices", [
|
||||
this.currentOrder.account_move,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
await this.postPushOrderResolve([this.currentOrder.server_id]);
|
||||
|
||||
this.afterOrderValidation(true);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
/** @odoo-module */
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
import { Order, Payment } from "@point_of_sale/app/store/models";
|
||||
import { floatIsZero } from "@web/core/utils/numbers";
|
||||
|
||||
patch(Order.prototype, "pos_online_payment.Order", {
|
||||
_get_online_payment_url() {
|
||||
return `${this.pos.base_url}/pos/pay/${this.server_id}?access_token=${this.access_token}`;
|
||||
},
|
||||
_get_online_payment_qr_code_data() {
|
||||
return this.server_id ? this._make_qr_code_data(this._get_online_payment_url()) : false;
|
||||
},
|
||||
async update_online_payments_data_with_server(orm, next_online_payment_amount) {
|
||||
if (!this.server_id) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const opData = await orm.call("pos.order", "get_and_set_online_payments_data", [this.server_id, next_online_payment_amount]);
|
||||
return this.process_online_payments_data_from_server(opData);
|
||||
} catch (ex) {
|
||||
console.error("update_online_payments_data_with_server failed: ", ex);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
async process_online_payments_data_from_server(opData) {
|
||||
if (!opData) {
|
||||
return false;
|
||||
}
|
||||
if (opData.id !== this.server_id) {
|
||||
console.error("Called process_online_payments_data_from_server on the wrong order.");
|
||||
}
|
||||
|
||||
if ("paid_order" in opData) {
|
||||
opData.is_paid = true;
|
||||
this.uiState.PaymentScreen?.onlinePaymentPopup?.setReceivedOrderServerOPData(opData);
|
||||
return opData;
|
||||
} else {
|
||||
opData.is_paid = false;
|
||||
}
|
||||
|
||||
if ("deleted" in opData && opData["deleted"]) {
|
||||
// The current order was previously saved on the server in the draft state, and has been deleted.
|
||||
this.server_id = false;
|
||||
}
|
||||
|
||||
let newDoneOnlinePayment = false;
|
||||
|
||||
const opLinesToUpdate = this.paymentlines.filter(
|
||||
(line) => line.payment_method.is_online_payment && ["waiting", "done"].includes(line.get_payment_status())
|
||||
);
|
||||
for (const op of opData.online_payments) {
|
||||
const matchingLineIndex = opLinesToUpdate.findIndex(
|
||||
(pl) => pl.payment_method.id === op.payment_method_id && floatIsZero(pl.amount - op.amount, this.pos.currency.decimal_places)
|
||||
);
|
||||
let opLine = null;
|
||||
if (matchingLineIndex > -1) {
|
||||
opLine = opLinesToUpdate[matchingLineIndex];
|
||||
|
||||
opLinesToUpdate.splice(matchingLineIndex, 1);
|
||||
}
|
||||
if (!opLine) {
|
||||
opLine = new Payment(
|
||||
{},
|
||||
{
|
||||
order: this,
|
||||
payment_method: this.pos.payment_methods_by_id[op.payment_method_id],
|
||||
pos: this.pos,
|
||||
}
|
||||
);
|
||||
this.paymentlines.add(opLine);
|
||||
opData['modified_payment_lines'] = true;
|
||||
}
|
||||
opLine.set_amount(op.amount);
|
||||
opLine.can_be_reversed = false;
|
||||
if (opLine.get_payment_status() !== "done") {
|
||||
newDoneOnlinePayment = true;
|
||||
}
|
||||
opLine.set_payment_status("done");
|
||||
}
|
||||
for (const missingInServerLine of opLinesToUpdate) {
|
||||
if (missingInServerLine.get_payment_status() === "done") {
|
||||
this.paymentlines.remove(missingInServerLine);
|
||||
opData['modified_payment_lines'] = true;
|
||||
}
|
||||
}
|
||||
if (newDoneOnlinePayment || opData['modified_payment_lines']) {
|
||||
this.uiState.PaymentScreen?.onlinePaymentPopup?.cancel();
|
||||
}
|
||||
|
||||
return opData;
|
||||
},
|
||||
});
|
||||
|
||||
patch(Payment.prototype, "pos_online_payment.Payment", {
|
||||
//@override
|
||||
export_as_JSON() {
|
||||
if (this.payment_method.is_online_payment) {
|
||||
return null; // It is the role of the server to save the online payment, not the role of the POS session.
|
||||
} else {
|
||||
return this._super();
|
||||
}
|
||||
},
|
||||
//@override
|
||||
canBeAdjusted() {
|
||||
if (this.payment_method.is_online_payment) {
|
||||
return false;
|
||||
} else {
|
||||
return this._super();
|
||||
}
|
||||
},
|
||||
});
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { AbstractAwaitablePopup } from "@point_of_sale/app/popup/abstract_awaitable_popup";
|
||||
|
||||
export class OnlinePaymentPopup extends AbstractAwaitablePopup {
|
||||
static template = "pos_online_payment.OnlinePaymentPopup";
|
||||
|
||||
setup() {
|
||||
super.setup();
|
||||
if (this.props.order.uiState.PaymentScreen) {
|
||||
this.props.order.uiState.PaymentScreen.onlinePaymentPopup = this;
|
||||
}
|
||||
}
|
||||
setReceivedOrderServerOPData(opData) {
|
||||
this.opData = opData;
|
||||
this.confirm();
|
||||
}
|
||||
async confirm() {
|
||||
super.confirm();
|
||||
delete this.props.order.uiState.PaymentScreen?.onlinePaymentPopup;
|
||||
}
|
||||
cancel() {
|
||||
super.cancel();
|
||||
delete this.props.order.uiState.PaymentScreen?.onlinePaymentPopup;
|
||||
}
|
||||
async getPayload() {
|
||||
return this.opData;
|
||||
}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
<t t-name="pos_online_payment.OnlinePaymentPopup" owl="1">
|
||||
<div class="popup online-payment-popup">
|
||||
<main class="body">
|
||||
<div class="title">
|
||||
Scan to Pay
|
||||
</div>
|
||||
<div class="instructions">
|
||||
<p>Invite your customer to scan the QR code to pay: </p>
|
||||
<img class="qr-code" t-att-src="props.qrCode" alt="QR Code to pay"/>
|
||||
</div>
|
||||
<div class="info">
|
||||
<div><span>Amount: </span><span class="amount" t-esc="env.utils.formatCurrency(props.amount)"/></div>
|
||||
<div><span>Order reference: </span><span t-esc="props.order.name"/></div>
|
||||
<div><span>Order id: </span><span t-esc="props.order.server_id"/></div>
|
||||
</div>
|
||||
</main>
|
||||
<footer class="footer footer-flex">
|
||||
<div class="button cancel" style="background-color: #F3BBBB; color: rgb(168, 89, 89);" t-on-click="cancel">Cancel</div>
|
||||
</footer>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,54 @@
|
||||
.pos-customer_facing_display .online-payment {
|
||||
height: 100%;
|
||||
font-size: min(max(2vw, .9rem), 1.6rem);
|
||||
overflow: hidden;
|
||||
-webkit-display: flex;
|
||||
-moz-display: flex;
|
||||
-ms-display: flex;
|
||||
-o-display: flex;
|
||||
display: flex;
|
||||
-webkit-flex-direction: column;
|
||||
-moz-flex-direction: column;
|
||||
-ms-flex-direction: column;
|
||||
-o-flex-direction: column;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.pos-customer_facing_display .online-payment .instructions {
|
||||
-webkit-box-flex: 1 0 75%;
|
||||
-webkit-flex: 1 0 75%;
|
||||
-moz-box-flex: 1 0 75%;
|
||||
-ms-flex: 1 0 75%;
|
||||
flex: 1 0 75%;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pos-customer_facing_display .online-payment .instructions p {
|
||||
padding-top: .3rem;
|
||||
margin-bottom: 0;
|
||||
font-size: min(max(2.9vw, 0.4rem), 1.6rem);
|
||||
}
|
||||
|
||||
.pos-customer_facing_display .online-payment .instructions .qr-code {
|
||||
height: max(min(min(75vh, 75vw), 60%), 5rem);
|
||||
width: auto;
|
||||
overflow: visible;
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
.pos-customer_facing_display .online-payment .instructions .spacer {
|
||||
height: 10%;
|
||||
}
|
||||
|
||||
.pos-customer_facing_display .online-payment .info {
|
||||
padding: 0 2%;
|
||||
font-size: min(max(3vw, 0.6rem), 1.6rem);
|
||||
max-height: 25%;
|
||||
height: unset;
|
||||
justify-content: left;
|
||||
text-align: left;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
.pos .online-payment-popup .title {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.pos .online-payment-popup .instructions p {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.pos .online-payment-popup .instructions .qr-code {
|
||||
width: min(max(50%, 15rem), 75%);
|
||||
height: auto;
|
||||
margin: .3rem auto .3rem auto;
|
||||
}
|
||||
|
||||
.pos .online-payment-popup .info {
|
||||
border-top: solid 1px rgba(60, 60, 60, 0.1);
|
||||
padding-top: .6rem;
|
||||
justify-content: left;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.pos .online-payment-popup .footer .button {
|
||||
margin: auto;
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { Chrome } from "@point_of_sale/../tests/tours/helpers/ChromeTourMethods";
|
||||
import { ProductScreen } from "@point_of_sale/../tests/tours/helpers/ProductScreenTourMethods";
|
||||
import { PaymentScreen } from "@point_of_sale/../tests/tours/helpers/PaymentScreenTourMethods";
|
||||
import { OnlinePaymentPopup } from "@pos_online_payment/../tests/tours/helpers/OnlinePaymentPopupTourMethods";
|
||||
import { ReceiptScreen } from "@point_of_sale/../tests/tours/helpers/ReceiptScreenTourMethods";
|
||||
import { ErrorPopup } from "@point_of_sale/../tests/tours/helpers/ErrorPopupTourMethods";
|
||||
import { getSteps, startSteps } from "@point_of_sale/../tests/tours/helpers/utils";
|
||||
import { registry } from "@web/core/registry";
|
||||
|
||||
startSteps();
|
||||
|
||||
ProductScreen.do.confirmOpeningPopup();
|
||||
ProductScreen.exec.addOrderline("Letter Tray", "10");
|
||||
ProductScreen.check.selectedOrderlineHas("Letter Tray", "10.0");
|
||||
ProductScreen.do.clickPayButton();
|
||||
PaymentScreen.check.totalIs("48.0");
|
||||
PaymentScreen.check.emptyPaymentlines("48.0");
|
||||
|
||||
PaymentScreen.do.clickPaymentMethod("Online payment");
|
||||
PaymentScreen.do.enterPaymentLineAmount("Online payment", "48");
|
||||
PaymentScreen.check.selectedPaymentlineHas("Online payment", "48.0");
|
||||
PaymentScreen.check.remainingIs("0.0");
|
||||
PaymentScreen.check.changeIs("0.0");
|
||||
PaymentScreen.check.validateButtonIsHighlighted(true);
|
||||
PaymentScreen.do.clickValidate();
|
||||
OnlinePaymentPopup.check.isShown();
|
||||
OnlinePaymentPopup.check.amountIs("48.0");
|
||||
OnlinePaymentPopup.do.fakeOnlinePaymentPaidData();
|
||||
OnlinePaymentPopup.check.isNotShown();
|
||||
ReceiptScreen.check.isShown();
|
||||
ReceiptScreen.check.receiptIsThere();
|
||||
|
||||
registry
|
||||
.category("web_tour.tours")
|
||||
.add("OnlinePaymentLocalFakePaidDataTour", { test: true, url: "/pos/ui", steps: getSteps() });
|
||||
|
||||
startSteps();
|
||||
|
||||
ProductScreen.do.confirmOpeningPopup();
|
||||
ProductScreen.exec.addOrderline("Letter Tray", "10");
|
||||
ProductScreen.check.selectedOrderlineHas("Letter Tray", "10.0");
|
||||
ProductScreen.do.clickPayButton();
|
||||
PaymentScreen.check.totalIs("48.0");
|
||||
PaymentScreen.check.emptyPaymentlines("48.0");
|
||||
|
||||
PaymentScreen.do.clickPaymentMethod("Online payment");
|
||||
PaymentScreen.do.enterPaymentLineAmount("Online payment", "47");
|
||||
PaymentScreen.check.selectedPaymentlineHas("Online payment", "47.0");
|
||||
PaymentScreen.check.remainingIs("1.0");
|
||||
PaymentScreen.check.changeIs("0.0");
|
||||
PaymentScreen.check.validateButtonIsHighlighted(false);
|
||||
PaymentScreen.do.clickPaymentMethod("Cash");
|
||||
PaymentScreen.do.enterPaymentLineAmount("Cash", "2");
|
||||
PaymentScreen.check.selectedPaymentlineHas("Cash", "2.0");
|
||||
PaymentScreen.check.remainingIs("0.0");
|
||||
PaymentScreen.check.changeIs("1.0");
|
||||
PaymentScreen.check.validateButtonIsHighlighted(true);
|
||||
PaymentScreen.do.clickValidate();
|
||||
ErrorPopup.check.isShown();
|
||||
ErrorPopup.do.clickConfirm();
|
||||
PaymentScreen.do.clickPaymentline("Online payment", "47.0");
|
||||
PaymentScreen.do.clickPaymentlineDelButton("Online payment", "47.0");
|
||||
PaymentScreen.do.clickPaymentMethod("Online payment");
|
||||
PaymentScreen.check.selectedPaymentlineHas("Online payment", "46.0");
|
||||
PaymentScreen.do.clickPaymentMethod("Online payment");
|
||||
PaymentScreen.check.selectedPaymentlineHas("Online payment", "0.0");
|
||||
PaymentScreen.check.remainingIs("0.0");
|
||||
PaymentScreen.check.changeIs("0.0");
|
||||
PaymentScreen.check.validateButtonIsHighlighted(true);
|
||||
PaymentScreen.do.clickValidate();
|
||||
ErrorPopup.check.isShown();
|
||||
ErrorPopup.do.clickConfirm();
|
||||
PaymentScreen.do.clickPaymentline("Online payment", "0.0");
|
||||
PaymentScreen.do.clickPaymentlineDelButton("Online payment", "0.0");
|
||||
PaymentScreen.do.clickPaymentline("Cash", "2.0");
|
||||
PaymentScreen.do.enterPaymentLineAmount("Cash", "3");
|
||||
PaymentScreen.check.selectedPaymentlineHas("Cash", "3.0");
|
||||
PaymentScreen.do.clickPaymentMethod("Online payment");
|
||||
PaymentScreen.check.selectedPaymentlineHas("Online payment", "-1.0");
|
||||
PaymentScreen.check.remainingIs("0.0");
|
||||
PaymentScreen.check.changeIs("0.0");
|
||||
PaymentScreen.check.validateButtonIsHighlighted(true);
|
||||
PaymentScreen.do.clickValidate();
|
||||
ErrorPopup.check.isShown();
|
||||
ErrorPopup.do.clickConfirm();
|
||||
|
||||
registry
|
||||
.category("web_tour.tours")
|
||||
.add("OnlinePaymentErrorsTour", { test: true, url: "/pos/ui", steps: getSteps() });
|
||||
|
||||
startSteps();
|
||||
|
||||
ProductScreen.do.confirmOpeningPopup();
|
||||
ProductScreen.exec.addOrderline("Letter Tray", "10");
|
||||
ProductScreen.check.selectedOrderlineHas("Letter Tray", "10.0");
|
||||
ProductScreen.do.clickPayButton();
|
||||
PaymentScreen.check.totalIs("48.0");
|
||||
PaymentScreen.check.emptyPaymentlines("48.0");
|
||||
|
||||
PaymentScreen.do.clickPaymentMethod("Online payment");
|
||||
PaymentScreen.do.enterPaymentLineAmount("Online payment", "48");
|
||||
PaymentScreen.check.selectedPaymentlineHas("Online payment", "48.0");
|
||||
PaymentScreen.check.remainingIs("0.0");
|
||||
PaymentScreen.check.changeIs("0.0");
|
||||
PaymentScreen.check.validateButtonIsHighlighted(true);
|
||||
PaymentScreen.do.clickValidate();
|
||||
OnlinePaymentPopup.check.isShown();
|
||||
OnlinePaymentPopup.check.amountIs("48.0");
|
||||
OnlinePaymentPopup.check.waitForOnlinePayment();
|
||||
OnlinePaymentPopup.check.isNotShown();
|
||||
ReceiptScreen.check.isShown();
|
||||
ReceiptScreen.check.receiptIsThere();
|
||||
Chrome.do.closeSession();
|
||||
|
||||
registry
|
||||
.category("web_tour.tours")
|
||||
.add("OnlinePaymentServerFakePaymentTour", { test: true, url: "/pos/ui", steps: getSteps() });
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { createTourMethods } from "@point_of_sale/../tests/tours/helpers/utils";
|
||||
|
||||
class Do {
|
||||
clickCancel() {
|
||||
return [
|
||||
{
|
||||
content: "click cancel button",
|
||||
trigger: ".online-payment-popup .footer .cancel",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
fakeOnlinePaymentPaidData() {
|
||||
return [
|
||||
{
|
||||
content: "fake online payment paid data",
|
||||
trigger: ".online-payment-popup",
|
||||
run: () => {
|
||||
const currentOrder = odoo.__WOWL_DEBUG__.root.env.services.pos.get_order();
|
||||
|
||||
const fakePaidOrder = currentOrder.export_as_JSON();
|
||||
fakePaidOrder.id = currentOrder.server_id;
|
||||
|
||||
currentOrder.process_online_payments_data_from_server({
|
||||
"id": currentOrder.server_id,
|
||||
"paid_order": fakePaidOrder,
|
||||
});
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
class Check {
|
||||
isShown() {
|
||||
return [
|
||||
{
|
||||
content: "online payment popup is shown",
|
||||
trigger: ".modal-dialog .online-payment-popup",
|
||||
isCheck: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
isNotShown() {
|
||||
return [
|
||||
{
|
||||
content: "online payment popup is not shown",
|
||||
trigger: "body:not(:has(.online-payment-popup))",
|
||||
isCheck: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the displayed amount to pay is the provided amount.
|
||||
* @param {String} amount
|
||||
*/
|
||||
amountIs(amount) {
|
||||
return [
|
||||
{
|
||||
content: `displayed amount is ${amount}`,
|
||||
trigger: `.online-payment-popup .body .info .amount:contains("${amount}")`,
|
||||
isCheck: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to replace the POS bus web socket communication that doesn't seem to work when executing a test tour.
|
||||
* The server is regularly checked to see if the fake online payment has been done.
|
||||
*
|
||||
* @param {integer} checksAmount
|
||||
* @param {integer} delayBetweenChecks
|
||||
* @returns
|
||||
*/
|
||||
waitForOnlinePayment(checksAmount = 10, delayBetweenChecks = 3000) {
|
||||
const waitingStep = this.isNotShown()[0];
|
||||
waitingStep.content = "wait for online payment";
|
||||
waitingStep.timeout = checksAmount * delayBetweenChecks + 3000;
|
||||
return [
|
||||
{
|
||||
content: "start checks for online payment",
|
||||
trigger: ".online-payment-popup",
|
||||
run: () => {
|
||||
let checkIndex = 0;
|
||||
const checkFunc = async () => {
|
||||
const currentOrder = odoo.__WOWL_DEBUG__.root.env.services.pos.get_order();
|
||||
let opData;
|
||||
if (currentOrder) {
|
||||
opData = await currentOrder.update_online_payments_data_with_server(odoo.__WOWL_DEBUG__.root.env.services.orm, false);
|
||||
}
|
||||
const isMaxChecksReached = checkIndex >= checksAmount - 1;
|
||||
const isOrderPaid = opData && opData.is_paid;
|
||||
if (!isOrderPaid && !isMaxChecksReached) {
|
||||
checkIndex++;
|
||||
setTimeout(checkFunc, delayBetweenChecks);
|
||||
}
|
||||
};
|
||||
setTimeout(checkFunc, delayBetweenChecks);
|
||||
},
|
||||
},
|
||||
waitingStep,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// FIXME: this is a horrible hack to export an object as named exports.
|
||||
// eslint-disable-next-line no-undef
|
||||
Object.assign(__exports, createTourMethods("OnlinePaymentPopup", Do, Check));
|
||||
@@ -0,0 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import test_frontend
|
||||
@@ -0,0 +1,62 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import json
|
||||
|
||||
from odoo.tools import mute_logger
|
||||
from odoo.addons.payment.tests.http_common import PaymentHttpCommon
|
||||
from odoo.addons.pos_online_payment.controllers.payment_portal import PaymentPortal
|
||||
|
||||
|
||||
class OnlinePaymentCommon(PaymentHttpCommon):
|
||||
|
||||
def _fake_http_get_request(self, route):
|
||||
url = self._build_url(route)
|
||||
response = self._make_http_get_request(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
return response
|
||||
|
||||
def _fake_open_pos_order_pay_page(self, pos_order_id, access_token):
|
||||
response = self._fake_http_get_request(PaymentPortal._get_pay_route(pos_order_id, access_token))
|
||||
return self._get_tx_context(response, 'o_payment_checkout')
|
||||
|
||||
def _fake_request_pos_order_pay_transaction_page(self, pos_order_id, route_values):
|
||||
uri = f'/pos/pay/transaction/{pos_order_id}'
|
||||
url = self._build_url(uri)
|
||||
response = self._make_json_rpc_request(url, route_values)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
resp_content = json.loads(response.content)
|
||||
return resp_content['result']
|
||||
|
||||
def _fake_open_pos_order_pay_confirmation_page(self, pos_order_id, access_token, tx_id):
|
||||
self._fake_http_get_request(PaymentPortal._get_landing_route(pos_order_id, access_token, tx_id=tx_id))
|
||||
|
||||
def _fake_online_payment(self, pos_order_id, access_token, expected_payment_provider_id):
|
||||
tx_context = self._fake_open_pos_order_pay_page(pos_order_id, access_token)
|
||||
|
||||
# Code inspired by addons/payment/tests/test_flows.py
|
||||
# Route values are taken from tx_context result of /pay route to correctly simulate the flow
|
||||
route_values = {
|
||||
k: tx_context[k]
|
||||
for k in [
|
||||
'amount',
|
||||
'currency_id',
|
||||
'reference_prefix',
|
||||
'partner_id',
|
||||
'access_token',
|
||||
'landing_route',
|
||||
]
|
||||
}
|
||||
provider_id = tx_context['provider_ids'][0]
|
||||
self.assertEqual(expected_payment_provider_id, provider_id, 'The available provider is unexpected')
|
||||
route_values.update({
|
||||
'flow': 'direct',
|
||||
'payment_option_id': provider_id,
|
||||
'tokenization_requested': False,
|
||||
})
|
||||
|
||||
with mute_logger('odoo.addons.payment.models.payment_transaction'):
|
||||
processing_values = self._fake_request_pos_order_pay_transaction_page(pos_order_id, route_values)
|
||||
tx_sudo = self._get_tx(processing_values['reference'])
|
||||
tx_sudo._set_done()
|
||||
self._fake_open_pos_order_pay_confirmation_page(pos_order_id, access_token, tx_sudo.id)
|
||||
@@ -0,0 +1,216 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from unittest.mock import patch
|
||||
import threading
|
||||
|
||||
from odoo import tools, Command, fields
|
||||
from odoo.addons.account.tests.common import AccountTestInvoicingCommon
|
||||
from odoo.addons.pos_online_payment.models.pos_order import PosOrder
|
||||
from odoo.addons.pos_online_payment.tests.online_payment_common import OnlinePaymentCommon
|
||||
from odoo.addons.account.models.account_payment_method import AccountPaymentMethod
|
||||
from odoo.osv.expression import AND
|
||||
|
||||
import odoo.tests
|
||||
|
||||
|
||||
@odoo.tests.tagged('post_install', '-at_install')
|
||||
class TestUi(AccountTestInvoicingCommon, OnlinePaymentCommon):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls, chart_template_ref=None):
|
||||
super().setUpClass(chart_template_ref=chart_template_ref)
|
||||
|
||||
# Code from addons/account_payment/tests/common.py:
|
||||
Method_get_payment_method_information = AccountPaymentMethod._get_payment_method_information
|
||||
|
||||
def _get_payment_method_information(self):
|
||||
res = Method_get_payment_method_information(self)
|
||||
res['none'] = {'mode': 'multi', 'domain': [('type', '=', 'bank')]}
|
||||
return res
|
||||
|
||||
with patch.object(AccountPaymentMethod, '_get_payment_method_information', _get_payment_method_information):
|
||||
cls.env['account.payment.method'].sudo().create({
|
||||
'name': 'Dummy method',
|
||||
'code': 'none',
|
||||
'payment_type': 'inbound'
|
||||
})
|
||||
# End of code from addons/account_payment/tests/common.py
|
||||
|
||||
# Code inspired by addons/point_of_sale/tests/common.py:
|
||||
cls.company = cls.company_data['company']
|
||||
cls.cash_journal = cls.env['account.journal'].create({
|
||||
'name': 'Cash Journal for POS OP Test',
|
||||
'type': 'cash',
|
||||
'company_id': cls.company.id,
|
||||
'code': 'POPCH',
|
||||
'sequence': 10,
|
||||
})
|
||||
|
||||
cls.old_account_default_pos_receivable_account_id = cls.company.account_default_pos_receivable_account_id
|
||||
cls.account_default_pos_receivable_account_id = cls.env['account.account'].create({
|
||||
'code': 'X1012.POSOP',
|
||||
'name': 'Debtors - (POSOP)',
|
||||
'account_type': 'asset_receivable',
|
||||
'reconcile': True,
|
||||
})
|
||||
cls.company.account_default_pos_receivable_account_id = cls.account_default_pos_receivable_account_id
|
||||
cls.receivable_cash_account = cls.copy_account(cls.company.account_default_pos_receivable_account_id, {'name': 'POS OP Test Receivable Cash'})
|
||||
|
||||
cls.cash_payment_method = cls.env['pos.payment.method'].create({
|
||||
'name': 'Cash',
|
||||
'journal_id': cls.cash_journal.id,
|
||||
'receivable_account_id': cls.receivable_cash_account.id,
|
||||
'company_id': cls.company.id,
|
||||
})
|
||||
# End of code inspired by addons/point_of_sale/tests/common.py
|
||||
|
||||
cls.payment_provider = cls.provider # The dummy_provider used by the tests of the 'payment' module.
|
||||
|
||||
cls.payment_provider_old_company_id = cls.payment_provider.company_id.id
|
||||
cls.payment_provider_old_journal_id = cls.payment_provider.journal_id.id
|
||||
cls.payment_provider.company_id = cls.company.id
|
||||
cls.payment_provider.journal_id = cls.company_data['default_journal_bank'].id
|
||||
|
||||
cls.online_payment_method = cls.env['pos.payment.method'].create({
|
||||
'name': 'Online payment',
|
||||
'is_online_payment': True,
|
||||
'online_payment_provider_ids': [Command.set([cls.payment_provider.id])],
|
||||
})
|
||||
|
||||
cls.sales_journal = cls.env['account.journal'].create({
|
||||
'name': 'Sales Journal for POS OP Test',
|
||||
'code': 'POPSJ',
|
||||
'type': 'sale',
|
||||
'company_id': cls.company.id
|
||||
})
|
||||
|
||||
cls.pos_config = cls.env['pos.config'].create({
|
||||
'name': 'POS OP Test Shop',
|
||||
'module_pos_restaurant': False,
|
||||
'invoice_journal_id': cls.sales_journal.id,
|
||||
'journal_id': cls.sales_journal.id,
|
||||
'payment_method_ids': [Command.link(cls.cash_payment_method.id), Command.link(cls.online_payment_method.id)],
|
||||
})
|
||||
|
||||
# Code from addons/point_of_sale/tests/test_frontend.py:
|
||||
cls.pos_user = cls.env['res.users'].create({
|
||||
'name': 'A simple PoS man!',
|
||||
'login': 'pos_op_user',
|
||||
'password': 'pos_op_user',
|
||||
'groups_id': [
|
||||
(4, cls.env.ref('base.group_user').id),
|
||||
(4, cls.env.ref('point_of_sale.group_pos_user').id),
|
||||
],
|
||||
})
|
||||
cls.pos_user.partner_id.email = 'pos_op_user@test.com'
|
||||
# End of code from addons/point_of_sale/tests/test_frontend.py
|
||||
|
||||
# Code from addons/account_payment/tests/common.py
|
||||
@classmethod
|
||||
def _prepare_provider(cls, provider_code='none', company=None, update_values=None):
|
||||
""" Override of `payment` to prepare and return the first provider matching the given
|
||||
provider and company.
|
||||
|
||||
If no provider is found in the given company, we duplicate the one from the base company.
|
||||
All other providers belonging to the same company are disabled to avoid any interferences.
|
||||
|
||||
:param str provider_code: The code of the provider to prepare.
|
||||
:param recordset company: The company of the provider to prepare, as a `res.company` record.
|
||||
:param dict update_values: The values used to update the provider.
|
||||
:return: The provider to prepare, if found.
|
||||
:rtype: recordset of `payment.provider`
|
||||
"""
|
||||
provider = super()._prepare_provider(provider_code, company, update_values)
|
||||
if not provider.journal_id:
|
||||
provider.journal_id = cls.env['account.journal'].search(
|
||||
[('company_id', '=', provider.company_id.id), ('type', '=', 'bank')],
|
||||
limit=1,
|
||||
)
|
||||
return provider
|
||||
# End of code from addons/account_payment/tests/common.py
|
||||
|
||||
def setUp(self):
|
||||
self.enable_reconcile_after_done_patcher = False
|
||||
|
||||
real_get_and_set_online_payments_data = PosOrder.get_and_set_online_payments_data
|
||||
|
||||
def _fake_get_and_set_online_payments_data(method_self, next_online_payment_amount=False):
|
||||
pos_order_id = method_self.id
|
||||
order_access_token = method_self.access_token
|
||||
expected_payment_provider_id = self.payment_provider.id
|
||||
if not isinstance(next_online_payment_amount, bool) and not tools.float_is_zero(next_online_payment_amount, precision_rounding=method_self.currency_id.rounding) and next_online_payment_amount > 0:
|
||||
# Delay must be long enough to execute _fake_online_payment after the current RPC call is answered
|
||||
t = threading.Timer(5, self._fake_online_payment, args=(pos_order_id, order_access_token, expected_payment_provider_id))
|
||||
t.start()
|
||||
|
||||
return real_get_and_set_online_payments_data(method_self, next_online_payment_amount)
|
||||
|
||||
self._fake_get_and_set_online_payments_data = _fake_get_and_set_online_payments_data
|
||||
|
||||
super(TestUi, self).setUp()
|
||||
|
||||
self.assertTrue(self.company)
|
||||
self.assertTrue(self.cash_journal)
|
||||
self.assertTrue(self.account_default_pos_receivable_account_id)
|
||||
self.assertTrue(self.receivable_cash_account)
|
||||
self.assertTrue(self.sales_journal)
|
||||
self.assertTrue(self.cash_payment_method)
|
||||
self.assertTrue(self.payment_provider)
|
||||
self.assertTrue(self.online_payment_method)
|
||||
self.assertTrue(self.pos_config)
|
||||
self.assertTrue(self.pos_user)
|
||||
|
||||
def _open_session_ui(self):
|
||||
self.pos_config.with_user(self.pos_user).open_ui()
|
||||
|
||||
# Checks that the products used in the tours are available in this pos_config.
|
||||
# This code is executed here because _loader_params_product_product is defined in pos.session
|
||||
# and not in pos.config.
|
||||
params = self.pos_config.current_session_id._loader_params_product_product()
|
||||
self.assertTrue(params)
|
||||
pos_config_products_domain = params['search_params']['domain']
|
||||
self.assertTrue(pos_config_products_domain)
|
||||
tests_products_domain = AND([pos_config_products_domain, ['&', '&', ('name', '=', 'Letter Tray'), ('list_price', '=', 4.8), ('available_in_pos', '=', True)]])
|
||||
# active_test=False to follow pos.config:get_pos_ui_product_product_by_params
|
||||
self.assertEqual(self.env['product.product'].with_context(active_test=False).search_count(tests_products_domain, limit=1), 1)
|
||||
|
||||
def _start_tour(self, tour_name):
|
||||
self.start_tour("/pos/ui?config_id=%d" % self.pos_config.id, tour_name, login="pos_op_user")
|
||||
|
||||
def test_server_fake_payment_tour(self):
|
||||
self._open_session_ui()
|
||||
|
||||
before_tour_datetime = fields.Datetime.now()
|
||||
with patch.object(PosOrder, 'get_and_set_online_payments_data', self._fake_get_and_set_online_payments_data):
|
||||
self._start_tour('OnlinePaymentServerFakePaymentTour')
|
||||
|
||||
test_orders = self.env['pos.order'].search(['&', ('config_id', '=', self.pos_config.id), ('date_order', '>=', before_tour_datetime)])
|
||||
self.assertEqual(len(test_orders), 1)
|
||||
for order in test_orders:
|
||||
self.assertEqual(order.state, 'done', "Validated order has payment of " + str(order.amount_paid) + " and total of " + str(order.amount_total))
|
||||
|
||||
def test_local_fake_paid_data_tour(self):
|
||||
self._open_session_ui()
|
||||
self._start_tour('OnlinePaymentLocalFakePaidDataTour')
|
||||
|
||||
def test_errors_tour(self):
|
||||
self._open_session_ui()
|
||||
self._start_tour('OnlinePaymentErrorsTour')
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
# Restore company values after the tests
|
||||
cls.company.account_default_pos_receivable_account_id = cls.old_account_default_pos_receivable_account_id
|
||||
|
||||
# Restore dummy_provider values after the tests
|
||||
cls.payment_provider.company_id = cls.payment_provider_old_company_id
|
||||
cls.payment_provider.journal_id = cls.payment_provider_old_journal_id
|
||||
|
||||
# The online payment method cannot be deleted because it is used by a payment in the database.
|
||||
# It would require to delete the paid orders of the tests, the corresponding accounting, the session data...
|
||||
cls.pos_config.payment_method_ids = [Command.unlink(cls.online_payment_method.id)]
|
||||
cls.cash_payment_method.unlink()
|
||||
cls.receivable_cash_account.unlink()
|
||||
cls.cash_journal.unlink()
|
||||
cls.account_default_pos_receivable_account_id.unlink()
|
||||
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0"?>
|
||||
<odoo>
|
||||
|
||||
<record id="view_account_payment_form_inherit_pos_online_payment" model="ir.ui.view">
|
||||
<field name="name">view.account.payment.form.inherit.pos_online_payment</field>
|
||||
<field name="model">account.payment</field>
|
||||
<field name="inherit_id" ref="account.view_account_payment_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//div[@name='button_box']" position="inside">
|
||||
<field name="pos_order_id" invisible="1"/>
|
||||
<button name="action_view_pos_order" type="object"
|
||||
class="oe_stat_button" icon="fa-shopping-cart"
|
||||
attrs="{'invisible': [('pos_order_id', '=', False)]}">
|
||||
<field name="pos_order_id" widget="statinfo" string="POS Order"/>
|
||||
</button>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -0,0 +1,119 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- Display of /pos/pay (the order of the if conditions matters)-->
|
||||
<template id="pay">
|
||||
<t t-call="portal.frontend_layout">
|
||||
<t t-set="page_title" t-value="'Payment'" />
|
||||
<t t-set="additional_title">
|
||||
<t t-esc="page_title" />
|
||||
</t>
|
||||
<div class="wrap">
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-lg-7">
|
||||
<b>Order Reference:</b>
|
||||
<t t-esc="reference_prefix" />
|
||||
<br />
|
||||
<b>Order id:</b>
|
||||
<t t-esc="pos_order_id" />
|
||||
<br />
|
||||
<div t-if="not currency" class="alert alert-danger">
|
||||
<strong>Error:</strong> The currency is missing or invalid. </div>
|
||||
<div t-elif="not amount" class="alert alert-info">
|
||||
There is nothing to pay.
|
||||
</div>
|
||||
<div t-elif="not providers and not tokens" class="alert alert-danger">
|
||||
<strong>No suitable payment option could be found.</strong><br /> If you believe that it is an error, please contact the website administrator. </div>
|
||||
<t t-else="">
|
||||
<b>Amount:</b>
|
||||
<t t-esc="amount"
|
||||
t-options="{'widget': 'monetary', 'display_currency': currency}" />
|
||||
<t t-call="payment.checkout" />
|
||||
</t>
|
||||
<div class="col-md-4 offset-md-3 mt-2 ps-0" t-if="exit_route">
|
||||
<a role="button"
|
||||
class="btn btn-secondary"
|
||||
t-att-href="exit_route">
|
||||
Cancel payment </a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<!-- Display of /pos/pay/confirmation -->
|
||||
<template id="pay_confirmation">
|
||||
<t t-call="portal.frontend_layout">
|
||||
<t t-set="page_title" t-value="'Payment Confirmation'" />
|
||||
<t t-set="additional_title">
|
||||
<t t-esc="page_title" />
|
||||
</t>
|
||||
<div class="wrap">
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-lg-6">
|
||||
<div>
|
||||
<t t-if="state == 'error'">
|
||||
<div class="alert alert-danger">
|
||||
<strong>Error:</strong> There was a problem during the payment process. </div>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<t t-call="payment.transaction_status" />
|
||||
<div class="mb-3 row">
|
||||
<t t-if="state == 'success'">
|
||||
<label for="form_amount" class="col-md-3 col-form-label" style="width: 30%;">
|
||||
Amount:
|
||||
</label>
|
||||
<span name="form_amount"
|
||||
class="col-md-9 col-form-label"
|
||||
t-esc="amount"
|
||||
t-options="{'widget': 'monetary', 'display_currency': currency}"
|
||||
style="width: 70%;" />
|
||||
</t>
|
||||
<label for="form_order_reference" class="col-md-3 col-form-label">
|
||||
Order Reference:
|
||||
</label>
|
||||
<span name="form_order_reference"
|
||||
class="col-md-9 col-form-label"
|
||||
t-esc="order_reference" />
|
||||
<label for="form_tx_reference" class="col-md-3 col-form-label">
|
||||
Transaction Reference:
|
||||
</label>
|
||||
<span name="form_tx_reference"
|
||||
class="col-md-9 col-form-label"
|
||||
t-esc="tx_reference" />
|
||||
<label for="form_order_id" class="col-md-3 col-form-label" style="width: 30%;">
|
||||
Order id:
|
||||
</label>
|
||||
<span name="form_order_id"
|
||||
class="col-md-9 col-form-label"
|
||||
t-esc="pos_order_id"
|
||||
style="width: 70%;" />
|
||||
</div>
|
||||
</t>
|
||||
<div class="col-md-4 offset-md-3 mt-2 ps-0" t-if="pay_route and state != 'success'">
|
||||
<a role="button"
|
||||
class="btn btn-secondary"
|
||||
t-att-href="pay_route">
|
||||
Try again </a>
|
||||
</div>
|
||||
<div class="col-md-4 offset-md-3 mt-2 ps-0" t-if="exit_route">
|
||||
<a role="button"
|
||||
class="btn btn-primary"
|
||||
t-att-href="exit_route">
|
||||
<t t-esc="'Continue' if state == 'success' else 'Cancel payment'" />
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-md-5 text-muted" t-if="state == 'success'"> Processed by <t t-esc="provider_name" /></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
</odoo>
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="payment_transaction_form" model="ir.ui.view">
|
||||
<field name="name">payment.transaction.form</field>
|
||||
<field name="model">payment.transaction</field>
|
||||
<field name="inherit_id" ref="payment.payment_transaction_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<button name="action_view_refunds" position="before">
|
||||
<field name="pos_order_id" invisible="1"/>
|
||||
<button name="action_view_pos_order" type="object"
|
||||
class="oe_stat_button" icon="fa-shopping-cart"
|
||||
attrs="{'invisible': [('pos_order_id', '=', False)]}">
|
||||
<field name="pos_order_id" widget="statinfo" string="POS Order"/>
|
||||
</button>
|
||||
</button>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -0,0 +1,52 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="pos_payment_method_view_form_inherit_pos_online_payment" model="ir.ui.view">
|
||||
<field name="name">pos.payment.method.form.inherit.pos_online_payment</field>
|
||||
<field name="model">pos.payment.method</field>
|
||||
<field name="inherit_id" ref="point_of_sale.pos_payment_method_view_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//form/sheet" position="before">
|
||||
<field name="has_an_online_payment_provider" invisible="1"/>
|
||||
<div class="alert alert-danger mb-0" role="alert" attrs="{'invisible': ['|', ('is_online_payment', '=', False), ('has_an_online_payment_provider', '=', True)]}">
|
||||
You have not activated any <bold><a type="action" name="%(payment.action_payment_provider)d" class="alert-link" role="button">payment provider</a></bold> to allow online payments.
|
||||
</div>
|
||||
</xpath>
|
||||
<xpath expr="//form/sheet/group/group/field[@name='split_transactions']" position="before">
|
||||
<field name="is_online_payment"/>
|
||||
</xpath>
|
||||
<xpath expr="//form/sheet/group/group/field[@name='split_transactions']" position="attributes">
|
||||
<attribute name="attrs">{'invisible': [('is_online_payment', '=', True)]}</attribute>
|
||||
</xpath>
|
||||
<xpath expr="//form/sheet/group/group/field[@name='journal_id']" position="attributes">
|
||||
<attribute name="attrs">{'invisible': [('is_online_payment', '=', True)], 'required': ['&', ('is_online_payment', '=', False), ('split_transactions', '=', False)]}</attribute>
|
||||
</xpath>
|
||||
<xpath expr="//form/sheet/group/group/field[@name='receivable_account_id']" position="attributes">
|
||||
<attribute name="attrs">{'invisible': ['|', ('is_online_payment', '=', True), ('split_transactions', '=', True)]}</attribute>
|
||||
</xpath>
|
||||
<xpath expr="//form/sheet/group[@name='Payment methods']/group" position="after">
|
||||
<group attrs="{'invisible': [('is_online_payment', '=', False)]}">
|
||||
<div colspan="2">
|
||||
<label for="online_payment_provider_ids"/>
|
||||
<field name="online_payment_provider_ids" widget="many2many_tags" options="{'no_create': True}" placeholder="All available providers" />
|
||||
<button name="%(payment.action_payment_provider)d" icon="fa-arrow-right" type="action" string="Payment Providers" class="btn-link" />
|
||||
</div>
|
||||
</group>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="pos_payment_method_view_tree_inherit_pos_online_payment" model="ir.ui.view">
|
||||
<field name="name">pos.payment.method.tree.inherit.pos_online_payment</field>
|
||||
<field name="model">pos.payment.method</field>
|
||||
<field name="inherit_id" ref="point_of_sale.pos_payment_method_view_tree"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//tree" position="attributes">
|
||||
<attribute name="decoration-danger">is_online_payment and not has_an_online_payment_provider</attribute>
|
||||
</xpath>
|
||||
<xpath expr="//tree" position="inside">
|
||||
<field name="is_online_payment" optional="hide"/>
|
||||
<field name="has_an_online_payment_provider" invisible="1"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_pos_payment_form" model="ir.ui.view">
|
||||
<field name="name">pos.payment.form</field>
|
||||
<field name="model">pos.payment</field>
|
||||
<field name="inherit_id" ref="point_of_sale.view_pos_payment_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='transaction_id']" position='after'>
|
||||
<field name="online_account_payment_id" readonly="1" attrs="{'invisible': [('online_account_payment_id', '=', False)]}"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -61,18 +61,15 @@ class PosOrder(models.Model):
|
||||
self.send_table_count_notification(tables)
|
||||
return order_ids
|
||||
|
||||
@api.model
|
||||
def create_from_ui(self, orders, draft=False):
|
||||
orders = super().create_from_ui(orders, draft)
|
||||
order_ids = self.env['pos.order'].browse([order['id'] for order in orders])
|
||||
self.send_table_count_notification(order_ids.table_id)
|
||||
return orders
|
||||
def _process_saved_order(self, draft):
|
||||
order_id = super()._process_saved_order(draft)
|
||||
self.send_table_count_notification(self.table_id)
|
||||
return order_id
|
||||
|
||||
def send_table_count_notification(self, table_ids):
|
||||
messages = []
|
||||
for config in self.env['pos.config'].search([('floor_ids', 'in', table_ids.floor_id.ids)]):
|
||||
config_cur_session = config.current_session_id
|
||||
|
||||
if config_cur_session:
|
||||
order_count = config.get_tables_order_count_and_printing_changes()
|
||||
messages.append((config_cur_session._get_bus_channel_name(), 'TABLE_ORDER_COUNT', order_count))
|
||||
|
||||
Reference in New Issue
Block a user