forked from Mapan/odoo17e
2021 lines
107 KiB
Python
2021 lines
107 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
|
|
|
import logging
|
|
from dateutil.relativedelta import relativedelta
|
|
from psycopg2.extensions import TransactionRollbackError
|
|
from ast import literal_eval
|
|
from collections import defaultdict
|
|
import traceback
|
|
|
|
from odoo import fields, models, _, api, Command, SUPERUSER_ID, modules
|
|
from odoo.exceptions import UserError, ValidationError
|
|
from odoo.tools.float_utils import float_is_zero
|
|
from odoo.osv import expression
|
|
from odoo.tools import config, format_amount, plaintext2html, split_every, str2bool
|
|
from odoo.tools.date_utils import get_timedelta
|
|
from odoo.tools.misc import format_date
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
SUBSCRIPTION_DRAFT_STATE = ['1_draft', '2_renewal']
|
|
SUBSCRIPTION_PROGRESS_STATE = ['3_progress', '4_paused']
|
|
SUBSCRIPTION_CLOSED_STATE = ['6_churn', '5_renewed']
|
|
|
|
SUBSCRIPTION_STATES = [
|
|
('1_draft', 'Quotation'), # Quotation for a new subscription
|
|
('2_renewal', 'Renewal Quotation'), # Renewal Quotation for existing subscription
|
|
('3_progress', 'In Progress'), # Active Subscription or confirmed renewal for active subscription
|
|
('4_paused', 'Paused'), # Active subscription with paused invoicing
|
|
('5_renewed', 'Renewed'), # Active or ended subscription that has been renewed
|
|
('6_churn', 'Churned'), # Closed or ended subscription
|
|
('7_upsell', 'Upsell'), # Quotation or SO upselling a subscription
|
|
]
|
|
|
|
|
|
class SaleOrder(models.Model):
|
|
_name = "sale.order"
|
|
_inherit = ["rating.mixin", "sale.order"]
|
|
|
|
def _get_default_starred_user_ids(self):
|
|
return [(4, self.env.uid)]
|
|
|
|
###################
|
|
# Recurring order #
|
|
###################
|
|
is_subscription = fields.Boolean("Recurring", compute='_compute_is_subscription', store=True, index=True)
|
|
plan_id = fields.Many2one('sale.subscription.plan', compute='_compute_plan_id', string='Recurring Plan',
|
|
ondelete='restrict', readonly=False, store=True, index='btree_not_null')
|
|
subscription_state = fields.Selection(
|
|
string='Subscription Status',
|
|
selection=SUBSCRIPTION_STATES, readonly=False,
|
|
compute='_compute_subscription_state', store=True, index='btree_not_null', tracking=True, group_expand='_group_expand_states',
|
|
)
|
|
|
|
subscription_id = fields.Many2one('sale.order', string='Parent Contract', ondelete='restrict', copy=False, index='btree_not_null')
|
|
origin_order_id = fields.Many2one('sale.order', string='First contract', ondelete='restrict', store=True, copy=False,
|
|
compute='_compute_origin_order_id', index='btree_not_null')
|
|
subscription_child_ids = fields.One2many('sale.order', 'subscription_id')
|
|
|
|
start_date = fields.Date(string='Start Date',
|
|
compute='_compute_start_date',
|
|
readonly=False,
|
|
store=True,
|
|
tracking=True,
|
|
help="The start date indicate when the subscription periods begin.")
|
|
last_invoice_date = fields.Date(string='Last invoice date', compute='_compute_last_invoice_date')
|
|
next_invoice_date = fields.Date(
|
|
string='Date of Next Invoice',
|
|
compute='_compute_next_invoice_date',
|
|
store=True, copy=False,
|
|
readonly=False,
|
|
tracking=True,
|
|
help="The next invoice will be created on this date then the period will be extended.")
|
|
end_date = fields.Date(string='End Date', tracking=True,
|
|
help="If set in advance, the subscription will be set to renew 1 month before the date and will be closed on the date set in this field.")
|
|
first_contract_date = fields.Date(
|
|
compute='_compute_first_contract_date',
|
|
store=True,
|
|
help="The first contract date is the start date of the first contract of the sequence. It is common across a subscription and its renewals.")
|
|
close_reason_id = fields.Many2one("sale.order.close.reason", string="Close Reason", copy=False, tracking=True)
|
|
|
|
#############
|
|
# Invoicing #
|
|
#############
|
|
payment_token_id = fields.Many2one('payment.token', 'Payment Token', check_company=True, help='If not set, the automatic payment will fail.',
|
|
domain="[('partner_id', 'child_of', commercial_partner_id), ('company_id', '=', company_id)]", copy=False)
|
|
# technical, flag the contract failing for unknown reason (!= payment_exception) to avoid process them again and again
|
|
is_batch = fields.Boolean(default=False, copy=False) # technical, batch of invoice processed at the same time
|
|
# technical flag to avoid process the same subscription in several parallel crons
|
|
is_invoice_cron = fields.Boolean(string='Is a Subscription invoiced in cron', default=False, copy=False)
|
|
payment_exception = fields.Boolean("Contract in exception",
|
|
help="Automatic payment with token failed. The payment provider configuration and token should be checked",
|
|
copy=False)
|
|
pending_transaction = fields.Boolean(help="The last transaction of the order is currently pending",
|
|
copy=False)
|
|
payment_term_id = fields.Many2one(tracking=True)
|
|
|
|
###################
|
|
# KPI / reporting #
|
|
###################
|
|
kpi_1month_mrr_delta = fields.Float('KPI 1 Month MRR Delta')
|
|
kpi_1month_mrr_percentage = fields.Float('KPI 1 Month MRR Percentage')
|
|
kpi_3months_mrr_delta = fields.Float('KPI 3 months MRR Delta')
|
|
kpi_3months_mrr_percentage = fields.Float('KPI 3 Months MRR Percentage')
|
|
|
|
team_user_id = fields.Many2one('res.users', string="Team Leader", related="team_id.user_id", readonly=False)
|
|
commercial_partner_id = fields.Many2one('res.partner', related='partner_id.commercial_partner_id')
|
|
|
|
recurring_total = fields.Monetary(compute='_compute_recurring_total', string="Total Recurring", store=True)
|
|
recurring_monthly = fields.Monetary(compute='_compute_recurring_monthly', string="Monthly Recurring",
|
|
store=True, tracking=True)
|
|
non_recurring_total = fields.Monetary(compute='_compute_non_recurring_total', string="Total Non Recurring Revenue")
|
|
order_log_ids = fields.One2many('sale.order.log', 'order_id', string='Subscription Logs', readonly=True, copy=False)
|
|
percentage_satisfaction = fields.Integer(
|
|
compute="_compute_percentage_satisfaction",
|
|
string="% Happy", store=True, compute_sudo=True, default=-1,
|
|
help="Calculate the ratio between the number of the best ('great') ratings and the total number of ratings")
|
|
health = fields.Selection([('normal', 'Neutral'), ('done', 'Good'), ('bad', 'Bad')], string="Health", copy=False,
|
|
default='normal', help="Show the health status")
|
|
|
|
###########
|
|
# Notes #
|
|
###########
|
|
note_order = fields.Many2one('sale.order', compute='_compute_note_order', search='_search_note_order')
|
|
internal_note = fields.Html()
|
|
internal_note_display = fields.Html(compute='_compute_internal_note_display', inverse='_inverse_internal_note_display')
|
|
|
|
###########
|
|
# UI / UX #
|
|
###########
|
|
recurring_details = fields.Html(compute='_compute_recurring_details')
|
|
is_renewing = fields.Boolean(compute='_compute_is_renewing')
|
|
is_upselling = fields.Boolean(compute='_compute_is_upselling')
|
|
display_late = fields.Boolean(compute='_compute_display_late')
|
|
archived_product_ids = fields.Many2many('product.product', string='Archived Products', compute='_compute_archived')
|
|
archived_product_count = fields.Integer("Archived Product", compute='_compute_archived')
|
|
history_count = fields.Integer(compute='_compute_history_count')
|
|
upsell_count = fields.Integer(compute='_compute_upsell_count')
|
|
renewal_count = fields.Integer(compute="_compute_renewal_count")
|
|
has_recurring_line = fields.Boolean(compute='_compute_has_recurring_line')
|
|
|
|
starred_user_ids = fields.Many2many('res.users', 'sale_order_starred_user_rel', 'order_id', 'user_id',
|
|
default=lambda s: s._get_default_starred_user_ids(), string='Members')
|
|
starred = fields.Boolean(compute='_compute_starred', inverse='_inverse_starred',
|
|
string='Show Subscription on dashboard',
|
|
help="Whether this subscription should be displayed on the dashboard or not")
|
|
|
|
user_closable = fields.Boolean(related="plan_id.user_closable")
|
|
user_quantity = fields.Boolean(related="plan_id.user_quantity")
|
|
user_extend = fields.Boolean(related="plan_id.user_extend")
|
|
|
|
_sql_constraints = [
|
|
('sale_subscription_state_coherence',
|
|
"CHECK(NOT (is_subscription=TRUE AND state = 'sale' AND subscription_state='1_draft'))",
|
|
"You cannot set to draft a confirmed subscription. Please create a new quotation"),
|
|
('check_start_date_lower_next_invoice_date', 'CHECK((next_invoice_date IS NULL OR start_date IS NULL) OR (next_invoice_date >= start_date))',
|
|
'The next invoice date of a sale order should be after its start date.'),
|
|
]
|
|
|
|
@api.constrains('subscription_state', 'subscription_id', 'pricelist_id')
|
|
def _constraint_subscription_upsell_multi_currency(self):
|
|
for so in self:
|
|
if so.subscription_state == '7_upsell' and so.subscription_id.pricelist_id.currency_id != so.pricelist_id.currency_id:
|
|
raise ValidationError(_('You cannot upsell a subscription using a different currency.'))
|
|
|
|
@api.constrains('plan_id', 'state', 'order_line')
|
|
def _constraint_subscription_plan(self):
|
|
recurring_product_orders = self.order_line.filtered(lambda l: l.product_id.recurring_invoice).order_id
|
|
for so in self:
|
|
if so.state in ['draft', 'cancel'] or so.subscription_state == '7_upsell':
|
|
continue
|
|
if so.subscription_id and not so.subscription_state:
|
|
# so created before merge sale.subscription into sale.order upgrade.
|
|
# This is the so that created the sale.subscription records.
|
|
continue
|
|
if so in recurring_product_orders and not so.plan_id:
|
|
raise UserError(_('You cannot save a sale order with recurring product and no subscription plan.'))
|
|
if so.plan_id and so not in recurring_product_orders:
|
|
raise UserError(_('You cannot save a sale order with a subscription plan and no recurring product.'))
|
|
|
|
@api.constrains('subscription_state', 'state')
|
|
def _constraint_canceled_subscription(self):
|
|
incompatible_states = SUBSCRIPTION_PROGRESS_STATE + ['5_renewed']
|
|
for so in self:
|
|
if so.state == 'cancel' and so.subscription_state in incompatible_states:
|
|
raise ValidationError(_(
|
|
'A canceled SO cannot be in progress. You should close %s before canceling it.',
|
|
so.name))
|
|
|
|
@api.depends('plan_id')
|
|
def _compute_is_subscription(self):
|
|
for order in self:
|
|
# upsells have recurrence but are not considered subscription. The method don't depend on subscription_state
|
|
# to avoid recomputing the is_subscription value each time the sub_state is updated. it would trigger
|
|
# other recompute we want to avoid
|
|
if not order.plan_id or order.subscription_state == '7_upsell':
|
|
order.is_subscription = False
|
|
continue
|
|
order.is_subscription = True
|
|
# is_subscription value is not always updated in this method but subscription_state should always
|
|
# be recomputed when this method is triggered.
|
|
# without this call, subscription_state is not updated when it should and
|
|
self.env.add_to_compute(self.env['sale.order']._fields['subscription_state'], self)
|
|
|
|
@api.depends('is_subscription')
|
|
def _compute_subscription_state(self):
|
|
# The compute method is used to set a default state for quotations
|
|
# Once the order is confirmed, the state is updated by the actions (renew etc)
|
|
for order in self:
|
|
if order.state not in ['draft', 'sent']:
|
|
continue
|
|
elif order.subscription_state in ['2_renewal', '7_upsell']:
|
|
continue
|
|
elif order.is_subscription or order.state == 'draft' and order.subscription_state == '1_draft':
|
|
# We keep the subscription state 1_draft to keep the subscription quotation in the subscription app
|
|
# quotation view.
|
|
order.subscription_state = '2_renewal' if order.subscription_id else '1_draft'
|
|
else:
|
|
order.subscription_state = False
|
|
|
|
def _compute_sale_order_template_id(self):
|
|
if not self.env.context.get('default_is_subscription', False):
|
|
return super(SaleOrder, self)._compute_sale_order_template_id()
|
|
for order in self:
|
|
if not order._origin.id and order.company_id.sale_order_template_id.is_subscription:
|
|
order.sale_order_template_id = order.company_id.sale_order_template_id
|
|
|
|
def _compute_type_name(self):
|
|
other_orders = self.env['sale.order']
|
|
for order in self:
|
|
if order.is_subscription and order.state == 'sale':
|
|
order.type_name = _('Subscription')
|
|
elif order.subscription_state == '7_upsell':
|
|
order.type_name = _('Quotation')
|
|
elif order.subscription_state == '2_renewal':
|
|
order.type_name = _('Renewal Quotation')
|
|
else:
|
|
other_orders |= order
|
|
|
|
super(SaleOrder, other_orders)._compute_type_name()
|
|
|
|
@api.depends('rating_percentage_satisfaction')
|
|
def _compute_percentage_satisfaction(self):
|
|
for subscription in self:
|
|
subscription.percentage_satisfaction = int(subscription.rating_percentage_satisfaction)
|
|
|
|
@api.depends('starred_user_ids')
|
|
@api.depends_context('uid')
|
|
def _compute_starred(self):
|
|
for subscription in self:
|
|
subscription.starred = self.env.user in subscription.starred_user_ids
|
|
|
|
def _inverse_starred(self):
|
|
starred_subscriptions = not_star_subscriptions = self.env['sale.order'].sudo()
|
|
for subscription in self:
|
|
if self.env.user in subscription.starred_user_ids:
|
|
starred_subscriptions |= subscription
|
|
else:
|
|
not_star_subscriptions |= subscription
|
|
not_star_subscriptions.write({'starred_user_ids': [(4, self.env.uid)]})
|
|
starred_subscriptions.write({'starred_user_ids': [(3, self.env.uid)]})
|
|
|
|
@api.depends('subscription_state', 'state', 'is_subscription', 'amount_untaxed')
|
|
def _compute_recurring_monthly(self):
|
|
""" Compute the amount monthly recurring revenue. When a subscription has a parent still ongoing.
|
|
Depending on invoice_ids force the recurring monthly to be recomputed regularly, even for the first invoice
|
|
where confirmation is set the next_invoice_date and first invoice do not update it (in automatic mode).
|
|
"""
|
|
for order in self:
|
|
if order.is_subscription or order.subscription_state == '7_upsell':
|
|
order.recurring_monthly = sum(order.order_line.mapped('recurring_monthly'))
|
|
continue
|
|
order.recurring_monthly = 0
|
|
|
|
@api.depends('subscription_state', 'state', 'is_subscription', 'amount_untaxed')
|
|
def _compute_recurring_total(self):
|
|
""" Compute the amount monthly recurring revenue. When a subscription has a parent still ongoing.
|
|
Depending on invoice_ids force the recurring monthly to be recomputed regularly, even for the first invoice
|
|
where confirmation is set the next_invoice_date and first invoice do not update it (in automatic mode).
|
|
"""
|
|
for order in self:
|
|
if order.is_subscription or order.subscription_state == '7_upsell':
|
|
order.recurring_total = sum(order.order_line.filtered(lambda l: l.recurring_invoice).mapped('price_subtotal'))
|
|
continue
|
|
order.recurring_total = 0
|
|
|
|
@api.depends('amount_untaxed', 'recurring_total')
|
|
def _compute_non_recurring_total(self):
|
|
for order in self:
|
|
order.non_recurring_total = order.amount_untaxed - order.recurring_total
|
|
|
|
@api.depends('is_subscription', 'recurring_total')
|
|
def _compute_recurring_details(self):
|
|
subscription_orders = self.filtered(lambda sub: sub.is_subscription or sub.subscription_id)
|
|
self.recurring_details = ""
|
|
if subscription_orders.ids:
|
|
for so in subscription_orders:
|
|
lang_code = so.partner_id.lang
|
|
recurring_amount = so.recurring_total
|
|
non_recurring_amount = so.amount_untaxed - recurring_amount
|
|
recurring_formatted_amount = so.currency_id and format_amount(self.env, recurring_amount, so.currency_id, lang_code) or recurring_amount
|
|
non_recurring_formatted_amount = so.currency_id and format_amount(self.env, non_recurring_amount, so.currency_id, lang_code) or non_recurring_amount
|
|
rendering_values = [{
|
|
'non_recurring': non_recurring_formatted_amount,
|
|
'recurring': recurring_formatted_amount,
|
|
}]
|
|
so.recurring_details = self.env['ir.qweb']._render('sale_subscription.recurring_details', {'rendering_values': rendering_values})
|
|
|
|
def _compute_access_url(self):
|
|
super()._compute_access_url()
|
|
for order in self:
|
|
# Quotations are handled in the quotation menu
|
|
if order.is_subscription and order.subscription_state in SUBSCRIPTION_PROGRESS_STATE + SUBSCRIPTION_CLOSED_STATE:
|
|
order.access_url = '/my/subscriptions/%s' % order.id
|
|
|
|
@api.depends('order_line.product_id', 'order_line.product_id.active')
|
|
def _compute_archived(self):
|
|
# Search which products are archived when reading the subscriptions lines
|
|
archived_product_ids = self.env['product.product'].search(
|
|
[('id', 'in', self.order_line.product_id.ids), ('recurring_invoice', '=', True),
|
|
('active', '=', False)])
|
|
for order in self:
|
|
products = archived_product_ids.filtered(lambda p: p.id in order.order_line.product_id.ids)
|
|
order.archived_product_ids = [(6, 0, products.ids)]
|
|
order.archived_product_count = len(products)
|
|
|
|
def _compute_start_date(self):
|
|
for so in self:
|
|
if not so.is_subscription:
|
|
so.start_date = False
|
|
elif not so.start_date:
|
|
so.start_date = fields.Date.today()
|
|
|
|
@api.depends('origin_order_id.start_date', 'origin_order_id', 'start_date')
|
|
def _compute_first_contract_date(self):
|
|
for so in self:
|
|
if so.origin_order_id:
|
|
so.first_contract_date = so.origin_order_id.start_date
|
|
else:
|
|
# First contract of the sequence
|
|
so.first_contract_date = so.start_date
|
|
|
|
@api.depends('subscription_child_ids', 'origin_order_id')
|
|
def _get_invoiced(self):
|
|
"""
|
|
Compute the invoices and their counts
|
|
For subscription, we find all the invoice lines related to the orders
|
|
descending from the origin_order_id
|
|
"""
|
|
subscription_ids = []
|
|
so_by_origin = defaultdict(lambda: self.env['sale.order'])
|
|
parent_order_ids = []
|
|
for order in self:
|
|
if order.is_subscription and not isinstance(order.id, models.NewId):
|
|
subscription_ids.append(order.id)
|
|
origin_key = order.origin_order_id.id if order.origin_order_id else order.id
|
|
parent_order_ids.append(origin_key)
|
|
so_by_origin[origin_key] += order
|
|
|
|
subscriptions = self.browse(subscription_ids)
|
|
res = super(SaleOrder, self - subscriptions)._get_invoiced()
|
|
if not subscriptions:
|
|
return res
|
|
# Ensure that we give value to everyone
|
|
subscriptions.update({
|
|
'invoice_ids': [],
|
|
'invoice_count': 0
|
|
})
|
|
|
|
if not so_by_origin or not subscription_ids:
|
|
return res
|
|
|
|
self.flush_recordset(fnames=['origin_order_id'])
|
|
all_subscription_ids = self.search([('origin_order_id', 'in', parent_order_ids)]).ids + parent_order_ids
|
|
|
|
query = """
|
|
SELECT COALESCE(origin_order_id, so.id),
|
|
array_agg(DISTINCT am.id) AS move_ids
|
|
FROM sale_order so
|
|
JOIN sale_order_line sol ON sol.order_id = so.id
|
|
JOIN sale_order_line_invoice_rel solam ON sol.id = solam.order_line_id
|
|
JOIN account_move_line aml ON aml.id = solam.invoice_line_id
|
|
JOIN account_move am ON am.id = aml.move_id
|
|
WHERE am.company_id IN %s
|
|
AND so.id IN %s
|
|
AND am.move_type IN ('out_invoice', 'out_refund')
|
|
GROUP BY COALESCE(origin_order_id, so.id)
|
|
"""
|
|
|
|
self.env.cr.execute(query, [tuple(self.env.companies.ids), tuple(all_subscription_ids)])
|
|
orders_vals = self.env.cr.fetchall()
|
|
for origin_order_id, invoices_ids in orders_vals:
|
|
so_by_origin[origin_order_id].update({
|
|
'invoice_ids': invoices_ids,
|
|
'invoice_count': len(invoices_ids)
|
|
})
|
|
return res
|
|
|
|
@api.depends('is_subscription', 'state', 'start_date', 'subscription_state')
|
|
def _compute_next_invoice_date(self):
|
|
for so in self:
|
|
if not so.is_subscription and so.subscription_state != '7_upsell':
|
|
so.next_invoice_date = False
|
|
elif not so.next_invoice_date and so.state == 'sale':
|
|
# Define a default next invoice date.
|
|
# It is increased by _update_next_invoice_date or when posting a invoice when when necessary
|
|
so.next_invoice_date = so.start_date or fields.Date.today()
|
|
|
|
@api.depends('start_date', 'state', 'next_invoice_date')
|
|
def _compute_last_invoice_date(self):
|
|
for order in self:
|
|
last_date = order.next_invoice_date and order.plan_id.billing_period and order.next_invoice_date - order.plan_id.billing_period
|
|
start_date = order.start_date or fields.Date.today()
|
|
if order.state == 'sale' and last_date and last_date >= start_date:
|
|
# we use get_timedelta and not the effective invoice date because
|
|
# we don't want gaps. Invoicing date could be shifted because of technical issues.
|
|
order.last_invoice_date = last_date
|
|
else:
|
|
order.last_invoice_date = False
|
|
|
|
@api.depends('subscription_child_ids')
|
|
def _compute_renewal_count(self):
|
|
self.renewal_count = 0
|
|
if not any(self.mapped('subscription_state')):
|
|
return
|
|
result = self.env['sale.order']._read_group([
|
|
('subscription_state', '=', '2_renewal'),
|
|
('state', 'in', ['draft', 'sent']),
|
|
('subscription_id', 'in', self.ids)
|
|
],
|
|
['subscription_id'],
|
|
['__count'],
|
|
)
|
|
counters = {subscription.id: count for subscription, count in result}
|
|
for so in self:
|
|
so.renewal_count = counters.get(so.id, 0)
|
|
|
|
@api.depends('subscription_child_ids')
|
|
def _compute_upsell_count(self):
|
|
self.upsell_count = 0
|
|
if not any(self.mapped('subscription_state')):
|
|
return
|
|
result = self.env['sale.order']._read_group([
|
|
('subscription_state', '=', '7_upsell'),
|
|
('state', 'in', ['draft', 'sent']),
|
|
('subscription_id', 'in', self.ids)
|
|
],
|
|
['subscription_id'],
|
|
['__count'],
|
|
)
|
|
counters = {subscription.id: count for subscription, count in result}
|
|
for so in self:
|
|
so.upsell_count = counters.get(so.id, 0)
|
|
|
|
@api.depends('origin_order_id')
|
|
def _compute_history_count(self):
|
|
if not any(self.mapped('subscription_state')):
|
|
self.history_count = 0
|
|
return
|
|
origin_ids = self.origin_order_id.ids + self.ids
|
|
result = self.env['sale.order']._read_group([
|
|
('state', 'not in', ['cancel', 'draft']),
|
|
('origin_order_id', 'in', origin_ids)
|
|
],
|
|
['origin_order_id'],
|
|
['__count'],
|
|
)
|
|
counters = {origin_order.id: count + 1 for origin_order, count in result}
|
|
for so in self:
|
|
so.history_count = counters.get(so.origin_order_id.id or so.id, 0)
|
|
|
|
@api.depends('is_subscription', 'subscription_state')
|
|
def _compute_origin_order_id(self):
|
|
for order in self:
|
|
if (order.is_subscription or order.subscription_state == '7_upsell') and not order.origin_order_id:
|
|
order.origin_order_id = order.subscription_id.origin_order_id or order.subscription_id
|
|
|
|
def _track_subtype(self, init_values):
|
|
self.ensure_one()
|
|
if 'subscription_state' in init_values:
|
|
return self.env.ref('sale_subscription.subtype_state_change')
|
|
return super()._track_subtype(init_values)
|
|
|
|
@api.depends('sale_order_template_id')
|
|
def _compute_plan_id(self):
|
|
for order in self:
|
|
if order.sale_order_template_id and order.sale_order_template_id.plan_id:
|
|
order.plan_id = order.sale_order_template_id.plan_id
|
|
else:
|
|
order.plan_id = order.company_id.subscription_default_plan_id
|
|
|
|
def _compute_is_renewing(self):
|
|
self.is_renewing = False
|
|
renew_order_ids = self.env['sale.order'].search([
|
|
('id', 'in', self.subscription_child_ids.ids),
|
|
('subscription_state', '=', '2_renewal'),
|
|
('state', 'in', ['draft', 'sent']),
|
|
]).subscription_id
|
|
renew_order_ids.is_renewing = True
|
|
|
|
def _compute_is_upselling(self):
|
|
self.is_upselling = False
|
|
upsell_order_ids = self.env['sale.order'].search([
|
|
('id', 'in', self.subscription_child_ids.ids),
|
|
('state', 'in', ['draft', 'sent']),
|
|
('subscription_state', '=', '7_upsell')
|
|
]).subscription_id
|
|
upsell_order_ids.is_upselling = True
|
|
|
|
def _compute_display_late(self):
|
|
today = fields.Date.today()
|
|
for order in self:
|
|
order.display_late = order.subscription_state in SUBSCRIPTION_PROGRESS_STATE and order.next_invoice_date and order.next_invoice_date < today
|
|
|
|
@api.depends('order_line')
|
|
def _compute_has_recurring_line(self):
|
|
recurring_product_orders = self.order_line.filtered(lambda l: l.product_id.recurring_invoice).order_id
|
|
recurring_product_orders.has_recurring_line = True
|
|
(self - recurring_product_orders).has_recurring_line = False
|
|
|
|
@api.depends('subscription_id')
|
|
def _compute_note_order(self):
|
|
for order in self:
|
|
if order.internal_note or not order.subscription_id:
|
|
order.note_order = order
|
|
else:
|
|
order.note_order = order.subscription_id.note_order
|
|
|
|
def _search_note_order(self, operator, value):
|
|
if operator not in ['in', '=']:
|
|
return NotImplemented
|
|
ooids = self.search_read([('id', operator, value)], ['origin_order_id', 'id'], load=None)
|
|
ooids = [v['origin_order_id'] or v['id'] for v in ooids]
|
|
return [('origin_order_id', 'in', ooids), ('internal_note', '=', False)]
|
|
|
|
@api.depends('note_order.internal_note')
|
|
def _compute_internal_note_display(self):
|
|
for order in self:
|
|
order.internal_note_display = order.note_order.internal_note
|
|
|
|
def _inverse_internal_note_display(self):
|
|
for order in self:
|
|
order.note_order.internal_note = order.internal_note_display
|
|
|
|
def _mail_track(self, tracked_fields, initial_values):
|
|
""" For a given record, fields to check (tuple column name, column info)
|
|
and initial values, return a structure that is a tuple containing :
|
|
- a set of updated column names
|
|
- a list of ORM (0, 0, values) commands to create 'mail.tracking.value' """
|
|
res = super()._mail_track(tracked_fields, initial_values)
|
|
if not self.is_subscription:
|
|
return res
|
|
# When the mrr is < 0, the contract is considered free, it does not invoice and therefore we should not consider that amount in the logs
|
|
mrr = max(self.recurring_monthly, 0) if self.subscription_state in SUBSCRIPTION_PROGRESS_STATE else 0
|
|
initial_mrr = max(initial_values.get('recurring_monthly', mrr), 0) if initial_values.get('subscription_state', self.subscription_state) in SUBSCRIPTION_PROGRESS_STATE else 0
|
|
values = {'event_date': fields.Date.context_today(self),
|
|
'order_id': self.id,
|
|
'currency_id': self.currency_id.id,
|
|
'subscription_state': self.subscription_state,
|
|
'recurring_monthly': mrr,
|
|
'amount_signed': mrr - initial_mrr,
|
|
'user_id': self.user_id.id,
|
|
'team_id': self.team_id.id}
|
|
self.env['sale.order.log']._create_log(values, initial_values)
|
|
return res
|
|
|
|
def _prepare_invoice(self):
|
|
vals = super()._prepare_invoice()
|
|
if self.sale_order_template_id.journal_id:
|
|
vals['journal_id'] = self.sale_order_template_id.journal_id.id
|
|
return vals
|
|
|
|
@api.depends('order_line.qty_invoiced')
|
|
def _compute_amount_to_invoice(self):
|
|
non_recurring = self.env['sale.order']
|
|
for order in self:
|
|
if not order.is_subscription:
|
|
non_recurring += order
|
|
continue
|
|
|
|
non_recurring_lines = order.order_line.filtered(lambda line: not line.recurring_invoice)
|
|
amount_invoiced = 0
|
|
for invoice in order.invoice_ids.filtered(lambda invoice: invoice.state == 'posted'):
|
|
prices = sum(
|
|
invoice_line.price_total for invoice_line in invoice.line_ids
|
|
if all(sale_line in non_recurring_lines for sale_line in invoice_line.sale_line_ids)
|
|
)
|
|
amount_invoiced += invoice.currency_id._convert(
|
|
prices * -invoice.direction_sign,
|
|
order.currency_id,
|
|
invoice.company_id,
|
|
invoice.date,
|
|
)
|
|
|
|
amount_invoicable = sum(
|
|
line.price_total if line.product_id.invoice_policy != 'delivery' else line.price_total * line.qty_to_invoice / (
|
|
line.product_uom_qty or 1)
|
|
for line in order.order_line
|
|
)
|
|
order.amount_to_invoice = amount_invoicable - amount_invoiced
|
|
|
|
super(SaleOrder, non_recurring)._compute_amount_to_invoice()
|
|
|
|
def _notify_thread(self, message, msg_vals=False, **kwargs):
|
|
if not kwargs.get('model_description') and self.is_subscription:
|
|
kwargs['model_description'] = _("Subscription")
|
|
super()._notify_thread(message, msg_vals=msg_vals, **kwargs)
|
|
|
|
###########
|
|
# CRUD #
|
|
###########
|
|
|
|
@api.model_create_multi
|
|
def create(self, vals_list):
|
|
orders = super().create(vals_list)
|
|
for order, vals in zip(orders, vals_list):
|
|
if order.is_subscription:
|
|
order.subscription_state = vals.get('subscription_state', '1_draft')
|
|
return orders
|
|
|
|
def write(self, vals):
|
|
subscriptions = self.filtered('is_subscription')
|
|
old_partners = {s.id: s.partner_id.id for s in subscriptions}
|
|
res = super().write(vals)
|
|
for subscription in subscriptions:
|
|
if subscription.partner_id.id != old_partners[subscription.id]:
|
|
subscription.message_unsubscribe([old_partners[subscription.id]])
|
|
subscription.message_subscribe(subscription.partner_id.ids)
|
|
return res
|
|
|
|
@api.ondelete(at_uninstall=False)
|
|
def _unlink_except_draft_or_cancel(self):
|
|
for order in self:
|
|
if order.state not in ['draft', 'sent'] and order.subscription_state and order.subscription_state not in SUBSCRIPTION_DRAFT_STATE + SUBSCRIPTION_CLOSED_STATE:
|
|
raise UserError(_('You can not delete a confirmed subscription. You must first close and cancel it before you can delete it.'))
|
|
return super(SaleOrder, self)._unlink_except_draft_or_cancel()
|
|
|
|
def copy_data(self, default=None):
|
|
if default is None:
|
|
default = {}
|
|
if self.subscription_state == '7_upsell':
|
|
default.update({
|
|
"client_order_ref": self.client_order_ref,
|
|
"subscription_id": self.subscription_id.id,
|
|
"origin_order_id": self.origin_order_id.id,
|
|
'subscription_state': '7_upsell'
|
|
})
|
|
elif self.subscription_state and 'subscription_state' not in default:
|
|
default.update({
|
|
'subscription_state': '1_draft'
|
|
})
|
|
return super().copy_data(default)
|
|
|
|
###########
|
|
# Actions #
|
|
###########
|
|
|
|
def action_update_prices(self):
|
|
# Resetting the price_unit will break the link to the parent_line_id. action_update_prices will recompute
|
|
# the price and _compute_parent_line_id will be recomputed.
|
|
self.order_line.price_unit = False
|
|
super(SaleOrder, self).action_update_prices()
|
|
|
|
def action_archived_product(self):
|
|
archived_product_ids = self.with_context(active_test=False).archived_product_ids
|
|
action = self.env["ir.actions.actions"]._for_xml_id("product.product_normal_action_sell")
|
|
action['domain'] = [('id', 'in', archived_product_ids.ids), ('active', '=', False)]
|
|
action['context'] = dict(literal_eval(action.get('context')), search_default_inactive=True)
|
|
return action
|
|
|
|
def action_draft(self):
|
|
for order in self:
|
|
if (order.state == 'cancel'
|
|
and order.is_subscription
|
|
and any(state in ['draft', 'posted'] for state in order.order_line.invoice_lines.move_id.mapped('state'))):
|
|
raise UserError(
|
|
_('You cannot set to draft a canceled quotation linked to invoiced subscriptions. Please create a new quotation.'))
|
|
res = super().action_draft()
|
|
for order in self:
|
|
if order.is_subscription:
|
|
order.subscription_state = '2_renewal' if order.subscription_id else '1_draft'
|
|
return res
|
|
|
|
|
|
def _action_cancel(self):
|
|
for order in self:
|
|
if order.subscription_state == '7_upsell':
|
|
if order.state in ['sale', 'done']:
|
|
cancel_message_body = _("The upsell %s has been canceled. Please recheck the quantities as they may have been affected by this cancellation.", order._get_html_link())
|
|
else:
|
|
cancel_message_body = _("The upsell %s has been canceled.", order._get_html_link())
|
|
order.subscription_id.message_post(body=cancel_message_body)
|
|
elif order.subscription_state == '2_renewal':
|
|
cancel_message_body = _("The renewal %s has been canceled.", order._get_html_link())
|
|
order.subscription_id.message_post(body=cancel_message_body)
|
|
elif (order.subscription_state in SUBSCRIPTION_PROGRESS_STATE + SUBSCRIPTION_DRAFT_STATE
|
|
and not any(state in ['draft', 'posted'] for state in order.order_line.invoice_lines.move_id.mapped('state'))):
|
|
# subscription_id means a renewal because no upsell could enter this condition
|
|
# When we cancel a quote or a confirmed subscription that was not invoiced, we remove the order logs and
|
|
# reopen the parent order if the conditions are met.
|
|
# We know if the order is a renewal with transfer log by looking at the logs of the parent and the log of the order.
|
|
transfer_logs = order.subscription_id and order.order_log_ids.filtered(lambda log: log.event_type == '3_transfer' and log.amount_signed >= 0)
|
|
# last transfer amount
|
|
transfer_amount = transfer_logs and transfer_logs[:1].amount_signed
|
|
parent_transfer_log = transfer_amount and order.subscription_id.order_log_ids.filtered(lambda log: log.event_type == '3_transfer' and log.amount_signed == - transfer_amount)
|
|
last_parent_log = order.subscription_id.order_log_ids.sorted()[:1]
|
|
if parent_transfer_log and parent_transfer_log == last_parent_log:
|
|
# Delete the parent transfer log if it is the last log of the parent.
|
|
parent_transfer_log.sudo().unlink()
|
|
# Reopen the parent order and avoid recreating logs
|
|
order.subscription_id.with_context(tracking_disable=True).set_open()
|
|
parent_link = order.subscription_id._get_html_link()
|
|
cancel_activity_body = _("""Subscription %s has been canceled. The parent order %s has been reopened.
|
|
You should close %s if the customer churned, or renew it if the customer continue the service.
|
|
Note: if you already created a new subscription instead of renewing it, please cancel your newly
|
|
created subscription and renew %s instead""", order._get_html_link(),
|
|
parent_link,
|
|
parent_link,
|
|
parent_link)
|
|
order.activity_schedule(
|
|
'mail.mail_activity_data_todo',
|
|
summary=_("Check reopened subscription"),
|
|
note=cancel_activity_body,
|
|
user_id=order.subscription_id.user_id.id
|
|
)
|
|
elif order.subscription_state in SUBSCRIPTION_PROGRESS_STATE + ['5_renewed']:
|
|
raise ValidationError(_('You cannot cancel a subscription that has been invoiced.'))
|
|
if order.is_subscription:
|
|
order.subscription_state = False
|
|
order.order_log_ids.sudo().unlink()
|
|
return super()._action_cancel()
|
|
|
|
|
|
def _prepare_confirmation_values(self):
|
|
"""
|
|
Override of the sale method. sale.order in self should have the same subscription_state in order to process
|
|
them in batch.
|
|
:return: dict of values
|
|
"""
|
|
values = super()._prepare_confirmation_values()
|
|
if all(self.mapped('is_subscription')):
|
|
values['subscription_state'] = '3_progress'
|
|
return values
|
|
|
|
def action_confirm(self):
|
|
"""Update and/or create subscriptions on order confirmation."""
|
|
recurring_order = self.env['sale.order']
|
|
upsell = self.env['sale.order']
|
|
renewal = self.env['sale.order']
|
|
|
|
# The sale_subscription override of `_compute_discount` added `order_id.start_date` and
|
|
# `order_id.subscription_state` to `api.depends`; as this method modifies these fields,
|
|
# the discount field requires protection to avoid overwriting manually applied discounts
|
|
with self.env.protecting([self.order_line._fields['discount']], self.order_line):
|
|
for order in self:
|
|
if order.subscription_id:
|
|
if order.subscription_state == '7_upsell' and order.state in ['draft', 'sent']:
|
|
upsell |= order
|
|
elif order.subscription_state == '2_renewal':
|
|
renewal |= order
|
|
if order.is_subscription:
|
|
recurring_order |= order
|
|
if not order.subscription_state:
|
|
order.subscription_state = '1_draft'
|
|
elif order.subscription_state != '7_upsell' and order.subscription_state:
|
|
order.subscription_state = False
|
|
|
|
# _prepare_confirmation_values will update subscription_state for all confirmed subscription.
|
|
# We call super for two batches to avoid trigger the stage_coherence constraint.
|
|
res_sub = super(SaleOrder, recurring_order).action_confirm()
|
|
res_other = super(SaleOrder, self - recurring_order).action_confirm()
|
|
recurring_order._confirm_subscription()
|
|
renewal._confirm_renewal()
|
|
upsell._confirm_upsell()
|
|
|
|
return res_sub and res_other
|
|
|
|
def action_quotation_send(self):
|
|
if len(self) == 1:
|
|
# Raise error before other popup if used on one SO.
|
|
has_recurring_line = self.order_line.filtered(lambda l: l.product_id.recurring_invoice)
|
|
if has_recurring_line and not self.plan_id:
|
|
raise UserError(_('You cannot send a sale order with recurring product and no subscription plan.'))
|
|
if self.plan_id and not has_recurring_line:
|
|
raise UserError(_('You cannot send a sale order with a subscription plan and no recurring product.'))
|
|
return super().action_quotation_send()
|
|
|
|
def _confirm_subscription(self):
|
|
today = fields.Date.today()
|
|
for sub in self:
|
|
sub._portal_ensure_token()
|
|
# We set the start date and invoice date at the date of confirmation
|
|
if not sub.start_date:
|
|
sub.start_date = today
|
|
if sub.plan_id.billing_period_value <= 0:
|
|
raise UserError(_("Recurring period must be a positive number. Please ensure the input is a valid positive numeric value."))
|
|
sub._set_deferred_end_date_from_template()
|
|
sub.order_line._reset_subscription_qty_to_invoice()
|
|
if sub._check_token_saving_conditions():
|
|
sub._save_token_from_payment()
|
|
|
|
def _set_deferred_end_date_from_template(self):
|
|
self.ensure_one()
|
|
if self.sale_order_template_id and not self.sale_order_template_id.is_unlimited and not self.end_date:
|
|
self.write({'end_date': self.start_date + self.sale_order_template_id.duration - relativedelta(days=1)})
|
|
|
|
def _confirm_upsell(self):
|
|
"""
|
|
When confirming an upsell order, the recurring product lines must be updated
|
|
"""
|
|
today = fields.Date.today()
|
|
for so in self:
|
|
# We check the subscription direct invoice and not the one related to the whole SO
|
|
if (so.start_date or today) >= so.subscription_id.next_invoice_date:
|
|
raise ValidationError(_("You cannot upsell a subscription whose next invoice date is in the past.\n"
|
|
"Please, invoice directly the %s contract.", so.subscription_id.name))
|
|
existing_line_ids = self.subscription_id.order_line
|
|
dummy, update_values = self.update_existing_subscriptions()
|
|
updated_line_ids = self.env['sale.order.line'].browse({val[1] for val in update_values})
|
|
new_lines_ids = self.subscription_id.order_line - existing_line_ids
|
|
# Example: with a new yearly line starting in june when the expected next invoice date is december,
|
|
# discount is 50% and the default next_invoice_date will be in june too.
|
|
# We need to get the default next_invoice_date that was saved on the upsell because the compute has no way
|
|
# to differentiate new line created by an upsell and new line created by the user.
|
|
for upsell in self:
|
|
upsell.subscription_id.message_post(body=_("The upsell %s has been confirmed.", upsell._get_html_link()))
|
|
for line in (updated_line_ids | new_lines_ids).with_context(skip_line_status_compute=True):
|
|
# The upsell invoice will take care of the invoicing for this period
|
|
line.qty_to_invoice = 0
|
|
line.qty_invoiced = line.product_uom_qty
|
|
# We force the invoice status because the current period will be invoiced by the upsell flow
|
|
# when the upsell so is invoiced
|
|
line.invoice_status = 'no'
|
|
|
|
def _confirm_renewal(self):
|
|
"""
|
|
When confirming a renewal order, the recurring product lines must be updated
|
|
"""
|
|
today = fields.Date.today()
|
|
for renew in self:
|
|
# When parent subscription reaches his end_date, it will be closed with a close_reason_renew, so it won't be considered as a simple churn.
|
|
parent = renew.subscription_id
|
|
if renew.start_date < parent.next_invoice_date:
|
|
raise ValidationError(_("You cannot validate a renewal quotation starting before the next invoice date "
|
|
"of the parent contract. Please update the start date after the %s.", format_date(self.env, parent.next_invoice_date)))
|
|
elif parent.start_date == parent.next_invoice_date:
|
|
raise ValidationError(_("You can not upsell or renew a subscription that has not been invoiced yet. "
|
|
"Please, update directly the %s contract or invoice it first.", parent.name))
|
|
elif parent.subscription_state == '5_renewed':
|
|
raise ValidationError(_("You cannot renew a subscription that has been renewed. "))
|
|
elif self.search_count([('origin_order_id', '=', renew.origin_order_id.id),
|
|
('subscription_state', 'in', SUBSCRIPTION_PROGRESS_STATE),
|
|
('id', 'not in', [parent.id, renew.id])], limit=1):
|
|
raise ValidationError(_("You cannot renew a contract that already has an active subscription. "))
|
|
elif parent.state in ['sale', 'done'] and parent.subscription_state == '6_churn' and parent.next_invoice_date == renew.start_date:
|
|
parent.reopen_order()
|
|
auto_commit = not bool(config['test_enable'] or config['test_file'])
|
|
# Force the creation of the reopen logs.
|
|
self._subscription_commit_cursor(auto_commit=auto_commit)
|
|
# Make sure to delete the churn log as it won't be cleaned by mail-track
|
|
churn_logs = parent.order_log_ids.filtered(lambda log: log.event_type == '2_churn')
|
|
churn_log = churn_logs and churn_logs[-1]
|
|
churn_log.sudo().unlink()
|
|
other_renew_so_ids = parent.subscription_child_ids.filtered(lambda so: so.subscription_state == '2_renewal' and so.state != 'cancel') - renew
|
|
if other_renew_so_ids:
|
|
other_renew_so_ids._action_cancel()
|
|
|
|
renew_msg_body = _("This subscription is renewed in %s with a change of plan.", renew._get_html_link())
|
|
parent.message_post(body=renew_msg_body)
|
|
renew_close_reason_id = self.env.ref('sale_subscription.close_reason_renew')
|
|
end_of_contract_reason_id = self.env.ref('sale_subscription.close_reason_end_of_contract')
|
|
close_reason_id = renew_close_reason_id if parent.subscription_state != "6_churn" else end_of_contract_reason_id
|
|
parent.set_close(close_reason_id=close_reason_id.id, renew=True)
|
|
parent.update({'end_date': parent.next_invoice_date})
|
|
# This can create hole that are not taken into account by progress_sub upselling, it's an assumed choice over more upselling complexity
|
|
start_date = renew.start_date or parent.next_invoice_date
|
|
renew.write({'date_order': today, 'start_date': start_date})
|
|
if renew._check_token_saving_conditions():
|
|
renew._save_token_from_payment()
|
|
|
|
def _check_token_saving_conditions(self):
|
|
""" Check if all conditions match for saving the payment token on the subscription. """
|
|
self.ensure_one()
|
|
last_transaction = self.transaction_ids.sudo()._get_last()
|
|
last_token = last_transaction.token_id
|
|
subscription_fully_paid = self.currency_id.compare_amounts(last_transaction.amount, self.amount_total) >= 0
|
|
transaction_authorized = last_transaction and last_transaction.renewal_state == "authorized"
|
|
return last_token and last_transaction and subscription_fully_paid and transaction_authorized
|
|
|
|
def _save_token_from_payment(self):
|
|
self.ensure_one()
|
|
last_token = self.transaction_ids.sudo()._get_last().token_id.id
|
|
if last_token:
|
|
self.payment_token_id = last_token
|
|
|
|
def _group_expand_states(self, states, domain, order):
|
|
return ['3_progress', '4_paused']
|
|
|
|
@api.model
|
|
def read_group(self, domain, fields, groupby, offset=0, limit=None, orderby=False, lazy=True):
|
|
res = super().read_group(domain, fields, groupby, offset=offset, limit=limit, orderby=orderby, lazy=lazy)
|
|
if groupby and groupby[0] == 'subscription_state':
|
|
# Sort because group expand force progress and paused as first
|
|
res = sorted(res, key=lambda r: r.get('subscription_state') or '')
|
|
return res
|
|
|
|
@api.model
|
|
def _get_associated_so_action(self):
|
|
return {
|
|
"type": "ir.actions.act_window",
|
|
"res_model": "sale.order",
|
|
"views": [[self.env.ref('sale_subscription.sale_subscription_view_tree').id, "tree"],
|
|
[self.env.ref('sale_subscription.sale_subscription_primary_form_view').id, "form"],
|
|
[False, "kanban"], [False, "calendar"], [False, "pivot"], [False, "graph"]],
|
|
}
|
|
|
|
def open_subscription_history(self):
|
|
self.ensure_one()
|
|
action = {
|
|
"type": "ir.actions.act_window",
|
|
"res_model": "sale.order",
|
|
"views": [[self.env.ref('sale_subscription.sale_subscription_quotation_tree_view').id, "tree"],
|
|
[self.env.ref('sale_subscription.sale_subscription_primary_form_view').id, "form"]]
|
|
}
|
|
origin_order_id = self.origin_order_id.id or self.id
|
|
action['name'] = _("History")
|
|
action['domain'] = [('state', 'not in', ['cancel', 'draft']), '|', ('id', '=', origin_order_id), ('origin_order_id', '=', origin_order_id)]
|
|
action['context'] = {
|
|
**action.get('context', {}),
|
|
'create': False,
|
|
}
|
|
return action
|
|
|
|
def open_subscription_renewal(self):
|
|
self.ensure_one()
|
|
action = self._get_associated_so_action()
|
|
action['name'] = _("Renewal Quotations")
|
|
renewal = self.subscription_child_ids.filtered(lambda so: so.subscription_state == '2_renewal')
|
|
if len(renewal) == 1:
|
|
action['res_id'] = renewal.id
|
|
action['views'] = [(self.env.ref('sale_subscription.sale_subscription_primary_form_view').id, 'form')]
|
|
else:
|
|
action['domain'] = [('subscription_id', '=', self.id), ('subscription_state', '=', '2_renewal'), ('state', 'in', ['draft', 'sent'])]
|
|
action['views'] = [(self.env.ref('sale.view_quotation_tree').id, 'tree'),
|
|
(self.env.ref('sale_subscription.sale_subscription_primary_form_view').id, 'form')]
|
|
|
|
action['context'] = {
|
|
**action.get('context', {}),
|
|
'create': False,
|
|
}
|
|
return action
|
|
|
|
def open_subscription_upsell(self):
|
|
self.ensure_one()
|
|
action = self._get_associated_so_action()
|
|
action['name'] = _("Upsell Quotations")
|
|
upsell = self.subscription_child_ids.filtered(lambda so: so.subscription_state == '7_upsell' and so.state in ['draft', 'sent'])
|
|
if len(upsell) == 1:
|
|
action['res_id'] = upsell.id
|
|
action['views'] = [(self.env.ref('sale_subscription.sale_subscription_primary_form_view').id, 'form')]
|
|
else:
|
|
action['domain'] = [('subscription_id', '=', self.id), ('subscription_state', '=', '7_upsell'), ('state', 'in', ['draft', 'sent'])]
|
|
action['views'] = [(self.env.ref('sale.view_quotation_tree').id, 'tree'),
|
|
(self.env.ref('sale_subscription.sale_subscription_primary_form_view').id, 'form')]
|
|
action['context'] = {
|
|
**action.get('context', {}),
|
|
'create': False,
|
|
}
|
|
return action
|
|
|
|
def action_open_subscriptions(self):
|
|
""" Display the linked subscription and adapt the view to the number of records to display."""
|
|
self.ensure_one()
|
|
subscriptions = self.order_line.mapped('subscription_id')
|
|
action = self.env["ir.actions.actions"]._for_xml_id("sale_subscription.sale_subscription_action")
|
|
if len(subscriptions) > 1:
|
|
action['domain'] = [('id', 'in', subscriptions.ids)]
|
|
elif len(subscriptions) == 1:
|
|
form_view = [(self.env.ref('sale_subscription.sale_subscription_view_form').id, 'form')]
|
|
if 'views' in action:
|
|
action['views'] = form_view + [(state, view) for state, view in action['views'] if view != 'form']
|
|
else:
|
|
action['views'] = form_view
|
|
action['res_id'] = subscriptions.ids[0]
|
|
else:
|
|
action = {'type': 'ir.actions.act_window_close'}
|
|
action['context'] = dict(self._context, create=False)
|
|
return action
|
|
|
|
def action_sale_order_log(self):
|
|
self.ensure_one()
|
|
action = self.env["ir.actions.actions"]._for_xml_id("sale_subscription.sale_order_log_analysis_action")
|
|
origin_order_ids = self.origin_order_id.ids + self.ids
|
|
genealogy_orders_ids = self.search(['|', ('id', 'in', origin_order_ids), ('origin_order_id', 'in', origin_order_ids)])
|
|
action.update({
|
|
'name': _('MRR changes'),
|
|
'domain': [('order_id', 'in', genealogy_orders_ids.ids)],
|
|
'context': {'search_default_group_by_event_date': 1},
|
|
})
|
|
return action
|
|
|
|
def _create_renew_upsell_order(self, subscription_state, message_body):
|
|
self.ensure_one()
|
|
if self.start_date == self.next_invoice_date:
|
|
raise ValidationError(_("You can not upsell or renew a subscription that has not been invoiced yet. "
|
|
"Please, update directly the %s contract or invoice it first.", self.name))
|
|
values = self._prepare_upsell_renew_order_values(subscription_state)
|
|
order = self.env['sale.order'].create(values)
|
|
self.subscription_child_ids = [Command.link(order.id)]
|
|
order.message_post(body=message_body)
|
|
if subscription_state == '7_upsell':
|
|
parent_message_body = _("An upsell quotation %s has been created", order._get_html_link())
|
|
else:
|
|
parent_message_body = _("A renewal quotation %s has been created", order._get_html_link())
|
|
self.message_post(body=parent_message_body)
|
|
order.order_line._compute_tax_id()
|
|
return order
|
|
|
|
def _prepare_renew_upsell_order(self, subscription_state, message_body):
|
|
order = self._create_renew_upsell_order(subscription_state, message_body)
|
|
action = self._get_associated_so_action()
|
|
action['name'] = _('Upsell') if subscription_state == '7_upsell' else _('Renew')
|
|
action['views'] = [(self.env.ref('sale_subscription.sale_subscription_primary_form_view').id, 'form')]
|
|
action['res_id'] = order.id
|
|
return action
|
|
|
|
def _get_order_digest(self, origin='', template='sale_subscription.sale_order_digest', lang=None):
|
|
self.ensure_one()
|
|
values = {'origin': origin,
|
|
'record_url': self._get_html_link(),
|
|
'start_date': self.start_date,
|
|
'next_invoice_date': self.next_invoice_date,
|
|
'recurring_monthly': self.recurring_monthly,
|
|
'untaxed_amount': self.amount_untaxed,
|
|
'quotation_template': self.sale_order_template_id.name} # see if we don't want plan instead
|
|
return self.env['ir.qweb'].with_context(lang=lang)._render(template, values)
|
|
|
|
def subscription_open_related(self):
|
|
self.ensure_one()
|
|
action = self._get_associated_so_action()
|
|
action['views'] = [(self.env.ref('sale_subscription.sale_subscription_primary_form_view').id, 'form')]
|
|
if self.subscription_state == '5_renewed':
|
|
action['res_id'] = self.subscription_child_ids.filtered(lambda c: c.subscription_state not in ['7_upsell', '2_renewal'])[0].id
|
|
elif self.subscription_state in ['2_renewal', '7_upsell']:
|
|
action['res_id'] = self.subscription_id.id
|
|
else:
|
|
return
|
|
return action
|
|
|
|
def prepare_renewal_order(self):
|
|
self.ensure_one()
|
|
lang = self.partner_id.lang or self.env.user.lang
|
|
renew_msg_body = self._get_order_digest(origin='renewal', lang=lang)
|
|
action = self._prepare_renew_upsell_order('2_renewal', renew_msg_body)
|
|
|
|
return action
|
|
|
|
def prepare_upsell_order(self):
|
|
self.ensure_one()
|
|
lang = self.partner_id.lang or self.env.user.lang
|
|
upsell_msg_body = self._get_order_digest(origin='upsell', lang=lang)
|
|
action = self._prepare_renew_upsell_order('7_upsell', upsell_msg_body)
|
|
return action
|
|
|
|
def reopen_order(self):
|
|
if self and set(self.mapped('subscription_state')) != {'6_churn'}:
|
|
raise UserError(_("You cannot reopen a subscription that isn't closed."))
|
|
self.set_open()
|
|
|
|
def pause_subscription(self):
|
|
self.filtered(lambda so: so.subscription_state == '3_progress').write({'subscription_state': '4_paused'})
|
|
|
|
def resume_subscription(self):
|
|
self.filtered(lambda so: so.subscription_state == '4_paused').write({'subscription_state': '3_progress'})
|
|
|
|
def create_alternative(self):
|
|
self.ensure_one()
|
|
alternative_so = self.copy({
|
|
'origin_order_id': self.origin_order_id.id,
|
|
'subscription_id': self.subscription_id.id,
|
|
'subscription_state': self.env.context.get('default_subscription_state', '2_renewal'),
|
|
})
|
|
action = alternative_so._get_associated_so_action()
|
|
action['views'] = [(self.env.ref('sale_subscription.sale_subscription_primary_form_view').id, 'form')]
|
|
action['res_id'] = alternative_so.id
|
|
return action
|
|
|
|
def _should_be_locked(self):
|
|
self.ensure_one()
|
|
should_lock = super()._should_be_locked()
|
|
return should_lock and not self.is_subscription
|
|
|
|
####################
|
|
# Business Methods #
|
|
####################
|
|
|
|
def _upsell_context(self):
|
|
return {"skip_next_invoice_update": True}
|
|
|
|
def update_existing_subscriptions(self):
|
|
"""
|
|
Update subscriptions already linked to the order by updating or creating lines.
|
|
This method is only called on upsell confirmation
|
|
:rtype: list(integer)
|
|
:return: ids of modified subscriptions
|
|
"""
|
|
create_values, update_values = [], []
|
|
context = self._upsell_context()
|
|
for order in self:
|
|
# We don't propagate the line description from the upsell order to the subscription
|
|
create_values, update_values = order.order_line.filtered(lambda sol: not sol.display_type)._subscription_update_line_data(order.subscription_id)
|
|
order.subscription_id.with_context(**context).write({'order_line': create_values + update_values})
|
|
return create_values, update_values
|
|
|
|
def _set_closed_state(self, renew=False):
|
|
for order in self:
|
|
renewal_order = order.subscription_child_ids.filtered(lambda s: s.subscription_state in SUBSCRIPTION_PROGRESS_STATE)
|
|
progress_renewed = order.subscription_state in SUBSCRIPTION_PROGRESS_STATE
|
|
if renew and renewal_order and progress_renewed:
|
|
order.subscription_state = '5_renewed'
|
|
order.locked = True
|
|
else:
|
|
order.subscription_state = '6_churn'
|
|
|
|
def set_close(self, close_reason_id=None, renew=False):
|
|
"""
|
|
Close subscriptions
|
|
:param int close_reason_id: id of the sale.order.close.reason
|
|
:return: True
|
|
"""
|
|
self._set_closed_state(renew)
|
|
today = fields.Date.context_today(self)
|
|
values = {'end_date': today}
|
|
if close_reason_id:
|
|
values['close_reason_id'] = close_reason_id
|
|
self.update(values)
|
|
else:
|
|
renew_close_reason_id = self.env.ref('sale_subscription.close_reason_renew').id
|
|
end_of_contract_reason_id = self.env.ref('sale_subscription.close_reason_end_of_contract').id
|
|
close_reason_unknown_id = self.env.ref('sale_subscription.close_reason_unknown').id
|
|
for sub in self:
|
|
if renew:
|
|
close_reason_id = renew_close_reason_id
|
|
elif sub.end_date and sub.end_date <= today:
|
|
close_reason_id = end_of_contract_reason_id
|
|
else:
|
|
close_reason_id = close_reason_unknown_id
|
|
sub.update(dict(**values, close_reason_id=close_reason_id))
|
|
return True
|
|
|
|
def set_open(self):
|
|
for order in self:
|
|
if order.subscription_state == '6_churn' and order.end_date:
|
|
order.end_date = False
|
|
reopen_activity_body = _("Subscription %s has been reopened. The end date has been removed", order._get_html_link())
|
|
order.activity_schedule(
|
|
'mail.mail_activity_data_todo',
|
|
summary=_("Check reopened subscription"),
|
|
note=reopen_activity_body,
|
|
user_id=order.user_id.id
|
|
)
|
|
self.filtered('is_subscription').update({'subscription_state': '3_progress', 'state': 'sale', 'close_reason_id': False, 'locked': False})
|
|
|
|
@api.model
|
|
def _cron_update_kpi(self):
|
|
subscriptions = self.search([('subscription_state', '=', '3_progress'), ('is_subscription', '=', True)])
|
|
subscriptions._compute_kpi()
|
|
|
|
def _prepare_upsell_renew_order_values(self, subscription_state):
|
|
"""
|
|
Create a new draft order with the same lines as the parent subscription. All recurring lines are linked to their parent lines
|
|
:return: dict of new sale order values
|
|
"""
|
|
self.ensure_one()
|
|
today = fields.Date.today()
|
|
if subscription_state == '7_upsell' and self.next_invoice_date <= max(self.first_contract_date or today, today):
|
|
raise UserError(_('You cannot create an upsell for this subscription because it :\n'
|
|
' - Has not started yet.\n'
|
|
' - Has no invoiced period in the future.'))
|
|
lang_code = self.partner_id.lang
|
|
subscription = self.with_company(self.company_id)
|
|
order_lines = self.with_context(lang=lang_code).order_line._get_renew_upsell_values(subscription_state, period_end=self.next_invoice_date)
|
|
is_subscription = subscription_state == '2_renewal'
|
|
option_lines_data = [Command.link(option.copy().id) for option in subscription.with_context(lang=lang_code).sale_order_option_ids]
|
|
if subscription_state == '7_upsell':
|
|
start_date = fields.Date.today()
|
|
next_invoice_date = self.next_invoice_date
|
|
else:
|
|
# renewal
|
|
start_date = self.next_invoice_date
|
|
next_invoice_date = self.next_invoice_date # the next invoice date is the start_date for new contract
|
|
return {
|
|
'is_subscription': is_subscription,
|
|
'subscription_id': subscription.id,
|
|
'pricelist_id': subscription.pricelist_id.id,
|
|
'partner_id': subscription.partner_id.id,
|
|
'partner_invoice_id': subscription.partner_invoice_id.id,
|
|
'partner_shipping_id': subscription.partner_shipping_id.id,
|
|
'order_line': order_lines,
|
|
'analytic_account_id': subscription.analytic_account_id.id,
|
|
'subscription_state': subscription_state,
|
|
'origin': subscription.client_order_ref,
|
|
'client_order_ref': subscription.client_order_ref,
|
|
'origin_order_id': subscription.origin_order_id.id,
|
|
'note': subscription.note,
|
|
'user_id': subscription.user_id.id,
|
|
'payment_term_id': subscription.payment_term_id.id,
|
|
'company_id': subscription.company_id.id,
|
|
'sale_order_template_id': self.sale_order_template_id.id,
|
|
'sale_order_option_ids': option_lines_data,
|
|
'payment_token_id': False,
|
|
'start_date': start_date,
|
|
'next_invoice_date': next_invoice_date,
|
|
'plan_id': subscription.plan_id.id,
|
|
}
|
|
|
|
def _compute_kpi(self):
|
|
for subscription in self:
|
|
delta_1month = subscription._get_subscription_delta(fields.Date.today() - relativedelta(months=1))
|
|
delta_3months = subscription._get_subscription_delta(fields.Date.today() - relativedelta(months=3))
|
|
subscription.write({
|
|
'kpi_1month_mrr_delta': delta_1month['delta'],
|
|
'kpi_1month_mrr_percentage': delta_1month['percentage'],
|
|
'kpi_3months_mrr_delta': delta_3months['delta'],
|
|
'kpi_3months_mrr_percentage': delta_3months['percentage'],
|
|
})
|
|
|
|
def _get_portal_return_action(self):
|
|
""" Return the action used to display orders when returning from customer portal. """
|
|
if self.is_subscription:
|
|
return self.env.ref('sale_subscription.sale_subscription_action')
|
|
else:
|
|
return super(SaleOrder, self)._get_portal_return_action()
|
|
|
|
####################
|
|
# Invoicing Methods #
|
|
####################
|
|
|
|
@api.model
|
|
def _cron_recurring_create_invoice(self):
|
|
deferred_account = self.env.company.deferred_revenue_account_id
|
|
deferred_journal = self.env.company.deferred_journal_id
|
|
if not deferred_account or not deferred_journal:
|
|
raise ValidationError(_("The deferred settings are not properly set. Please complete them to generate subscription deferred revenues"))
|
|
return self._create_recurring_invoice()
|
|
|
|
def _get_invoiceable_lines(self, final=False):
|
|
date_from = self.env.context.get('invoiceable_date_from', fields.Date.today())
|
|
res = super()._get_invoiceable_lines(final=final)
|
|
res = res.filtered(lambda l: not l.recurring_invoice or l.order_id.subscription_state == '7_upsell')
|
|
automatic_invoice = self.env.context.get('recurring_automatic')
|
|
|
|
invoiceable_line_ids = []
|
|
downpayment_line_ids = []
|
|
pending_section = None
|
|
for line in self.order_line:
|
|
if line.display_type == 'line_section':
|
|
# Only add section if one of its lines is invoiceable
|
|
pending_section = line
|
|
continue
|
|
|
|
if line.state != 'sale':
|
|
continue
|
|
|
|
if automatic_invoice:
|
|
# We don't invoice line before their SO's next_invoice_date
|
|
line_condition = line.order_id.next_invoice_date and line.order_id.next_invoice_date <= date_from and line.order_id.start_date and line.order_id.start_date <= date_from
|
|
else:
|
|
# We don't invoice line past their SO's end_date
|
|
line_condition = not line.order_id.end_date or (line.order_id.next_invoice_date and line.order_id.next_invoice_date < line.order_id.end_date)
|
|
|
|
line_to_invoice = False
|
|
if line in res:
|
|
# Line was already marked as to be invoiced
|
|
line_to_invoice = True
|
|
elif line.order_id.subscription_state == '7_upsell':
|
|
# Super() already select everything that is needed for upsells
|
|
line_to_invoice = False
|
|
elif line.display_type or not line.recurring_invoice:
|
|
# Avoid invoicing section/notes or lines starting in the future or not starting at all
|
|
line_to_invoice = False
|
|
elif line_condition:
|
|
if(
|
|
line.product_id.invoice_policy == 'order'
|
|
and line.order_id.subscription_state != '5_renewed'
|
|
):
|
|
# Invoice due lines
|
|
line_to_invoice = True
|
|
elif (
|
|
line.product_id.invoice_policy == 'delivery'
|
|
and not float_is_zero(
|
|
line.qty_delivered,
|
|
precision_rounding=line.product_id.uom_id.rounding,
|
|
)
|
|
):
|
|
line_to_invoice = True
|
|
|
|
if line_to_invoice:
|
|
if line.is_downpayment:
|
|
# downpayment line must be kept at the end in its dedicated section
|
|
downpayment_line_ids.append(line.id)
|
|
continue
|
|
if pending_section:
|
|
invoiceable_line_ids.append(pending_section.id)
|
|
pending_section = False
|
|
invoiceable_line_ids.append(line.id)
|
|
|
|
return self.env["sale.order.line"].browse(invoiceable_line_ids + downpayment_line_ids)
|
|
|
|
def _subscription_post_success_free_renewal(self):
|
|
""" Action done after the successful payment has been performed """
|
|
self.ensure_one()
|
|
msg_body = _(
|
|
'Automatic renewal succeeded. Free subscription. Next Invoice: %(inv)s. No email sent.',
|
|
inv=self.next_invoice_date
|
|
)
|
|
self.message_post(body=msg_body)
|
|
|
|
def _subscription_post_success_payment(self, transaction, invoices, automatic=True):
|
|
"""
|
|
Action done after the successful payment has been performed
|
|
:param transaction: single payment.transaction record
|
|
:param invoices: account.move recordset
|
|
:param automatic: True if the transaction was created during the subscription invoicing cron
|
|
"""
|
|
self.ensure_one()
|
|
transaction.ensure_one()
|
|
for invoice in invoices:
|
|
invoice.write({'payment_reference': transaction.reference, 'ref': transaction.reference})
|
|
if automatic:
|
|
msg_body = _(
|
|
'Automatic payment succeeded. Payment reference: %(ref)s. Amount: %(amount)s. Contract set to: In Progress, Next Invoice: %(inv)s. Email sent to customer.',
|
|
ref=transaction._get_html_link(title=transaction.reference),
|
|
amount=transaction.amount,
|
|
inv=self.next_invoice_date,
|
|
)
|
|
else:
|
|
msg_body = _(
|
|
'Manual payment succeeded. Payment reference: %(ref)s. Amount: %(amount)s. Contract set to: In Progress, Next Invoice: %(inv)s. Email sent to customer.',
|
|
ref=transaction._get_html_link(title=transaction.reference),
|
|
amount=transaction.amount,
|
|
inv=self.next_invoice_date,
|
|
)
|
|
self.message_post(body=msg_body)
|
|
if invoice.state != 'posted':
|
|
invoice.with_context(ocr_trigger_delta=15)._post()
|
|
|
|
def _get_subscription_mail_payment_context(self, mail_ctx=None):
|
|
self.ensure_one()
|
|
if not mail_ctx:
|
|
mail_ctx = {}
|
|
return {**self._context, **mail_ctx, **{'total_amount': self.amount_total,
|
|
'currency_name': self.currency_id.name,
|
|
'responsible_email': self.user_id.email,
|
|
'code': self.client_order_ref}}
|
|
|
|
def _update_next_invoice_date(self):
|
|
""" Update the next_invoice_date according to the periodicity of the order.
|
|
At quotation confirmation, last_invoice_date is false, next_invoice is start date and start_date is today
|
|
by default. The next_invoice_date should be bumped up each time an invoice is created except for the
|
|
first period.
|
|
|
|
The next invoice date should be updated according to these rules :
|
|
-> If the trigger is manuel : We should always increment the next_invoice_date
|
|
-> If the trigger is automatic & date_next_invoice < today :
|
|
-> If there is a payment_token : We should increment at the payment reconciliation
|
|
-> If there is no token : We always increment the next_invoice_date even if there is nothing to invoice
|
|
"""
|
|
for order in self:
|
|
if not order.is_subscription:
|
|
continue
|
|
last_invoice_date = order.next_invoice_date or order.start_date
|
|
if last_invoice_date:
|
|
order.next_invoice_date = last_invoice_date + order.plan_id.billing_period
|
|
|
|
def _update_subscription_payment_failure_values(self):
|
|
# allow to override the subscription values in case of payment failure
|
|
return {}
|
|
|
|
def _post_invoice_hook(self):
|
|
# This method allow a hook after invoicing
|
|
self.order_line._reset_subscription_quantity_post_invoice()
|
|
# Trigger the amount_to_invoice recomputation now that all updates on sale.order have been made
|
|
self.env.add_to_compute(self.env['sale.order']._fields['amount_to_invoice'], self)
|
|
|
|
def _handle_post_invoice_hook_exception(self):
|
|
# Method to overwrite to handle SaleOrderLine._reset_subscription_quantity_post_invoice exceptions.
|
|
return
|
|
|
|
def _handle_subscription_payment_failure(self, invoice, transaction):
|
|
current_date = fields.Date.today()
|
|
reminder_mail_template = self.env.ref('sale_subscription.email_payment_reminder', raise_if_not_found=False)
|
|
close_mail_template = self.env.ref('sale_subscription.email_payment_close', raise_if_not_found=False)
|
|
invoice.unlink()
|
|
for order in self:
|
|
auto_close_days = self.plan_id.auto_close_limit or 15
|
|
date_close = order.next_invoice_date + relativedelta(days=auto_close_days)
|
|
close_contract = current_date >= date_close
|
|
email_context = order._get_subscription_mail_payment_context()
|
|
_logger.info('Failed to create recurring invoice for contract %s', order.client_order_ref or order.name)
|
|
if close_contract:
|
|
close_mail_template.with_context(email_context).send_mail(order.id)
|
|
_logger.debug("Sending Contract Closure Mail to %s for contract %s and closing contract",
|
|
order.partner_id.email, order.id)
|
|
msg_body = _("Automatic payment failed after multiple attempts. Contract closed automatically.")
|
|
order.message_post(body=msg_body)
|
|
subscription_values = {'payment_exception': False}
|
|
# close the contract as needed
|
|
order.set_close(close_reason_id=order.env.ref('sale_subscription.close_reason_auto_close_limit_reached').id)
|
|
else:
|
|
msg_body = _('Automatic payment failed. No email sent this time. Error: %s', transaction and transaction.state_message or _('No valid Payment Method'))
|
|
if (fields.Date.today() - order.next_invoice_date).days in [2, 7, 14]:
|
|
email_context.update({'date_close': date_close, 'payment_token': order.payment_token_id.display_name})
|
|
reminder_mail_template.with_context(email_context).send_mail(order.id)
|
|
_logger.debug("Sending Payment Failure Mail to %s for contract %s and setting contract to pending", order.partner_id.email, order.id)
|
|
msg_body = _('Automatic payment failed. Email sent to customer. Error: %s', transaction and transaction.state_message or _('No Payment Method'))
|
|
order.message_post(body=msg_body)
|
|
# payment failed (not catched in exception) but we should not retry directly.
|
|
# flag with is_batch to avoid processing it again in another batch
|
|
subscription_values = {'payment_exception': False, 'is_batch': True}
|
|
subscription_values.update(order._update_subscription_payment_failure_values())
|
|
order.write(subscription_values)
|
|
|
|
def _invoice_is_considered_free(self, invoiceable_lines):
|
|
"""
|
|
In some case, we want to skip the invoice generation for subscription.
|
|
By default, we only consider it free if the amount is 0, but we could use other criterion
|
|
:return: bool: true if the contract is free
|
|
:return: bool: true if the contract should be in exception
|
|
"""
|
|
# By design if self is a recordset, all currency are similar
|
|
currency = self.currency_id[:1]
|
|
amount_total = sum(invoiceable_lines.mapped('price_total'))
|
|
non_recurring_line = invoiceable_lines.filtered(lambda l: not l.recurring_invoice)
|
|
is_free, is_exception = False, False
|
|
mrr = sum(self.mapped('recurring_monthly'))
|
|
if currency.compare_amounts(mrr, 0) < 0 and non_recurring_line:
|
|
# We have a mix of recurring lines whose sum is negative and non-recurring lines to invoice
|
|
# We don't know what to do
|
|
is_free = True
|
|
is_exception = True
|
|
elif currency.compare_amounts(amount_total, 0) < 1:
|
|
# We can't create an invoice, it will be impossible to validate
|
|
is_free = True
|
|
elif currency.compare_amounts(mrr, 0) < 1 and not non_recurring_line:
|
|
# We have a recurring null/negative amount. It is not desired even if we have a non-recurring positive amount
|
|
is_free = True
|
|
return is_free, is_exception
|
|
|
|
def _recurring_invoice_domain(self, extra_domain=None):
|
|
if not extra_domain:
|
|
extra_domain = []
|
|
current_date = fields.Date.today()
|
|
search_domain = [('is_batch', '=', False),
|
|
('is_invoice_cron', '=', False),
|
|
('is_subscription', '=', True),
|
|
('subscription_state', '=', '3_progress'),
|
|
('payment_exception', '=', False),
|
|
('pending_transaction', '=', False),
|
|
'|', ('next_invoice_date', '<=', current_date), ('end_date', '<=', current_date)]
|
|
if extra_domain:
|
|
search_domain = expression.AND([search_domain, extra_domain])
|
|
return search_domain
|
|
|
|
def _get_invoice_grouping_keys(self):
|
|
if any(self.mapped('is_subscription')):
|
|
return super()._get_invoice_grouping_keys() + ['payment_token_id', 'partner_invoice_id']
|
|
else:
|
|
return super()._get_invoice_grouping_keys()
|
|
|
|
def _get_auto_invoice_grouping_keys(self):
|
|
grouping_keys = super()._get_invoice_grouping_keys() + ['payment_token_id', 'partner_invoice_id']
|
|
grouping_keys = list(set(grouping_keys))
|
|
grouping_keys.remove('partner_id')
|
|
return grouping_keys
|
|
|
|
|
|
def _recurring_invoice_get_subscriptions(self, grouped=False, batch_size=30):
|
|
""" Return a boolean and an iterable of recordsets.
|
|
The boolean is true if batch_size is smaller than the number of remaining records
|
|
If grouped, each recordset contains SO with the same grouping keys.
|
|
"""
|
|
need_cron_trigger = False
|
|
limit = False
|
|
if self:
|
|
domain = [('id', 'in', self.ids), ('subscription_state', 'in', SUBSCRIPTION_PROGRESS_STATE)]
|
|
batch_size = False
|
|
else:
|
|
domain = self._recurring_invoice_domain()
|
|
limit = batch_size and batch_size + 1
|
|
|
|
if grouped:
|
|
all_subscriptions = self.read_group(
|
|
domain,
|
|
['id:array_agg'],
|
|
self._get_auto_invoice_grouping_keys(),
|
|
limit=limit, lazy=False)
|
|
all_subscriptions = [self.browse(res['id']) for res in all_subscriptions]
|
|
else:
|
|
all_subscriptions = self.search(domain, limit=limit)
|
|
|
|
if batch_size:
|
|
need_cron_trigger = len(all_subscriptions) > batch_size
|
|
all_subscriptions = all_subscriptions[:batch_size]
|
|
|
|
return all_subscriptions, need_cron_trigger
|
|
|
|
def _subscription_commit_cursor(self, auto_commit):
|
|
if auto_commit:
|
|
self.env.cr.commit()
|
|
else:
|
|
self.env.flush_all()
|
|
self.env.cr.flush()
|
|
|
|
def _subscription_rollback_cursor(self, auto_commit):
|
|
if auto_commit:
|
|
self.env.cr.rollback()
|
|
|
|
# The following function is used so that it can be overwritten in test files
|
|
def _subscription_launch_cron_parallel(self, batch_size):
|
|
self.env.ref('sale_subscription.account_analytic_cron_for_invoice')._trigger()
|
|
|
|
def _create_recurring_invoice(self, batch_size=30):
|
|
today = fields.Date.today()
|
|
auto_commit = not bool(config['test_enable'] or config['test_file'])
|
|
grouped_invoice = self.env['ir.config_parameter'].get_param('sale_subscription.invoice_consolidation', False)
|
|
all_subscriptions, need_cron_trigger = self._recurring_invoice_get_subscriptions(grouped=grouped_invoice, batch_size=batch_size)
|
|
if not all_subscriptions:
|
|
return self.env['account.move']
|
|
|
|
# We mark current batch as having been seen by the cron
|
|
all_invoiceable_lines = self.env['sale.order.line']
|
|
for subscription in all_subscriptions:
|
|
subscription.is_invoice_cron = True
|
|
# Don't spam sale with assigned emails.
|
|
subscription = subscription.with_context(mail_auto_subscribe_no_notify=True)
|
|
# Close ending subscriptions
|
|
auto_close_subscription = subscription.filtered_domain([('end_date', '!=', False)])
|
|
closed_contract = auto_close_subscription._subscription_auto_close()
|
|
subscription -= closed_contract
|
|
all_invoiceable_lines += subscription.with_context(recurring_automatic=True)._get_invoiceable_lines()
|
|
|
|
lines_to_reset_qty = self.env['sale.order.line']
|
|
account_moves = self.env['account.move']
|
|
move_to_send_ids = []
|
|
# Set quantity to invoice before the invoice creation. If something goes wrong, the line will appear as "to invoice"
|
|
# It prevents the use of _compute method and compare the today date and the next_invoice_date in the compute which would be bad for perfs
|
|
all_invoiceable_lines._reset_subscription_qty_to_invoice()
|
|
self._subscription_commit_cursor(auto_commit)
|
|
for subscription in all_subscriptions:
|
|
if len(subscription) == 1:
|
|
subscription = subscription[0] # Trick to not prefetch other subscriptions is all_subscription is recordset, as the cache is currently invalidated at each iteration
|
|
|
|
# We check that the subscription should not be processed or that it has not already been set to "in exception" by previous cron failure
|
|
# We only invoice contract in sale state. Locked contracts are invoiced in advance. They are frozen.
|
|
subscription = subscription.filtered(lambda sub: sub.subscription_state == '3_progress' and not sub.payment_exception)
|
|
if not subscription:
|
|
continue
|
|
try:
|
|
self._subscription_commit_cursor(auto_commit) # To avoid a rollback in case something is wrong, we create the invoices one by one
|
|
draft_invoices = subscription.invoice_ids.filtered(lambda am: am.state == 'draft')
|
|
if subscription.payment_token_id and draft_invoices:
|
|
draft_invoices.button_cancel()
|
|
elif draft_invoices:
|
|
# Skip subscription if no payment_token, and it has a draft invoice
|
|
continue
|
|
invoiceable_lines = all_invoiceable_lines.filtered(lambda l: l.order_id.id in subscription.ids)
|
|
invoice_is_free, is_exception = subscription._invoice_is_considered_free(invoiceable_lines)
|
|
if not invoiceable_lines or invoice_is_free:
|
|
updatable_invoice_date = subscription.filtered(lambda sub: sub.next_invoice_date and sub.next_invoice_date <= today)
|
|
if is_exception:
|
|
for sub in subscription:
|
|
# Mix between recurring and non-recurring lines. We let the contract in exception, it should be
|
|
# handled manually
|
|
msg_body = _(
|
|
"Mix of negative recurring lines and non-recurring line. The contract should be fixed manually",
|
|
inv=sub.next_invoice_date
|
|
)
|
|
sub.message_post(body=msg_body)
|
|
subscription.payment_exception = True
|
|
# We still update the next_invoice_date if it is due
|
|
elif updatable_invoice_date:
|
|
updatable_invoice_date._update_next_invoice_date()
|
|
if invoice_is_free:
|
|
for line in invoiceable_lines:
|
|
line.qty_invoiced = line.product_uom_qty
|
|
updatable_invoice_date._subscription_post_success_free_renewal()
|
|
continue
|
|
|
|
try:
|
|
with self.env.protecting([subscription._fields['amount_to_invoice']], subscription):
|
|
invoice = subscription.with_context(recurring_automatic=True)._create_invoices(final=True)
|
|
lines_to_reset_qty |= invoiceable_lines
|
|
except Exception as e:
|
|
# We only raise the error in test, if the transaction is broken we should raise the exception
|
|
if not auto_commit and isinstance(e, TransactionRollbackError):
|
|
raise
|
|
# we suppose that the payment is run only once a day
|
|
self._subscription_rollback_cursor(auto_commit)
|
|
for sub in subscription:
|
|
email_context = sub._get_subscription_mail_payment_context()
|
|
error_message = _("Error during renewal of contract %s (Payment not recorded)", sub.name)
|
|
_logger.exception(error_message)
|
|
body = self._get_traceback_body(e, error_message)
|
|
mail = self.env['mail.mail'].sudo().create(
|
|
{'body_html': body, 'subject': error_message,
|
|
'email_to': email_context['responsible_email'], 'auto_delete': True})
|
|
mail.send()
|
|
continue
|
|
self._subscription_commit_cursor(auto_commit)
|
|
# Handle automatic payment or invoice posting
|
|
with self.env.protecting([subscription._fields['amount_to_invoice']], subscription):
|
|
existing_invoices = subscription.with_context(recurring_automatic=True)._handle_automatic_invoices(invoice, auto_commit) or self.env['account.move']
|
|
account_moves |= existing_invoices
|
|
if all(inv.state != 'draft' for inv in existing_invoices):
|
|
# when the invoice is not confirmed, we keep it and keep the payment_exception flag
|
|
# Failed payment that delete the invoice will also be handled here and the flag will be removed
|
|
subscription.with_context(mail_notrack=True).payment_exception = False
|
|
if not subscription.mapped('payment_token_id'): # _get_auto_invoice_grouping_keys groups by token too
|
|
move_to_send_ids += existing_invoices.ids
|
|
self._subscription_commit_cursor(auto_commit)
|
|
except Exception:
|
|
name_list = [f"{sub.name} {sub.client_order_ref}" for sub in subscription]
|
|
_logger.exception("Error during renewal of contract %s", "; ".join(name_list))
|
|
self._subscription_rollback_cursor(auto_commit)
|
|
self._subscription_commit_cursor(auto_commit)
|
|
self._process_invoices_to_send(self.env['account.move'].browse(move_to_send_ids))
|
|
self._subscription_commit_cursor(auto_commit)
|
|
# There is still some subscriptions to process. Then, make sure the CRON will be triggered again asap.
|
|
if need_cron_trigger:
|
|
self._subscription_launch_cron_parallel(batch_size)
|
|
else:
|
|
if self:
|
|
invoice_sub = self.filtered('is_subscription')
|
|
else:
|
|
invoice_sub = self.search([('is_invoice_cron', '=', True)])
|
|
|
|
try:
|
|
invoice_sub._post_invoice_hook()
|
|
self._subscription_commit_cursor(auto_commit)
|
|
except Exception as e:
|
|
self._subscription_rollback_cursor(auto_commit)
|
|
_logger.exception("Error during post invoice action: %s", e)
|
|
invoice_sub._handle_post_invoice_hook_exception()
|
|
|
|
failing_subscriptions = self.search([('is_batch', '=', True)])
|
|
(failing_subscriptions | invoice_sub).write({'is_batch': False, 'is_invoice_cron': False})
|
|
self._subscription_commit_cursor(auto_commit)
|
|
return account_moves
|
|
|
|
def _create_invoices(self, grouped=False, final=False, date=None):
|
|
""" Override to increment periods when needed """
|
|
order_already_invoiced = self.env['sale.order']
|
|
for order in self:
|
|
if not order.is_subscription:
|
|
continue
|
|
if order.order_line.invoice_lines.move_id.filtered(lambda r: r.move_type in ('out_invoice', 'out_refund') and r.state == 'draft'):
|
|
order_already_invoiced |= order
|
|
if order_already_invoiced:
|
|
order_error = ", ".join(order_already_invoiced.mapped('name'))
|
|
raise ValidationError(_("The following recurring orders have draft invoices. Please Confirm them or cancel them "
|
|
"before creating new invoices. %s.", order_error))
|
|
invoices = super()._create_invoices(grouped=grouped, final=final, date=date)
|
|
return invoices
|
|
|
|
def _subscription_auto_close(self):
|
|
""" Handle contracts that need to be automatically closed/set to renews.
|
|
This method is only called during a cron
|
|
"""
|
|
current_date = fields.Date.context_today(self)
|
|
close_contract_ids = self.filtered(lambda contract: contract.end_date and contract.end_date <= current_date)
|
|
close_contract_ids.set_close()
|
|
return close_contract_ids
|
|
|
|
def _process_auto_invoice(self, invoice):
|
|
""" Hook for extension, to support different invoice states """
|
|
invoice.action_post()
|
|
return
|
|
|
|
def _handle_automatic_invoices(self, invoice, auto_commit):
|
|
""" This method handle the subscription with or without payment token """
|
|
Mail = self.env['mail.mail']
|
|
# Set the contract in exception. If something go wrong, the exception remains.
|
|
self.with_context(mail_notrack=True).write({'payment_exception': True})
|
|
payment_token = self.payment_token_id
|
|
|
|
if not payment_token or len(payment_token) > 1:
|
|
self._process_auto_invoice(invoice)
|
|
return invoice
|
|
|
|
if not payment_token.partner_id.country_id:
|
|
msg_body = _('Automatic payment failed. No country specified on payment_token\'s partner')
|
|
for order in self:
|
|
order.message_post(body=msg_body)
|
|
invoice.unlink()
|
|
self._subscription_commit_cursor(auto_commit)
|
|
return
|
|
|
|
existing_transactions = self.transaction_ids
|
|
try:
|
|
# execute payment
|
|
self.pending_transaction = True
|
|
if invoice.currency_id.compare_amounts(invoice.amount_total_signed, 0) < 0:
|
|
# Something is wrong, we are trying to create a negative transaction. probably because a manual change in the order
|
|
# payment_exception is still true. We keep the draft invoice to allow salesmen understand what is going on.
|
|
self.pending_transaction = False
|
|
msg_body = _("Automatic payment failed. Check the corresponding invoice %s. We can't automatically process negative payment",
|
|
invoice._get_html_link())
|
|
for order in self:
|
|
order.message_post(body=msg_body)
|
|
self._subscription_commit_cursor(auto_commit)
|
|
# We return the draft invoice because it should be analyzed by the accounting to understand the issue
|
|
return invoice
|
|
transaction = self._do_payment(payment_token, invoice, auto_commit=auto_commit)
|
|
# commit change as soon as we try the payment, so we have a trace in the payment_transaction table
|
|
|
|
# if no transaction or failure, log error, rollback and remove invoice
|
|
if not transaction or transaction.renewal_state == 'cancel':
|
|
self._handle_subscription_payment_failure(invoice, transaction)
|
|
self._subscription_commit_cursor(auto_commit)
|
|
return
|
|
# if transaction is a success, post a message
|
|
elif transaction.renewal_state == 'authorized':
|
|
self._subscription_commit_cursor(auto_commit)
|
|
invoice._post()
|
|
self._subscription_commit_cursor(auto_commit)
|
|
|
|
except Exception as e:
|
|
last_tx_sudo = (self.transaction_ids - existing_transactions).sudo()
|
|
if last_tx_sudo and last_tx_sudo.renewal_state in ['pending', 'done']:
|
|
payment_state = _("Payment recorded: %s", last_tx_sudo.reference)
|
|
else:
|
|
payment_state = _("Payment not recorded")
|
|
error_message = _("Error during renewal of contract %s %s %s",
|
|
self.ids,
|
|
', '.join(self.mapped(lambda order: order.client_order_ref or order.name)),
|
|
payment_state)
|
|
body = self._get_traceback_body(e, error_message)
|
|
_logger.exception(error_message)
|
|
self._subscription_rollback_cursor(auto_commit)
|
|
mail = Mail.sudo().create([{
|
|
'body_html': body, 'subject': error_message,
|
|
'email_to': order._get_subscription_mail_payment_context().get('responsible_email'), 'auto_delete': True
|
|
} for order in self])
|
|
mail.send()
|
|
if invoice.state == 'draft':
|
|
if not last_tx_sudo or last_tx_sudo.renewal_state in ['pending', 'authorized']:
|
|
invoice.unlink()
|
|
return
|
|
return invoice
|
|
|
|
def _get_traceback_body(self, exc, body):
|
|
if not str2bool(self.env['ir.config_parameter'].sudo().get_param('sale_subscription.full_mail_traceback')):
|
|
return plaintext2html("%s\n\n%s" % (body, str(exc)))
|
|
return plaintext2html("%s\n\n%s\n%s" % (
|
|
body,
|
|
''.join(traceback.format_tb(exc.__traceback__)),
|
|
str(exc)),
|
|
)
|
|
|
|
def _get_expired_subscriptions(self):
|
|
# We don't use CURRENT_DATE to allow using freeze_time in tests.
|
|
today = fields.Datetime.today()
|
|
self.env.cr.execute(
|
|
"""
|
|
SELECT (so.next_invoice_date + INTERVAL '1 day' * COALESCE(ssp.auto_close_limit,15)) AS "payment_limit",
|
|
so.next_invoice_date,
|
|
so.id AS so_id
|
|
FROM sale_order so
|
|
LEFT JOIN sale_subscription_plan ssp ON ssp.id=so.plan_id
|
|
WHERE so.is_subscription
|
|
AND so.state = 'sale'
|
|
AND so.subscription_state = '3_progress'
|
|
AND (so.next_invoice_date + INTERVAL '1 day' * COALESCE(ssp.auto_close_limit,15))< %s
|
|
""", [today.strftime('%Y-%m-%d')]
|
|
)
|
|
return self.env.cr.dictfetchall()
|
|
|
|
def _get_unpaid_subscriptions(self):
|
|
# TODO FLDA SEE THAT O_O
|
|
# We don't use CURRENT_DATE to allow using freeze_time in tests.
|
|
today = fields.Datetime.today()
|
|
self.env.cr.execute(
|
|
"""
|
|
WITH payment_limit_query AS (
|
|
SELECT (aml2.dm + INTERVAL '1 day' * COALESCE(ssp.auto_close_limit,15) ) AS "payment_limit",
|
|
aml2.dm AS date_maturity,
|
|
ssp.billing_period_unit AS unit,
|
|
ssp.billing_period_value AS duration,
|
|
CASE
|
|
WHEN ssp.billing_period_unit='week' THEN INTERVAL '1 day' * 7 * ssp.billing_period_value
|
|
WHEN ssp.billing_period_unit='month' THEN INTERVAL '1 day' * 30 * ssp.billing_period_value
|
|
WHEN ssp.billing_period_unit='year' THEN INTERVAL '1 day' * 365 * ssp.billing_period_value
|
|
END AS conversion,
|
|
ssp.billing_period_value || ' ' || ssp.billing_period_unit AS recurrence,
|
|
am.payment_state AS payment_state,
|
|
am.id AS am_id,
|
|
so.id AS so_id,
|
|
so.next_invoice_date AS next_invoice_date
|
|
FROM sale_order so
|
|
JOIN sale_order_line sol ON sol.order_id = so.id
|
|
JOIN account_move_line aml ON aml.subscription_id = so.id
|
|
JOIN account_move am ON am.id = aml.move_id
|
|
JOIN sale_subscription_plan ssp ON ssp.id=so.plan_id
|
|
JOIN sale_order_line_invoice_rel rel ON rel.invoice_line_id=aml.id
|
|
LEFT JOIN LATERAL ( SELECT MAX(date_maturity) AS dm FROM account_move_line aml WHERE aml.move_id = am.id) AS aml2 ON TRUE
|
|
WHERE so.is_subscription
|
|
AND so.state = 'sale'
|
|
AND so.subscription_state ='3_progress'
|
|
AND am.payment_state = 'not_paid'
|
|
AND am.move_type = 'out_invoice'
|
|
AND am.state = 'posted'
|
|
AND rel.order_line_id=sol.id
|
|
GROUP BY so_id, am_id, ssp.auto_close_limit, payment_state, aml2.dm, ssp.billing_period_unit, ssp.billing_period_value
|
|
)
|
|
SELECT payment_limit::DATE,
|
|
date_maturity,
|
|
recurrence,
|
|
next_invoice_date - plq.conversion AS last_invoice_date,
|
|
payment_state,
|
|
am_id,
|
|
so_id,
|
|
next_invoice_date
|
|
FROM
|
|
payment_limit_query plq
|
|
WHERE payment_limit < %s and payment_limit >= (next_invoice_date - plq.conversion)::DATE
|
|
""", [today.strftime('%Y-%m-%d')]
|
|
)
|
|
return self.env.cr.dictfetchall()
|
|
|
|
def _handle_unpaid_subscriptions(self):
|
|
unpaid_result = self._get_unpaid_subscriptions()
|
|
return {res['so_id']: res['am_id'] for res in unpaid_result}
|
|
|
|
def _cron_subscription_expiration(self):
|
|
# Flush models according to following SQL requests
|
|
self.env['sale.order'].flush_model(
|
|
fnames=['order_line', 'plan_id', 'state', 'subscription_state', 'next_invoice_date'])
|
|
self.env['account.move'].flush_model(fnames=['payment_state', 'line_ids'])
|
|
self.env['account.move.line'].flush_model(fnames=['move_id', 'sale_line_ids'])
|
|
self.env['sale.subscription.plan'].flush_model(fnames=['auto_close_limit'])
|
|
today = fields.Date.today()
|
|
# set to close if date is passed or if renewed sale order passed
|
|
domain_close = [
|
|
('is_subscription', '=', True),
|
|
('end_date', '<', today),
|
|
('state', '=', 'sale'),
|
|
('subscription_state', 'in', SUBSCRIPTION_PROGRESS_STATE)]
|
|
subscriptions_close = self.search(domain_close)
|
|
unpaid_results = self._handle_unpaid_subscriptions()
|
|
unpaid_ids = unpaid_results.keys()
|
|
expired_result = self._get_expired_subscriptions()
|
|
expired_ids = [r['so_id'] for r in expired_result]
|
|
subscriptions_close |= self.env['sale.order'].browse(unpaid_ids) | self.env['sale.order'].browse(expired_ids)
|
|
auto_commit = not bool(config['test_enable'] or config['test_file'])
|
|
expired_close_reason = self.env.ref('sale_subscription.close_reason_auto_close_limit_reached')
|
|
unpaid_close_reason = self.env.ref('sale_subscription.close_reason_unpaid_subscription')
|
|
for batched_to_close in split_every(30, subscriptions_close.ids, self.env['sale.order'].browse):
|
|
unpaid_so = self.env['sale.order']
|
|
expired_so = self.env['sale.order']
|
|
for so in batched_to_close:
|
|
if so.id in unpaid_ids:
|
|
unpaid_so |= so
|
|
account_move = self.env['account.move'].browse(unpaid_results[so.id])
|
|
so.message_post(
|
|
body=_("The last invoice (%s) of this subscription is unpaid after the due date.",
|
|
account_move._get_html_link()),
|
|
partner_ids=so.team_user_id.partner_id.ids,
|
|
)
|
|
elif so.id in expired_ids:
|
|
expired_so |= so
|
|
|
|
unpaid_so.set_close(close_reason_id=unpaid_close_reason.id)
|
|
expired_so.set_close(close_reason_id=expired_close_reason.id)
|
|
(batched_to_close - unpaid_so - expired_so).set_close()
|
|
if auto_commit:
|
|
self.env.cr.commit()
|
|
return dict(closed=subscriptions_close.ids)
|
|
|
|
def _get_subscription_delta(self, date):
|
|
self.ensure_one()
|
|
delta, percentage = False, False
|
|
subscription_log = self.env['sale.order.log'].search([
|
|
('order_id', '=', self.id),
|
|
('event_type', 'in', ['0_creation', '1_expansion', '15_contraction', '2_transfer']),
|
|
('event_date', '<=', date)],
|
|
order='event_date desc',
|
|
limit=1)
|
|
if subscription_log:
|
|
delta = self.recurring_monthly - subscription_log.recurring_monthly
|
|
percentage = delta / subscription_log.recurring_monthly if subscription_log.recurring_monthly != 0 else 100
|
|
return {'delta': delta, 'percentage': percentage}
|
|
|
|
def _nothing_to_invoice_error_message(self):
|
|
error_message = super()._nothing_to_invoice_error_message()
|
|
if any(self.mapped('is_subscription')):
|
|
error_message += _(
|
|
"\n- You are trying to invoice recurring orders that are past their end date. Please change their end date or renew them "
|
|
"before creating new invoices."
|
|
)
|
|
return error_message
|
|
|
|
def _do_payment(self, payment_token, invoice, auto_commit=False):
|
|
values = [{
|
|
'provider_id': payment_token.provider_id.id,
|
|
'payment_method_id': payment_token.payment_method_id.id,
|
|
'sale_order_ids': self.ids,
|
|
'amount': invoice.amount_total,
|
|
'currency_id': invoice.currency_id.id,
|
|
'partner_id': invoice.partner_id.id,
|
|
'token_id': payment_token.id,
|
|
'operation': 'offline',
|
|
'invoice_ids': [(6, 0, [invoice.id])],
|
|
'subscription_action': 'automatic_send_mail',
|
|
}]
|
|
transactions_sudo = self.env['payment.transaction'].sudo().create(values)
|
|
self._subscription_commit_cursor(auto_commit)
|
|
for tx_sudo in transactions_sudo:
|
|
# Protect the transaction row to prevent sql concurrent updates.
|
|
# This cron is having the lead and can update the transaction values from the value returned from the API.
|
|
# No other cursor should be able to update the transaction while this cron is handling this TX
|
|
self.env.cr.execute("SELECT 1 FROM payment_transaction WHERE id=%s FOR UPDATE", [tx_sudo.id])
|
|
tx_sudo._send_payment_request()
|
|
return transactions_sudo
|
|
|
|
def _send_success_mail(self, invoices, tx):
|
|
"""
|
|
Send mail once the transaction to pay subscription invoice has succeeded
|
|
:param invoices: one or more account.move recordset
|
|
:param tx: single payment.transaction
|
|
"""
|
|
template = self.env.ref('sale_subscription.email_payment_success').sudo()
|
|
current_date = fields.Date.today()
|
|
subscription_ids = []
|
|
for invoice in invoices:
|
|
# We may have different subscriptions per invoice
|
|
subscriptions = invoice.invoice_line_ids.subscription_id
|
|
if not subscriptions or invoice.is_move_sent or not invoice._is_ready_to_be_sent() or invoice.state != 'posted':
|
|
continue
|
|
invoice_values = {sub.id: invoice for sub in subscriptions}
|
|
subscription_ids += subscriptions.ids
|
|
for subscription in self.env['sale.order'].browse(subscription_ids):
|
|
linked_invoices = invoice_values[subscription.id]
|
|
# Most of the time, we invoice one sub per invoice
|
|
next_date = subscription.next_invoice_date or current_date
|
|
# if no recurring next date, have next invoice be today + interval
|
|
if not subscription.next_invoice_date:
|
|
error_msg = "The success mail could not be sent for subscription %s and invoice %s." % (subscription.name, invoice.name)
|
|
_logger.error(error_msg)
|
|
continue
|
|
email_context = {**self.env.context.copy(),
|
|
'payment_token': subscription.payment_token_id.payment_details,
|
|
'5_renewed': True,
|
|
'total_amount': tx.amount,
|
|
'next_date': next_date,
|
|
'previous_date': subscription.next_invoice_date,
|
|
'email_to': subscription.partner_id.email,
|
|
'code': subscription.client_order_ref,
|
|
'subscription_name': subscription.name,
|
|
'currency': subscription.currency_id.name,
|
|
'date_end': subscription.end_date}
|
|
_logger.debug("Sending Payment Confirmation Mail to %s for subscription %s", subscription.partner_id.email, subscription.id)
|
|
|
|
linked_invoices.is_move_sent = True
|
|
linked_invoices.with_context(email_context)._generate_pdf_and_send_invoice(template)
|
|
|
|
@api.model
|
|
def _process_invoices_to_send(self, account_moves):
|
|
for invoice in account_moves:
|
|
if not invoice.is_move_sent and invoice._is_ready_to_be_sent() and invoice.state == 'posted':
|
|
subscription = invoice.line_ids.subscription_id
|
|
subscription.validate_and_send_invoice(invoice)
|
|
invoice.message_subscribe(subscription.user_id.partner_id.ids)
|
|
elif invoice.line_ids.subscription_id:
|
|
invoice.message_subscribe(invoice.line_ids.subscription_id.user_id.partner_id.ids)
|
|
|
|
def validate_and_send_invoice(self, invoice):
|
|
email_context = {**self.env.context.copy(), **{
|
|
'total_amount': invoice.amount_total,
|
|
'email_to': invoice.partner_id.email,
|
|
'code': ', '.join(subscription.client_order_ref or subscription.name for subscription in self),
|
|
'currency': invoice.currency_id.name,
|
|
'no_new_invoice': True}}
|
|
auto_commit = not bool(config['test_enable'] or config['test_file'])
|
|
self._subscription_commit_cursor(auto_commit)
|
|
if self.plan_id.invoice_mail_template_id:
|
|
_logger.debug("Sending Invoice Mail to %s for subscription %s", self.partner_id.mapped('email'), self.ids)
|
|
invoice.with_context(email_context)._generate_pdf_and_send_invoice(self.plan_id.invoice_mail_template_id)
|
|
|
|
def _assign_token(self, tx):
|
|
""" Callback method to assign a token after the validation of a transaction.
|
|
Note: self.ensure_one()
|
|
:param recordset tx: The validated transaction, as a `payment.transaction` record
|
|
:return: Whether the conditions were met to execute the callback
|
|
"""
|
|
if tx.renewal_state == 'authorized':
|
|
self.payment_token_id = tx.token_id.id
|
|
return True
|
|
return False
|
|
|
|
def _get_name_portal_content_view(self):
|
|
return 'sale_subscription.subscription_portal_content' if self.is_subscription else super()._get_name_portal_content_view()
|
|
|
|
def _get_upsell_portal_url(self):
|
|
self.ensure_one()
|
|
upsell = self.subscription_child_ids.filtered(lambda so: so.subscription_state == '7_upsell' and so.state == 'sent')[:1]
|
|
return upsell and upsell.get_portal_url()
|
|
|
|
def _get_renewal_portal_url(self):
|
|
self.ensure_one()
|
|
renewal = self.subscription_child_ids.filtered(lambda so: so.subscription_state == '2_renewal' and so.state == 'sent')[:1]
|
|
return renewal and renewal.get_portal_url()
|
|
|
|
def _can_be_edited_on_portal(self):
|
|
self.ensure_one()
|
|
if self.is_subscription:
|
|
return (not self.next_invoice_date or self.next_invoice_date == self.start_date) and \
|
|
self.subscription_state in SUBSCRIPTION_DRAFT_STATE + SUBSCRIPTION_PROGRESS_STATE
|
|
else:
|
|
return super()._can_be_edited_on_portal()
|