From 63cf7d3e9f4b4f018618d5daaf8bbf6bc144974c Mon Sep 17 00:00:00 2001
From: Dave Lasley
Date: Fri, 5 May 2017 13:43:25 -0700
Subject: [PATCH] [ADD] contract_auto: Automatic Payment For Contracts
---
contract_payment_auto/README.rst | 89 +++++
contract_payment_auto/__init__.py | 5 +
contract_payment_auto/__manifest__.py | 27 ++
contract_payment_auto/data/ir_cron_data.xml | 19 ++
.../data/mail_template_data.xml | 98 ++++++
contract_payment_auto/models/__init__.py | 8 +
.../models/account_analytic_account.py | 174 ++++++++++
.../models/account_analytic_contract.py | 108 +++++++
.../models/account_invoice.py | 12 +
contract_payment_auto/models/res_partner.py | 18 ++
contract_payment_auto/tests/__init__.py | 6 +
.../tests/test_account_analytic_account.py | 303 ++++++++++++++++++
.../tests/test_account_analytic_contract.py | 51 +++
.../views/account_analytic_account_view.xml | 44 +++
.../views/account_analytic_contract_view.xml | 36 +++
.../views/res_partner_view.xml | 22 ++
16 files changed, 1020 insertions(+)
create mode 100644 contract_payment_auto/README.rst
create mode 100644 contract_payment_auto/__init__.py
create mode 100644 contract_payment_auto/__manifest__.py
create mode 100644 contract_payment_auto/data/ir_cron_data.xml
create mode 100644 contract_payment_auto/data/mail_template_data.xml
create mode 100644 contract_payment_auto/models/__init__.py
create mode 100644 contract_payment_auto/models/account_analytic_account.py
create mode 100644 contract_payment_auto/models/account_analytic_contract.py
create mode 100644 contract_payment_auto/models/account_invoice.py
create mode 100644 contract_payment_auto/models/res_partner.py
create mode 100644 contract_payment_auto/tests/__init__.py
create mode 100644 contract_payment_auto/tests/test_account_analytic_account.py
create mode 100644 contract_payment_auto/tests/test_account_analytic_contract.py
create mode 100644 contract_payment_auto/views/account_analytic_account_view.xml
create mode 100644 contract_payment_auto/views/account_analytic_contract_view.xml
create mode 100644 contract_payment_auto/views/res_partner_view.xml
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
+
+
+
+
+
+
+
+
+
+