diff --git a/contract_payment_auto/README.rst b/contract_payment_auto/README.rst new file mode 100644 index 00000000..160757bf --- /dev/null +++ b/contract_payment_auto/README.rst @@ -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 +`_. 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 `_. + +Contributors +------------ + +* Dave Lasley + + +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. diff --git a/contract_payment_auto/__init__.py b/contract_payment_auto/__init__.py new file mode 100644 index 00000000..44db863b --- /dev/null +++ b/contract_payment_auto/__init__.py @@ -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 diff --git a/contract_payment_auto/__manifest__.py b/contract_payment_auto/__manifest__.py new file mode 100644 index 00000000..0efcab79 --- /dev/null +++ b/contract_payment_auto/__manifest__.py @@ -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, +} diff --git a/contract_payment_auto/data/ir_cron_data.xml b/contract_payment_auto/data/ir_cron_data.xml new file mode 100644 index 00000000..5e51ad8c --- /dev/null +++ b/contract_payment_auto/data/ir_cron_data.xml @@ -0,0 +1,19 @@ + + + + + + + + Contract Automatic Payments + hours + 1 + account.analytic.account + cron_retry_auto_pay + () + + + diff --git a/contract_payment_auto/data/mail_template_data.xml b/contract_payment_auto/data/mail_template_data.xml new file mode 100644 index 00000000..3f8fc004 --- /dev/null +++ b/contract_payment_auto/data/mail_template_data.xml @@ -0,0 +1,98 @@ + + + + + + + + Invoice - AutoPay To Retry + ${(object.user_id.email and '%s <%s>' % (object.user_id.name, object.user_id.email) or '')|safe} + Automatic Payment Failure (Ref ${object.number or 'n/a'}) + ${object.partner_id.id} + + + + Invoice_${(object.number or '').replace('/','_')}_${object.state == 'draft' and 'draft' or ''} + ${object.partner_id.lang} + + 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: + (${object.partner_id.parent_id.name}) + % endif + , +

+ +

+ The automatic payment for your invoice + + + ${object.number} + + % if object.origin: + (with reference: ${object.origin} ) + % endif + + failed. +

+ +

+ Please verify that your payment information is correct, and that funds are + available in the account. +

+ +]]> +
+
+ + + Invoice - AutoPay Failed + ${(object.user_id.email and '%s <%s>' % (object.user_id.name, object.user_id.email) or '')|safe} + Automatic Payment Failure (Ref ${object.number or 'n/a'}) + ${object.partner_id.id} + + + + Invoice_${(object.number or '').replace('/','_')}_${object.state == 'draft' and 'draft' or ''} + ${object.partner_id.lang} + + 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: + (${object.partner_id.parent_id.name}) + % endif + , +

+ +

+ The automatic payment for your invoice + + + ${object.number} + + % if object.origin: + (with reference: ${object.origin} ) + % endif + + failed. +

+ +

+ Please verify that your payment information is correct, and that funds are + available in the account. +

+ +]]> +
+
+ +
diff --git a/contract_payment_auto/models/__init__.py b/contract_payment_auto/models/__init__.py new file mode 100644 index 00000000..8d376f0d --- /dev/null +++ b/contract_payment_auto/models/__init__.py @@ -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 diff --git a/contract_payment_auto/models/account_analytic_account.py b/contract_payment_auto/models/account_analytic_account.py new file mode 100644 index 00000000..dc0ec207 --- /dev/null +++ b/contract_payment_auto/models/account_analytic_account.py @@ -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) diff --git a/contract_payment_auto/models/account_analytic_contract.py b/contract_payment_auto/models/account_analytic_contract.py new file mode 100644 index 00000000..d4a6e4bd --- /dev/null +++ b/contract_payment_auto/models/account_analytic_contract.py @@ -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 ''}", + + } diff --git a/contract_payment_auto/models/account_invoice.py b/contract_payment_auto/models/account_invoice.py new file mode 100644 index 00000000..68dd823a --- /dev/null +++ b/contract_payment_auto/models/account_invoice.py @@ -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() diff --git a/contract_payment_auto/models/res_partner.py b/contract_payment_auto/models/res_partner.py new file mode 100644 index 00000000..ebb434c7 --- /dev/null +++ b/contract_payment_auto/models/res_partner.py @@ -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.', + ) diff --git a/contract_payment_auto/tests/__init__.py b/contract_payment_auto/tests/__init__.py new file mode 100644 index 00000000..66e69117 --- /dev/null +++ b/contract_payment_auto/tests/__init__.py @@ -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 diff --git a/contract_payment_auto/tests/test_account_analytic_account.py b/contract_payment_auto/tests/test_account_analytic_account.py new file mode 100644 index 00000000..d75faa96 --- /dev/null +++ b/contract_payment_auto/tests/test_account_analytic_account.py @@ -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() diff --git a/contract_payment_auto/tests/test_account_analytic_contract.py b/contract_payment_auto/tests/test_account_analytic_contract.py new file mode 100644 index 00000000..1465aa2d --- /dev/null +++ b/contract_payment_auto/tests/test_account_analytic_contract.py @@ -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, + ) diff --git a/contract_payment_auto/views/account_analytic_account_view.xml b/contract_payment_auto/views/account_analytic_account_view.xml new file mode 100644 index 00000000..c17723ad --- /dev/null +++ b/contract_payment_auto/views/account_analytic_account_view.xml @@ -0,0 +1,44 @@ + + + + + + + + Contract Auto Pay + account.analytic.account + + + +
+ +
+ + + + + + + + + + + + + + +
+
+ +
diff --git a/contract_payment_auto/views/account_analytic_contract_view.xml b/contract_payment_auto/views/account_analytic_contract_view.xml new file mode 100644 index 00000000..6396ee83 --- /dev/null +++ b/contract_payment_auto/views/account_analytic_contract_view.xml @@ -0,0 +1,36 @@ + + + + + + + + Contract Template Auto Pay + account.analytic.contract + + + + + + + + + + + + + + + + + + + + + + diff --git a/contract_payment_auto/views/res_partner_view.xml b/contract_payment_auto/views/res_partner_view.xml new file mode 100644 index 00000000..bb599deb --- /dev/null +++ b/contract_payment_auto/views/res_partner_view.xml @@ -0,0 +1,22 @@ + + + + + + + + Res Partner Auto Pay + res.partner + + + + + + + + + +