diff --git a/.copier-answers.yml b/.copier-answers.yml index d239f59d7c5..adc59b2a0cf 100644 --- a/.copier-answers.yml +++ b/.copier-answers.yml @@ -1,5 +1,5 @@ # Do NOT update manually; changes here will be overwritten by Copier -_commit: v1.29 +_commit: v1.35 _src_path: git+https://github.com/OCA/oca-addons-repo-template additional_ruff_rules: [] ci: GitHub diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000000..e0d56685a95 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +test-requirements.txt merge=union diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a06488079fe..529462e9bc0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -42,7 +42,7 @@ jobs: makepot: "true" services: postgres: - image: postgres:12.0 + image: postgres:12 env: POSTGRES_USER: odoo POSTGRES_PASSWORD: odoo diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e46391bdeac..8ed3ce3d85d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -39,11 +39,11 @@ repos: language: fail files: '[a-zA-Z0-9_]*/i18n/en\.po$' - repo: https://github.com/sbidoul/whool - rev: v1.2 + rev: v1.3 hooks: - id: whool-init - repo: https://github.com/oca/maintainer-tools - rev: bf9ecb9938b6a5deca0ff3d870fbd3f33341fded + rev: b89f767503be6ab2b11e4f50a7557cb20066e667 hooks: # update the NOT INSTALLABLE ADDONS section above - id: oca-update-pre-commit-excluded-addons @@ -95,6 +95,7 @@ repos: additional_dependencies: - "eslint@9.12.0" - "eslint-plugin-jsdoc@50.3.1" + - "globals@16.0.0" - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.6.0 hooks: diff --git a/.pylintrc b/.pylintrc index 7c62b6d2edd..d103ffcd9a5 100644 --- a/.pylintrc +++ b/.pylintrc @@ -25,19 +25,25 @@ disable=all enable=anomalous-backslash-in-string, api-one-deprecated, api-one-multi-together, - assignment-from-none, - attribute-deprecated, class-camelcase, - dangerous-default-value, dangerous-view-replace-wo-priority, - development-status-allowed, duplicate-id-csv, - duplicate-key, duplicate-xml-fields, duplicate-xml-record-id, eval-referenced, - eval-used, incoherent-interpreter-exec-perm, + openerp-exception-warning, + redundant-modulename-xml, + relative-import, + rst-syntax-error, + wrong-tabs-instead-of-spaces, + xml-syntax-error, + assignment-from-none, + attribute-deprecated, + dangerous-default-value, + development-status-allowed, + duplicate-key, + eval-used, license-allowed, manifest-author-string, manifest-deprecated-key, @@ -48,56 +54,50 @@ enable=anomalous-backslash-in-string, method-inverse, method-required-super, method-search, - openerp-exception-warning, pointless-statement, pointless-string-statement, print-used, redundant-keyword-arg, - redundant-modulename-xml, reimported, - relative-import, return-in-init, - rst-syntax-error, sql-injection, too-few-format-args, translation-field, translation-required, unreachable, use-vim-comment, - wrong-tabs-instead-of-spaces, - xml-syntax-error, - attribute-string-redundant, character-not-valid-in-resource-link, - consider-merging-classes-inherited, - context-overridden, create-user-wo-reset-password, dangerous-filter-wo-user, dangerous-qweb-replace-wo-priority, deprecated-data-xml-node, deprecated-openerp-xml-node, duplicate-po-message-definition, - except-pass, file-not-used, + missing-newline-extrafiles, + old-api7-method-defined, + po-msgstr-variables, + po-syntax-error, + str-format-used, + unnecessary-utf8-coding-comment, + xml-attribute-translatable, + xml-deprecated-qweb-directive, + xml-deprecated-tree-attribute, + attribute-string-redundant, + consider-merging-classes-inherited, + context-overridden, + except-pass, invalid-commit, manifest-maintainers-list, - missing-newline-extrafiles, missing-readme, missing-return, odoo-addons-relative-import, - old-api7-method-defined, - po-msgstr-variables, - po-syntax-error, renamed-field-parameter, resource-not-exist, - str-format-used, test-folder-imported, translation-contains-variable, translation-positional-used, - unnecessary-utf8-coding-comment, website-manifest-key-not-valid-uri, - xml-attribute-translatable, - xml-deprecated-qweb-directive, - xml-deprecated-tree-attribute, external-request-timeout, # messages that do not cause the lint step to fail consider-merging-classes-inherited, @@ -114,7 +114,8 @@ enable=anomalous-backslash-in-string, old-api7-method-defined, redefined-builtin, too-complex, - unnecessary-utf8-coding-comment + unnecessary-utf8-coding-comment, + manifest-external-assets [REPORTS] diff --git a/.pylintrc-mandatory b/.pylintrc-mandatory index 018fd61cdd7..73674c04d42 100644 --- a/.pylintrc-mandatory +++ b/.pylintrc-mandatory @@ -17,19 +17,25 @@ disable=all enable=anomalous-backslash-in-string, api-one-deprecated, api-one-multi-together, - assignment-from-none, - attribute-deprecated, class-camelcase, - dangerous-default-value, dangerous-view-replace-wo-priority, - development-status-allowed, duplicate-id-csv, - duplicate-key, duplicate-xml-fields, duplicate-xml-record-id, eval-referenced, - eval-used, incoherent-interpreter-exec-perm, + openerp-exception-warning, + redundant-modulename-xml, + relative-import, + rst-syntax-error, + wrong-tabs-instead-of-spaces, + xml-syntax-error, + assignment-from-none, + attribute-deprecated, + dangerous-default-value, + development-status-allowed, + duplicate-key, + eval-used, license-allowed, manifest-author-string, manifest-deprecated-key, @@ -40,56 +46,50 @@ enable=anomalous-backslash-in-string, method-inverse, method-required-super, method-search, - openerp-exception-warning, pointless-statement, pointless-string-statement, print-used, redundant-keyword-arg, - redundant-modulename-xml, reimported, - relative-import, return-in-init, - rst-syntax-error, sql-injection, too-few-format-args, translation-field, translation-required, unreachable, use-vim-comment, - wrong-tabs-instead-of-spaces, - xml-syntax-error, - attribute-string-redundant, character-not-valid-in-resource-link, - consider-merging-classes-inherited, - context-overridden, create-user-wo-reset-password, dangerous-filter-wo-user, dangerous-qweb-replace-wo-priority, deprecated-data-xml-node, deprecated-openerp-xml-node, duplicate-po-message-definition, - except-pass, file-not-used, + missing-newline-extrafiles, + old-api7-method-defined, + po-msgstr-variables, + po-syntax-error, + str-format-used, + unnecessary-utf8-coding-comment, + xml-attribute-translatable, + xml-deprecated-qweb-directive, + xml-deprecated-tree-attribute, + attribute-string-redundant, + consider-merging-classes-inherited, + context-overridden, + except-pass, invalid-commit, manifest-maintainers-list, - missing-newline-extrafiles, missing-readme, missing-return, odoo-addons-relative-import, - old-api7-method-defined, - po-msgstr-variables, - po-syntax-error, renamed-field-parameter, resource-not-exist, - str-format-used, test-folder-imported, translation-contains-variable, translation-positional-used, - unnecessary-utf8-coding-comment, website-manifest-key-not-valid-uri, - xml-attribute-translatable, - xml-deprecated-qweb-directive, - xml-deprecated-tree-attribute, external-request-timeout [REPORTS] diff --git a/eslint.config.cjs b/eslint.config.cjs index 0d5731f89a8..dd0cbe0aef0 100644 --- a/eslint.config.cjs +++ b/eslint.config.cjs @@ -1,3 +1,4 @@ +var globals = require('globals'); jsdoc = require("eslint-plugin-jsdoc"); const config = [{ @@ -16,6 +17,8 @@ const config = [{ openerp: "readonly", owl: "readonly", luxon: "readonly", + QUnit: "readonly", + ...globals.browser, }, ecmaVersion: 2024, @@ -191,7 +194,7 @@ const config = [{ }, }, { - files: ["**/*.esm.js"], + files: ["**/*.esm.js", "**/*test.js"], languageOptions: { ecmaVersion: 2024, diff --git a/payment_easypay/README.rst b/payment_easypay/README.rst new file mode 100644 index 00000000000..34fcde53f3f --- /dev/null +++ b/payment_easypay/README.rst @@ -0,0 +1,182 @@ +========================= +Payment Provider: EasyPay +========================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:18683b94ae7c9a06946da28d38adbe620211c40ec8588e685b2ed0de2d2e2518 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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-LGPL--3-blue.png + :target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html + :alt: License: LGPL-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_easypay + :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_easypay + :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 EasyPay as a payment provider in Odoo, allowing +customers to pay via credit card and other payment methods using +EasyPay's secure payment gateway. + +EasyPay is a Portuguese payment service provider that supports multiple +payment methods including credit cards, Multibanco, MB WAY, SEPA Direct +Debit, and more. + +Learn more about EasyPay at https://www.easypay.pt/ + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +To configure this module, you need to: + +1. Go to **Accounting > Configuration > Payment Providers** or **Website + > Configuration > Payment Providers** +2. Search for **EasyPay** and open the provider form +3. Fill in the required credentials: + + - **Account ID**: Your EasyPay Account ID (obtain from EasyPay + dashboard) + - **API Key**: Your EasyPay API Key (obtain from EasyPay dashboard) + - **Payment Method**: Select the payment method you want to use + (Credit Card, Multibanco, MB WAY, etc.) + - **Use Checkout**: Enable this to use EasyPay's integrated checkout + experience + +4. Configure the provider state: + + - Set to **Test Mode** to use the test environment + (https://api.test.easypay.pt) + - Set to **Enabled** to use the production environment + (https://api.prod.easypay.pt) + +5. Save the configuration + +For testing purposes, you can use the following credentials: + +- **Account ID**: 2b0f63e2-9fb5-4e52-aca0-b4bf0339bbe6 +- **API Key**: eae4aa59-8e5b-4ec2-887d-b02768481a92 + +**Note**: These test credentials only work in Test Mode. + +Webhook Configuration +--------------------- + +To receive automatic payment status updates, configure the following +webhook URL in your EasyPay dashboard: + +- **Webhook URL**: https://yourdomain.com/payment/easypay/webhook + +This ensures that payment status changes are immediately reflected in +Odoo. + +Production Setup +---------------- + +Get Production Credentials +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +1. Sign up at https://www.easypay.pt/ +2. Complete merchant verification +3. Get your production credentials from dashboard + +Configure for Production +~~~~~~~~~~~~~~~~~~~~~~~~ + +1. Open EasyPay provider in Odoo +2. Update credentials with production values +3. Change **State** to **Enabled** +4. Configure webhook in EasyPay dashboard: + +  + +:: + + URL: https://yourdomain.com/payment/easypay/webhook + Events: Generic, Transaction, Authorisation + +5. Test with real card (small amount) +6. Publish the payment provider + +Usage +===== + +Once configured, customers can use EasyPay to make payments: + +1. During checkout, select **EasyPay** as the payment method +2. Click **Pay Now** +3. Depending on the configuration: + + - **With Checkout**: An integrated payment form will appear where + customers can enter their payment details + - **Without Checkout**: Customers will be redirected to EasyPay's + payment page + +4. Enter the card details (for testing): + + - **Card**: 4111111111111111 + - **CVV**: 123 + - **Expiry**: 12/25 + +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 +------- + +* Open Source Integrators + +Contributors +------------ + +- Open Source Integrators + +This work was developed with the aid of AI tools under human guidance +and supervision, specifically Cascade (IDE coding assistant) and +Anthropic Claude. All AI-assisted changes were reviewed and approved by +human maintainers. + +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. + +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_easypay/__init__.py b/payment_easypay/__init__.py new file mode 100644 index 00000000000..0ea0170d8eb --- /dev/null +++ b/payment_easypay/__init__.py @@ -0,0 +1,17 @@ +# Copyright 2025 Open Source Integrators +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). + +from . import controllers +from . import models + +from odoo.addons.payment import reset_payment_provider, setup_provider + + +def post_init_hook(env): + """Set up the EasyPay provider after module installation.""" + setup_provider(env, "easypay") + + +def uninstall_hook(env): + """Clean up the EasyPay provider before module uninstallation.""" + reset_payment_provider(env, "easypay") diff --git a/payment_easypay/__manifest__.py b/payment_easypay/__manifest__.py new file mode 100644 index 00000000000..e371464f106 --- /dev/null +++ b/payment_easypay/__manifest__.py @@ -0,0 +1,31 @@ +# Copyright 2025 Open Source Integrators +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). + +{ + "name": "Payment Provider: EasyPay", + "version": "18.0.1.0.0", + "category": "Accounting/Payment Providers", + "summary": "Payment Provider for EasyPay credit card processor", + "author": "Open Source Integrators, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/account-payment", + "license": "LGPL-3", + "depends": ["payment"], + "data": [ + "views/payment_easypay_templates.xml", + "views/payment_provider_views.xml", + "data/payment_provider_data.xml", + "data/payment_method_data.xml", + ], + "demo": [ + "demo/payment_provider_demo.xml", + ], + "assets": { + "web.assets_frontend": [ + "payment_easypay/static/src/js/payment_form.esm.js", + "payment_easypay/static/src/scss/payment_easypay.scss", + ], + }, + "installable": True, + "post_init_hook": "post_init_hook", + "uninstall_hook": "uninstall_hook", +} diff --git a/payment_easypay/const.py b/payment_easypay/const.py new file mode 100644 index 00000000000..124f3f519ae --- /dev/null +++ b/payment_easypay/const.py @@ -0,0 +1,59 @@ +# Copyright 2025 Open Source Integrators +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). + +# EasyPay API version +API_VERSION = "2.0" + +# EasyPay API endpoints +API_URL_TEST = "https://api.test.easypay.pt" +API_URL_PROD = "https://api.prod.easypay.pt" + +# The codes of the payment methods to activate when EasyPay is activated. +DEFAULT_PAYMENT_METHOD_CODES = { + # Primary payment methods. + "card", + # Brand payment methods. + "visa", + "mastercard", +} + +# Supported payment methods mapping +PAYMENT_METHODS_MAPPING = { + "cc": "Credit/Debit Card", + # The following payment methods are supported by EasyPay API but not fully + # implemented/tested in this module. Uncomment and test before using in production. + # "mb": "Multibanco", + # "mbw": "MB WAY", + # "dd": "SEPA Direct Debit", + # "vi": "Virtual IBAN", + # "ap": "Apple Pay", + # "gp": "Google Pay", + # "sw": "Samsung Pay", +} + +# Payment types +PAYMENT_TYPE_SALE = "sale" +PAYMENT_TYPE_AUTHORISATION = "authorisation" + +# Mapping of transaction states to EasyPay payment statuses. +# See EasyPay API documentation for exhaustive status list. +STATUS_MAPPING = { + "draft": (), + "pending": ("pending",), + "authorized": ("authorized", "authorised"), + "done": ("success", "captured", "paid"), + "cancel": ("cancelled",), + "error": ("failed",), +} + +# Events which are handled by the webhook +HANDLED_WEBHOOK_EVENTS = [ + "generic", + "authorisation", + "transaction", +] + +# Supported countries (Portugal and territories where EasyPay operates) +SUPPORTED_COUNTRIES = { + "PT", # Portugal +} diff --git a/payment_easypay/controllers/__init__.py b/payment_easypay/controllers/__init__.py new file mode 100644 index 00000000000..39c936def03 --- /dev/null +++ b/payment_easypay/controllers/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2025 Open Source Integrators +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). + +from . import main diff --git a/payment_easypay/controllers/main.py b/payment_easypay/controllers/main.py new file mode 100644 index 00000000000..772d0863d01 --- /dev/null +++ b/payment_easypay/controllers/main.py @@ -0,0 +1,290 @@ +# Copyright 2025 Open Source Integrators +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). + +import json +import logging +import pprint + +import werkzeug + +from odoo import http +from odoo.http import request + +_logger = logging.getLogger(__name__) + + +class EasyPayController(http.Controller): + """Controller to handle EasyPay webhooks and redirects.""" + + _return_url = "/payment/easypay/return" + _webhook_url = "/payment/easypay/webhook" + + @http.route( + _return_url, + type="http", + auth="public", + methods=["GET", "POST"], + csrf=False, + save_session=False, + ) + def easypay_return_from_redirect(self, **data): + """Process the return from EasyPay after payment. + + :param dict data: The GET/POST data from EasyPay + :return: Redirect to payment status page + """ + # If POST with JSON body, parse it + if ( + request.httprequest.method == "POST" + and request.httprequest.content_type == "application/json" + ): + try: + data = json.loads(request.httprequest.data.decode("utf-8")) + except Exception as e: + _logger.error("Error parsing JSON body: %s", e) + _logger.info("EasyPay return endpoint called") + _logger.debug("Request method: %s", request.httprequest.method) + _logger.debug("Request URL: %s", request.httprequest.url) + _logger.debug( + "Request headers:\n%s", pprint.pformat(dict(request.httprequest.headers)) + ) + _logger.debug("Return data:\n%s", pprint.pformat(data)) + + # Extract transaction reference and payment ID + reference = data.get("key") or data.get("reference") + payment_id = data.get("id") + notification_type = data.get("type") + + # If no data provided in POST body, check URL query params + if not reference and not payment_id: + reference = request.httprequest.args.get( + "key" + ) or request.httprequest.args.get("reference") + payment_id = request.httprequest.args.get("id") + _logger.debug( + "Checking URL params - reference: %s, payment_id: %s", + reference, + payment_id, + ) + + # If this is a Generic notification (has 'type' field), + # fetch full payment details + if payment_id and notification_type: + _logger.info( + "Generic notification received for %s, fetching full payment details", + reference, + ) + tx_sudo = ( + request.env["payment.transaction"] + .sudo() + .search([("reference", "=", reference)], limit=1) + ) + if tx_sudo: + try: + payment_data = tx_sudo.provider_id._easypay_make_request( + f"/2.0/single/{payment_id}", method="GET" + ) + _logger.debug( + "Payment data from API:\n%s", pprint.pformat(payment_data) + ) + tx_sudo._handle_notification_data("easypay", payment_data) + _logger.info("Transaction %s updated from API data", reference) + except Exception as e: + _logger.exception("EasyPay: Error fetching payment status: %s", e) + else: + _logger.warning("No transaction found for reference: %s", reference) + elif reference: + # Direct payment data (not a Generic notification) + tx_sudo = ( + request.env["payment.transaction"] + .sudo() + .search([("reference", "=", reference)], limit=1) + ) + if tx_sudo: + tx_sudo._handle_notification_data("easypay", data) + _logger.info("Transaction %s updated from notification data", reference) + + return werkzeug.utils.redirect("/payment/status", code=303) + + @http.route( + _webhook_url, + type="json", + auth="public", + methods=["POST"], + csrf=False, + save_session=False, + ) + def easypay_webhook(self, **data): + """Process webhook notifications from EasyPay. + + EasyPay sends Generic notifications with: id, key, type, status, messages, date + We fetch the full payment details from the API and process them. + + :param dict data: The webhook data from EasyPay + :return: Empty response + """ + _logger.info("EasyPay webhook called") + _logger.debug("Webhook data:\n%s", pprint.pformat(data)) + + try: + # Generic notification format: {id, key, type, status, messages, date} + payment_id = data.get("id") + reference = data.get("key") + notification_type = data.get("type") + notification_status = data.get("status") + + _logger.debug( + "Webhook - ID: %s, Key: %s, Type: %s, Status: %s", + payment_id, + reference, + notification_type, + notification_status, + ) + + if payment_id or reference: + # Find the transaction + tx_sudo = request.env["payment.transaction"].sudo() + if reference: + tx_sudo = tx_sudo.search([("reference", "=", reference)], limit=1) + elif payment_id: + tx_sudo = tx_sudo.search( + [ + "|", + ("easypay_payment_id", "=", payment_id), + ("easypay_checkout_id", "=", payment_id), + ], + limit=1, + ) + + if tx_sudo: + _logger.info( + "Webhook for %s, fetching full payment details", + tx_sudo.reference, + ) + try: + payment_data = tx_sudo.provider_id._easypay_make_request( + f"/2.0/single/{payment_id}", method="GET" + ) + _logger.debug( + "Payment data from API:\n%s", pprint.pformat(payment_data) + ) + tx_sudo._handle_notification_data("easypay", payment_data) + _logger.info( + "Transaction %s updated from webhook", tx_sudo.reference + ) + except Exception as e: + _logger.exception("Error fetching payment details: %s", e) + else: + _logger.warning( + "Webhook for unknown transaction: %s", reference or payment_id + ) + else: + _logger.warning("Received webhook without reference or ID") + + except Exception as e: + _logger.exception("Error processing webhook: %s", e) + + return {} + + @http.route( + "/payment/easypay/checkout/success", + type="http", + auth="public", + methods=["GET"], + csrf=False, + save_session=False, + ) + def easypay_checkout_success(self, **data): + """Handle successful checkout completion. + + :param dict data: The GET data from checkout + :return: Redirect to payment status page + """ + _logger.info( + "Checkout success from EasyPay with data:\n%s", pprint.pformat(data) + ) + + # Extract checkout ID or reference to find the transaction + checkout_id = data.get("id") + reference = data.get("key") or data.get("reference") + + if checkout_id or reference: + # Find the transaction + tx_sudo = request.env["payment.transaction"].sudo() + if reference: + tx_sudo = tx_sudo.search( + [ + "&", + ("reference", "=", reference), + ("provider_code", "=", "easypay"), + ], + limit=1, + ) + elif checkout_id: + tx_sudo = tx_sudo.search( + [ + "&", + ("easypay_checkout_id", "=", checkout_id), + ("provider_code", "=", "easypay"), + ], + limit=1, + ) + + if tx_sudo: + # Fetch payment details from EasyPay API + try: + endpoint = f"/2.0/checkout/{tx_sudo.easypay_checkout_id}" + payment_data = tx_sudo.provider_id._easypay_make_request( + endpoint, method="GET" + ) + _logger.info( + "Fetched checkout details for %s:\n%s", + tx_sudo.reference, + pprint.pformat(payment_data), + ) + # Process the payment data + tx_sudo._handle_notification_data("easypay", payment_data) + except Exception as e: + _logger.exception( + "Error fetching checkout details for %s: %s", + tx_sudo.reference, + e, + ) + else: + _logger.warning( + "Checkout success but transaction not found: %s", + reference or checkout_id, + ) + + return request.redirect("/payment/status") + + @http.route( + "/payment/easypay/checkout/cancel", + type="http", + auth="public", + methods=["GET"], + csrf=False, + save_session=False, + ) + def easypay_checkout_cancel(self, **data): + """Handle checkout cancellation. + + :param dict data: The GET data from checkout + :return: Redirect to payment status page + """ + _logger.info( + "Checkout cancelled from EasyPay with data:\n%s", pprint.pformat(data) + ) + + # Try to find and cancel the transaction + reference = data.get("key") or data.get("reference") + if reference: + tx_sudo = ( + request.env["payment.transaction"] + .sudo() + .search([("reference", "=", reference)]) + ) + if tx_sudo: + tx_sudo._set_canceled(state_message="Payment cancelled by customer") + + return request.redirect("/payment/status") diff --git a/payment_easypay/data/neutralize.sql b/payment_easypay/data/neutralize.sql new file mode 100644 index 00000000000..09acced6da7 --- /dev/null +++ b/payment_easypay/data/neutralize.sql @@ -0,0 +1,6 @@ +-- disable easypay payment provider +UPDATE payment_provider + SET state = 'test', + easypay_account_id = '2b0f63e2-9fb5-4e52-aca0-b4bf0339bbe6', + easypay_api_key = 'eae4aa59-8e5b-4ec2-887d-b02768481a92' + WHERE code = 'easypay' and state != 'test'; diff --git a/payment_easypay/data/payment_method_data.xml b/payment_easypay/data/payment_method_data.xml new file mode 100644 index 00000000000..1d94880e900 --- /dev/null +++ b/payment_easypay/data/payment_method_data.xml @@ -0,0 +1,10 @@ + + + + + + + + diff --git a/payment_easypay/data/payment_provider_data.xml b/payment_easypay/data/payment_provider_data.xml new file mode 100644 index 00000000000..8bee3f2ba86 --- /dev/null +++ b/payment_easypay/data/payment_provider_data.xml @@ -0,0 +1,9 @@ + + + + EasyPay - Credit/Debit Cards + easypay + + + + diff --git a/payment_easypay/demo/payment_provider_demo.xml b/payment_easypay/demo/payment_provider_demo.xml new file mode 100644 index 00000000000..a3f27bede7a --- /dev/null +++ b/payment_easypay/demo/payment_provider_demo.xml @@ -0,0 +1,10 @@ + + + + test + 2b0f63e2-9fb5-4e52-aca0-b4bf0339bbe6 + eae4aa59-8e5b-4ec2-887d-b02768481a92 + cc + True + + diff --git a/payment_easypay/i18n/payment_easypay.pot b/payment_easypay/i18n/payment_easypay.pot new file mode 100644 index 00000000000..e9f4a60c2e9 --- /dev/null +++ b/payment_easypay/i18n/payment_easypay.pot @@ -0,0 +1,85 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * payment_easypay +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-10-11 15:21+0000\n" +"PO-Revision-Date: 2025-10-11 15:21+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: payment_easypay +#: model_terms:ir.ui.view,arch_db:payment_easypay.easypay_form_redirect +msgid "" +"\n" +"
\n" +" Redirecting to EasyPay..." +msgstr "" + +#. module: payment_easypay +#. odoo-python +#: code:addons/payment_easypay/models/payment_transaction.py:0 +msgid "Cannot capture: No EasyPay payment ID found" +msgstr "" + +#. module: payment_easypay +#. odoo-python +#: code:addons/payment_easypay/models/payment_transaction.py:0 +msgid "Cannot refund: No EasyPay transaction ID found" +msgstr "" + +#. module: payment_easypay +#. odoo-python +#: code:addons/payment_easypay/models/payment_transaction.py:0 +msgid "Cannot void: No EasyPay payment ID found" +msgstr "" + +#. module: payment_easypay +#. odoo-python +#: code:addons/payment_easypay/models/payment_provider.py:0 +msgid "Could not establish the connection to the API." +msgstr "" + +#. module: payment_easypay +#. odoo-python +#: code:addons/payment_easypay/models/payment_transaction.py:0 +msgid "No EasyPay payment ID found for this transaction" +msgstr "" + +#. module: payment_easypay +#. odoo-python +#: code:addons/payment_easypay/models/payment_transaction.py:0 +msgid "No transaction found matching reference %s." +msgstr "" + +#. module: payment_easypay +#: model:ir.model,name:payment_easypay.model_payment_provider +msgid "Payment Provider" +msgstr "" + +#. module: payment_easypay +#: model:ir.model,name:payment_easypay.model_payment_transaction +msgid "Payment Transaction" +msgstr "" + +#. module: payment_easypay +#. odoo-python +#: code:addons/payment_easypay/models/payment_provider.py:0 +msgid "" +"The communication with the API failed.\n" +"EasyPay gave us the following info about the problem:\n" +"'%s'" +msgstr "" + +#. module: payment_easypay +#. odoo-python +#: code:addons/payment_easypay/models/payment_provider.py:0 +msgid "Unsupported HTTP method: %s" +msgstr "" diff --git a/payment_easypay/i18n/pt.po b/payment_easypay/i18n/pt.po new file mode 100644 index 00000000000..d4827d4bc86 --- /dev/null +++ b/payment_easypay/i18n/pt.po @@ -0,0 +1,92 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * payment_easypay +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-10-11 15:21+0000\n" +"PO-Revision-Date: 2025-10-11 15:21+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: pt\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: payment_easypay +#: model_terms:ir.ui.view,arch_db:payment_easypay.easypay_form_redirect +msgid "" +"\n" +"
\n" +" Redirecting to EasyPay..." +msgstr "" +"\n" +"
\n" +" A redirecionar para EasyPay..." + +#. module: payment_easypay +#. odoo-python +#: code:addons/payment_easypay/models/payment_transaction.py:0 +msgid "Cannot capture: No EasyPay payment ID found" +msgstr "Não é possível capturar: Nenhum ID de pagamento EasyPay encontrado" + +#. module: payment_easypay +#. odoo-python +#: code:addons/payment_easypay/models/payment_transaction.py:0 +msgid "Cannot refund: No EasyPay transaction ID found" +msgstr "Não é possível reembolsar: Nenhum ID de transação EasyPay encontrado" + +#. module: payment_easypay +#. odoo-python +#: code:addons/payment_easypay/models/payment_transaction.py:0 +msgid "Cannot void: No EasyPay payment ID found" +msgstr "Não é possível anular: Nenhum ID de pagamento EasyPay encontrado" + +#. module: payment_easypay +#. odoo-python +#: code:addons/payment_easypay/models/payment_provider.py:0 +msgid "Could not establish the connection to the API." +msgstr "Não foi possível estabelecer a ligação à API." + +#. module: payment_easypay +#. odoo-python +#: code:addons/payment_easypay/models/payment_transaction.py:0 +msgid "No EasyPay payment ID found for this transaction" +msgstr "Nenhum ID de pagamento EasyPay encontrado para esta transação" + +#. module: payment_easypay +#. odoo-python +#: code:addons/payment_easypay/models/payment_transaction.py:0 +msgid "No transaction found matching reference %s." +msgstr "Nenhuma transação encontrada com a referência %s." + +#. module: payment_easypay +#: model:ir.model,name:payment_easypay.model_payment_provider +msgid "Payment Provider" +msgstr "Fornecedor de Pagamento" + +#. module: payment_easypay +#: model:ir.model,name:payment_easypay.model_payment_transaction +msgid "Payment Transaction" +msgstr "Transação de Pagamento" + +#. module: payment_easypay +#. odoo-python +#: code:addons/payment_easypay/models/payment_provider.py:0 +msgid "" +"The communication with the API failed.\n" +"EasyPay gave us the following info about the problem:\n" +"'%s'" +msgstr "" +"A comunicação com a API falhou.\n" +"O EasyPay forneceu a seguinte informação sobre o problema:\n" +"'%s'" + +#. module: payment_easypay +#. odoo-python +#: code:addons/payment_easypay/models/payment_provider.py:0 +msgid "Unsupported HTTP method: %s" +msgstr "Método HTTP não suportado: %s" diff --git a/payment_easypay/models/__init__.py b/payment_easypay/models/__init__.py new file mode 100644 index 00000000000..b367e7a9ffe --- /dev/null +++ b/payment_easypay/models/__init__.py @@ -0,0 +1,5 @@ +# Copyright 2025 Open Source Integrators +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). + +from . import payment_provider +from . import payment_transaction diff --git a/payment_easypay/models/payment_provider.py b/payment_easypay/models/payment_provider.py new file mode 100644 index 00000000000..f3bdf2e6a91 --- /dev/null +++ b/payment_easypay/models/payment_provider.py @@ -0,0 +1,248 @@ +# Copyright 2025 Open Source Integrators +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). + +import logging +import pprint + +import requests + +from odoo import _, fields, models +from odoo.exceptions import ValidationError + +from .. import const +from .. import utils as easypay_utils + +_logger = logging.getLogger(__name__) + + +class PaymentProvider(models.Model): + _inherit = "payment.provider" + + code = fields.Selection( + selection_add=[("easypay", "EasyPay")], ondelete={"easypay": "set default"} + ) + easypay_account_id = fields.Char( + string="Account ID", + help="The Account ID provided by EasyPay", + ) + easypay_api_key = fields.Char( + string="API Key", + help="The API Key provided by EasyPay", + groups="base.group_system", + ) + easypay_payment_method = fields.Selection( + selection=[("cc", "Credit/Debit Card")], + string="Payment Method", + default="cc", + required_if_provider="easypay", + help="The payment method to use with EasyPay", + ) + easypay_use_checkout = fields.Boolean( + string="Use Checkout", + default=False, + help="Use EasyPay Checkout for a better user experience", + ) + + # === COMPUTE METHODS ===# + + def _compute_feature_support_fields(self): + """Override of `payment` to enable additional features.""" + res = super()._compute_feature_support_fields() + self.filtered(lambda p: p.code == "easypay").update( + { + "support_manual_capture": "full_only", + "support_refund": "partial", + } + ) + return res + + def _easypay_get_inline_form_values(self, amount, currency, partner_id, tx_sudo): + """Return the inline form values for EasyPay Checkout. + + Note: self.ensure_one() + + :param float amount: The transaction amount + :param res.currency currency: The transaction currency + :param res.partner partner_id: The transaction partner + :param payment.transaction tx_sudo: The sudoed transaction + :return: The inline form values + :rtype: str (JSON) + """ + self.ensure_one() + + if self.easypay_use_checkout: + response = self._easypay_create_checkout_session(tx_sudo) + tx_sudo.easypay_checkout_id = response.get("id") + + import json + + return json.dumps( + { + "checkout_manifest": response.get("session"), + "checkout_id": response.get("id"), + "api_url": self._easypay_get_api_url(), + } + ) + + return json.dumps({}) + + # === BUSINESS METHODS - GETTERS ===# + + def _get_default_payment_method_codes(self): + """Override of `payment` to return the default payment method codes.""" + default_codes = super()._get_default_payment_method_codes() + if self.code != "easypay": + return default_codes + return const.DEFAULT_PAYMENT_METHOD_CODES + + def _easypay_get_api_url(self): + """Return the API URL based on the provider state. + + Note: self.ensure_one() + + :return: The API URL + :rtype: str + """ + self.ensure_one() + if self.state == "enabled": + return const.API_URL_PROD + return const.API_URL_TEST + + # === BUSINESS METHODS - PAYMENT FLOW ===# + + def _easypay_make_request(self, endpoint, payload=None, method="POST"): + """Make a request to the EasyPay API at the specified endpoint. + + Note: self.ensure_one() + + :param str endpoint: The API endpoint (e.g., '/2.0/single') + :param dict payload: The payload of the request + :param str method: The HTTP method of the request + :return: The JSON-formatted content of the response + :rtype: dict + :raise: ValidationError if an HTTP error occurs + """ + self.ensure_one() + + url = f"{self._easypay_get_api_url()}{endpoint}" + headers = { + "AccountId": easypay_utils.get_account_id(self), + "ApiKey": easypay_utils.get_api_key(self), + "Content-Type": "application/json", + } + + _logger.debug( + "API request to %s with payload:\n%s", url, pprint.pformat(payload) + ) + + try: + if method == "GET": + response = requests.get(url, headers=headers, timeout=60) + elif method == "POST": + response = requests.post(url, json=payload, headers=headers, timeout=60) + elif method == "PATCH": + response = requests.patch( + url, json=payload, headers=headers, timeout=60 + ) + elif method == "DELETE": + response = requests.delete(url, headers=headers, timeout=60) + else: + raise ValidationError(_("Unsupported HTTP method: %s") % method) + + response.raise_for_status() + return response.json() + except requests.exceptions.ConnectionError as err: + _logger.exception("unable to reach endpoint at %s", url) + raise ValidationError( + _("EasyPay: Could not establish the connection to the API.") + ) from err + except requests.exceptions.HTTPError as err: + _logger.exception("invalid API request at %s with data %s", url, payload) + error_msg = "" + try: + error_msg = response.json().get("message", "") + except Exception: # pragma: no cover - fallback when no JSON + try: + error_msg = response.text[:500] + except Exception: # pragma: no cover - last resort + error_msg = "" + raise ValidationError( + _( + "EasyPay: The communication with the API failed.\n" + "EasyPay gave us the following info about the problem:\n" + "'%s'" + ) + % error_msg + ) from err + + def _easypay_create_checkout_session(self, tx_sudo): + """Create a checkout session with EasyPay. + + Note: self.ensure_one() + + :param payment.transaction tx_sudo: The sudoed transaction of the payment. + :return: The checkout session data + :rtype: dict + """ + self.ensure_one() + + # EasyPay supports EUR. Prevent invalid currency requests early. + if tx_sudo.currency_id.name != "EUR": + raise ValidationError( + _("EasyPay: Only EUR currency is supported for checkout.") + ) + + payload = { + "type": ["single"], + "payment": { + "methods": [self.easypay_payment_method], + "type": const.PAYMENT_TYPE_SALE, + "capture": { + "descriptive": tx_sudo.reference, + "transaction_key": tx_sudo.reference, + }, + "currency": tx_sudo.currency_id.name, + "key": tx_sudo.reference, + }, + "order": { + "key": tx_sudo.reference, + "value": tx_sudo.amount, + }, + "customer": easypay_utils.include_customer_data(tx_sudo), + } + return self._easypay_make_request("/2.0/checkout", payload) + + def _easypay_create_single_payment(self, tx_sudo): + """Create a single payment with EasyPay. + + Note: self.ensure_one() + + :param payment.transaction tx_sudo: The sudoed transaction of the payment. + :return: The payment data + :rtype: dict + """ + self.ensure_one() + + # EasyPay supports EUR. Prevent invalid currency requests early. + if tx_sudo.currency_id.name != "EUR": + raise ValidationError( + _("EasyPay: Only EUR currency is supported for payments.") + ) + + base_url = tx_sudo.provider_id.get_base_url() + return_url = f"{base_url}/payment/easypay/return" + _logger.debug("Single payment return URL: %s", return_url) + payload = { + "type": const.PAYMENT_TYPE_SALE, + "method": self.easypay_payment_method, + "value": tx_sudo.amount, + "currency": tx_sudo.currency_id.name, + "key": tx_sudo.reference, + "capture": { + "descriptive": tx_sudo.reference, + "transaction_key": tx_sudo.reference, + }, + "customer": easypay_utils.include_customer_data(tx_sudo), + "return_url": return_url, + } + return self._easypay_make_request("/2.0/single", payload) diff --git a/payment_easypay/models/payment_transaction.py b/payment_easypay/models/payment_transaction.py new file mode 100644 index 00000000000..f996fb28448 --- /dev/null +++ b/payment_easypay/models/payment_transaction.py @@ -0,0 +1,332 @@ +# Copyright 2025 Open Source Integrators +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). + +import logging +import pprint + +from odoo import _, fields, models +from odoo.exceptions import ValidationError + +from .. import const + +_logger = logging.getLogger(__name__) + + +class PaymentTransaction(models.Model): + _inherit = "payment.transaction" + + easypay_payment_id = fields.Char( + string="EasyPay Payment ID", + help="The payment ID returned by EasyPay", + readonly=True, + ) + easypay_transaction_id = fields.Char( + string="EasyPay Transaction ID", + help="The transaction ID returned by EasyPay", + readonly=True, + ) + easypay_checkout_id = fields.Char( + string="EasyPay Checkout ID", + help="The checkout session ID returned by EasyPay", + readonly=True, + ) + easypay_payment_url = fields.Char( + string="EasyPay Payment URL", + help="The URL to redirect the customer to complete payment", + readonly=True, + ) + + def _get_specific_processing_values(self, processing_values): + """Override of payment to return EasyPay-specific processing values. + + Note: self.ensure_one() from `_get_processing_values` + + :param dict processing_values: The generic processing values of the transaction + :return: The dict of provider-specific processing values + :rtype: dict + """ + res = super()._get_specific_processing_values(processing_values) + if self.provider_code != "easypay": + return res + + if self.provider_id.easypay_use_checkout: + # Checkout flow - SDK-based inline payment + response = self.provider_id._easypay_create_checkout_session(self.sudo()) + _logger.info("EasyPay: Checkout session response: %s", response) + self.easypay_checkout_id = response.get("id") + manifest = response.get("session") + res.update( + { + "checkout_manifest": manifest, + "checkout_id": self.easypay_checkout_id, + "api_url": self.provider_id._easypay_get_api_url(), + } + ) + else: + # Single Payment flow - redirect to hosted page + response = self.provider_id._easypay_create_single_payment(self.sudo()) + _logger.info("EasyPay: Single payment response: %s", response) + self.easypay_payment_id = response.get("id") + payment_url = response.get("method", {}).get("url") + _logger.info( + "EasyPay: Payment ID: %s, URL: %s", self.easypay_payment_id, payment_url + ) + res.update( + { + "easypay_payment_id": self.easypay_payment_id, + "easypay_payment_url": payment_url, + } + ) + return res + + def _get_specific_rendering_values(self, processing_values): + """Override of payment to return EasyPay-specific rendering values. + + Note: self.ensure_one() from `_get_processing_values` + + :param dict processing_values: The generic and specific processing values + :return: The dict of provider-specific rendering values + :rtype: dict + """ + res = super()._get_specific_rendering_values(processing_values) + if self.provider_code != "easypay": + return res + + if self.provider_id.easypay_use_checkout: + # Checkout flow - pass SDK data + res.update( + { + "checkout_manifest": processing_values.get("checkout_manifest"), + "checkout_id": processing_values.get("checkout_id"), + "api_url": processing_values.get("api_url"), + } + ) + else: + # Single Payment flow - pass redirect URL + res.update( + { + "easypay_payment_url": processing_values.get("easypay_payment_url"), + } + ) + return res + + def _get_tx_from_notification_data(self, provider_code, notification_data): + """Override of payment to find the transaction based on EasyPay data. + + :param str provider_code: The provider code + :param dict notification_data: The notification data + :return: The transaction + :rtype: recordset of `payment.transaction` + :raise ValidationError: If the transaction is not found + """ + tx = super()._get_tx_from_notification_data(provider_code, notification_data) + if provider_code != "easypay" or len(tx) == 1: + return tx + + # Try to find transaction by reference or EasyPay IDs + reference = notification_data.get("key") + payment_id = notification_data.get("id") + + if reference: + tx = self.search( + [("reference", "=", reference), ("provider_code", "=", "easypay")] + ) + elif payment_id: + tx = self.search( + [ + "|", + ("easypay_payment_id", "=", payment_id), + ("easypay_checkout_id", "=", payment_id), + ("provider_code", "=", "easypay"), + ] + ) + + if not tx: + raise ValidationError( + _( + "EasyPay: No transaction found matching reference %s.", + reference, + ) + ) + return tx + + def _process_notification_data(self, notification_data): + """Override of payment to process the notification data. + + Note: self.ensure_one() from `_handle_notification_data` + + :param dict notification_data: The notification data + :return: None + """ + super()._process_notification_data(notification_data) + if self.provider_code != "easypay": + return + + _logger.debug( + "Processing notification for %s:\n%s", + self.reference, + pprint.pformat(notification_data), + ) + + # Extract relevant data from notification + payment_id = notification_data.get("id") + # EasyPay API uses 'payment_status' for Single Payment + # and 'status' for other flows + status = notification_data.get("payment_status") or notification_data.get( + "status" + ) + + # Update transaction with EasyPay data + if payment_id and not self.easypay_payment_id: + self.easypay_payment_id = payment_id + + # Map 'paid' status to 'success' for consistency + if status == "paid": + status = "success" + + # Update the payment state + payment_state = next( + ( + state + for state, easypay_statuses in const.STATUS_MAPPING.items() + if status in easypay_statuses + ), + None, + ) + + if payment_state == "pending": + self._set_pending() + elif payment_state == "authorized": + self._set_authorized() + elif payment_state == "done": + self._set_done() + elif payment_state == "cancel": + self._set_canceled() + elif payment_state == "error": + error_msg = notification_data.get("messages", ["Payment failed"]) + if isinstance(error_msg, list): + error_msg = ", ".join(error_msg) + self._set_error(error_msg) + else: + _logger.warning( + "received notification for transaction with reference %s " + "with unknown status: %s", + self.reference, + status, + ) + + def _easypay_get_payment_details(self): + """Fetch payment details from EasyPay API. + + Note: self.ensure_one() + + :return: The payment details + :rtype: dict + :raise ValidationError: If no payment ID is found + """ + self.ensure_one() + + if not self.easypay_payment_id: + raise ValidationError(_("No EasyPay payment ID found for this transaction")) + + endpoint = f"/2.0/single/{self.easypay_payment_id}" + return self.provider_id._easypay_make_request(endpoint, method="GET") + + def _send_refund_request(self, amount_to_refund=None): + """Override of payment to send a refund request to EasyPay. + + Note: self.ensure_one() + + :param float amount_to_refund: The amount to refund + :return: The refund transaction + :rtype: recordset of `payment.transaction` + :raise ValidationError: If no transaction ID is found + """ + if self.provider_code != "easypay": + return super()._send_refund_request(amount_to_refund=amount_to_refund) + + # Get the capture/transaction ID + if not self.easypay_transaction_id: + # Try to fetch it from the payment details + payment_details = self._easypay_get_payment_details() + capture_data = payment_details.get("capture", {}) + self.easypay_transaction_id = capture_data.get("id") + + if not self.easypay_transaction_id: + raise ValidationError(_("Cannot refund: No EasyPay transaction ID found")) + + # Create refund request + refund_amount = amount_to_refund or self.amount + payload = { + "transaction_id": self.easypay_transaction_id, + "value": refund_amount, + } + + endpoint = f"/2.0/capture/{self.easypay_transaction_id}/refund" + response = self.provider_id._easypay_make_request(endpoint, payload) + _logger.info( + "refund request response for transaction with reference %s:\n%s", + self.reference, + pprint.pformat(response), + ) + + # Create refund transaction + refund_tx = self._create_refund_transaction(amount_to_refund=refund_amount) + refund_tx.easypay_payment_id = response.get("id") + + return refund_tx + + def _send_capture_request(self): + """Override of payment to send a capture request to EasyPay. + + Note: self.ensure_one() + + :return: None + :raise ValidationError: If no payment ID is found + """ + if self.provider_code != "easypay": + return super()._send_capture_request() + + if not self.easypay_payment_id: + raise ValidationError(_("Cannot capture: No EasyPay payment ID found")) + + # Create capture request + payload = { + "descriptive": self.reference, + "transaction_key": self.reference, + "value": self.amount, + } + + endpoint = "/2.0/capture" + response = self.provider_id._easypay_make_request(endpoint, payload) + _logger.info( + "capture request response for transaction with reference %s:\n%s", + self.reference, + pprint.pformat(response), + ) + + self.easypay_transaction_id = response.get("id") + self._set_done() + + def _send_void_request(self): + """Override of payment to send a void request to EasyPay. + + Note: self.ensure_one() + + :return: None + :raise ValidationError: If no payment ID is found + """ + if self.provider_code != "easypay": + return super()._send_void_request() + + if not self.easypay_payment_id: + raise ValidationError(_("Cannot void: No EasyPay payment ID found")) + + endpoint = f"/2.0/authorisation/{self.easypay_payment_id}/void" + response = self.provider_id._easypay_make_request(endpoint, {}) + _logger.info( + "void request response for transaction with reference %s:\n%s", + self.reference, + pprint.pformat(response), + ) + self._set_canceled() diff --git a/payment_easypay/pyproject.toml b/payment_easypay/pyproject.toml new file mode 100644 index 00000000000..4231d0cccb3 --- /dev/null +++ b/payment_easypay/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/payment_easypay/readme/CONFIGURE.md b/payment_easypay/readme/CONFIGURE.md new file mode 100644 index 00000000000..c4edc330b42 --- /dev/null +++ b/payment_easypay/readme/CONFIGURE.md @@ -0,0 +1,59 @@ +To configure this module, you need to: + +1. Go to **Accounting \> Configuration \> Payment Providers** or + **Website \> Configuration \> Payment Providers** +2. Search for **EasyPay** and open the provider form +3. Fill in the required credentials: + - **Account ID**: Your EasyPay Account ID (obtain from EasyPay + dashboard) + - **API Key**: Your EasyPay API Key (obtain from EasyPay dashboard) + - **Payment Method**: Select the payment method you want to use + (Credit Card, Multibanco, MB WAY, etc.) + - **Use Checkout**: Enable this to use EasyPay's integrated checkout + experience +4. Configure the provider state: + - Set to **Test Mode** to use the test environment + () + - Set to **Enabled** to use the production environment + () +5. Save the configuration + +For testing purposes, you can use the following credentials: + +- **Account ID**: 2b0f63e2-9fb5-4e52-aca0-b4bf0339bbe6 +- **API Key**: eae4aa59-8e5b-4ec2-887d-b02768481a92 + +**Note**: These test credentials only work in Test Mode. + +## Webhook Configuration + +To receive automatic payment status updates, configure the following +webhook URL in your EasyPay dashboard: + +- **Webhook URL**: + +This ensures that payment status changes are immediately reflected in +Odoo. + +## Production Setup + +### Get Production Credentials + +1. Sign up at +2. Complete merchant verification +3. Get your production credentials from dashboard + +### Configure for Production + +1. Open EasyPay provider in Odoo +2. Update credentials with production values +3. Change **State** to **Enabled** +4. Configure webhook in EasyPay dashboard: + +  + + URL: https://yourdomain.com/payment/easypay/webhook + Events: Generic, Transaction, Authorisation + +5. Test with real card (small amount) +6. Publish the payment provider diff --git a/payment_easypay/readme/CONTRIBUTORS.md b/payment_easypay/readme/CONTRIBUTORS.md new file mode 100644 index 00000000000..8c6aeb456cd --- /dev/null +++ b/payment_easypay/readme/CONTRIBUTORS.md @@ -0,0 +1,6 @@ +- Open Source Integrators \<\> + +This work was developed with the aid of AI tools under human guidance +and supervision, specifically Cascade (IDE coding assistant) and +Anthropic Claude. All AI-assisted changes were reviewed and approved by +human maintainers. diff --git a/payment_easypay/readme/DESCRIPTION.md b/payment_easypay/readme/DESCRIPTION.md new file mode 100644 index 00000000000..30774da30e9 --- /dev/null +++ b/payment_easypay/readme/DESCRIPTION.md @@ -0,0 +1,9 @@ +This module integrates EasyPay as a payment provider in Odoo, allowing +customers to pay via credit card and other payment methods using +EasyPay's secure payment gateway. + +EasyPay is a Portuguese payment service provider that supports multiple +payment methods including credit cards, Multibanco, MB WAY, SEPA Direct +Debit, and more. + +Learn more about EasyPay at diff --git a/payment_easypay/readme/USAGE.md b/payment_easypay/readme/USAGE.md new file mode 100644 index 00000000000..592209905f7 --- /dev/null +++ b/payment_easypay/readme/USAGE.md @@ -0,0 +1,13 @@ +Once configured, customers can use EasyPay to make payments: + +1. During checkout, select **EasyPay** as the payment method +2. Click **Pay Now** +3. Depending on the configuration: + - **With Checkout**: An integrated payment form will appear where + customers can enter their payment details + - **Without Checkout**: Customers will be redirected to EasyPay's + payment page +4. Enter the card details (for testing): + - **Card**: 4111111111111111 + - **CVV**: 123 + - **Expiry**: 12/25 diff --git a/payment_easypay/static/description/icon.png b/payment_easypay/static/description/icon.png new file mode 100644 index 00000000000..3e54b5c2298 Binary files /dev/null and b/payment_easypay/static/description/icon.png differ diff --git a/payment_easypay/static/description/icon.svg b/payment_easypay/static/description/icon.svg new file mode 100644 index 00000000000..63aa0d32579 --- /dev/null +++ b/payment_easypay/static/description/icon.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + EasyPay + diff --git a/payment_easypay/static/description/index.html b/payment_easypay/static/description/index.html new file mode 100644 index 00000000000..26c8e1be77f --- /dev/null +++ b/payment_easypay/static/description/index.html @@ -0,0 +1,81 @@ + + + + + Payment Provider: EasyPay + + +
+
+

EasyPay Payment Provider

+

Accept payments through EasyPay

+
+
+ EasyPay +
+
+
+

+ This module integrates EasyPay as a payment provider in Odoo, allowing customers to pay via credit card and other payment methods using EasyPay's secure payment gateway. +

+

+ EasyPay is a Portuguese payment service provider that supports multiple payment methods including: +

+
    +
  • Credit/Debit Cards (Visa, Mastercard)
  • +
  • Multibanco
  • +
  • MB WAY
  • +
  • SEPA Direct Debit
  • +
  • Virtual IBAN
  • +
  • Apple Pay
  • +
  • Google Pay
  • +
  • Samsung Pay
  • +
+
+
+
+ +
+
+

Features

+
+

Integrated Checkout

+

+ Use EasyPay's integrated checkout experience for a seamless payment flow directly on your website. +

+
+
+

Multiple Payment Methods

+

+ Support for various payment methods popular in Portugal and Europe. +

+
+
+
+
+

Webhook Support

+

+ Automatic payment status updates via webhooks ensure real-time synchronization. +

+
+
+

Refunds & Captures

+

+ Support for manual capture and full refunds directly from Odoo. +

+
+
+
+ +
+
+

Easy Configuration

+
+

+ Simple configuration with just your EasyPay Account ID and API Key. Choose between test and production environments with a single click. +

+
+
+
+ + diff --git a/payment_easypay/static/description/logo.svg b/payment_easypay/static/description/logo.svg new file mode 100644 index 00000000000..a2b038faca0 --- /dev/null +++ b/payment_easypay/static/description/logo.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/payment_easypay/static/src/js/payment_form.esm.js b/payment_easypay/static/src/js/payment_form.esm.js new file mode 100644 index 00000000000..0681ab9a216 --- /dev/null +++ b/payment_easypay/static/src/js/payment_form.esm.js @@ -0,0 +1,108 @@ +/* eslint-disable jsdoc/check-tag-names */ +/** @odoo-module **/ + +import {_t} from "@web/core/l10n/translation"; +import paymentForm from "@payment/js/payment_form"; + +paymentForm.include({ + easypayCheckoutInstance: null, + + async _processRedirectFlow( + providerCode, + paymentOptionId, + paymentMethodCode, + processingValues + ) { + if (providerCode !== "easypay") { + return await this._super(...arguments); + } + + // Check if this is Single Payment flow (has payment URL) or Checkout flow (has manifest) + if (processingValues.easypay_payment_url) { + // Single Payment - use standard redirect flow + console.log( + "EasyPay: Using Single Payment flow - redirecting to:", + processingValues.easypay_payment_url + ); + return await this._super(...arguments); + } + + // Checkout flow - load SDK and render inline + console.log("EasyPay: Using Checkout flow with SDK"); + const manifest = processingValues.checkout_manifest; + const checkoutId = processingValues.checkout_id; + const apiUrl = processingValues.api_url; + + if (!manifest || !checkoutId || !apiUrl) { + console.error("EasyPay: Missing checkout configuration"); + this._displayErrorDialog( + _t("Configuration Error"), + _t("Missing payment configuration. Please try again.") + ); + this._enableButton(); + return; + } + + const radio = document.querySelector('input[name="o_payment_radio"]:checked'); + const inlineForm = this._getInlineForm(radio); + + if (!inlineForm) { + console.error("EasyPay: Inline form container not found"); + this._displayErrorDialog( + _t("Configuration Error"), + _t("Payment form container not found. Please try again.") + ); + this._enableButton(); + return; + } + + inlineForm.innerHTML = '
'; + + try { + // Wait for SDK to load from template script tag + let attempts = 0; + while (!window.easypayCheckout && attempts < 50) { + await new Promise((resolve) => setTimeout(resolve, 100)); + attempts++; + } + + if (!window.easypayCheckout || !window.easypayCheckout.startCheckout) { + throw new Error("EasyPay Checkout SDK not loaded after 5 seconds"); + } + + const isTestMode = apiUrl.includes("test"); + + this.easypayCheckoutInstance = window.easypayCheckout.startCheckout( + manifest, + { + id: "easypay-checkout", + display: "inline", + testing: isTestMode, + onSuccess: (successInfo) => { + console.log("EasyPay: Payment success", successInfo); + window.location = `/payment/easypay/checkout/success?id=${checkoutId}`; + }, + onError: (error) => { + console.error("EasyPay: Payment error", error); + this._displayErrorDialog( + _t("Payment Error"), + _t("An error occurred during payment processing.") + ); + this._enableButton(); + }, + onClose: () => { + console.log("EasyPay: Checkout closed"); + window.location = `/payment/easypay/checkout/cancel?id=${checkoutId}`; + }, + } + ); + } catch (error) { + console.error("EasyPay: Error loading Checkout SDK", error); + this._displayErrorDialog( + _t("Payment Error"), + _t("Could not load payment form. Please try again.") + ); + this._enableButton(); + } + }, +}); diff --git a/payment_easypay/static/src/scss/payment_easypay.scss b/payment_easypay/static/src/scss/payment_easypay.scss new file mode 100644 index 00000000000..b3d01500952 --- /dev/null +++ b/payment_easypay/static/src/scss/payment_easypay.scss @@ -0,0 +1,39 @@ +/* Copyright 2025 Open Source Integrators + * License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). + */ + +.easypay_redirect_form { + text-align: center; + padding: 2rem; + + .fa-spinner { + color: #00a09d; + } + + span { + font-size: 1.2rem; + margin-top: 1rem; + display: block; + } +} + +#easypay_checkout_container { + min-height: 400px; + padding: 1rem; +} + +#easypay-checkout { + width: 100%; +} + +.payment_icon_easypay { + display: inline-block; + padding: 0.5rem; + background-color: #00a09d; + color: white; + border-radius: 4px; + + i { + font-size: 1.5rem; + } +} diff --git a/payment_easypay/tests/__init__.py b/payment_easypay/tests/__init__.py new file mode 100644 index 00000000000..ec986d0b462 --- /dev/null +++ b/payment_easypay/tests/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2025 Open Source Integrators +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). + +from . import test_easypay diff --git a/payment_easypay/tests/test_easypay.py b/payment_easypay/tests/test_easypay.py new file mode 100644 index 00000000000..346a1b227b1 --- /dev/null +++ b/payment_easypay/tests/test_easypay.py @@ -0,0 +1,397 @@ +# Copyright 2025 Open Source Integrators +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). + +from unittest.mock import MagicMock, patch + +from odoo.exceptions import ValidationError +from odoo.tests import tagged +from odoo.tests.common import HttpCase, TransactionCase + + +@tagged("post_install", "-at_install") +class TestEasyPay(TransactionCase): + """Test EasyPay payment provider.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.provider = cls.env["payment.provider"].create( + { + "name": "EasyPay Test", + "code": "easypay", + "state": "test", + "easypay_account_id": "test-account-id", + "easypay_api_key": "test-api-key", + "easypay_payment_method": "cc", + "easypay_use_checkout": True, + } + ) + cls.partner = cls.env["res.partner"].create( + { + "name": "Test Partner", + "email": "test@example.com", + "phone": "+351911234567", + } + ) + cls.currency = cls.env.ref("base.EUR") + cls.payment_method = cls.env.ref("payment.payment_method_card") + + def test_provider_creation(self): + """Test that the provider is created correctly.""" + self.assertEqual(self.provider.code, "easypay") + self.assertEqual(self.provider.easypay_payment_method, "cc") + self.assertTrue(self.provider.easypay_use_checkout) + + def test_api_url_test_mode(self): + """Test that the correct API URL is returned for test mode.""" + self.provider.state = "test" + api_url = self.provider._easypay_get_api_url() + self.assertEqual(api_url, "https://api.test.easypay.pt") + + def test_api_url_production_mode(self): + """Test that the correct API URL is returned for production mode.""" + self.provider.state = "enabled" + api_url = self.provider._easypay_get_api_url() + self.assertEqual(api_url, "https://api.prod.easypay.pt") + + def test_transaction_creation(self): + """Test that a transaction can be created.""" + tx = self.env["payment.transaction"].create( + { + "provider_id": self.provider.id, + "payment_method_id": self.payment_method.id, + "reference": "TEST-001", + "amount": 100.0, + "currency_id": self.currency.id, + "partner_id": self.partner.id, + } + ) + self.assertEqual(tx.provider_code, "easypay") + self.assertEqual(tx.amount, 100.0) + + @patch("requests.post") + def test_create_checkout_session(self, mock_post): + """Test creating a checkout session with mocked API.""" + mock_response = MagicMock() + mock_response.json.return_value = { + "id": "checkout-123", + "session": "manifest-data", + "status": "pending", + } + mock_response.raise_for_status.return_value = None + mock_post.return_value = mock_response + + tx = self.env["payment.transaction"].create( + { + "provider_id": self.provider.id, + "payment_method_id": self.payment_method.id, + "reference": "TEST-CHECKOUT-001", + "amount": 100.0, + "currency_id": self.currency.id, + "partner_id": self.partner.id, + } + ) + + result = self.provider._easypay_create_checkout_session(tx.sudo()) + self.assertEqual(result["id"], "checkout-123") + self.assertEqual(result["session"], "manifest-data") + self.assertTrue(mock_post.called) + + # Verify the payload sent to EasyPay + call_args = mock_post.call_args + payload = call_args[1]["json"] + self.assertEqual(payload["type"], ["single"]) + self.assertEqual(payload["payment"]["methods"], ["cc"]) + self.assertEqual(payload["payment"]["currency"], "EUR") + self.assertEqual(payload["order"]["value"], 100.0) + + def test_notification_processing_success(self): + """Test processing a successful payment notification.""" + tx = self.env["payment.transaction"].create( + { + "provider_id": self.provider.id, + "payment_method_id": self.payment_method.id, + "reference": "TEST-002", + "amount": 50.0, + "currency_id": self.currency.id, + "partner_id": self.partner.id, + } + ) + + notification_data = { + "id": "payment-123", + "key": "TEST-002", + "status": "success", + "type": "capture", + } + + tx._process_notification_data(notification_data) + self.assertEqual(tx.state, "done") + self.assertEqual(tx.easypay_payment_id, "payment-123") + + def test_notification_processing_failed(self): + """Test processing a failed payment notification.""" + tx = self.env["payment.transaction"].create( + { + "provider_id": self.provider.id, + "payment_method_id": self.payment_method.id, + "reference": "TEST-003", + "amount": 75.0, + "currency_id": self.currency.id, + "partner_id": self.partner.id, + } + ) + + notification_data = { + "id": "payment-456", + "key": "TEST-003", + "status": "failed", + "messages": ["Payment declined"], + } + + tx._process_notification_data(notification_data) + self.assertEqual(tx.state, "error") + + def test_currency_validation_checkout(self): + """Test that non-EUR currency raises ValidationError for checkout.""" + usd_currency = self.env.ref("base.USD") + tx = self.env["payment.transaction"].create( + { + "provider_id": self.provider.id, + "payment_method_id": self.payment_method.id, + "reference": "TEST-USD-001", + "amount": 100.0, + "currency_id": usd_currency.id, + "partner_id": self.partner.id, + } + ) + + with self.assertRaises(ValidationError) as context: + self.provider._easypay_create_checkout_session(tx.sudo()) + + self.assertIn("Only EUR currency is supported", str(context.exception)) + + @patch("requests.post") + def test_create_single_payment(self, mock_post): + """Test creating a single payment with mocked API.""" + mock_response = MagicMock() + mock_response.json.return_value = { + "id": "payment-789", + "status": "pending", + "method": { + "type": "cc", + "url": "https://pay.easypay.pt/xyz", + }, + } + mock_response.raise_for_status.return_value = None + mock_post.return_value = mock_response + + self.provider.easypay_use_checkout = False + tx = self.env["payment.transaction"].create( + { + "provider_id": self.provider.id, + "payment_method_id": self.payment_method.id, + "reference": "TEST-SINGLE-001", + "amount": 50.0, + "currency_id": self.currency.id, + "partner_id": self.partner.id, + } + ) + + result = self.provider._easypay_create_single_payment(tx.sudo()) + self.assertEqual(result["id"], "payment-789") + self.assertEqual(result["method"]["url"], "https://pay.easypay.pt/xyz") + self.assertTrue(mock_post.called) + + # Verify the payload + call_args = mock_post.call_args + payload = call_args[1]["json"] + self.assertEqual(payload["type"], "sale") + self.assertEqual(payload["method"], "cc") + self.assertEqual(payload["value"], 50.0) + self.assertEqual(payload["currency"], "EUR") + + def test_currency_validation_single_payment(self): + """Test that non-EUR currency raises ValidationError for single payment.""" + usd_currency = self.env.ref("base.USD") + self.provider.easypay_use_checkout = False + tx = self.env["payment.transaction"].create( + { + "provider_id": self.provider.id, + "payment_method_id": self.payment_method.id, + "reference": "TEST-USD-002", + "amount": 75.0, + "currency_id": usd_currency.id, + "partner_id": self.partner.id, + } + ) + + with self.assertRaises(ValidationError) as context: + self.provider._easypay_create_single_payment(tx.sudo()) + + self.assertIn("Only EUR currency is supported", str(context.exception)) + + def test_get_tx_from_notification_data(self): + """Test finding transaction from notification data.""" + tx = self.env["payment.transaction"].create( + { + "provider_id": self.provider.id, + "payment_method_id": self.payment_method.id, + "reference": "TEST-NOTIF-001", + "amount": 25.0, + "currency_id": self.currency.id, + "partner_id": self.partner.id, + } + ) + + notification_data = { + "key": "TEST-NOTIF-001", + "id": "payment-999", + } + + found_tx = tx._get_tx_from_notification_data("easypay", notification_data) + self.assertEqual(found_tx.id, tx.id) + + def test_notification_processing_authorized(self): + """Test processing an authorized payment notification.""" + tx = self.env["payment.transaction"].create( + { + "provider_id": self.provider.id, + "payment_method_id": self.payment_method.id, + "reference": "TEST-AUTH-001", + "amount": 150.0, + "currency_id": self.currency.id, + "partner_id": self.partner.id, + } + ) + + notification_data = { + "id": "payment-auth-123", + "key": "TEST-AUTH-001", + "status": "authorised", + "type": "authorisation", + } + + tx._process_notification_data(notification_data) + self.assertEqual(tx.state, "authorized") + self.assertEqual(tx.easypay_payment_id, "payment-auth-123") + + @patch("requests.post") + def test_http_error_handling(self, mock_post): + """Test that HTTP errors are properly handled and logged.""" + mock_response = MagicMock() + mock_response.json.return_value = { + "message": "Invalid payment method", + } + mock_response.raise_for_status.side_effect = Exception("400 Bad Request") + mock_post.return_value = mock_response + + tx = self.env["payment.transaction"].create( + { + "provider_id": self.provider.id, + "payment_method_id": self.payment_method.id, + "reference": "TEST-ERROR-001", + "amount": 10.0, + "currency_id": self.currency.id, + "partner_id": self.partner.id, + } + ) + + with self.assertRaises(ValidationError): + self.provider._easypay_create_checkout_session(tx.sudo()) + + +@tagged("post_install", "-at_install") +class TestEasyPayController(HttpCase): + """Test EasyPay controller endpoints.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.provider = cls.env["payment.provider"].create( + { + "name": "EasyPay Test Controller", + "code": "easypay", + "state": "test", + "easypay_account_id": "test-account-id", + "easypay_api_key": "test-api-key", + "easypay_payment_method": "cc", + "easypay_use_checkout": True, + } + ) + cls.partner = cls.env["res.partner"].create( + { + "name": "Test Partner Controller", + "email": "controller@example.com", + "phone": "+351911234567", + } + ) + cls.currency = cls.env.ref("base.EUR") + cls.payment_method = cls.env.ref("payment.payment_method_card") + + @patch("requests.get") + def test_checkout_success_callback(self, mock_get): + """Test checkout success callback fetches payment data and updates + transaction. + """ + # Create transaction with checkout ID + tx = self.env["payment.transaction"].create( + { + "provider_id": self.provider.id, + "payment_method_id": self.payment_method.id, + "reference": "TEST-SUCCESS-001", + "amount": 99.99, + "currency_id": self.currency.id, + "partner_id": self.partner.id, + "easypay_checkout_id": "checkout-success-123", + } + ) + + # Mock the API response for fetching checkout details + mock_response = MagicMock() + mock_response.json.return_value = { + "id": "checkout-success-123", + "key": "TEST-SUCCESS-001", + "status": "success", + "type": "capture", + } + mock_response.raise_for_status.return_value = None + mock_get.return_value = mock_response + + # Simulate the success callback + response = self.url_open( + "/payment/easypay/checkout/success?id=checkout-success-123" + ) + + # Verify redirect to payment status + self.assertEqual(response.status_code, 200) + + # Verify transaction was updated + tx.invalidate_recordset() + self.assertEqual(tx.state, "done") + self.assertTrue(mock_get.called) + + def test_checkout_cancel_callback(self): + """Test checkout cancel callback sets transaction to canceled.""" + tx = self.env["payment.transaction"].create( + { + "provider_id": self.provider.id, + "payment_method_id": self.payment_method.id, + "reference": "TEST-CANCEL-001", + "amount": 50.0, + "currency_id": self.currency.id, + "partner_id": self.partner.id, + } + ) + + # Simulate the cancel callback + response = self.url_open( + f"/payment/easypay/checkout/cancel?reference={tx.reference}" + ) + + # Verify redirect + self.assertEqual(response.status_code, 200) + + # Verify transaction was canceled + tx.invalidate_recordset() + self.assertEqual(tx.state, "cancel") diff --git a/payment_easypay/utils.py b/payment_easypay/utils.py new file mode 100644 index 00000000000..f032d3d8464 --- /dev/null +++ b/payment_easypay/utils.py @@ -0,0 +1,89 @@ +# Copyright 2025 Open Source Integrators +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). + + +def get_account_id(provider_sudo): + """Return the Account ID for EasyPay. + + Note: This method serves as a hook for potential future extensions. + + :param recordset provider_sudo: The provider on which the key should be read, as a + sudoed `payment.provider` record. + :return: The Account ID + :rtype: str + """ + return provider_sudo.easypay_account_id + + +def get_api_key(provider_sudo): + """Return the API Key for EasyPay. + + Note: This method serves as a hook for potential future extensions. + + :param recordset provider_sudo: The provider on which the key should be read, as a + sudoed `payment.provider` record. + :return: The API Key + :rtype: str + """ + return provider_sudo.easypay_api_key + + +def include_customer_data(tx_sudo): + """Include customer data from the transaction to the payload of the API request. + + Note: `self.ensure_one()` + + :param payment.transaction tx_sudo: The sudoed transaction of the payment. + :return: The subset of the API payload that includes customer data. + :rtype: dict + """ + tx_sudo.ensure_one() + + return { + "name": tx_sudo.partner_name or "", + "email": tx_sudo.partner_email or "", + "phone": tx_sudo.partner_phone or "", + "key": str(tx_sudo.partner_id.id), + } + + +def include_shipping_address(tx_sudo): + """Include the shipping address of the related sales order or invoice to + the payload. + + If no related sales order or invoice exists, the address is not included. + + Note: `self.ensure_one()` + + :param payment.transaction tx_sudo: The sudoed transaction of the payment. + :return: The subset of the API payload that includes the shipping address. + :rtype: dict + """ + tx_sudo.ensure_one() + + if "sale_order_ids" in tx_sudo._fields and tx_sudo.sale_order_ids: + order = tx_sudo.sale_order_ids[:1] + return format_shipping_address(order.partner_shipping_id) + elif "invoice_ids" in tx_sudo._fields and tx_sudo.invoice_ids: + invoice = tx_sudo.invoice_ids[:1] + return format_shipping_address(invoice.partner_shipping_id) + return {} + + +def format_shipping_address(shipping_partner): + """Format the shipping address to comply with the payload structure of the + API request. + + :param res.partner shipping_partner: The shipping partner. + :return: The formatted shipping address. + :rtype: dict + """ + return { + "name": shipping_partner.name or shipping_partner.parent_id.name, + "address": { + "street": shipping_partner.street or "", + "city": shipping_partner.city or "", + "postal_code": shipping_partner.zip or "", + "country": shipping_partner.country_id.code or "", + }, + } diff --git a/payment_easypay/views/payment_easypay_templates.xml b/payment_easypay/views/payment_easypay_templates.xml new file mode 100644 index 00000000000..b03eca23415 --- /dev/null +++ b/payment_easypay/views/payment_easypay_templates.xml @@ -0,0 +1,36 @@ + + + +