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 <noreply@anthropic.com>
This commit is contained in:
metatroncubeswdev 2026-08-17 21:59:15 -04:00
parent 91e91fe1b8
commit 7e860b65c5
17 changed files with 335 additions and 1 deletions

View File

@ -0,0 +1,2 @@
from . import models
from . import controllers

View File

@ -27,7 +27,15 @@ Sellable to any Canadian organization accepting Interac e-Transfer.
'payment', 'payment',
'account', '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': [], 'demo': [],
'images': ['static/description/banner.png'], 'images': ['static/description/banner.png'],
'application': False, 'application': False,

View File

@ -0,0 +1,3 @@
DEFAULT_PAYMENT_METHOD_CODES = {
'interac',
}

View File

@ -0,0 +1 @@
from . import main

View File

@ -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')

View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record id="ir_cron_interac_auto_cancel" model="ir.cron">
<field name="name">Interac: Auto-cancel Unconfirmed Payments</field>
<field name="model_id" ref="payment.model_payment_transaction"/>
<field name="state">code</field>
<field name="code">model._cron_auto_cancel_interac()</field>
<field name="interval_number">1</field>
<field name="interval_type">hours</field>
<field name="active" eval="True"/>
</record>
</data>
</odoo>

View File

@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record id="mail_template_interac_instructions" model="mail.template">
<field name="name">Interac: Payment Instructions</field>
<field name="model_id" ref="payment.model_payment_transaction"/>
<field name="subject">{{ object.company_id.name }}: Interac e-Transfer instructions</field>
<field name="partner_to">{{ object.partner_id.id }}</field>
<field name="auto_delete" eval="True"/>
<field name="body_html" type="html">
<div style="margin: 0px; padding: 0px; font-size: 13px;">
<p>Dear <t t-out="object.partner_id.name or ''">Customer</t>,</p>
<p>To complete your order with <t t-out="object.company_id.name or ''"/>, please send an Interac e-Transfer:</p>
<ul>
<li><strong>Send to:</strong> <t t-out="object.provider_id.interac_recipient_email or ''"/></li>
<li><strong>Amount:</strong> <t t-out="format_amount(object.amount, object.currency_id)"/></li>
<li><strong>Reference code (use as the e-transfer message/security question if possible):</strong>
<t t-out="object.reference"/></li>
<li><strong>Please send within:</strong> <t t-out="object.provider_id.interac_deadline_hours"/> hours,
or the order will be automatically cancelled.</li>
</ul>
</div>
</field>
</record>
<record id="mail_template_interac_cancelled" model="mail.template">
<field name="name">Interac: Payment Cancelled</field>
<field name="model_id" ref="payment.model_payment_transaction"/>
<field name="subject">{{ object.company_id.name }}: Interac e-Transfer window expired</field>
<field name="partner_to">{{ object.partner_id.id }}</field>
<field name="auto_delete" eval="True"/>
<field name="body_html" type="html">
<div style="margin: 0px; padding: 0px; font-size: 13px;">
<p>Dear <t t-out="object.partner_id.name or ''">Customer</t>,</p>
<p>
We did not receive confirmation of your Interac e-Transfer for order reference
<t t-out="object.reference"/> within the payment window, so it has been cancelled.
Please place your order again if you would still like to proceed.
</p>
</div>
</field>
</record>
</data>
</odoo>

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="payment_method_interac" model="payment.method">
<field name="name">Interac e-Transfer</field>
<field name="code">interac</field>
<field name="sequence">1001</field>
<field name="active">False</field>
<field name="support_tokenization">False</field>
<field name="support_express_checkout">False</field>
<field name="support_manual_capture">none</field>
<field name="support_refund">none</field>
</record>
</odoo>

View File

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="payment_provider_interac" model="payment.provider">
<field name="name">Interac e-Transfer</field>
<field name="code">interac</field>
<field name="state">disabled</field>
<field name="redirect_form_view_id" ref="redirect_form"/>
<field name="pending_msg" type="html">
<p>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.</p>
</field>
<field name="payment_method_ids" eval="[Command.set([ref('community_interac.payment_method_interac')])]"/>
</record>
</odoo>

View File

@ -0,0 +1,2 @@
from . import payment_provider
from . import payment_transaction

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="module_category_interac" model="ir.module.category">
<field name="name">Interac Payments</field>
<field name="sequence">24</field>
</record>
<record id="privilege_interac" model="res.groups.privilege">
<field name="name">Interac Payments</field>
<field name="category_id" ref="module_category_interac"/>
</record>
<record id="group_interac_verifier" model="res.groups">
<field name="name">Interac Payment Verifier</field>
<field name="privilege_id" ref="privilege_interac"/>
<field name="implied_ids" eval="[(4, ref('base.group_user'))]"/>
<field name="comment">Can view pending Interac e-Transfers and confirm receipt.</field>
</record>
</odoo>

View File

@ -0,0 +1 @@
from . import test_interac

View File

@ -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')

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="redirect_form">
<form t-att-action="api_url" method="post">
<input type="hidden" name="reference" t-att-value="reference"/>
</form>
</template>
</odoo>

View File

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_payment_transaction_list_interac" model="ir.ui.view">
<field name="name">payment.transaction.list.interac.pending</field>
<field name="model">payment.transaction</field>
<field name="arch" type="xml">
<list string="Pending Interac Payments">
<field name="reference"/>
<field name="partner_id"/>
<field name="amount" widget="monetary"/>
<field name="currency_id" column_invisible="1"/>
<field name="create_date"/>
<field name="last_state_change"/>
<button name="action_confirm_interac_payment" type="object"
string="Payment Received" class="btn-primary"/>
</list>
</field>
</record>
<record id="action_payment_transaction_interac_pending" model="ir.actions.act_window">
<field name="name">Pending Interac Payments</field>
<field name="res_model">payment.transaction</field>
<field name="view_mode">list,form</field>
<field name="view_id" ref="view_payment_transaction_list_interac"/>
<field name="domain">[('provider_code', '=', 'interac'), ('state', '=', 'pending')]</field>
</record>
<menuitem id="menu_interac_root" name="Interac Payments" sequence="29"
groups="community_interac.group_interac_verifier"/>
<menuitem id="menu_interac_pending" name="Pending Payments"
parent="menu_interac_root" action="action_payment_transaction_interac_pending" sequence="10"/>
</odoo>