mirror of
https://github.com/OCA/contract.git
synced 2025-02-13 17:57:24 +02:00
Merge pull request #67 from LasLabs/release/10.0/LABS-411-contract_auto
[10.0] [ADD] contract_auto: Automatic Payment For Contracts
This commit is contained in:
14
.travis.yml
14
.travis.yml
@@ -7,26 +7,26 @@ python:
|
||||
|
||||
addons:
|
||||
apt:
|
||||
# only add the two lines below if you need wkhtmltopdf for your tests
|
||||
# sources:
|
||||
# - pov-wkhtmltopdf
|
||||
sources:
|
||||
- pov-wkhtmltopdf
|
||||
packages:
|
||||
- expect-dev # provides unbuffer utility
|
||||
- python-lxml # because pip installation is slow
|
||||
- python-simplejson
|
||||
- python-serial
|
||||
- python-yaml
|
||||
# - wkhtmltopdf # only add if needed and check the before_install section below
|
||||
- wkhtmltopdf
|
||||
|
||||
# set up an X server to run wkhtmltopdf.
|
||||
#before_install:
|
||||
# - "export DISPLAY=:910.0"
|
||||
# - "sh -e /etc/init.d/xvfb start"
|
||||
before_install:
|
||||
- "export DISPLAY=:910.0"
|
||||
- "sh -e /etc/init.d/xvfb start"
|
||||
|
||||
env:
|
||||
global:
|
||||
- VERSION="10.0" TESTS="0" LINT_CHECK="0" TRANSIFEX="0"
|
||||
- TRANSIFEX_USER='transbot@odoo-community.org'
|
||||
- WKHTMLTOPDF_VERSION="0.12.4"
|
||||
- secure: ArnbVaF5+ry6zVysZ7HA9xcnQrodo8FsXXcon9yINTYRfDC9QEr+/mTLAPRl+lVLdtWV2GuGuX0vPYzBxFWpY3LR0BpKqXzx0G51s94zR2WWEmizYFzFhpAuIxoU4CYNckHFSUaPAQJhwB/pYx9+H/W6bMjG/VnZBq+AmBJ2Kh0=
|
||||
|
||||
matrix:
|
||||
|
||||
89
contract_payment_auto/README.rst
Normal file
89
contract_payment_auto/README.rst
Normal file
@@ -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.
|
||||
5
contract_payment_auto/__init__.py
Normal file
5
contract_payment_auto/__init__.py
Normal file
@@ -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
|
||||
27
contract_payment_auto/__manifest__.py
Normal file
27
contract_payment_auto/__manifest__.py
Normal file
@@ -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,
|
||||
}
|
||||
19
contract_payment_auto/data/ir_cron_data.xml
Normal file
19
contract_payment_auto/data/ir_cron_data.xml
Normal file
@@ -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>
|
||||
98
contract_payment_auto/data/mail_template_data.xml
Normal file
98
contract_payment_auto/data/mail_template_data.xml
Normal file
@@ -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>
|
||||
8
contract_payment_auto/models/__init__.py
Normal file
8
contract_payment_auto/models/__init__.py
Normal file
@@ -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
|
||||
174
contract_payment_auto/models/account_analytic_account.py
Normal file
174
contract_payment_auto/models/account_analytic_account.py
Normal file
@@ -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)
|
||||
108
contract_payment_auto/models/account_analytic_contract.py
Normal file
108
contract_payment_auto/models/account_analytic_contract.py
Normal file
@@ -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 ''}",
|
||||
|
||||
}
|
||||
12
contract_payment_auto/models/account_invoice.py
Normal file
12
contract_payment_auto/models/account_invoice.py
Normal file
@@ -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()
|
||||
18
contract_payment_auto/models/res_partner.py
Normal file
18
contract_payment_auto/models/res_partner.py
Normal file
@@ -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.',
|
||||
)
|
||||
6
contract_payment_auto/tests/__init__.py
Normal file
6
contract_payment_auto/tests/__init__.py
Normal file
@@ -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
|
||||
303
contract_payment_auto/tests/test_account_analytic_account.py
Normal file
303
contract_payment_auto/tests/test_account_analytic_account.py
Normal file
@@ -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>
|
||||
22
contract_payment_auto/views/res_partner_view.xml
Normal file
22
contract_payment_auto/views/res_partner_view.xml
Normal file
@@ -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>
|
||||
Reference in New Issue
Block a user