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:
parent
91e91fe1b8
commit
7e860b65c5
@ -0,0 +1,2 @@
|
||||
from . import models
|
||||
from . import controllers
|
||||
@ -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,
|
||||
|
||||
3
addons/community_interac/const.py
Normal file
3
addons/community_interac/const.py
Normal file
@ -0,0 +1,3 @@
|
||||
DEFAULT_PAYMENT_METHOD_CODES = {
|
||||
'interac',
|
||||
}
|
||||
1
addons/community_interac/controllers/__init__.py
Normal file
1
addons/community_interac/controllers/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
from . import main
|
||||
15
addons/community_interac/controllers/main.py
Normal file
15
addons/community_interac/controllers/main.py
Normal 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')
|
||||
14
addons/community_interac/data/ir_cron.xml
Normal file
14
addons/community_interac/data/ir_cron.xml
Normal 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>
|
||||
44
addons/community_interac/data/mail_templates.xml
Normal file
44
addons/community_interac/data/mail_templates.xml
Normal 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>
|
||||
13
addons/community_interac/data/payment_method_data.xml
Normal file
13
addons/community_interac/data/payment_method_data.xml
Normal 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>
|
||||
15
addons/community_interac/data/payment_provider_data.xml
Normal file
15
addons/community_interac/data/payment_provider_data.xml
Normal 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>
|
||||
2
addons/community_interac/models/__init__.py
Normal file
2
addons/community_interac/models/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
from . import payment_provider
|
||||
from . import payment_transaction
|
||||
27
addons/community_interac/models/payment_provider.py
Normal file
27
addons/community_interac/models/payment_provider.py
Normal 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
|
||||
61
addons/community_interac/models/payment_transaction.py
Normal file
61
addons/community_interac/models/payment_transaction.py
Normal 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
|
||||
19
addons/community_interac/security/interac_security.xml
Normal file
19
addons/community_interac/security/interac_security.xml
Normal 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>
|
||||
@ -0,0 +1 @@
|
||||
from . import test_interac
|
||||
69
addons/community_interac/tests/test_interac.py
Normal file
69
addons/community_interac/tests/test_interac.py
Normal 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')
|
||||
@ -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>
|
||||
32
addons/community_interac/views/payment_transaction_views.xml
Normal file
32
addons/community_interac/views/payment_transaction_views.xml
Normal 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>
|
||||
Loading…
x
Reference in New Issue
Block a user