diff --git a/payment_sequra/README.rst b/payment_sequra/README.rst new file mode 100644 index 00000000000..83ef062b53b --- /dev/null +++ b/payment_sequra/README.rst @@ -0,0 +1,90 @@ +======================= +SeQura Payment Provider +======================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:9e0ee693a90d9f4b4b2dcb1000aadf108a00e8ae9a080cb33f865ba085a092ad + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Faccount--payment-lightgray.png?logo=github + :target: https://github.com/OCA/account-payment/tree/18.0/payment_sequra + :alt: OCA/account-payment +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/account-payment-18-0/account-payment-18-0-payment_sequra + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/account-payment&target_branch=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module integrates SeQura using a combination of the Checkout API +and Order Update API. It leverages the generic redirection-based payment +flow provided by the payment module to create and initialize SeQura +transactions using the same request payload that would normally be sent +from SeQura’s embedded checkout. + +**Table of contents** + +.. contents:: + :local: + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Tecnativa + +Contributors +------------ + +- `Tecnativa `__: + + - Juan Carlos Oñate + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +.. |maintainer-juancarlosonate-tecnativa| image:: https://github.com/juancarlosonate-tecnativa.png?size=40px + :target: https://github.com/juancarlosonate-tecnativa + :alt: juancarlosonate-tecnativa + +Current `maintainer `__: + +|maintainer-juancarlosonate-tecnativa| + +This module is part of the `OCA/account-payment `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/payment_sequra/__init__.py b/payment_sequra/__init__.py new file mode 100644 index 00000000000..bb03ffb76ff --- /dev/null +++ b/payment_sequra/__init__.py @@ -0,0 +1,12 @@ +from . import models +from . import controllers + +from odoo.addons.payment import setup_provider, reset_payment_provider + + +def post_init_hook(env): + setup_provider(env, "sequra") + + +def uninstall_hook(env): + reset_payment_provider(env, "sequra") diff --git a/payment_sequra/__manifest__.py b/payment_sequra/__manifest__.py new file mode 100644 index 00000000000..d62abdd90c5 --- /dev/null +++ b/payment_sequra/__manifest__.py @@ -0,0 +1,22 @@ +# Copyright 2025 Juan Carlos Oñate - Tecnativa +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +{ + "name": "SeQura Payment Provider", + "summary": "Integrates SeQura as a payment provider", + "version": "18.0.1.0.0", + "category": "Accounting/Payment Providers", + "author": "Tecnativa, Odoo Community Association (OCA)", + "maintainers": ["juancarlosonate-tecnativa"], + "license": "AGPL-3", + "website": "https://github.com/OCA/account-payment", + "depends": ["sale", "stock"], + "data": [ + "views/payment_provider_views.xml", + "views/payment_sequra_templates.xml", + "data/payment_provider_data.xml", + "data/stock_picking_actions.xml", + ], + "post_init_hook": "post_init_hook", + "uninstall_hook": "uninstall_hook", + "installable": True, +} diff --git a/payment_sequra/const.py b/payment_sequra/const.py new file mode 100644 index 00000000000..5a9fa8be588 --- /dev/null +++ b/payment_sequra/const.py @@ -0,0 +1,6 @@ +DEFAULT_PAYMENT_METHOD_CODES = { + "pp3", +} +SUPPORTED_CURRENCIES = [ + "EUR", +] diff --git a/payment_sequra/controllers/__init__.py b/payment_sequra/controllers/__init__.py new file mode 100644 index 00000000000..12a7e529b67 --- /dev/null +++ b/payment_sequra/controllers/__init__.py @@ -0,0 +1 @@ +from . import main diff --git a/payment_sequra/controllers/main.py b/payment_sequra/controllers/main.py new file mode 100644 index 00000000000..571a3e858ca --- /dev/null +++ b/payment_sequra/controllers/main.py @@ -0,0 +1,52 @@ +# Copyright 2025 Juan Carlos Oñate - Tecnativa +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +import logging + +from odoo import http +from odoo.http import request + +_logger = logging.getLogger(__name__) + + +class SequraController(http.Controller): + _return_url = "/payment/sequra/return" + _notify_url = "/payment/sequra/notify" + _abort_url = "/payment/sequra/abort" + _webhook_url = "/payment/sequra/webhook" + + @http.route(_return_url, type="http", methods=["GET"], auth="public", csrf=False) + def sequra_return(self, **data): + return request.redirect("/payment/status") + + @http.route(_notify_url, type="http", methods=["POST"], auth="public", csrf=False) + def sequra_notify(self, **data): + try: + request.env["payment.transaction"].sudo()._handle_notification_data( + "sequra", data + ) + except Exception: + _logger.exception("Error processing SeQura IPN") + return request.make_response("Internal Server Error", status=500) + return request.make_response("OK", status=200) + + @http.route( + "/payment/sequra/abort/", + type="http", + methods=["GET"], + auth="public", + csrf=False, + ) + def sequra_abort(self, transaction=None, **data): + if transaction: + _logger.warning( + "SeQura checkout aborted for transaction %s", transaction.reference + ) + transaction._set_canceled() + else: + _logger.warning("No transaction found for SeQura abort call.") + return request.redirect("/payment/status") + + @http.route(_webhook_url, type="http", methods=["POST"], auth="public", csrf=False) + def sequra_webhook(self, **data): + return request.make_response("OK", status=200) diff --git a/payment_sequra/data/payment_provider_data.xml b/payment_sequra/data/payment_provider_data.xml new file mode 100644 index 00000000000..4c7515111f6 --- /dev/null +++ b/payment_sequra/data/payment_provider_data.xml @@ -0,0 +1,33 @@ + + + + Part payments + pp3 + + + + SeQura + + sequra + + + sequra_merchant_id + sequra_account_key + sequra_account_secret + + + + diff --git a/payment_sequra/data/stock_picking_actions.xml b/payment_sequra/data/stock_picking_actions.xml new file mode 100644 index 00000000000..bfa20b80a19 --- /dev/null +++ b/payment_sequra/data/stock_picking_actions.xml @@ -0,0 +1,11 @@ + + + + Send Shipment Notification to SeQura + + + form,list + code + records._sequra_notify_shipment() + + diff --git a/payment_sequra/models/__init__.py b/payment_sequra/models/__init__.py new file mode 100644 index 00000000000..f51fc167fe2 --- /dev/null +++ b/payment_sequra/models/__init__.py @@ -0,0 +1,3 @@ +from . import payment_transaction +from . import payment_provider +from . import stock_picking diff --git a/payment_sequra/models/payment_provider.py b/payment_sequra/models/payment_provider.py new file mode 100644 index 00000000000..abcf403a639 --- /dev/null +++ b/payment_sequra/models/payment_provider.py @@ -0,0 +1,84 @@ +# Copyright 2025 Juan Carlos Oñate - Tecnativa +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +import base64 +import logging + +import requests + +from odoo import fields, models +from odoo.exceptions import UserError + +from .. import const + +_logger = logging.getLogger(__name__) + + +class PaymentProvider(models.Model): + _inherit = "payment.provider" + + code = fields.Selection( + selection_add=[("sequra", "SeQura")], ondelete={"sequra": "set default"} + ) + sequra_merchant_id = fields.Char( + required_if_provider="sequra", groups="base.group_system" + ) + sequra_account_key = fields.Char( + required_if_provider="sequra", groups="base.group_system" + ) + sequra_account_secret = fields.Char( + required_if_provider="sequra", groups="base.group_system" + ) + + def _get_supported_currencies(self): + """Override of `payment` to return the supported currencies.""" + supported_currencies = super()._get_supported_currencies() + if self.code == "sequra": + supported_currencies = supported_currencies.filtered( + lambda c: c.name in const.SUPPORTED_CURRENCIES + ) + return supported_currencies + + def _sequra_get_base_url(self): + self.ensure_one() + return ( + "https://sandbox.sequrapi.com" + if self.state == "test" + else "https://api.sequrapi.com" + ) + + def _sequra_make_request(self, endpoint, payload=None, method="POST"): + self.ensure_one() + base_url = self._sequra_get_base_url() + url = endpoint if endpoint.startswith("http") else f"{base_url}{endpoint}" + credentials = f"{self.sequra_account_key}:{self.sequra_account_secret}" + encoded_credentials = base64.b64encode(credentials.encode("utf-8")).decode( + "utf-8" + ) + headers = { + "Accept": "application/json", + "Content-Type": "application/json", + "Sequra-Merchant-Id": self.sequra_merchant_id, + "Authorization": f"Basic {encoded_credentials}", + } + try: + response = requests.request( + method, url, json=payload, headers=headers, timeout=20 + ) + except requests.RequestException as e: + _logger.exception("Connection error contacting SeQura API: %s", e) + raise UserError(self.env._("Could not reach SeQura API: %s") % e) from e + if response.status_code not in (200, 202, 204, 409): + _logger.error( + "SeQura API error [%s]: %s", response.status_code, response.text + ) + raise UserError( + self.env._("SeQura API request failed (%s): %s") + % (response.status_code, response.text) + ) + return response + + def _get_default_payment_method_codes(self): + default_codes = super()._get_default_payment_method_codes() + if self.code != "sequra": + return default_codes + return const.DEFAULT_PAYMENT_METHOD_CODES diff --git a/payment_sequra/models/payment_transaction.py b/payment_sequra/models/payment_transaction.py new file mode 100644 index 00000000000..3bff854e218 --- /dev/null +++ b/payment_sequra/models/payment_transaction.py @@ -0,0 +1,266 @@ +# Copyright 2025 Juan Carlos Oñate - Tecnativa +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +import logging +import platform + +from werkzeug import urls + +import odoo.release +from odoo import models +from odoo.exceptions import ValidationError +from odoo.http import request + +from ..controllers.main import SequraController + +_logger = logging.getLogger(__name__) + + +class PaymentTransaction(models.Model): + _inherit = "payment.transaction" + + def _get_specific_rendering_values(self, processing_values): + res = super()._get_specific_rendering_values(processing_values) + if self.provider_code != "sequra": + return res + payload = self._sequra_prepare_order_request_payload() + response = self.provider_id._sequra_make_request("/orders", payload=payload) + location = response.headers.get("Location") + if not location: + raise ValidationError( + self.env._("SeQura did not return a Location header. Response: %s") + % response.text + ) + self.provider_reference = location.rstrip("/").split("/")[-1] + checkout_url = f"{location.rstrip('/')}/embedded_form" + return {"api_url": checkout_url, "product": self.payment_method_id.code} + + def _get_sequra_delivery_name(self): + order = self.sale_order_ids[:1] + carrier = getattr(order, "carrier_id", None) + if carrier and getattr(carrier, "name", None): + return carrier.name + return self.env._("Standard delivery") + + def _get_sequra_items(self): + self.ensure_one() + order = self.sale_order_ids[:1] + if order: + return [ + { + "reference": line.product_id.id, + "name": line.product_id.name, + "quantity": int(line.product_uom_qty), + "price_with_tax": int(line.price_reduce_taxinc * 100), + "total_with_tax": int(line.price_total * 100), + "downloadable": line.product_id.type == "service", + } + for line in order.order_line.filtered( + lambda line: not line.display_type + ) + ] + invoice = self.invoice_ids[:1] + if invoice: + return [ + { + "reference": line.product_id.id, + "name": line.product_id.name, + "quantity": int(line.quantity), + "price_with_tax": int(line.price_total * 100), + "total_with_tax": int(line.price_total * 100), + "downloadable": line.product_id.type == "service", + } + for line in invoice.invoice_line_ids.filtered( + lambda line: line.display_type not in ("line_note", "line_section") + ) + ] + + def _sequra_prepare_order_request_payload(self): + # https://docs.sequrapi.com/checkout/order_documentation.html + self.ensure_one() + base_url = self.provider_id.get_base_url() + notify_url = urls.url_join(base_url, SequraController._notify_url) + return_url = urls.url_join(base_url, SequraController._return_url) + abort_url = urls.url_join( + base_url, f"{SequraController._abort_url}/{self.env['ir.http']._slug(self)}" + ) + webhook_url = urls.url_join(base_url, SequraController._webhook_url) + environ = {} + if request and hasattr(request, "httprequest"): + environ = request.httprequest.environ + order = self.sale_order_ids[:1] + if order: + partner = order.partner_id + partner_invoice = order.partner_invoice_id + partner_shipping = order.partner_shipping_id + else: + partner = self.partner_id + partner_invoice = partner + partner_shipping = partner + return { + "order": { + "state": "", + "merchant": { + "id": self.provider_id.sequra_merchant_id, + "notify_url": notify_url, + "return_url": return_url, + "abort_url": abort_url, + "events_webhook": {"url": webhook_url}, + }, + "merchant_reference": { + "order_ref_1": self.reference, + }, + "cart": { + "currency": self.currency_id.name or "EUR", + "gift": False, + "order_total_with_tax": int(self.amount * 100), + "items": self._get_sequra_items(), + }, + "customer": { + "given_names": partner.name, + "surnames": partner.name, + "email": partner.email or "noemail@example.com", + "logged_in": "unknown", + "language_code": (partner.lang or "es_ES").replace("_", "-"), + "ip_number": environ.get("HTTP_X_FORWARDED_FOR") + or environ.get("REMOTE_ADDR") + or "127.0.0.1", + "user_agent": environ.get("HTTP_USER_AGENT"), + }, + "delivery_method": { + "name": self._get_sequra_delivery_name(), + }, + "delivery_address": { + "given_names": partner_shipping.name, + "surnames": partner_shipping.name, + "company": partner_shipping.company_name or "N/A", + "address_line_1": partner_shipping.street or "N/A", + "address_line_2": partner_shipping.street2 or "N/A", + "postal_code": partner_shipping.zip or "00000", + "city": partner_shipping.city or "Unknown", + "country_code": partner_shipping.country_id.code or "ES", + }, + "invoice_address": { + "given_names": partner_invoice.name, + "surnames": partner_invoice.name, + "company": partner_invoice.company_name or "N/A", + "address_line_1": partner_invoice.street or "N/A", + "address_line_2": partner_invoice.street2 or "N/A", + "postal_code": partner_invoice.zip or "00000", + "city": partner_invoice.city or "Unknown", + "country_code": partner_invoice.country_id.code or "ES", + }, + "gui": { + "layout": "smartphone" + if environ.get("HTTP_SEC_CH_UA_MOBILE") == "?1" + else "desktop" + }, + "platform": { + "name": base_url, + "version": odoo.release.version, + "uname": platform.uname().system, + "db_name": self.env.cr.dbname, + "db_version": str(self.env.cr._cnx.server_version), + }, + } + } + + def _get_tx_from_notification_data(self, provider_code, notification_data): + tx = super()._get_tx_from_notification_data(provider_code, notification_data) + if provider_code != "sequra" or len(tx) == 1: + return tx + reference = notification_data.get("order_ref") + if not reference: + raise ValidationError( + self.env._("SeQura: Received data with missing reference.") + ) + tx = self.search( + [("provider_reference", "=", reference), ("provider_code", "=", "sequra")], + limit=1, + ) + if not tx: + raise ValidationError( + self.env._( + "SeQura: No transaction found matching reference %s.", reference + ) + ) + return tx + + def _sequra_update_order_state(self, state): + self.ensure_one() + if not self.provider_reference: + _logger.error( + "SeQura: Missing provider_reference for tx %s", self.reference + ) + return False + base_url = ( + f"{self.provider_id._sequra_get_base_url()}/orders/" + f"{self.provider_reference}" + ) + payload = self._sequra_prepare_order_request_payload() + payload["order"]["state"] = state + try: + response = self.provider_id._sequra_make_request( + base_url, + method="PUT", + payload=payload, + ) + if response.status_code == 200: + return response + elif response.status_code == 409: + _logger.warning( + "SeQura responded with 409 Conflict for %s", self.reference + ) + elif response.status_code == 410: + _logger.warning("SeQura responded with 410 Gone for %s", self.reference) + else: + _logger.error( + "Unexpected SeQura response (%s): %s", + response.status_code, + response.text, + ) + except Exception: + _logger.exception("Error sending PUT to SeQura for tx %s", self.reference) + return response + + def _process_notification_data(self, notification_data): + super()._process_notification_data(notification_data) + if self.provider_code != "sequra": + return + self.ensure_one() + payment_status = notification_data.get("sq_state") or notification_data.get( + "state" + ) + if not payment_status: + raise ValidationError(self.env._("SeQura: Missing payment status.")) + state = ( + "confirmed" if payment_status in ("approved", "confirmed") else "on_hold" + ) + response = self._sequra_update_order_state(state) + if payment_status == "approved" and response.ok: + self._set_done() + elif payment_status in ("pending", "needs_review", "on_hold") and response.ok: + self._set_pending() + elif not response.ok: + _logger.error( + "SeQura: Failed to update order %s to %s. Response: %s", + self.reference, + state, + response.text, + ) + self._set_error( + self.env._( + "SeQura update failed (%s %s): %s", + response.status_code, + response.reason, + response.text, + ) + ) + else: + _logger.warning( + "SeQura: Received unrecognized payment status '%s' for tx %s", + payment_status, + self.reference, + ) + self._set_error( + self.env._("Unknown SeQura payment status: %s", payment_status) + ) diff --git a/payment_sequra/models/stock_picking.py b/payment_sequra/models/stock_picking.py new file mode 100644 index 00000000000..11a7ebdd45c --- /dev/null +++ b/payment_sequra/models/stock_picking.py @@ -0,0 +1,76 @@ +# Copyright 2025 Juan Carlos Oñate - Tecnativa +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +from odoo import models +from odoo.exceptions import UserError + + +class StockPicking(models.Model): + _inherit = "stock.picking" + + def _action_done(self): + res = super()._action_done() + self._sequra_notify_shipment() + return res + + def _sequra_notify_shipment(self): + # Notify SeQura when the full order is shipped. + for picking in self.filtered("sale_id"): + transactions = self.env["payment.transaction"].search( + [ + ("sale_order_ids", "in", picking.sale_id.ids), + ("provider_code", "=", "sequra"), + ("state", "in", ["done", "authorized"]), + ] + ) + for tx in transactions: + if not tx.provider_reference: + raise UserError( + self.env._( + "SeQura: Cannot send shipment notification for " + "transaction %s - missing provider_reference" + ) + % tx.reference + ) + payload = self._sequra_prepare_shipment_payload(tx) + endpoint = ( + f"/merchants/{tx.provider_id.sequra_merchant_id}" + f"/orders/{tx.reference}" + ) + response = tx.provider_id._sequra_make_request( + endpoint=endpoint, + payload=payload, + method="PUT", + ) + # SeQura accepts 200, 202, 204 as success codes + if response.status_code not in (200, 202, 204): + raise UserError( + self.env._( + "SeQura: Shipment notification failed for %s. " + "Status: %s, Response: %s" + ) + % (tx.reference, response.status_code, response.text) + ) + + def _sequra_prepare_shipment_payload(self, transaction): + """Prepare the payload for SeQura shipment notification. + + When the full order is shipped, we send: + - unshipped_cart: empty (order_total_with_tax = 0, items = []) + - shipped_cart: all items from the original cart + """ + payload = transaction._sequra_prepare_order_request_payload() + items = transaction._get_sequra_items() + currency = transaction.currency_id.name or "EUR" + total = int(transaction.amount * 100) + payload["order"].pop("cart", None) + payload["order"]["unshipped_cart"] = { + "currency": currency, + "order_total_with_tax": 0, + "items": [], + } + payload["order"]["shipped_cart"] = { + "currency": currency, + "order_total_with_tax": total, + "items": items, + } + return payload diff --git a/payment_sequra/pyproject.toml b/payment_sequra/pyproject.toml new file mode 100644 index 00000000000..4231d0cccb3 --- /dev/null +++ b/payment_sequra/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/payment_sequra/readme/CONTRIBUTORS.md b/payment_sequra/readme/CONTRIBUTORS.md new file mode 100644 index 00000000000..a4415b28c37 --- /dev/null +++ b/payment_sequra/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- [Tecnativa](https://www.tecnativa.com): + - Juan Carlos Oñate diff --git a/payment_sequra/readme/DESCRIPTION.md b/payment_sequra/readme/DESCRIPTION.md new file mode 100644 index 00000000000..82e901365d5 --- /dev/null +++ b/payment_sequra/readme/DESCRIPTION.md @@ -0,0 +1,5 @@ +This module integrates SeQura using a combination of the Checkout API and +Order Update API. It leverages the generic redirection-based payment flow +provided by the payment module to create and initialize SeQura transactions +using the same request payload that would normally be sent from SeQura’s +embedded checkout. diff --git a/payment_sequra/static/description/icon.png b/payment_sequra/static/description/icon.png new file mode 100644 index 00000000000..5097105ab48 Binary files /dev/null and b/payment_sequra/static/description/icon.png differ diff --git a/payment_sequra/static/description/index.html b/payment_sequra/static/description/index.html new file mode 100644 index 00000000000..1514c74812e --- /dev/null +++ b/payment_sequra/static/description/index.html @@ -0,0 +1,432 @@ + + + + + +SeQura Payment Provider + + + +
+

