Files

317 lines
14 KiB
Python
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from math import floor
from odoo import _, api, fields, models
from odoo.exceptions import UserError
from odoo.fields import Domain
from odoo.http import request
from odoo.tools import format_amount
class ProductTemplate(models.Model):
_inherit = 'product.template'
@api.constrains('optional_product_ids')
def _constraints_optional_product_ids(self):
for template in self:
if not template.recurring_invoice:
continue
plan_ids = set(template.subscription_rule_ids.plan_id.ids)
for optional_template in template.optional_product_ids:
if not optional_template.recurring_invoice:
continue
optional_plan_ids = optional_template.subscription_rule_ids.plan_id.ids
if not plan_ids.intersection(optional_plan_ids):
raise UserError(_('You cannot have an optional product that has a no common pricing\'s plan.'))
def _website_can_be_added(self, product=None) -> bool:
"""Return whether the product/template can be added to the active SO."""
self.ensure_one()
if not self.recurring_invoice:
return True
if request.cart._has_one_time_sale() and not self.allow_one_time_sale:
return False
has_pricing = bool(
self._get_recurring_pricing(
pricelist=request.pricelist,
variant=product,
plan_id=request.cart.plan_id.id,
)
)
if not has_pricing and not product:
# If pricings are only defined by variant, there are no pricing applicable to the
# template itself. In this situation, we need to search for a pricing on a variant
# otherwise customers wouldn't be able to add the product to their cart.
has_pricing = bool(
self._get_recurring_pricing(
pricelist=request.pricelist,
variant=self.product_variant_id,
plan_id=request.cart.plan_id.id,
)
)
return has_pricing or (
self.allow_one_time_sale
and not request.cart.plan_id
)
def _get_recurring_pricings(self, pricelist, variant=None, quantity=1.0):
"""Return the first pricing applicable for each of the available subscription plans."""
self.ensure_one()
pricings = self.env['product.pricelist.item']
domain = pricelist._get_applicable_rules_domain(
products=variant or self,
date=fields.Datetime.now(),
any_plan=True,
)
all_pricings = self.env['product.pricelist.item'].search(
domain, order=self.env['product.pricelist.item']._get_recurring_rules_order()
)
if pricelist:
# Add the rules not restricted to a specific pricelist, only for the plans that had no
# rule for the current pricelist.
domain = self.env['product.pricelist']._get_applicable_rules_domain(
products=variant or self,
date=fields.Datetime.now(),
any_plan=True,
)
all_pricings |= self.env['product.pricelist.item'].search(
Domain.AND([domain, [('plan_id', 'not in', all_pricings.plan_id.ids)]]),
order=self.env['product.pricelist.item']._get_recurring_rules_order()
)
found_plan_ids = set()
for pricing in all_pricings:
if (
pricing.plan_id.sudo().active
and (plan_id := pricing.plan_id.id) not in found_plan_ids
# No need for uom conversion since multi-uom is not supported for recurring
# products atm.
and pricing._is_applicable_for(product=variant or self, qty_in_product_uom=quantity)
):
found_plan_ids.add(plan_id)
pricings |= pricing
return pricings
def _get_additionnal_combination_info(self, product_or_template, quantity, uom, date, website):
res = super()._get_additionnal_combination_info(product_or_template, quantity, uom, date, website)
if not product_or_template.recurring_invoice:
return res
product = (product_or_template.is_product_variant and product_or_template) or self.env['product.product']
pricings = self._get_recurring_pricings(pricelist=request.pricelist, variant=product, quantity=quantity)
res['list_price'] = res['price'] # No pricelist discount for subscription prices
if not pricings:
res.update({
'is_subscription': True,
'is_plan_possible': False,
'pricings': [],
'allow_one_time_sale': not request.cart.plan_id and self.allow_one_time_sale,
})
return res
to_year = {'year': 1, 'month': 12, 'week': 52}
translation_mapping = {
'year': (self.env._('year'), self.env._('years')),
'month': (self.env._('month'), self.env._('months')),
'week': (self.env._('week'), self.env._('weeks')),
}
# Find the plan with the shortest billing period to use as base for comparison
base_plan = min(pricings.sudo().plan_id, key=lambda x: 1 / to_year[x.billing_period_unit])
minimum_period = base_plan.billing_period_unit
# Compute base period price and max price for discount calculation
base_plan_pricings = pricings.filtered(lambda pr: pr.plan_id == base_plan)
base_period_price = min(base_plan_pricings.mapped('fixed_price'), default=0.0) / base_plan.billing_period_value
max_price = max(pricings.mapped('fixed_price'), default=0.0)
currency = website.currency_id
requested_plan = request and request.params.get('plan_id')
requested_plan_id = requested_plan and requested_plan.isdigit() and int(requested_plan)
requested_plan_id = requested_plan_id or request.cart.plan_id.id
if requested_plan_id:
chosen_pricing = pricings.filtered(lambda pricing: pricing.plan_id.id == requested_plan_id) or pricings[0]
else:
chosen_pricing = pricings[0]
sales_price = res['price']
def _get_pricing_data(pricing):
if not pricing:
return {}
price = pricing._compute_price(
product=product_or_template,
quantity=quantity or 1.0,
date=date,
uom=product_or_template.uom_id,
currency=currency,
plan_id=pricing.plan_id.id,
)
if res.get('product_taxes', False):
price = self.env['product.template']._apply_taxes_to_price(
price, currency, res['product_taxes'], res['taxes'], product_or_template,
)
price_format = format_amount(self.env, amount=price, currency=currency)
pricing_plan_sudo = pricing.plan_id.sudo() # Not accessible to public users
price_in_minimum_period = (
price
/ pricing_plan_sudo.billing_period_value
* to_year[pricing_plan_sudo.billing_period_unit]
/ to_year[minimum_period]
)
if product_or_template.type == 'consu':
# For consumable products, use billing period (e.g., "3 months") instead of plan name
value = pricing.plan_id.billing_period_value
unit = pricing.plan_id.billing_period_unit
singular_unit, plural_unit = translation_mapping.get(unit)
unit_label = singular_unit if value == 1 else plural_unit
table_name = f"{value if value != 1 else ''} {unit_label}".strip()
else:
# For non-consumable products, use plan name with non-breaking spaces
table_name = pricing.plan_id.name.replace(" ", "\u00A0")
minimum_billing_period, _ = translation_mapping.get(minimum_period)
pricing_data = {
'plan_id': pricing_plan_sudo.id,
'price': f"{pricing.plan_id.name}: {price_format}",
'price_value': price,
'table_price': price_format,
'table_name': table_name,
'to_minimum_billing_period': f'{format_amount(self.env, amount=price_in_minimum_period, currency=currency)}'
f' / {minimum_billing_period}',
'can_be_added': request.cart.plan_id.id in (pricing_plan_sudo.id, False),
}
# Calculate discount percentage
if pricing.compute_price == 'percentage': # Percentage discount: use the value directly
discount = pricing.percent_price
elif product_or_template.allow_one_time_sale and 0 < price <= sales_price: # One-time sale: compare against sale price
discount = ((sales_price - price) * 100) / sales_price
elif (product_or_template.type == 'consu' and 0 < price <= max_price): # Consumables: compare against max price
discount = ((max_price - price) * 100) / max_price
elif 0 < price_in_minimum_period <= base_period_price: # Non-consumables: compare against base period price
discount = ((base_period_price - price_in_minimum_period) * 100) / base_period_price
else:
discount = 0.0
pricing_data['discounted_price'] = floor(discount) if discount > 0 else 0.0
return pricing_data
default_pricing_data = _get_pricing_data(chosen_pricing)
pricing_details = [
_get_pricing_data(pricing)
for pricing in pricings
]
unit_price = default_pricing_data.get('price_value', 0)
return {
**res,
'is_subscription': True,
'pricings': pricing_details,
'is_plan_possible': bool(chosen_pricing),
'price': unit_price,
'subscription_default_pricing_price': default_pricing_data.get('price', ''),
'subscription_default_pricing_plan_id': default_pricing_data.get('plan_id', False),
'subscription_pricing_select': (product_or_template.allow_one_time_sale or len(pricings) > 1) and not request.cart.plan_id,
'prevent_zero_price_sale': website.prevent_zero_price_sale and currency.is_zero(
unit_price,
),
'allow_one_time_sale': not request.cart.plan_id and self.allow_one_time_sale,
'allow_recurring': not request.cart._has_one_time_sale(),
'product_type': product_or_template.type, # Used to change pricing text in template based on product type
"temporal_unit_display": chosen_pricing.plan_id.sudo().billing_period_display_sentence if chosen_pricing else "",
}
# Search bar
def _search_render_results_prices(self, mapping, combination_info):
if not combination_info.get('is_subscription'):
return super()._search_render_results_prices(mapping, combination_info)
if not combination_info['is_plan_possible']:
return '', 0
return self.env['ir.ui.view']._render_template(
'website_sale_subscription.subscription_search_result_price',
values={
'subscription_default_pricing_price': combination_info['subscription_default_pricing_price'],
}
), 0
def _get_sales_prices(self, website):
prices = super()._get_sales_prices(website)
pricelist = request.pricelist
currency = website.currency_id
fiscal_position_sudo = request.fiscal_position
so_plan_id = request.cart.plan_id.id
date = fields.Date.context_today(self)
for template in self:
if not template.recurring_invoice:
continue
pricing = template._get_recurring_pricing(pricelist=pricelist, plan_id=so_plan_id)
if not pricing:
prices[template.id].update({
'is_subscription': True,
'is_plan_possible': False,
})
continue
unit_price = pricing._compute_price(
product=template,
quantity=1.0,
date=date,
uom=template.uom_id,
currency=currency,
plan_id=pricing.plan_id.id,
)
# taxes application
product_taxes = template.sudo().taxes_id._filter_taxes_by_company(self.env.company)
if product_taxes:
taxes = fiscal_position_sudo.map_tax(product_taxes)
unit_price = self.env['product.template']._apply_taxes_to_price(
unit_price, currency, product_taxes, taxes, template)
plan_sudo = pricing.plan_id.sudo()
prices[template.id].update({
'is_subscription': True,
'price_reduce': unit_price,
'is_plan_possible': True, # The plan can only be valid at this point
'temporal_unit_display': plan_sudo.billing_period_display_sentence,
})
return prices
def _get_contextual_pricelist(self):
pricelist = super()._get_contextual_pricelist()
if plan_id := self.env.context.get('plan_id'):
# This pricelist is used in _get_contextual_price to compute
# the price of a product according to the plan_id selected
return pricelist.with_context(plan_id=plan_id)
return pricelist
def _website_show_quick_add(self):
self.ensure_one()
return super()._website_show_quick_add() and self._website_can_be_added()
def _can_be_added_to_cart(self):
self.ensure_one()
return super()._can_be_added_to_cart() and self._website_can_be_added()