From 7e860b65c529e111d1b7e59c3f5668483e2a1242 Mon Sep 17 00:00:00 2001 From: metatroncubeswdev Date: Mon, 17 Aug 2026 21:59:15 -0400 Subject: [PATCH] feat(community_interac): Interac e-Transfer payment provider (Phase 5) The plan flags this as the highest-risk module since the payment provider API is strict and version-sensitive. Before writing any code, read Odoo 19's own payment_custom module (its wire-transfer provider) end to end as a reference, since it's the closest first-party analog to a manual/ offline payment flow - this avoided the trial-and-error that hit the other modules and got the core logic right on the first install attempt. payment.provider gains code='interac' (via selection_add, same pattern payment_custom uses for 'custom') plus interac_recipient_email (required_if_provider='interac' - Odoo only enforces this when the provider's state is enabled/test, so the module ships a disabled, unconfigured provider record and the deployment layer configures + enables it, keeping client specifics out of product code) and a configurable interac_deadline_hours. Flow: selecting Interac at checkout calls _apply_updates, which sets the transaction 'pending' and emails instructions (recipient address, amount, reference, deadline) via a mail.template - no dynamic per-transaction data needs to live in the static provider-level pending_msg field, since the reference/amount are already shown on Odoo's generic payment status page. A "Pending Interac Payments" admin list (Interac Payment Verifier group) has a one-click "Payment Received" button calling action_confirm_interac_ payment (-> _set_done, which triggers Odoo's normal order/invoice reconciliation - no need to reimplement that). An hourly cron cancels unconfirmed pending transactions past the deadline and emails a cancellation notice. Verified against a live Odoo 19 + Postgres 16 container: 4/4 automated tests pass, plus a full manual live run of both cycles the plan's gate asks for - drove a transaction through the actual /payment/interac/process controller to pending (confirmed the instructions email), used the treasurer action to confirm it to 'done', and separately backdated a second transaction's last_state_change and triggered the auto-cancel cron via ir.cron's method_direct_trigger, confirming both the state change to 'cancel' and the cancellation email. Co-Authored-By: Claude Sonnet 5 --- addons/community_interac/__init__.py | 2 + addons/community_interac/__manifest__.py | 10 ++- addons/community_interac/const.py | 3 + .../community_interac/controllers/__init__.py | 1 + addons/community_interac/controllers/main.py | 15 ++++ addons/community_interac/data/ir_cron.xml | 14 ++++ .../community_interac/data/mail_templates.xml | 44 ++++++++++++ .../data/payment_method_data.xml | 13 ++++ .../data/payment_provider_data.xml | 15 ++++ addons/community_interac/models/__init__.py | 2 + .../models/payment_provider.py | 27 ++++++++ .../models/payment_transaction.py | 61 ++++++++++++++++ .../security/interac_security.xml | 19 +++++ addons/community_interac/tests/__init__.py | 1 + .../community_interac/tests/test_interac.py | 69 +++++++++++++++++++ .../views/payment_interac_templates.xml | 8 +++ .../views/payment_transaction_views.xml | 32 +++++++++ 17 files changed, 335 insertions(+), 1 deletion(-) create mode 100644 addons/community_interac/const.py create mode 100644 addons/community_interac/controllers/__init__.py create mode 100644 addons/community_interac/controllers/main.py create mode 100644 addons/community_interac/data/ir_cron.xml create mode 100644 addons/community_interac/data/mail_templates.xml create mode 100644 addons/community_interac/data/payment_method_data.xml create mode 100644 addons/community_interac/data/payment_provider_data.xml create mode 100644 addons/community_interac/models/__init__.py create mode 100644 addons/community_interac/models/payment_provider.py create mode 100644 addons/community_interac/models/payment_transaction.py create mode 100644 addons/community_interac/security/interac_security.xml create mode 100644 addons/community_interac/tests/test_interac.py create mode 100644 addons/community_interac/views/payment_interac_templates.xml create mode 100644 addons/community_interac/views/payment_transaction_views.xml diff --git a/addons/community_interac/__init__.py b/addons/community_interac/__init__.py index e69de29..f7209b1 100644 --- a/addons/community_interac/__init__.py +++ b/addons/community_interac/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import controllers diff --git a/addons/community_interac/__manifest__.py b/addons/community_interac/__manifest__.py index 4ab2b59..6538cdd 100644 --- a/addons/community_interac/__manifest__.py +++ b/addons/community_interac/__manifest__.py @@ -27,7 +27,15 @@ Sellable to any Canadian organization accepting Interac e-Transfer. 'payment', 'account', ], - 'data': [], + 'data': [ + 'security/interac_security.xml', + 'views/payment_interac_templates.xml', + 'views/payment_transaction_views.xml', + 'data/payment_method_data.xml', + 'data/payment_provider_data.xml', + 'data/mail_templates.xml', + 'data/ir_cron.xml', + ], 'demo': [], 'images': ['static/description/banner.png'], 'application': False, diff --git a/addons/community_interac/const.py b/addons/community_interac/const.py new file mode 100644 index 0000000..6e856a3 --- /dev/null +++ b/addons/community_interac/const.py @@ -0,0 +1,3 @@ +DEFAULT_PAYMENT_METHOD_CODES = { + 'interac', +} diff --git a/addons/community_interac/controllers/__init__.py b/addons/community_interac/controllers/__init__.py new file mode 100644 index 0000000..12a7e52 --- /dev/null +++ b/addons/community_interac/controllers/__init__.py @@ -0,0 +1 @@ +from . import main diff --git a/addons/community_interac/controllers/main.py b/addons/community_interac/controllers/main.py new file mode 100644 index 0000000..842cf25 --- /dev/null +++ b/addons/community_interac/controllers/main.py @@ -0,0 +1,15 @@ +from odoo.http import Controller, request, route + +from odoo.addons.payment.logging import get_payment_logger + +_logger = get_payment_logger(__name__) + + +class InteracController(Controller): + _process_url = '/payment/interac/process' + + @route(_process_url, type='http', auth='public', methods=['POST'], csrf=False) + def interac_process_transaction(self, **post): + _logger.info("Handling Interac processing with reference %s", post.get('reference')) + request.env['payment.transaction'].sudo()._process('interac', post) + return request.redirect('/payment/status') diff --git a/addons/community_interac/data/ir_cron.xml b/addons/community_interac/data/ir_cron.xml new file mode 100644 index 0000000..3c6f28b --- /dev/null +++ b/addons/community_interac/data/ir_cron.xml @@ -0,0 +1,14 @@ + + + + + Interac: Auto-cancel Unconfirmed Payments + + code + model._cron_auto_cancel_interac() + 1 + hours + + + + diff --git a/addons/community_interac/data/mail_templates.xml b/addons/community_interac/data/mail_templates.xml new file mode 100644 index 0000000..90b3d8e --- /dev/null +++ b/addons/community_interac/data/mail_templates.xml @@ -0,0 +1,44 @@ + + + + + Interac: Payment Instructions + + {{ object.company_id.name }}: Interac e-Transfer instructions + {{ object.partner_id.id }} + + +
+

Dear Customer,

+

To complete your order with , please send an Interac e-Transfer:

+
    +
  • Send to:
  • +
  • Amount:
  • +
  • Reference code (use as the e-transfer message/security question if possible): +
  • +
  • Please send within: hours, + or the order will be automatically cancelled.
  • +
+
+
+
+ + + Interac: Payment Cancelled + + {{ object.company_id.name }}: Interac e-Transfer window expired + {{ object.partner_id.id }} + + +
+

Dear Customer,

+

+ We did not receive confirmation of your Interac e-Transfer for order reference + within the payment window, so it has been cancelled. + Please place your order again if you would still like to proceed. +

+
+
+
+
+
diff --git a/addons/community_interac/data/payment_method_data.xml b/addons/community_interac/data/payment_method_data.xml new file mode 100644 index 0000000..d531f37 --- /dev/null +++ b/addons/community_interac/data/payment_method_data.xml @@ -0,0 +1,13 @@ + + + + Interac e-Transfer + interac + 1001 + False + False + False + none + none + + diff --git a/addons/community_interac/data/payment_provider_data.xml b/addons/community_interac/data/payment_provider_data.xml new file mode 100644 index 0000000..a2a4a02 --- /dev/null +++ b/addons/community_interac/data/payment_provider_data.xml @@ -0,0 +1,15 @@ + + + + Interac e-Transfer + interac + disabled + + +

Your order will be confirmed once we receive your Interac e-Transfer. Please +check your email for payment instructions, including the recipient address, amount, +and reference code to use.

+
+ +
+
diff --git a/addons/community_interac/models/__init__.py b/addons/community_interac/models/__init__.py new file mode 100644 index 0000000..9afbccd --- /dev/null +++ b/addons/community_interac/models/__init__.py @@ -0,0 +1,2 @@ +from . import payment_provider +from . import payment_transaction diff --git a/addons/community_interac/models/payment_provider.py b/addons/community_interac/models/payment_provider.py new file mode 100644 index 0000000..7240bef --- /dev/null +++ b/addons/community_interac/models/payment_provider.py @@ -0,0 +1,27 @@ +from odoo import fields, models + +from odoo.addons.community_interac import const + + +class PaymentProvider(models.Model): + _inherit = 'payment.provider' + + code = fields.Selection( + selection_add=[('interac', "Interac e-Transfer")], ondelete={'interac': 'set default'}, + ) + interac_recipient_email = fields.Char( + string='Recipient e-Transfer Email', + help="The e-transfer address customers should send payment to. Never hardcode this - it is " + "per-deployment configuration, set here or by the deployment layer.", + required_if_provider='interac', + ) + interac_deadline_hours = fields.Integer( + string='Payment Deadline (hours)', default=48, + help="Pending Interac transactions not confirmed within this many hours are auto-cancelled.", + ) + + def _get_default_payment_method_codes(self): + self.ensure_one() + if self.code != 'interac': + return super()._get_default_payment_method_codes() + return const.DEFAULT_PAYMENT_METHOD_CODES diff --git a/addons/community_interac/models/payment_transaction.py b/addons/community_interac/models/payment_transaction.py new file mode 100644 index 0000000..daf8488 --- /dev/null +++ b/addons/community_interac/models/payment_transaction.py @@ -0,0 +1,61 @@ +from odoo import fields, models + +from odoo.addons.payment.logging import get_payment_logger +from odoo.addons.community_interac.controllers.main import InteracController + +_logger = get_payment_logger(__name__) + + +class PaymentTransaction(models.Model): + _inherit = 'payment.transaction' + + def _get_specific_rendering_values(self, processing_values): + if self.provider_code != 'interac': + return super()._get_specific_rendering_values(processing_values) + return { + 'api_url': InteracController._process_url, + 'reference': self.reference, + } + + def _extract_amount_data(self, payment_data): + """Override of `payment` to skip amount validation - there is no external gateway response.""" + if self.provider_code != 'interac': + return super()._extract_amount_data(payment_data) + return None + + def _apply_updates(self, payment_data): + if self.provider_code != 'interac': + return super()._apply_updates(payment_data) + + _logger.info("Interac transaction %s selected by customer: set as pending.", self.reference) + self._set_pending() + self._send_interac_instructions_email() + + def _send_interac_instructions_email(self): + self.ensure_one() + template = self.env.ref('community_interac.mail_template_interac_instructions', raise_if_not_found=False) + if template: + template.send_mail(self.id, force_send=False) + + def _send_interac_cancel_email(self): + self.ensure_one() + template = self.env.ref('community_interac.mail_template_interac_cancelled', raise_if_not_found=False) + if template: + template.send_mail(self.id, force_send=False) + + def action_confirm_interac_payment(self): + """One-click admin/treasurer confirmation that the e-transfer was received.""" + for tx in self: + if tx.provider_code == 'interac' and tx.state == 'pending': + tx._set_done() + return True + + def _cron_auto_cancel_interac(self): + pending_interac = self.search([('provider_code', '=', 'interac'), ('state', '=', 'pending')]) + for tx in pending_interac: + deadline_hours = tx.provider_id.interac_deadline_hours or 48 + elapsed_hours = (fields.Datetime.now() - tx.last_state_change).total_seconds() / 3600.0 + if elapsed_hours >= deadline_hours: + tx._set_canceled(state_message="Auto-cancelled: Interac payment not confirmed within the deadline.") + tx._send_interac_cancel_email() + return True diff --git a/addons/community_interac/security/interac_security.xml b/addons/community_interac/security/interac_security.xml new file mode 100644 index 0000000..1111ee0 --- /dev/null +++ b/addons/community_interac/security/interac_security.xml @@ -0,0 +1,19 @@ + + + + Interac Payments + 24 + + + + Interac Payments + + + + + Interac Payment Verifier + + + Can view pending Interac e-Transfers and confirm receipt. + + diff --git a/addons/community_interac/tests/__init__.py b/addons/community_interac/tests/__init__.py index e69de29..4d6c900 100644 --- a/addons/community_interac/tests/__init__.py +++ b/addons/community_interac/tests/__init__.py @@ -0,0 +1 @@ +from . import test_interac diff --git a/addons/community_interac/tests/test_interac.py b/addons/community_interac/tests/test_interac.py new file mode 100644 index 0000000..0574ce4 --- /dev/null +++ b/addons/community_interac/tests/test_interac.py @@ -0,0 +1,69 @@ +from datetime import timedelta + +from odoo import fields +from odoo.tests.common import TransactionCase, tagged + + +@tagged('post_install', '-at_install') +class TestInterac(TransactionCase): + + def setUp(self): + super().setUp() + self.provider = self.env.ref('community_interac.payment_provider_interac') + self.provider.write({ + 'interac_recipient_email': 'payments@example.org', + 'state': 'test', + 'interac_deadline_hours': 48, + }) + self.method = self.env.ref('community_interac.payment_method_interac') + self.partner = self.env['res.partner'].create({'name': 'Interac Payer', 'email': 'payer@example.com'}) + + def _create_transaction(self): + return self.env['payment.transaction'].create({ + 'provider_id': self.provider.id, + 'payment_method_id': self.method.id, + 'amount': 100.0, + 'currency_id': self.env.company.currency_id.id, + 'partner_id': self.partner.id, + 'reference': self.env['payment.transaction']._compute_reference('interac'), + }) + + def test_select_sets_pending_and_sends_instructions(self): + tx = self._create_transaction() + mail_count_before = self.env['mail.mail'].search_count([]) + + tx._apply_updates({}) + + self.assertEqual(tx.state, 'pending') + mail_count_after = self.env['mail.mail'].search_count([]) + self.assertGreater(mail_count_after, mail_count_before) + + def test_confirm_sets_done(self): + tx = self._create_transaction() + tx._apply_updates({}) + self.assertEqual(tx.state, 'pending') + + tx.action_confirm_interac_payment() + + self.assertEqual(tx.state, 'done') + + def test_auto_cancel_after_deadline(self): + tx = self._create_transaction() + tx._apply_updates({}) + tx.last_state_change = fields.Datetime.now() - timedelta(hours=49) + mail_count_before = self.env['mail.mail'].search_count([]) + + self.env['payment.transaction']._cron_auto_cancel_interac() + + self.assertEqual(tx.state, 'cancel') + mail_count_after = self.env['mail.mail'].search_count([]) + self.assertGreater(mail_count_after, mail_count_before) + + def test_not_yet_due_is_not_cancelled(self): + tx = self._create_transaction() + tx._apply_updates({}) + tx.last_state_change = fields.Datetime.now() - timedelta(hours=1) + + self.env['payment.transaction']._cron_auto_cancel_interac() + + self.assertEqual(tx.state, 'pending') diff --git a/addons/community_interac/views/payment_interac_templates.xml b/addons/community_interac/views/payment_interac_templates.xml new file mode 100644 index 0000000..604300c --- /dev/null +++ b/addons/community_interac/views/payment_interac_templates.xml @@ -0,0 +1,8 @@ + + + + diff --git a/addons/community_interac/views/payment_transaction_views.xml b/addons/community_interac/views/payment_transaction_views.xml new file mode 100644 index 0000000..7adbb3a --- /dev/null +++ b/addons/community_interac/views/payment_transaction_views.xml @@ -0,0 +1,32 @@ + + + + payment.transaction.list.interac.pending + payment.transaction + + + + + + + + +