Files
enterprise-addons/account_sepa_direct_debit/models/sdd_mandate.py
T

370 lines
16 KiB
Python

# Part of Odoo. See LICENSE file for full copyright and licensing details.
from datetime import datetime
from odoo import Command, _, api, fields, models
from odoo.exceptions import AccessError, RedirectWarning, UserError, ValidationError
from odoo.tools import date_utils
SDD_MIN_PRENOT_PERIOD = 2
SDD_FIRST_MIN_PRENOT_PERIOD = 5
class SddMandate(models.Model):
""" A class containing the data of a mandate sent by a customer to give its
consent to a company to collect the payments associated to his invoices
using SEPA Direct Debit.
"""
_name = 'sdd.mandate'
_inherit = ['mail.thread.main.attachment', 'mail.activity.mixin']
_description = 'SDD Mandate'
_check_company_auto = True
_order = 'start_date, id'
_name_unique = models.Constraint(
'unique(name)',
"Mandate identifier must be unique! Please choose another one.",
)
def _get_default_start_date(self):
return fields.Date.context_today(self)
state = fields.Selection(
selection=[
('draft', 'Draft'),
('active', 'Active'),
('cancelled', 'Cancelled'),
('revoked', 'Revoked'),
('closed', 'Closed'),
],
readonly=True,
default='draft',
tracking=True,
help="Draft: Validate before use.\n"
"Active: Valid mandates to collect payments.\n"
"Cancelled: Mandates never validated.\n"
"Closed: Expired or manually closed mandates. Previous transactions remain valid.\n"
"Revoked: Fraudulent mandates. Previous invoices might need reimbursement.\n"
)
is_sent = fields.Boolean(string="Sent to the customer", default=False)
# one-off mandates are fully supported, but hidden to the user for now. Let's see if they need it.
one_off = fields.Boolean(string='One-off Mandate',
default=False,
help="True if and only if this mandate can be used for only one transaction. It will automatically go from 'active' to 'closed' after its first use in payment if this option is set.\n")
name = fields.Char(string='Identifier', required=True, help="The unique identifier of this mandate.", default=lambda self: datetime.now().strftime('%f%S%M%H%d%m%Y'), copy=False)
debtor_id_code = fields.Char(string='Debtor Identifier', help="Free reference identifying the debtor in your company.")
partner_id = fields.Many2one(
comodel_name='res.partner',
string='Customer',
required=True,
index=True,
check_company=True,
help="Customer whose payments are to be managed by this mandate.",
)
partner_parent_id = fields.Many2one(
related='partner_id.parent_id',
comodel_name='res.partner',
string="Parent Partner",
readonly=True,
)
company_id = fields.Many2one(comodel_name='res.company', default=lambda self: self.env.company, help="Company for whose invoices the mandate can be used.")
partner_bank_id = fields.Many2one(
comodel_name='res.partner.bank',
string='IBAN',
check_company=True,
help="Account of the customer to collect payments from.",
)
start_date = fields.Date(
string="Start Date",
required=True,
default=_get_default_start_date,
help="Date from which the mandate can be used (inclusive).",
)
end_date = fields.Date(string="End Date", help="Date until which the mandate can be used. It will automatically be closed after this date.")
expiration_warning_already_sent = fields.Boolean(
string="Expiration warning sent",
default=False,
readonly=True,
required=True,
copy=False,
)
pre_notification_period = fields.Integer(
string="Pre-notification",
default=SDD_MIN_PRENOT_PERIOD, required=True,
help="The minimum notice period in days, used to inform the customer prior to collection.",
)
sdd_scheme = fields.Selection(string="SDD Scheme", selection=[('CORE', 'CORE'), ('B2B', 'B2B')],
required=True, default='CORE', help='The B2B scheme is an optional scheme,\noffered exclusively to business payers.\n'
'Some banks/businesses might not accept B2B SDD.',)
paid_invoice_ids = fields.One2many(string='Invoices Paid', comodel_name='account.move',
compute='_compute_from_moves',
help="Invoices paid using this mandate.")
paid_invoices_nber = fields.Integer(string='Paid Invoices Number',
compute='_compute_from_moves',
help="Number of invoices paid with this mandate.")
payment_ids = fields.One2many(string='Payments', comodel_name='account.payment',
compute='_compute_from_moves',
help="In-process and completed payments generated under this mandate.")
payments_to_collect_nber = fields.Integer(string='Direct Debit Payments to Collect',
compute='_compute_from_moves',
help="Number of in-process and completed payments generated under this mandate.")
mandate_pdf_file = fields.Binary(
string="Mandate Form PDF",
attachment=True,
copy=False,
readonly=True,
)
@api.ondelete(at_uninstall=False)
def _unlink_if_draft(self):
if self.filtered(lambda x: x.state != 'draft'):
raise UserError(_("Only mandates in draft state can be deleted."))
@api.model
def _sdd_get_usable_mandate(self, company_id, partner_id, date):
""" returns the first mandate found that can be used, accordingly to given parameters
or none if there is no such mandate.
"""
mandates = self.env['sdd.mandate'].search([
('state', '=', 'active'),
('start_date', '<=', date),
'|', ('end_date', '=', False), ('end_date', '>=', date),
('company_id', '=', company_id),
('partner_id', '=', partner_id),
],
limit=1,
order='start_date,id',
)
return mandates
def _compute_from_moves(self):
''' Retrieve the invoices reconciled to the payments through the reconciliation (account.partial.reconcile). '''
stored_mandates = self.filtered('id')
if not stored_mandates:
self.paid_invoices_nber = 0
self.payments_to_collect_nber = 0
self.paid_invoice_ids = False
self.payment_ids = False
return
results = dict(
self.env['account.payment']._read_group([
('sdd_mandate_id', 'in', self.ids),
('payment_method_code', 'in', self.env['account.payment.method']._get_sdd_payment_method_code()),
('state', 'in', ('in_process', 'paid')),
], groupby=['sdd_mandate_id'], aggregates=['id:recordset'])
)
for mandate in self:
payments = results.get(mandate, self.env['account.payment'])
mandate.payment_ids = [Command.set(payments.ids)]
mandate.payments_to_collect_nber = len(payments)
invoices = payments.reconciled_invoice_ids.filtered(lambda move: move.payment_state == 'paid')
mandate.paid_invoice_ids = [Command.set(invoices.ids)]
mandate.paid_invoices_nber = len(invoices)
def _update_and_partition_state_by_validity(self):
"""
Helper method to check whether a mandate can still be used or not (and if we're nearing its expiration date, to warn users)
This also close mandates that are past their validity date
:return: tuple containing the mandates recordsets split by their validity (valid, valid but expiring soon, invalid)
:rtype: dict
"""
today = fields.Date.context_today(self)
expiry_date_per_mandate = self._get_expiry_date_per_mandate()
active_mandates = self.filtered(lambda mandate: mandate.state == 'active')
valid_mandates = self.env['sdd.mandate']
expiring_mandates = self.env['sdd.mandate']
invalid_mandates = self - active_mandates
to_close = self.env['sdd.mandate']
for mandate in active_mandates:
expiry_date = expiry_date_per_mandate[mandate]
if mandate.start_date <= today <= expiry_date:
if today + date_utils.relativedelta(days=30) >= expiry_date:
expiring_mandates += mandate # Used to send warnings
else:
valid_mandates += mandate
elif today < mandate.start_date:
invalid_mandates += mandate
else:
to_close += mandate
invalid_mandates += mandate
# Closing invalid mandates that haven't been closed yet
to_close.state = 'closed'
return {
'valid': valid_mandates.with_prefetch(),
'expiring': expiring_mandates.with_prefetch(),
'invalid': invalid_mandates.with_prefetch(),
}
def _get_expiry_date_per_mandate(self):
"""
"The mandate expires 36 months after the last initiated collection."
- SEPA regulation
"""
expiry_date_per_mandate = {}
delay_36_months = date_utils.relativedelta(months=36)
latest_payment_date_per_mandate = dict(self.env['account.payment']._read_group([
('sdd_mandate_id', 'in', self.ids),
('payment_method_code', 'in', self.env['account.payment.method']._get_sdd_payment_method_code()),
('state', '=', 'paid'),
],
groupby=['sdd_mandate_id'],
aggregates=['date:max'],
))
for mandate in self:
last_payment_date = latest_payment_date_per_mandate.get(mandate) or mandate.start_date
dates = [mandate.end_date] if mandate.end_date else []
dates.append(last_payment_date + delay_36_months)
expiry_date_per_mandate[mandate] = min(dates) # Todo use records
return expiry_date_per_mandate
def _send_expiry_reminder(self):
self.ensure_one()
template = self.env.ref('account_sepa_direct_debit.email_template_sdd_mandate_expiring')
self.message_post_with_source(
source_ref=template,
subtype_id=self.env['ir.model.data']._xmlid_to_res_id('mail.mt_note'),
)
self.expiration_warning_already_sent = True
def action_send_and_print(self):
self.ensure_one()
self._ensure_required_data()
template = self.env.ref('account_sepa_direct_debit.email_template_sdd_new_mandate', raise_if_not_found=False)
return {
'name': _("Send"),
'type': 'ir.actions.act_window',
'view_mode': 'form',
'res_model': 'sdd.mandate.send',
'target': 'new',
'context': {
'default_mandate_id': self.id,
'default_template_id': template and template.id or False,
},
}
def action_validate_mandate(self):
""" Called by the 'validate' button of the form view.
"""
if not self.env.user.has_group('account.group_validate_bank_account'):
raise AccessError(self.env._("You don't have the rights to validate SDD mandates."))
self._ensure_required_data()
for mandate in self:
if not mandate.partner_bank_id:
raise UserError(_("A customer account is required to validate a SEPA Direct Debit mandate."))
if mandate.partner_bank_id.acc_type != 'iban':
raise UserError(_(
"SEPA Direct Debit scheme only accepts IBAN account numbers. "
"Please select an IBAN-compliant debtor account for this mandate."
))
if mandate.state == 'draft':
mandate.state = 'active'
def action_revoke_mandate(self):
""" Called by the 'revoke' button of the form view.
"""
self.state = 'revoked'
def action_cancel_mandate(self):
self.state = 'cancelled'
def action_close_mandate(self):
""" Called by the 'close' button of the form view.
Also automatically triggered by one-off mandate when they are used.
"""
for record in self:
if record.state != 'revoked':
record.end_date = fields.Date.today()
record.state = 'closed'
def action_view_paid_invoices(self):
return self.paid_invoice_ids._get_records_action(name=_('Paid Invoices'))
def action_view_payments_to_collect(self):
return self.payment_ids._get_records_action(name=_('Payments to Collect'))
def action_parent_id_from_sdd_mandate(self):
parent = self.partner_id.parent_id
return parent._get_records_action(name=_("Partner's Parent"))
@api.constrains('end_date', 'start_date')
def _validate_end_date(self):
for record in self:
if record.end_date and record.start_date and record.end_date < record.start_date:
raise UserError(_("The end date of the mandate must be posterior or equal to its start date."))
@api.constrains('debtor_id_code')
def _validate_debtor_id_code(self):
for record in self:
if record.debtor_id_code and len(record.debtor_id_code) > 35: # Arbitrary limitation given by SEPA regulation for the <id> element used for this field when generating the XML
raise UserError(_("The debtor identifier you specified exceeds the limitation of 35 characters imposed by SEPA regulation"))
@api.constrains('pre_notification_period')
def _validate_pre_notification_period(self):
for mandate in self:
if mandate.pre_notification_period < SDD_MIN_PRENOT_PERIOD: # Minimum required for collection
raise UserError(_(
"SEPA regulations set the minimum pre-notification period to a minimum of %s days "
"to allow enough time for the customer to check that their account is adequately funded.",
SDD_MIN_PRENOT_PERIOD
))
@api.constrains('partner_id', 'partner_bank_id')
def _validate_partner_bank_id(self):
for mandate in self:
if mandate.partner_bank_id and mandate.partner_id != mandate.partner_bank_id.partner_id:
raise ValidationError(_("Mandate customer and bank account need to match."))
def _ensure_required_data(self):
""" Helper to make sure we don't send/validate a mandate missing the required data """
for mandate in self:
if mandate.sdd_scheme == 'B2B' and not mandate.partner_id.is_company:
raise UserError(_("Under B2B SDD Scheme, the customer must be a company."))
stateless_partners = self.partner_id.filtered(lambda partner: not partner.country_id)
if stateless_partners:
msg = _("The customer must have a country")
if len(stateless_partners) == 1:
raise RedirectWarning(
msg,
action={
'name': _("SEPA direct debit stateless customer"),
'type': 'ir.actions.act_window',
'res_model': 'res.partner',
'views': [(False, 'form'), (False, 'list')],
'res_id': self.partner_id.id
},
button_text=_("Open customer"),
)
else:
raise RedirectWarning(
msg,
action={
'name': _("SEPA direct debit stateless customer"),
'type': 'ir.actions.act_window',
'res_model': 'res.partner',
'views': [(False, 'list'), (False, 'form')],
'domain': [('id', 'in', self.partner_id.ids)],
},
button_text=_("Open customers"),
)
@api.model
def cron_update_mandates_states(self):
mandates = self.search([('state', '=', 'active')])
mandates_per_validity = mandates._update_and_partition_state_by_validity()
mandates_per_validity['valid'].expiration_warning_already_sent = False # Reset the field if a new payment came, resetting the period
for mandate in mandates_per_validity['expiring'].filtered(lambda sddm: not sddm.expiration_warning_already_sent):
# Warning 30 days before expiration
mandate._send_expiry_reminder()