1
0
forked from Mapan/odoo17e
odoo17e-kedaikipas58/addons/sale_subscription/models/sale_order.py
2024-12-10 09:04:09 +07:00

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()