Browse Source
Merge pull request #67 from LasLabs/release/10.0/LABS-411-contract_auto
Merge pull request #67 from LasLabs/release/10.0/LABS-411-contract_auto
[10.0] [ADD] contract_auto: Automatic Payment For Contractspull/104/head
Dave Lasley
7 years ago
committed by
GitHub
17 changed files with 1027 additions and 7 deletions
-
14.travis.yml
-
89contract_payment_auto/README.rst
-
5contract_payment_auto/__init__.py
-
27contract_payment_auto/__manifest__.py
-
19contract_payment_auto/data/ir_cron_data.xml
-
98contract_payment_auto/data/mail_template_data.xml
-
8contract_payment_auto/models/__init__.py
-
174contract_payment_auto/models/account_analytic_account.py
-
108contract_payment_auto/models/account_analytic_contract.py
-
12contract_payment_auto/models/account_invoice.py
-
18contract_payment_auto/models/res_partner.py
-
6contract_payment_auto/tests/__init__.py
-
303contract_payment_auto/tests/test_account_analytic_account.py
-
51contract_payment_auto/tests/test_account_analytic_contract.py
-
44contract_payment_auto/views/account_analytic_account_view.xml
-
36contract_payment_auto/views/account_analytic_contract_view.xml
-
22contract_payment_auto/views/res_partner_view.xml
@ -0,0 +1,89 @@ |
|||
.. image:: https://img.shields.io/badge/licence-AGPL--3-blue.svg |
|||
:target: http://www.gnu.org/licenses/agpl-3.0-standalone.html |
|||
:alt: License: AGPL-3 |
|||
|
|||
===================== |
|||
Contract Auto Payment |
|||
===================== |
|||
|
|||
This module allows for the configuration of automatic payments on invoices that are created by a contract. |
|||
|
|||
Usage |
|||
===== |
|||
|
|||
Enable Automatic Payment |
|||
------------------------ |
|||
|
|||
* Navigate to a customer contract |
|||
* Check the `Auto Pay?` box to enable automatic payment |
|||
* Configure the options as desired |
|||
* Set the `Payment Token` to the payment token that should be used for automatic payment |
|||
|
|||
Automatic Payment Settings |
|||
-------------------------- |
|||
|
|||
The following settings are available at both the contract and contract template level: |
|||
|
|||
| Name | Description | |
|||
|------|-------------| |
|||
| Invoice Message | Message template that is used to send invoices to customers upon creation. | |
|||
| Payment Retry Message | Message template that is used to alert a customer that their automatic payment failed for some reason and will be retried. | |
|||
| Payment Fail Message | Message template that is used to alert a customer that their automatic payment failed and will no longer be retried. | |
|||
| Auto Pay Retries | Amount of times to attempt an automatic payment before discontinuing and removing the payment token from the contract/account payment method. | |
|||
| Auto Pay Retry Hours | Amount of hours that should lapse until retrying failed payments. | |
|||
|
|||
Payment Token |
|||
------------- |
|||
|
|||
A valid payment token is required to use this module. These tokens are typically created during the `website_sale` checkout process, but they can also be created manually at the acquirer. |
|||
|
|||
A payment token can be defined in one of two areas: |
|||
|
|||
* Contract - Defining a payment token in the contract will allow for the use of this token for automatic payments on this contract only. |
|||
* Partner - Defining a payment token in the partner will allow for the use of this token for automatic payments on all contracts for this partner that do not have a payment token defined. |
|||
|
|||
.. image:: https://odoo-community.org/website/image/ir.attachment/5784_f2813bd/datas |
|||
:alt: Try me on Runbot |
|||
:target: https://runbot.odoo-community.org/runbot/110/10.0 |
|||
|
|||
Known issues / Roadmap |
|||
====================== |
|||
|
|||
* None |
|||
|
|||
Bug Tracker |
|||
=========== |
|||
|
|||
Bugs are tracked on `GitHub Issues |
|||
<https://github.com/OCA/contract/issues>`_. In case of trouble, please |
|||
check there if your issue has already been reported. If you spotted it first, |
|||
help us smash it by providing detailed and welcomed feedback. |
|||
|
|||
Credits |
|||
======= |
|||
|
|||
Images |
|||
------ |
|||
|
|||
* Odoo Community Association: `Icon <https://github.com/OCA/maintainer-tools/blob/master/template/module/static/description/icon.svg>`_. |
|||
|
|||
Contributors |
|||
------------ |
|||
|
|||
* Dave Lasley <dave@laslabs.com> |
|||
|
|||
|
|||
Maintainer |
|||
---------- |
|||
|
|||
.. image:: https://odoo-community.org/logo.png |
|||
:alt: Odoo Community Association |
|||
:target: https://odoo-community.org |
|||
|
|||
This module is maintained by the OCA. |
|||
|
|||
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. |
|||
|
|||
To contribute to this module, please visit https://odoo-community.org. |
@ -0,0 +1,5 @@ |
|||
# -*- coding: utf-8 -*- |
|||
# Copyright 2017 LasLabs Inc. |
|||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). |
|||
|
|||
from . import models |
@ -0,0 +1,27 @@ |
|||
# -*- coding: utf-8 -*- |
|||
# Copyright 2017 LasLabs Inc. |
|||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). |
|||
|
|||
{ |
|||
"name": "Contract - Auto Payment", |
|||
"summary": "Adds automatic payments to contracts.", |
|||
"version": "10.0.1.0.0", |
|||
"category": "Contract Management", |
|||
"license": "AGPL-3", |
|||
"author": "LasLabs, " |
|||
"Odoo Community Association (OCA)", |
|||
"website": "https://laslabs.com", |
|||
"depends": [ |
|||
"contract", |
|||
"payment", |
|||
], |
|||
"data": [ |
|||
"data/mail_template_data.xml", |
|||
"data/ir_cron_data.xml", |
|||
"views/account_analytic_account_view.xml", |
|||
"views/account_analytic_contract_view.xml", |
|||
"views/res_partner_view.xml", |
|||
], |
|||
"installable": True, |
|||
"application": False, |
|||
} |
@ -0,0 +1,19 @@ |
|||
<?xml version="1.0" encoding="utf-8"?> |
|||
|
|||
<!-- |
|||
Copyright 2017 LasLabs Inc. |
|||
License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). |
|||
--> |
|||
|
|||
<odoo noupdate="1"> |
|||
|
|||
<record id="ir_cron_auto_pay" model="ir.cron"> |
|||
<field name="name">Contract Automatic Payments</field> |
|||
<field name="interval_type">hours</field> |
|||
<field name="interval_number">1</field> |
|||
<field name="model">account.analytic.account</field> |
|||
<field name="function">cron_retry_auto_pay</field> |
|||
<field name="args">()</field> |
|||
</record> |
|||
|
|||
</odoo> |
@ -0,0 +1,98 @@ |
|||
<?xml version="1.0" encoding="utf-8"?> |
|||
|
|||
<!-- |
|||
Copyright 2017 LasLabs Inc. |
|||
License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). |
|||
--> |
|||
|
|||
<odoo noupdate="1"> |
|||
|
|||
<record id="mail_template_auto_pay_retry" model="mail.template"> |
|||
<field name="name">Invoice - AutoPay To Retry</field> |
|||
<field name="email_from">${(object.user_id.email and '%s <%s>' % (object.user_id.name, object.user_id.email) or '')|safe}</field> |
|||
<field name="subject">Automatic Payment Failure (Ref ${object.number or 'n/a'})</field> |
|||
<field name="partner_to">${object.partner_id.id}</field> |
|||
<field name="model_id" ref="account.model_account_invoice"/> |
|||
<field name="auto_delete" eval="True"/> |
|||
<field name="report_template" ref="account.account_invoices"/> |
|||
<field name="report_name">Invoice_${(object.number or '').replace('/','_')}_${object.state == 'draft' and 'draft' or ''}</field> |
|||
<field name="lang">${object.partner_id.lang}</field> |
|||
<field name="body_html"><![CDATA[ |
|||
|
|||
<p> |
|||
Hello ${object.partner_id.name} |
|||
% set access_action = object.get_access_action() |
|||
% set access_url = access_action['type'] == 'ir.actions.act_url' and access_action['url'] or '/report/pdf/account.report_invoice/' + str(object.id) |
|||
% if object.partner_id.parent_id: |
|||
(<i>${object.partner_id.parent_id.name}</i>) |
|||
% endif |
|||
, |
|||
</p> |
|||
|
|||
<p> |
|||
The automatic payment for your invoice |
|||
<a href="${access_url}"> |
|||
<strong> |
|||
${object.number} |
|||
</strong> |
|||
% if object.origin: |
|||
(with reference: ${object.origin} ) |
|||
% endif |
|||
</a> |
|||
failed. |
|||
</p> |
|||
|
|||
<p> |
|||
Please verify that your payment information is correct, and that funds are |
|||
available in the account. |
|||
</p> |
|||
|
|||
]]> |
|||
</field> |
|||
</record> |
|||
|
|||
<record id="mail_template_auto_pay_fail" model="mail.template"> |
|||
<field name="name">Invoice - AutoPay Failed</field> |
|||
<field name="email_from">${(object.user_id.email and '%s <%s>' % (object.user_id.name, object.user_id.email) or '')|safe}</field> |
|||
<field name="subject">Automatic Payment Failure (Ref ${object.number or 'n/a'})</field> |
|||
<field name="partner_to">${object.partner_id.id}</field> |
|||
<field name="model_id" ref="account.model_account_invoice"/> |
|||
<field name="auto_delete" eval="True"/> |
|||
<field name="report_template" ref="account.account_invoices"/> |
|||
<field name="report_name">Invoice_${(object.number or '').replace('/','_')}_${object.state == 'draft' and 'draft' or ''}</field> |
|||
<field name="lang">${object.partner_id.lang}</field> |
|||
<field name="body_html"><![CDATA[ |
|||
|
|||
<p> |
|||
Hello ${object.partner_id.name} |
|||
% set access_action = object.get_access_action() |
|||
% set access_url = access_action['type'] == 'ir.actions.act_url' and access_action['url'] or '/report/pdf/account.report_invoice/' + str(object.id) |
|||
% if object.partner_id.parent_id: |
|||
(<i>${object.partner_id.parent_id.name}</i>) |
|||
% endif |
|||
, |
|||
</p> |
|||
|
|||
<p> |
|||
The automatic payment for your invoice |
|||
<a href="${access_url}"> |
|||
<strong> |
|||
${object.number} |
|||
</strong> |
|||
% if object.origin: |
|||
(with reference: ${object.origin} ) |
|||
% endif |
|||
</a> |
|||
failed. |
|||
</p> |
|||
|
|||
<p> |
|||
Please verify that your payment information is correct, and that funds are |
|||
available in the account. |
|||
</p> |
|||
|
|||
]]> |
|||
</field> |
|||
</record> |
|||
|
|||
</odoo> |
@ -0,0 +1,8 @@ |
|||
# -*- coding: utf-8 -*- |
|||
# Copyright 2017 LasLabs Inc. |
|||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). |
|||
|
|||
from . import account_analytic_account |
|||
from . import account_analytic_contract |
|||
from . import account_invoice |
|||
from . import res_partner |
@ -0,0 +1,174 @@ |
|||
# -*- coding: utf-8 -*- |
|||
# Copyright 2017 LasLabs Inc. |
|||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). |
|||
|
|||
import logging |
|||
|
|||
from datetime import datetime, timedelta |
|||
|
|||
from odoo import api, fields, models, _ |
|||
|
|||
|
|||
_logger = logging.getLogger(__name__) |
|||
|
|||
|
|||
class AccountAnalyticAccount(models.Model): |
|||
_inherit = 'account.analytic.account' |
|||
|
|||
payment_token_id = fields.Many2one( |
|||
string='Payment Token', |
|||
comodel_name='payment.token', |
|||
domain="[('partner_id', '=', partner_id)]", |
|||
context="{'default_partner_id': partner_id}", |
|||
help='This is the payment token that will be used to automatically ' |
|||
'reconcile debts against this account. If none is set, the ' |
|||
'bill to partner\'s default token will be used.', |
|||
) |
|||
|
|||
@api.multi |
|||
@api.onchange('partner_id') |
|||
def _onchange_partner_id_payment_token(self): |
|||
""" Clear the payment token when the partner is changed. """ |
|||
self.payment_token_id = self.env['payment.token'] |
|||
|
|||
@api.model |
|||
def cron_retry_auto_pay(self): |
|||
""" Retry automatic payments for appropriate invoices. """ |
|||
|
|||
invoice_lines = self.env['account.invoice.line'].search([ |
|||
('invoice_id.state', '=', 'open'), |
|||
('invoice_id.auto_pay_attempts', '>', 0), |
|||
('account_analytic_id.is_auto_pay', '=', True), |
|||
]) |
|||
now = datetime.now() |
|||
|
|||
for invoice_line in invoice_lines: |
|||
|
|||
account = invoice_line.account_analytic_id |
|||
invoice = invoice_line.invoice_id |
|||
fail_time = fields.Datetime.from_string(invoice.auto_pay_failed) |
|||
retry_delta = timedelta(hours=account.auto_pay_retry_hours) |
|||
retry_time = fail_time + retry_delta |
|||
|
|||
if retry_time < now: |
|||
account._do_auto_pay(invoice) |
|||
|
|||
@api.multi |
|||
def _create_invoice(self): |
|||
""" If automatic payment is enabled, perform auto pay actions. """ |
|||
invoice = super(AccountAnalyticAccount, self)._create_invoice() |
|||
if not self.is_auto_pay: |
|||
return invoice |
|||
self._do_auto_pay(invoice) |
|||
return invoice |
|||
|
|||
@api.multi |
|||
def _do_auto_pay(self, invoice): |
|||
""" Perform all automatic payment operations on open invoices. """ |
|||
self.ensure_one() |
|||
invoice.ensure_one() |
|||
invoice.action_invoice_open() |
|||
self._send_invoice_message(invoice) |
|||
self._pay_invoice(invoice) |
|||
|
|||
@api.multi |
|||
def _pay_invoice(self, invoice): |
|||
""" Pay the invoice using the account or partner token. """ |
|||
|
|||
if invoice.state != 'open': |
|||
_logger.info('Cannot pay an invoice that is not in open state.') |
|||
return |
|||
|
|||
if not invoice.residual: |
|||
_logger.debug('Cannot pay an invoice with no balance.') |
|||
return |
|||
|
|||
token = self.payment_token_id or self.partner_id.payment_token_id |
|||
if not token: |
|||
_logger.debug( |
|||
'Cannot pay an invoice without defining a payment token', |
|||
) |
|||
return |
|||
|
|||
transaction = self.env['payment.transaction'].create( |
|||
self._get_tx_vals(invoice), |
|||
) |
|||
valid_states = ['authorized', 'done'] |
|||
|
|||
try: |
|||
result = transaction.s2s_do_transaction() |
|||
if not result or transaction.state not in valid_states: |
|||
_logger.debug( |
|||
'Payment transaction failed (%s)', |
|||
transaction.state_message, |
|||
) |
|||
else: |
|||
# Success |
|||
return True |
|||
|
|||
except Exception: |
|||
_logger.exception( |
|||
'Payment transaction (%s) generated a gateway error.', |
|||
transaction.id, |
|||
) |
|||
|
|||
transaction.state = 'error' |
|||
invoice.write({ |
|||
'auto_pay_attempts': invoice.auto_pay_attempts + 1, |
|||
'auto_pay_failed': fields.Datetime.now(), |
|||
}) |
|||
|
|||
if invoice.auto_pay_attempts >= self.auto_pay_retries: |
|||
template = self.pay_fail_mail_template_id |
|||
self.write({ |
|||
'is_auto_pay': False, |
|||
'payment_token_id': False, |
|||
}) |
|||
if token == self.partner_id.payment_token_id: |
|||
self.partner_id.payment_token_id = False |
|||
|
|||
else: |
|||
template = self.pay_retry_mail_template_id |
|||
|
|||
if template: |
|||
template.send_mail(invoice.id) |
|||
|
|||
return |
|||
|
|||
@api.multi |
|||
def _get_tx_vals(self, invoice): |
|||
""" Return values for create of payment.transaction for invoice.""" |
|||
amount_due = invoice.residual |
|||
token = self.payment_token_id |
|||
partner = token.partner_id |
|||
reference = self.env['payment.transaction'].get_next_reference( |
|||
invoice.number, |
|||
) |
|||
return { |
|||
'reference': '%s' % reference, |
|||
'acquirer_id': token.acquirer_id.id, |
|||
'payment_token_id': token.id, |
|||
'amount': amount_due, |
|||
'state': 'draft', |
|||
'currency_id': invoice.currency_id.id, |
|||
'partner_id': partner.id, |
|||
'partner_country_id': partner.country_id.id, |
|||
'partner_city': partner.city, |
|||
'partner_zip': partner.zip, |
|||
'partner_email': partner.email, |
|||
} |
|||
|
|||
@api.multi |
|||
def _send_invoice_message(self, invoice): |
|||
""" Send the appropriate emails for the invoices if needed. """ |
|||
if invoice.sent: |
|||
return |
|||
if not self.invoice_mail_template_id: |
|||
return |
|||
_logger.info('Sending invoice %s, %s (template %s)', |
|||
invoice, invoice.number, self.invoice_mail_template_id) |
|||
mail_id = self.invoice_mail_template_id.send_mail(invoice.id) |
|||
invoice.with_context(mail_post_autofollow=True) |
|||
invoice.sent = True |
|||
invoice.message_post(body=_("Invoice sent")) |
|||
return self.env['mail.mail'].browse(mail_id) |
@ -0,0 +1,108 @@ |
|||
# -*- coding: utf-8 -*- |
|||
# Copyright 2017 LasLabs Inc. |
|||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). |
|||
|
|||
from odoo import api, fields, models |
|||
|
|||
|
|||
def _context_mail_templates(env): |
|||
return env['account.analytic.contract']._context_mail_templates() |
|||
|
|||
|
|||
class AccountAnalyticContract(models.Model): |
|||
_inherit = 'account.analytic.contract' |
|||
|
|||
invoice_mail_template_id = fields.Many2one( |
|||
string='Invoice Message', |
|||
comodel_name='mail.template', |
|||
default=lambda s: s._default_invoice_mail_template_id(), |
|||
domain="[('model', '=', 'account.invoice')]", |
|||
context=_context_mail_templates, |
|||
help="During the automatic payment process, an invoice will be " |
|||
"created and validated. If this template is selected, it will " |
|||
"automatically be sent to the customer during this process " |
|||
"using the defined template.", |
|||
) |
|||
pay_retry_mail_template_id = fields.Many2one( |
|||
string='Payment Retry Message', |
|||
comodel_name='mail.template', |
|||
default=lambda s: s._default_pay_retry_mail_template_id(), |
|||
domain="[('model', '=', 'account.invoice')]", |
|||
context=_context_mail_templates, |
|||
help="If automatic payment fails for some reason, but will be " |
|||
"re-attempted later, this message will be sent to the billed " |
|||
"partner.", |
|||
) |
|||
pay_fail_mail_template_id = fields.Many2one( |
|||
string='Payment Failed Message', |
|||
comodel_name='mail.template', |
|||
default=lambda s: s._default_pay_fail_mail_template_id(), |
|||
domain="[('model', '=', 'account.invoice')]", |
|||
context=_context_mail_templates, |
|||
help="If automatic payment fails for some reason, this message " |
|||
"will be sent to the billed partner.", |
|||
) |
|||
is_auto_pay = fields.Boolean( |
|||
string='Auto Pay?', |
|||
default=True, |
|||
help="Check this to enable automatic payment for invoices that are " |
|||
"created for this contract.", |
|||
) |
|||
auto_pay_retries = fields.Integer( |
|||
default=lambda s: s._default_auto_pay_retries(), |
|||
help="Amount times to retry failed/declined automatic payment " |
|||
"before giving up." |
|||
) |
|||
auto_pay_retry_hours = fields.Integer( |
|||
default=lambda s: s._default_auto_pay_retry_hours(), |
|||
help="Amount of hours that should lapse until a failed automatic " |
|||
"is retried.", |
|||
) |
|||
|
|||
@api.model |
|||
def _default_invoice_mail_template_id(self): |
|||
return self.env.ref( |
|||
'account.email_template_edi_invoice', |
|||
raise_if_not_found=False, |
|||
) |
|||
|
|||
@api.model |
|||
def _default_pay_retry_mail_template_id(self): |
|||
return self.env.ref( |
|||
'contract_payment_auto.mail_template_auto_pay_retry', |
|||
raise_if_not_found=False, |
|||
) |
|||
|
|||
@api.model |
|||
def _default_pay_fail_mail_template_id(self): |
|||
return self.env.ref( |
|||
'contract_payment_auto.mail_template_auto_pay_fail', |
|||
raise_if_not_found=False, |
|||
) |
|||
|
|||
@api.model |
|||
def _default_auto_pay_retries(self): |
|||
return 3 |
|||
|
|||
@api.model |
|||
def _default_auto_pay_retry_hours(self): |
|||
return 24 |
|||
|
|||
@api.model |
|||
def _context_mail_templates(self): |
|||
""" Return a context for use in mail templates. """ |
|||
default_model = self.env.ref('account.model_account_invoice') |
|||
report_template = self.env.ref('account.account_invoices') |
|||
return { |
|||
'default_model_id': default_model.id, |
|||
'default_email_from': "${(object.user_id.email and '%s <%s>' % " |
|||
"(object.user_id.name, object.user_id.email)" |
|||
" or '')|safe}", |
|||
'default_partner_to': '${object.partner_id.id}', |
|||
'default_lang': '${object.partner_id.lang}', |
|||
'default_auto_delete': True, |
|||
'report_template': report_template.id, |
|||
'report_name': "Invoice_${(object.number or '').replace('/','_')}" |
|||
"_${object.state == 'draft' and 'draft' or ''}", |
|||
|
|||
} |
@ -0,0 +1,12 @@ |
|||
# -*- coding: utf-8 -*- |
|||
# Copyright 2017 LasLabs Inc. |
|||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). |
|||
|
|||
from odoo import fields, models |
|||
|
|||
|
|||
class AccountInvoice(models.Model): |
|||
_inherit = 'account.invoice' |
|||
|
|||
auto_pay_attempts = fields.Integer() |
|||
auto_pay_failed = fields.Datetime() |
@ -0,0 +1,18 @@ |
|||
# -*- coding: utf-8 -*- |
|||
# Copyright 2017 LasLabs Inc. |
|||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). |
|||
|
|||
from odoo import fields, models |
|||
|
|||
|
|||
class ResPartner(models.Model): |
|||
_inherit = 'res.partner' |
|||
|
|||
payment_token_id = fields.Many2one( |
|||
string='Payment Token', |
|||
comodel_name='payment.token', |
|||
domain="[('id', 'in', payment_token_ids)]", |
|||
help='This is the payment token that will be used to automatically ' |
|||
'reconcile debts for this partner, if there is not one already ' |
|||
'set on the analytic account.', |
|||
) |
@ -0,0 +1,6 @@ |
|||
# -*- coding: utf-8 -*- |
|||
# Copyright 2017 LasLabs Inc. |
|||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). |
|||
|
|||
from . import test_account_analytic_account |
|||
from . import test_account_analytic_contract |
@ -0,0 +1,303 @@ |
|||
# -*- coding: utf-8 -*- |
|||
# Copyright 2017 LasLabs Inc. |
|||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). |
|||
|
|||
import mock |
|||
|
|||
from contextlib import contextmanager |
|||
|
|||
from odoo import fields |
|||
from odoo.tools import mute_logger |
|||
from odoo.tests.common import TransactionCase |
|||
|
|||
from ..models import account_analytic_account |
|||
|
|||
|
|||
class TestAccountAnalyticAccount(TransactionCase): |
|||
|
|||
def setUp(self): |
|||
super(TestAccountAnalyticAccount, self).setUp() |
|||
self.Model = self.env['account.analytic.account'] |
|||
self.partner = self.env.ref('base.res_partner_2') |
|||
self.product = self.env.ref('product.product_product_2') |
|||
self.product.taxes_id += self.env['account.tax'].search( |
|||
[('type_tax_use', '=', 'sale')], limit=1) |
|||
self.product.description_sale = 'Test description sale' |
|||
self.template_vals = { |
|||
'recurring_rule_type': 'yearly', |
|||
'recurring_interval': 12345, |
|||
'name': 'Test Contract Template', |
|||
'is_auto_pay': True, |
|||
} |
|||
self.template = self.env['account.analytic.contract'].create( |
|||
self.template_vals, |
|||
) |
|||
self.acquirer = self.env['payment.acquirer'].create({ |
|||
'name': 'Test Acquirer', |
|||
'provider': 'manual', |
|||
'view_template_id': self.env['ir.ui.view'].search([], limit=1).id, |
|||
}) |
|||
self.payment_token = self.env['payment.token'].create({ |
|||
'name': 'Test Token', |
|||
'partner_id': self.partner.id, |
|||
'active': True, |
|||
'acquirer_id': self.acquirer.id, |
|||
'acquirer_ref': 'Test', |
|||
}) |
|||
self.contract = self.Model.create({ |
|||
'name': 'Test Contract', |
|||
'partner_id': self.partner.id, |
|||
'pricelist_id': self.partner.property_product_pricelist.id, |
|||
'recurring_invoices': True, |
|||
'date_start': '2016-02-15', |
|||
'recurring_next_date': fields.Datetime.now(), |
|||
'payment_token_id': self.payment_token.id, |
|||
}) |
|||
self.contract_line = self.env['account.analytic.invoice.line'].create({ |
|||
'analytic_account_id': self.contract.id, |
|||
'product_id': self.product.id, |
|||
'name': 'Services from #START# to #END#', |
|||
'quantity': 1, |
|||
'uom_id': self.product.uom_id.id, |
|||
'price_unit': 100, |
|||
'discount': 50, |
|||
}) |
|||
|
|||
def _validate_invoice(self, invoice): |
|||
self.assertEqual(len(invoice), 1) |
|||
self.assertEqual(invoice._name, 'account.invoice') |
|||
|
|||
def _create_invoice(self, open=False, sent=False): |
|||
self.contract.is_auto_pay = False |
|||
invoice = self.contract._create_invoice() |
|||
if open or sent: |
|||
invoice.action_invoice_open() |
|||
if sent: |
|||
invoice.sent = True |
|||
self.contract.is_auto_pay = True |
|||
return invoice |
|||
|
|||
@contextmanager |
|||
def _mock_transaction(self, state='authorized', s2s_side_effect=None): |
|||
|
|||
Transactions = self.contract.env['payment.transaction'] |
|||
TransactionsCreate = Transactions.create |
|||
|
|||
if not callable(s2s_side_effect): |
|||
s2s_side_effect = [s2s_side_effect] |
|||
|
|||
s2s = mock.MagicMock() |
|||
s2s.side_effect = s2s_side_effect |
|||
|
|||
def create(vals): |
|||
record = TransactionsCreate(vals) |
|||
record.state = state |
|||
return record |
|||
|
|||
model_create = mock.MagicMock() |
|||
model_create.side_effect = create |
|||
|
|||
Transactions._patch_method('create', model_create) |
|||
Transactions._patch_method('s2s_do_transaction', s2s) |
|||
|
|||
try: |
|||
yield |
|||
finally: |
|||
Transactions._revert_method('create') |
|||
Transactions._revert_method('s2s_do_transaction') |
|||
|
|||
def test_onchange_partner_id_payment_token(self): |
|||
""" It should clear the payment token. """ |
|||
self.assertTrue(self.contract.payment_token_id) |
|||
self.contract._onchange_partner_id_payment_token() |
|||
self.assertFalse(self.contract.payment_token_id) |
|||
|
|||
def test_create_invoice_no_autopay(self): |
|||
""" It should return the new invoice without calling autopay. """ |
|||
self.contract.is_auto_pay = False |
|||
with mock.patch.object(self.contract, '_do_auto_pay') as method: |
|||
invoice = self.contract._create_invoice() |
|||
self._validate_invoice(invoice) |
|||
method.assert_not_called() |
|||
|
|||
def test_create_invoice_autopay(self): |
|||
""" It should return the new invoice after calling autopay. """ |
|||
with mock.patch.object(self.contract, '_do_auto_pay') as method: |
|||
invoice = self.contract._create_invoice() |
|||
self._validate_invoice(invoice) |
|||
method.assert_called_once_with(invoice) |
|||
|
|||
def test_do_auto_pay_ensure_one(self): |
|||
""" It should ensure_one on self. """ |
|||
with self.assertRaises(ValueError): |
|||
self.env['account.analytic.account']._do_auto_pay( |
|||
self._create_invoice(), |
|||
) |
|||
|
|||
def test_do_auto_pay_invoice_ensure_one(self): |
|||
""" It should ensure_one on the invoice. """ |
|||
with self.assertRaises(ValueError): |
|||
self.contract._do_auto_pay( |
|||
self.env['account.invoice'], |
|||
) |
|||
|
|||
def test_do_auto_pay_open_invoice(self): |
|||
""" It should open the invoice. """ |
|||
invoice = self._create_invoice() |
|||
self.contract._do_auto_pay(invoice) |
|||
self.assertEqual(invoice.state, 'open') |
|||
|
|||
def test_do_auto_pay_sends_message(self): |
|||
""" It should call the send message method with the invoice. """ |
|||
with mock.patch.object(self.contract, '_send_invoice_message') as m: |
|||
invoice = self._create_invoice() |
|||
self.contract._do_auto_pay(invoice) |
|||
m.assert_called_once_with(invoice) |
|||
|
|||
def test_do_auto_pay_does_pay(self): |
|||
""" It should try to pay the invoice. """ |
|||
with mock.patch.object(self.contract, '_pay_invoice') as m: |
|||
invoice = self._create_invoice() |
|||
self.contract._do_auto_pay(invoice) |
|||
m.assert_called_once_with(invoice) |
|||
|
|||
def test_pay_invoice_not_open(self): |
|||
""" It should return None if the invoice isn't open. """ |
|||
invoice = self._create_invoice() |
|||
res = self.contract._pay_invoice(invoice) |
|||
self.assertIs(res, None) |
|||
|
|||
def test_pay_invoice_no_residual(self): |
|||
""" It should return None if no residual on the invoice. """ |
|||
invoice = self._create_invoice() |
|||
invoice.state = 'open' |
|||
res = self.contract._pay_invoice(invoice) |
|||
self.assertIs(res, None) |
|||
|
|||
def test_pay_invoice_no_token(self): |
|||
""" It should return None if no payment token. """ |
|||
self.contract.payment_token_id = False |
|||
invoice = self._create_invoice(True) |
|||
res = self.contract._pay_invoice(invoice) |
|||
self.assertIs(res, None) |
|||
|
|||
def test_pay_invoice_success(self): |
|||
""" It should return True on success. """ |
|||
with self._mock_transaction(s2s_side_effect=True): |
|||
invoice = self._create_invoice(True) |
|||
res = self.contract._pay_invoice(invoice) |
|||
self.assertTrue(res) |
|||
|
|||
@mute_logger(account_analytic_account.__name__) |
|||
def test_pay_invoice_exception(self): |
|||
""" It should catch exceptions. """ |
|||
with self._mock_transaction(s2s_side_effect=Exception): |
|||
invoice = self._create_invoice(True) |
|||
res = self.contract._pay_invoice(invoice) |
|||
self.assertIs(res, None) |
|||
|
|||
def test_pay_invoice_invalid_state(self): |
|||
""" It should return None on invalid state. """ |
|||
with self._mock_transaction(s2s_side_effect=True): |
|||
invoice = self._create_invoice(True) |
|||
invoice.state = 'draft' |
|||
res = self.contract._pay_invoice(invoice) |
|||
self.assertIs(res, None) |
|||
|
|||
@mute_logger(account_analytic_account.__name__) |
|||
def test_pay_invoice_increments_retries(self): |
|||
""" It should increment invoice retries on failure. """ |
|||
with self._mock_transaction(s2s_side_effect=False): |
|||
invoice = self._create_invoice(True) |
|||
self.assertFalse(invoice.auto_pay_attempts) |
|||
self.contract._pay_invoice(invoice) |
|||
self.assertTrue(invoice.auto_pay_attempts) |
|||
|
|||
def test_pay_invoice_updates_fail_date(self): |
|||
""" It should update the invoice auto pay fail date on failure. """ |
|||
with self._mock_transaction(s2s_side_effect=False): |
|||
invoice = self._create_invoice(True) |
|||
self.assertFalse(invoice.auto_pay_failed) |
|||
self.contract._pay_invoice(invoice) |
|||
self.assertTrue(invoice.auto_pay_failed) |
|||
|
|||
def test_pay_invoice_too_many_attempts(self): |
|||
""" It should clear autopay after too many attempts. """ |
|||
with self._mock_transaction(s2s_side_effect=False): |
|||
invoice = self._create_invoice(True) |
|||
invoice.auto_pay_attempts = self.contract.auto_pay_retries - 1 |
|||
self.contract._pay_invoice(invoice) |
|||
self.assertFalse(self.contract.is_auto_pay) |
|||
self.assertFalse(self.contract.payment_token_id) |
|||
|
|||
def test_pay_invoice_too_many_attempts_partner_token(self): |
|||
""" It should clear the partner token when attempts were on it. """ |
|||
self.partner.payment_token_id = self.contract.payment_token_id |
|||
with self._mock_transaction(s2s_side_effect=False): |
|||
invoice = self._create_invoice(True) |
|||
invoice.auto_pay_attempts = self.contract.auto_pay_retries |
|||
self.contract._pay_invoice(invoice) |
|||
self.assertFalse(self.partner.payment_token_id) |
|||
|
|||
def test_get_tx_vals(self): |
|||
""" It should return a dict. """ |
|||
self.assertIsInstance( |
|||
self.contract._get_tx_vals(self._create_invoice()), |
|||
dict, |
|||
) |
|||
|
|||
def test_send_invoice_message_sent(self): |
|||
""" It should return None if the invoice has already been sent. """ |
|||
invoice = self._create_invoice(sent=True) |
|||
res = self.contract._send_invoice_message(invoice) |
|||
self.assertIs(res, None) |
|||
|
|||
def test_send_invoice_message_no_template(self): |
|||
""" It should return None if the invoice isn't sent. """ |
|||
invoice = self._create_invoice(True) |
|||
self.contract.invoice_mail_template_id = False |
|||
res = self.contract._send_invoice_message(invoice) |
|||
self.assertIs(res, None) |
|||
|
|||
def test_send_invoice_message_sets_invoice_state(self): |
|||
""" It should set the invoice to sent. """ |
|||
invoice = self._create_invoice(True) |
|||
self.assertFalse(invoice.sent) |
|||
self.contract._send_invoice_message(invoice) |
|||
self.assertTrue(invoice.sent) |
|||
|
|||
def test_send_invoice_message_returns_mail(self): |
|||
""" It should create and return the message. """ |
|||
invoice = self._create_invoice(True) |
|||
res = self.contract._send_invoice_message(invoice) |
|||
self.assertEqual(res._name, 'mail.mail') |
|||
|
|||
def test_cron_retry_auto_pay_needed(self): |
|||
""" It should auto-pay the correct invoice if needed. """ |
|||
invoice = self._create_invoice(True) |
|||
invoice.write({ |
|||
'auto_pay_attempts': 1, |
|||
'auto_pay_failed': '2015-01-01 00:00:00', |
|||
}) |
|||
meth = mock.MagicMock() |
|||
self.contract._patch_method('_do_auto_pay', meth) |
|||
try: |
|||
self.contract.cron_retry_auto_pay() |
|||
finally: |
|||
self.contract._revert_method('_do_auto_pay') |
|||
meth.assert_called_once_with(invoice) |
|||
|
|||
def test_cron_retry_auto_pay_skip(self): |
|||
""" It should skip invoices that don't need to be paid. """ |
|||
invoice = self._create_invoice(True) |
|||
invoice.write({ |
|||
'auto_pay_attempts': 1, |
|||
'auto_pay_failed': fields.Datetime.now(), |
|||
}) |
|||
meth = mock.MagicMock() |
|||
self.contract._patch_method('_do_auto_pay', meth) |
|||
try: |
|||
self.contract.cron_retry_auto_pay() |
|||
finally: |
|||
self.contract._revert_method('_do_auto_pay') |
|||
meth.assert_not_called() |
@ -0,0 +1,51 @@ |
|||
# -*- coding: utf-8 -*- |
|||
# Copyright 2017 LasLabs Inc. |
|||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). |
|||
|
|||
from odoo.tests.common import TransactionCase |
|||
|
|||
|
|||
class TestAccountAnalyticContract(TransactionCase): |
|||
|
|||
def setUp(self): |
|||
super(TestAccountAnalyticContract, self).setUp() |
|||
self.Model = self.env['account.analytic.contract'] |
|||
|
|||
def test_default_invoice_mail_template_id(self): |
|||
""" It should return a mail template associated with invoice. """ |
|||
res = self.Model._default_invoice_mail_template_id() |
|||
self.assertEqual( |
|||
res.model, 'account.invoice', |
|||
) |
|||
|
|||
def test_default_pay_retry_mail_template_id(self): |
|||
""" It should return a mail template associated with invoice. """ |
|||
res = self.Model._default_pay_retry_mail_template_id() |
|||
self.assertEqual( |
|||
res.model, 'account.invoice', |
|||
) |
|||
|
|||
def test_default_pay_fail_mail_template_id(self): |
|||
""" It should return a mail template associated with invoice. """ |
|||
res = self.Model._default_pay_fail_mail_template_id() |
|||
self.assertEqual( |
|||
res.model, 'account.invoice', |
|||
) |
|||
|
|||
def test_default_auto_pay_retries(self): |
|||
""" It should return an int. """ |
|||
self.assertIsInstance( |
|||
self.Model._default_auto_pay_retries(), int, |
|||
) |
|||
|
|||
def test_default_auto_pay_retry_hours(self): |
|||
""" It should return an int. """ |
|||
self.assertIsInstance( |
|||
self.Model._default_auto_pay_retry_hours(), int, |
|||
) |
|||
|
|||
def test_context_mail_templates(self): |
|||
""" It should return a dict. """ |
|||
self.assertIsInstance( |
|||
self.Model._context_mail_templates(), dict, |
|||
) |
@ -0,0 +1,44 @@ |
|||
<?xml version="1.0" encoding="utf-8"?> |
|||
|
|||
<!-- |
|||
Copyright 2017 LasLabs Inc. |
|||
License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). |
|||
--> |
|||
|
|||
<odoo> |
|||
|
|||
<record id="account_analytic_account_recurring_form_form" model="ir.ui.view"> |
|||
<field name="name">Contract Auto Pay</field> |
|||
<field name="model">account.analytic.account</field> |
|||
<field name="inherit_id" ref="contract.account_analytic_account_recurring_form_form"/> |
|||
<field name="arch" type="xml"> |
|||
<xpath expr="//button[@name='contract.act_recurring_invoices']" position="after"> |
|||
<br attrs="{'invisible': [('recurring_invoices','!=',True)]}" /> |
|||
<field name="is_auto_pay" |
|||
class="oe_inline" |
|||
attrs="{'invisible': [('recurring_invoices','!=',True)]}" |
|||
/> |
|||
<label for="is_auto_pay" |
|||
attrs="{'invisible': [('recurring_invoices','!=',True)]}" |
|||
/> |
|||
</xpath> |
|||
<xpath expr="//label[@for='recurring_invoice_line_ids']" position="before"> |
|||
<group name="group_auto_pay" |
|||
attrs="{'invisible': [('is_auto_pay', '=', False)]}" |
|||
> |
|||
<group> |
|||
<field name="payment_token_id" /> |
|||
<field name="invoice_mail_template_id" /> |
|||
<field name="pay_retry_mail_template_id" /> |
|||
</group> |
|||
<group> |
|||
<field name="pay_fail_mail_template_id" /> |
|||
<field name="auto_pay_retries" /> |
|||
<field name="auto_pay_retry_hours" /> |
|||
</group> |
|||
</group> |
|||
</xpath> |
|||
</field> |
|||
</record> |
|||
|
|||
</odoo> |
@ -0,0 +1,36 @@ |
|||
<?xml version="1.0" encoding="utf-8"?> |
|||
|
|||
<!-- |
|||
Copyright 2017 LasLabs Inc. |
|||
License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). |
|||
--> |
|||
|
|||
<odoo> |
|||
|
|||
<record id="account_analytic_contract_view_form" model="ir.ui.view"> |
|||
<field name="name">Contract Template Auto Pay</field> |
|||
<field name="model">account.analytic.contract</field> |
|||
<field name="inherit_id" ref="contract.account_analytic_contract_view_form"/> |
|||
<field name="arch" type="xml"> |
|||
<xpath expr="//group[@name='group_main_right']" position="inside"> |
|||
<field name="is_auto_pay" /> |
|||
</xpath> |
|||
<xpath expr="//group[@name='group_main']" position="after"> |
|||
<group name="group_auto_pay" |
|||
attrs="{'invisible': [('is_auto_pay', '=', False)]}" |
|||
> |
|||
<group> |
|||
<field name="invoice_mail_template_id" /> |
|||
<field name="pay_retry_mail_template_id" /> |
|||
<field name="auto_pay_retry_hours" /> |
|||
</group> |
|||
<group> |
|||
<field name="pay_fail_mail_template_id" /> |
|||
<field name="auto_pay_retries" /> |
|||
</group> |
|||
</group> |
|||
</xpath> |
|||
</field> |
|||
</record> |
|||
|
|||
</odoo> |
@ -0,0 +1,22 @@ |
|||
<?xml version="1.0" encoding="utf-8"?> |
|||
|
|||
<!-- |
|||
Copyright 2017 LasLabs Inc. |
|||
License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). |
|||
--> |
|||
|
|||
<odoo> |
|||
|
|||
<record id="view_partner_form" model="ir.ui.view"> |
|||
<field name="name">Res Partner Auto Pay</field> |
|||
<field name="model">res.partner</field> |
|||
<field name="inherit_id" ref="base.view_partner_form"/> |
|||
<field name="arch" type="xml"> |
|||
<xpath expr="//group[@name='sale']" position="inside"> |
|||
<field name="payment_token_ids" invisible="1" /> |
|||
<field name="payment_token_id" /> |
|||
</xpath> |
|||
</field> |
|||
</record> |
|||
|
|||
</odoo> |
Write
Preview
Loading…
Cancel
Save
Reference in new issue