SeQura Payment Provider

+ + +

Beta License: AGPL-3 OCA/account-payment Translate me on Weblate Try me on Runboat

+

This module integrates SeQura using a combination of the Checkout API +and Order Update API. It leverages the generic redirection-based payment +flow provided by the payment module to create and initialize SeQura +transactions using the same request payload that would normally be sent +from SeQura’s embedded checkout.

+

Table of contents

+ +
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Tecnativa
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

Current maintainer:

+

juancarlosonate-tecnativa

+

This module is part of the OCA/account-payment project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/payment_sequra/static/description/pp3.png b/payment_sequra/static/description/pp3.png new file mode 100644 index 00000000000..b0517b5c9f0 Binary files /dev/null and b/payment_sequra/static/description/pp3.png differ diff --git a/payment_sequra/tests/__init__.py b/payment_sequra/tests/__init__.py new file mode 100644 index 00000000000..96b5f13e836 --- /dev/null +++ b/payment_sequra/tests/__init__.py @@ -0,0 +1,2 @@ +from . import common +from . import test_payment_transaction diff --git a/payment_sequra/tests/common.py b/payment_sequra/tests/common.py new file mode 100644 index 00000000000..06413cc3e96 --- /dev/null +++ b/payment_sequra/tests/common.py @@ -0,0 +1,114 @@ +# Copyright 2025 Juan Carlos Oñate - Tecnativa +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from unittest.mock import MagicMock + +from odoo.fields import Command + +from odoo.addons.payment.tests.common import PaymentCommon + + +class SequraCommon(PaymentCommon): + SEQURA_ORDER_ID = "SQ-TEST-ORDER-123" + SEQURA_SANDBOX_URL = "https://sandbox.sequrapi.com" + SEQURA_CART_ORDER_ID = "CART-XYZ-789" + SEQURA_INVOICE_ORDER_ID = "INV-2025-001-SEQURA" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.provider = cls._prepare_provider( + "sequra", + update_values={ + "sequra_merchant_id": "MERCHANT-TEST-001", + }, + ) + cls.reference = "SO123-SETEST" + cls.amount = 199.99 + cls.currency = cls.env.ref("base.EUR") + cls.product = cls.env["product.product"].create( + { + "name": "Test Product", + "type": "consu", + "list_price": 100.0, + } + ) + cls.redirect_notification_data = { + "order_ref_1": cls.reference, + "sq_state": "approved", + } + cls.webhook_notification_data = { + "order_ref_1": cls.reference, + "state": "confirmed", + } + cls.verification_data = { + "status_code": 200, + "text": "OK", + } + cls.verification_data_error = { + "status_code": 500, + "text": "Error", + } + + def _create_simple_sale_order(self, quantity=1, price_unit=75.0): + sale_order = self.env["sale.order"].create( + { + "partner_id": self.partner.id, + "order_line": [ + Command.create( + { + "product_id": self.product.id, + "product_uom_qty": quantity, + "price_unit": price_unit, + } + ) + ], + } + ) + sale_order.action_confirm() + return sale_order + + def _create_simple_invoice(self, lines_data=None): + if lines_data is None: + lines_data = [{"quantity": 1, "price_unit": 100.0}] + invoice_lines = [] + for line_data in lines_data: + invoice_lines.append( + Command.create( + { + "product_id": self.product.id, + "quantity": line_data["quantity"], + "price_unit": line_data["price_unit"], + } + ) + ) + invoice = self.env["account.move"].create( + { + "move_type": "out_invoice", + "partner_id": self.partner.id, + "invoice_line_ids": invoice_lines, + } + ) + invoice.action_post() + return invoice + + def _create_mock_sequra_order_response(self, order_id=None): + order_id = order_id or self.SEQURA_CART_ORDER_ID + mock_response = MagicMock() + mock_response.headers = { + "Location": f"{self.SEQURA_SANDBOX_URL}/orders/{order_id}" + } + return mock_response + + def _create_mock_sequra_update_response(self): + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.ok = True + mock_response.text = "OK" + return mock_response + + def _create_sequra_notification_data(self, order_ref, state="approved"): + return { + "order_ref": order_ref, + "sq_state": state, + } diff --git a/payment_sequra/tests/test_payment_transaction.py b/payment_sequra/tests/test_payment_transaction.py new file mode 100644 index 00000000000..c537c4ba9d7 --- /dev/null +++ b/payment_sequra/tests/test_payment_transaction.py @@ -0,0 +1,137 @@ +# Copyright 2025 Juan Carlos Oñate - Tecnativa +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from unittest.mock import patch +from urllib.parse import quote as url_quote + +from odoo.tests import tagged +from odoo.tools import mute_logger + +from odoo.addons.payment.tests.http_common import PaymentHttpCommon +from odoo.addons.payment_sequra.tests.common import SequraCommon + + +@tagged("post_install", "-at_install") +class TestPaymentTransaction(SequraCommon, PaymentHttpCommon): + def test_prepare_order_payload_contains_required_fields(self): + tx = self._create_transaction(flow="redirect") + request_payload = tx._sequra_prepare_order_request_payload() + order = request_payload["order"] + self.assertIn("merchant", order) + self.assertIn("cart", order) + self.assertIn("customer", order) + self.assertIn("delivery_address", order) + self.assertIn("invoice_address", order) + self.assertIn("merchant_reference", order) + self.assertEqual(order["merchant_reference"]["order_ref_1"], tx.reference) + + @mute_logger("odoo.addons.payment_sequra.models.payment_transaction") + def test_rendering_values_include_checkout_url(self): + tx = self._create_transaction(flow="redirect") + with patch( + "odoo.addons.payment_sequra.models.payment_provider.PaymentProvider._sequra_make_request", + return_value=self._create_mock_sequra_order_response("ORDER123"), + ): + rendering_values = tx._get_specific_rendering_values({}) + self.assertIn("api_url", rendering_values) + self.assertIn("/embedded_form", rendering_values["api_url"]) + + @mute_logger("odoo.addons.payment_sequra.models.payment_transaction") + def test_process_notification_sets_done(self): + tx = self._create_transaction(flow="redirect") + tx.provider_reference = "ORDER123" + with patch( + "odoo.addons.payment_sequra.models.payment_provider.PaymentProvider._sequra_make_request", + return_value=self._create_mock_sequra_update_response(), + ): + tx._process_notification_data(self.redirect_notification_data) + self.assertEqual(tx.state, "done") + + @mute_logger("odoo.addons.payment_sequra.models.payment_transaction") + def test_process_notification_sets_error_on_failure(self): + tx = self._create_transaction(flow="redirect") + tx.provider_reference = "ORDER123" + mock_error_response = self._create_mock_sequra_update_response() + mock_error_response.status_code = 500 + mock_error_response.ok = False + mock_error_response.text = "Server Error" + mock_error_response.reason = "Internal Server Error" + with patch( + "odoo.addons.payment_sequra.models.payment_provider.PaymentProvider._sequra_make_request", + return_value=mock_error_response, + ): + tx._process_notification_data(self.redirect_notification_data) + self.assertEqual(tx.state, "error") + + def test_url_encoding_in_redirect_payload(self): + tx = self._create_transaction(flow="redirect") + payload = tx._sequra_prepare_order_request_payload() + encoded_ref = url_quote(tx.reference) + self.assertIn( + tx.reference, payload["order"]["merchant_reference"]["order_ref_1"] + ) + self.assertNotIn(" ", encoded_ref) + + @mute_logger("odoo.addons.payment_sequra.models.payment_transaction") + def test_complete_shopping_cart_flow(self): + sale_order = self._create_simple_sale_order(quantity=3, price_unit=75.0) + tx = self._create_transaction(flow="redirect") + tx.sale_order_ids = sale_order + with patch( + "odoo.addons.payment_sequra.models.payment_provider.PaymentProvider._sequra_make_request", + return_value=self._create_mock_sequra_order_response( + self.SEQURA_CART_ORDER_ID + ), + ): + rendering_values = tx._get_specific_rendering_values({}) + self.assertIn("api_url", rendering_values) + self.assertIn("/embedded_form", rendering_values["api_url"]) + self.assertEqual(tx.provider_reference, self.SEQURA_CART_ORDER_ID) + notification_data = self._create_sequra_notification_data( + self.SEQURA_CART_ORDER_ID, state="approved" + ) + with patch( + "odoo.addons.payment_sequra.models.payment_provider.PaymentProvider._sequra_make_request", + return_value=self._create_mock_sequra_update_response(), + ): + tx._process_notification_data(notification_data) + self.assertEqual(tx.state, "done") + + @mute_logger("odoo.addons.payment_sequra.models.payment_transaction") + def test_complete_invoice_payment_flow(self): + invoice = self._create_simple_invoice( + lines_data=[ + {"quantity": 2, "price_unit": 150.0}, + {"quantity": 1, "price_unit": 100.0}, + ] + ) + tx = self._create_transaction(flow="redirect") + tx.invoice_ids = invoice + with patch( + "odoo.addons.payment_sequra.models.payment_provider.PaymentProvider._sequra_make_request", + return_value=self._create_mock_sequra_order_response( + self.SEQURA_INVOICE_ORDER_ID + ), + ): + rendering_values = tx._get_specific_rendering_values({}) + self.assertIn("api_url", rendering_values) + self.assertTrue(rendering_values["api_url"].endswith("/embedded_form")) + self.assertEqual(tx.provider_reference, self.SEQURA_INVOICE_ORDER_ID) + notification_data = self._create_sequra_notification_data( + self.SEQURA_INVOICE_ORDER_ID, state="pending" + ) + with patch( + "odoo.addons.payment_sequra.models.payment_provider.PaymentProvider._sequra_make_request", + return_value=self._create_mock_sequra_update_response(), + ): + tx._process_notification_data(notification_data) + self.assertEqual(tx.state, "pending") + notification_data_approved = self._create_sequra_notification_data( + self.SEQURA_INVOICE_ORDER_ID, state="approved" + ) + with patch( + "odoo.addons.payment_sequra.models.payment_provider.PaymentProvider._sequra_make_request", + return_value=self._create_mock_sequra_update_response(), + ): + tx._process_notification_data(notification_data_approved) + self.assertEqual(tx.state, "done") diff --git a/payment_sequra/views/payment_provider_views.xml b/payment_sequra/views/payment_provider_views.xml new file mode 100644 index 00000000000..7b456b7cbb1 --- /dev/null +++ b/payment_sequra/views/payment_provider_views.xml @@ -0,0 +1,29 @@ + + + + Sequra Provider Form + payment.provider + + + + + + + + + + + + diff --git a/payment_sequra/views/payment_sequra_templates.xml b/payment_sequra/views/payment_sequra_templates.xml new file mode 100644 index 00000000000..ce0d0d01e32 --- /dev/null +++ b/payment_sequra/views/payment_sequra_templates.xml @@ -0,0 +1,8 @@ + + + +