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