[15.0][ADD] subscription_oca

This commit is contained in:
Carlos
2023-09-20 21:49:04 +02:00
committed by Carol
parent a657a08ead
commit d9024f3d21
34 changed files with 2636 additions and 0 deletions

View File

@@ -0,0 +1,99 @@
=======================
Subscription management
=======================
..
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! source digest: sha256:3772d65a58c07d0348bd13d3c882810c94bfb87389c62fec6d16fe8ef130252c
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png
:target: https://odoo-community.org/page/development-status
:alt: Beta
.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png
:target: http://www.gnu.org/licenses/agpl-3.0-standalone.html
:alt: License: AGPL-3
.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fcontract-lightgray.png?logo=github
:target: https://github.com/OCA/contract/tree/15.0/subscription_oca
:alt: OCA/contract
.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png
:target: https://translation.odoo-community.org/projects/contract-15-0/contract-15-0-subscription_oca
:alt: Translate me on Weblate
.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png
:target: https://runboat.odoo-community.org/builds?repo=OCA/contract&target_branch=15.0
:alt: Try me on Runboat
|badge1| |badge2| |badge3| |badge4| |badge5|
This module allows creating subscriptions that generate recurring invoices or orders. It also enables the sale of products that generate subscriptions.
**Table of contents**
.. contents::
:local:
Usage
=====
To make a subscription:
#. Go to *Subscriptions > Configuration > Subscription templates*.
#. Create the templates you consider, choosing the billing frequency: daily, monthly... and the method of creating the invoice and/or order.
#. Go to *Subscription > Subscriptions*.
#. Create a subscription and indicate the start date. When the *Subscriptions Management* cron job is executed, the subscription will begin and the first invoice will be created if the execution date matches the start date. The invoice will also be created when the execution date matches the next invoice date. Additionally, you can manually change the subscription status and create an invoice.
#. The cron job will also end the subscription if its end date has been reached.
To create subscriptions with the sale of a product:
#. Go to *Subscriptions > Subscriptions > Products*.
#. Create the product and in the sales tab, complete the fields *Subscribable product* and *Subscription template*
#. Create a sales order with the product and confirm it.
Known issues / Roadmap
======================
* Refactor all the onchanges that have business logic to computed write-able fields when possible. Keep onchanges only for UI purposes.
* Add tests.
Bug Tracker
===========
Bugs are tracked on `GitHub Issues <https://github.com/OCA/contract/issues>`_.
In case of trouble, please check there if your issue has already been reported.
If you spotted it first, help us to smash it by providing a detailed and welcomed
`feedback <https://github.com/OCA/contract/issues/new?body=module:%20subscription_oca%0Aversion:%2015.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**>`_.
Do not contact contributors directly about support or help with technical issues.
Credits
=======
Authors
~~~~~~~
* Domatix
Contributors
~~~~~~~~~~~~
* Carlos Martínez <carlos@domatix.com>
Maintainers
~~~~~~~~~~~
This module is maintained by the OCA.
.. image:: https://odoo-community.org/logo.png
:alt: Odoo Community Association
:target: https://odoo-community.org
OCA, or the Odoo Community Association, is a nonprofit organization whose
mission is to support the collaborative development of Odoo features and
promote its widespread use.
This module is part of the `OCA/contract <https://github.com/OCA/contract/tree/15.0/subscription_oca>`_ project on GitHub.
You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

View File

@@ -0,0 +1,2 @@
from . import models
from . import wizard

View File

@@ -0,0 +1,28 @@
# Copyright 2023 Domatix - Carlos Martínez
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
{
"name": "Subscription management",
"summary": "Generate recurring invoices.",
"version": "15.0.1.0.0",
"development_status": "Beta",
"category": "Subscription Management",
"website": "https://github.com/OCA/contract",
"license": "AGPL-3",
"author": "Domatix, Odoo Community Association (OCA)",
"depends": ["sale_management", "account"],
"data": [
"views/product_template_views.xml",
"views/sale_subscription_views.xml",
"views/sale_subscription_stage_views.xml",
"views/sale_subscription_tag_views.xml",
"views/sale_subscription_template_views.xml",
"views/sale_order_views.xml",
"views/res_partner_views.xml",
"data/ir_cron.xml",
"data/sale_subscription_data.xml",
"wizard/close_subscription_wizard.xml",
"security/ir.model.access.csv",
],
"installable": True,
"application": True,
}

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="ir_cron_subscription_management" model="ir.cron">
<field name="name">Subscriptions management</field>
<field eval="True" name="active" />
<field name="user_id" ref="base.user_root" />
<field name="interval_number">24</field>
<field name="interval_type">hours</field>
<field name="numbercall">-1</field>
<field eval="False" name="doall" />
<field ref="model_sale_subscription" name="model_id" />
<field name="state">code</field>
<field name="code">model.cron_subscription_management()</field>
</record>
</odoo>

View File

@@ -0,0 +1,76 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<data>
<record id="seq_id" model="ir.sequence">
<field name="name">sale_subscription_sequencer</field>
<field name="code">sale.subscription</field>
<field name="prefix">SUB</field>
<field name="padding">5</field>
</record>
</data>
<data noupdate="1">
<record id="subscription_stage_draft" model="sale.subscription.stage">
<!-- <field eval="True" name="active"/> -->
<field name="name">Ready to start</field>
<field name="sequence">0</field>
<field name="type">pre</field>
<field name="description">
Draft equivalent, a subscription is ready to start when is not marked as in progress but it can be at any moment. If there's no 'Closed'-type stage defined, when a subscription comes to an end by automatic means, it will be marked with this stage.
</field>
<field eval="False" name="fold" />
</record>
<record id="subscription_stage_in_progress" model="sale.subscription.stage">
<!-- <field eval="True" name="active"/> -->
<field name="name">In progress</field>
<field name="sequence">1</field>
<field name="type">in_progress</field>
<field eval="False" name="fold" />
<field name="description">
As an 'In progress'-type of stage, it will trigger the recurring invoicing process if applicable. If this stage is the first - sequence order - of the available 'In progress' types and there's a stage change from any other non-'In progress' types to this one, an invoice will be created automatically if the start date is the present day.
</field>
</record>
<record id="subscription_stage_closed" model="sale.subscription.stage">
<!-- <field eval="True" name="active"/> -->
<field name="name">Closed</field>
<field name="sequence">2</field>
<field name="type">post</field>
<field eval="False" name="fold" />
<field name="description">
The final stage of a subscription. There are two ways to mark a subscription as closed. The easiest one is using the kanban card-moving capabilities, pressing the 'Close subscription' button (only available if a subscription is in progress).
</field>
</record>
</data>
<record id="close_reason_expensive" model="sale.subscription.close.reason">
<!-- <field eval="True" name="active"/> -->
<field name="name">The subscription is too expensive</field>
</record>
<record id="close_reason_requirement" model="sale.subscription.close.reason">
<!-- <field eval="True" name="active"/> -->
<field name="name">Subscription does not meet my requirements</field>
</record>
<record id="close_reason_ended" model="sale.subscription.close.reason">
<!-- <field eval="True" name="active"/> -->
<field name="name">The subscription ended</field>
</record>
<record id="close_reason_use" model="sale.subscription.close.reason">
<!-- <field eval="True" name="active"/> -->
<field name="name">I don't really use it</field>
</record>
<record id="close_reason_other" model="sale.subscription.close.reason">
<!-- <field eval="True" name="active"/> -->
<field name="name">Other</field>
</record>
</odoo>

View File

@@ -0,0 +1,11 @@
from . import account_move
from . import product_template
from . import res_partner
from . import sale_order
from . import sale_order_line
from . import sale_subscription
from . import sale_subscription_close_reason
from . import sale_subscription_line
from . import sale_subscription_stage
from . import sale_subscription_tag
from . import sale_subscription_template

View File

@@ -0,0 +1,12 @@
# Copyright 2023 Domatix - Carlos Martínez
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from odoo import fields, models
class AccountMove(models.Model):
_inherit = "account.move"
subscription_id = fields.Many2one(
comodel_name="sale.subscription", string="Subscription"
)

View File

@@ -0,0 +1,12 @@
# Copyright 2023 Domatix - Carlos Martínez
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from odoo import fields, models
class Product(models.Model):
_inherit = "product.template"
subscribable = fields.Boolean(string="Subscribable product")
subscription_template_id = fields.Many2one(
comodel_name="sale.subscription.template", string="Subscription template"
)

View File

@@ -0,0 +1,33 @@
# Copyright 2023 Domatix - Carlos Martínez
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from odoo import fields, models
class Partner(models.Model):
_inherit = "res.partner"
subscription_ids = fields.One2many(
comodel_name="sale.subscription",
inverse_name="partner_id",
string="Subscriptions",
)
subscription_count = fields.Integer(
required=False,
compute="_compute_subscription_count",
)
def _compute_subscription_count(self):
for record in self:
record.subscription_count = len(record.subscription_ids)
def action_view_subscription_ids(self):
return {
"type": "ir.actions.act_window",
"res_model": "sale.subscription",
"domain": [("id", "in", self.subscription_ids.ids)],
"name": self.name,
"view_mode": "tree,form",
"context": {
"default_partner_id": self.id,
},
}

View File

@@ -0,0 +1,83 @@
# Copyright 2023 Domatix - Carlos Martínez
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from collections import defaultdict
from datetime import date
from dateutil.relativedelta import relativedelta
from odoo import api, fields, models
class SaleOrder(models.Model):
_inherit = "sale.order"
subscription_ids = fields.One2many(
comodel_name="sale.subscription",
inverse_name="sale_order_id",
string="Subscriptions",
)
subscriptions_count = fields.Integer(compute="_compute_subscriptions_count")
order_subscription_id = fields.Many2one(
comodel_name="sale.subscription", string="Subscription"
)
@api.depends("subscription_ids")
def _compute_subscriptions_count(self):
for record in self:
record.subscriptions_count = len(record.subscription_ids)
def action_view_subscriptions(self):
return {
"type": "ir.actions.act_window",
"res_model": "sale.subscription",
"domain": [("id", "in", self.subscription_ids.ids)],
"name": self.name,
"view_mode": "tree,form",
}
def get_next_interval(self, type_interval, interval):
date_start = date.today()
date_start += relativedelta(**{type_interval: interval})
return date_start
def create_subscription(self, lines, subscription_tmpl):
subscription_lines = []
for line in lines:
subscription_lines.append((0, 0, line.get_subscription_line_values()))
if subscription_tmpl:
rec = self.env["sale.subscription"].create(
{
"partner_id": self.partner_id.id,
"user_id": self._context["uid"],
"template_id": subscription_tmpl.id,
"pricelist_id": self.partner_id.property_product_pricelist.id,
"date_start": date.today(),
"sale_order_id": self.id,
"sale_subscription_line_ids": subscription_lines,
}
)
rec.action_start_subscription()
self.subscription_ids = [(4, rec.id)]
rec.recurring_next_date = self.get_next_interval(
subscription_tmpl.recurring_rule_type,
subscription_tmpl.recurring_interval,
)
def group_subscription_lines(self):
grouped = defaultdict(list)
for order_line in self.order_line.filtered(
lambda line: line.product_id.subscribable
):
grouped[
order_line.product_id.product_tmpl_id.subscription_template_id
].append(order_line)
return grouped
def action_confirm(self):
res = super(SaleOrder, self).action_confirm()
for record in self:
grouped = self.group_subscription_lines()
for tmpl, lines in grouped.items():
record.create_subscription(lines, tmpl)
return res

View File

@@ -0,0 +1,17 @@
# Copyright 2023 Domatix - Carlos Martínez
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from odoo import models
class SaleOrderLine(models.Model):
_inherit = "sale.order.line"
def get_subscription_line_values(self):
return {
"product_id": self.product_id.id,
"name": self.product_id.name,
"product_uom_qty": self.product_uom_qty,
"price_unit": self.price_unit,
"discount": self.discount,
"price_subtotal": self.price_subtotal,
}

View File

@@ -0,0 +1,470 @@
# Copyright 2023 Domatix - Carlos Martínez
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
import logging
from datetime import date, datetime
from dateutil.relativedelta import relativedelta
from odoo import _, api, fields, models
from odoo.exceptions import AccessError
logger = logging.getLogger(__name__)
class SaleSubscription(models.Model):
_name = "sale.subscription"
_description = "Subscription"
_inherit = ["mail.thread", "mail.activity.mixin"]
_order = "id desc"
color = fields.Integer("Color Index")
name = fields.Char(
compute="_compute_name",
store=True,
)
sequence = fields.Integer()
company_id = fields.Many2one(
"res.company",
"Company",
required=True,
index=True,
default=lambda self: self.env.company,
)
partner_id = fields.Many2one(
comodel_name="res.partner", required=True, string="Partner", index=True
)
fiscal_position_id = fields.Many2one(
"account.fiscal.position",
string="Fiscal Position",
domain="[('company_id', '=', company_id)]",
check_company=True,
)
active = fields.Boolean(default=True)
template_id = fields.Many2one(
comodel_name="sale.subscription.template",
required=True,
string="Subscription template",
)
code = fields.Char(
string="Reference",
default=lambda self: self.env["ir.sequence"].next_by_code("sale.subscription"),
)
in_progress = fields.Boolean(string="In progress", default=False)
recurring_rule_boundary = fields.Boolean(
string="Boundary", compute="_compute_rule_boundary", store=True
)
pricelist_id = fields.Many2one(
comodel_name="product.pricelist", required=True, string="Pricelist"
)
recurring_next_date = fields.Date(string="Next invoice date", default=date.today())
user_id = fields.Many2one(
comodel_name="res.users",
string="Commercial agent",
default=lambda self: self.env.user.id,
)
date_start = fields.Date(string="Start date", default=date.today())
date = fields.Date(
string="Finish date",
compute="_compute_rule_boundary",
store=True,
readonly=False,
)
description = fields.Text()
sale_order_id = fields.Many2one(
comodel_name="sale.order", string="Origin sale order"
)
terms = fields.Text(
string="Terms and conditions",
compute="_compute_terms",
store=True,
readonly=False,
)
invoice_ids = fields.One2many(
comodel_name="account.move",
inverse_name="subscription_id",
string="Invoices",
)
sale_order_ids = fields.One2many(
comodel_name="sale.order",
inverse_name="order_subscription_id",
string="Orders",
)
recurring_total = fields.Monetary(
compute="_compute_total", string="Recurring price", store=True
)
amount_tax = fields.Monetary(compute="_compute_total", store=True)
amount_total = fields.Monetary(compute="_compute_total", store=True)
tag_ids = fields.Many2many(comodel_name="sale.subscription.tag", string="Tags")
image = fields.Binary("Image", related="user_id.image_512", store=True)
journal_id = fields.Many2one(comodel_name="account.journal", string="Journal")
currency_id = fields.Many2one(
related="pricelist_id.currency_id",
depends=["pricelist_id"],
store=True,
ondelete="restrict",
)
@api.model
def _read_group_stage_ids(self, stages, domain, order):
stage_ids = stages.search([], order=order)
return stage_ids
stage_id = fields.Many2one(
comodel_name="sale.subscription.stage",
string="Stage",
tracking=True,
group_expand="_read_group_stage_ids",
store="true",
)
stage_str = fields.Char(
related="stage_id.name",
string="Etapa",
store=True,
)
sale_subscription_line_ids = fields.One2many(
comodel_name="sale.subscription.line",
inverse_name="sale_subscription_id",
)
sale_order_ids_count = fields.Integer(
compute="_compute_sale_order_ids_count", string="Sale orders"
)
account_invoice_ids_count = fields.Integer(
compute="_compute_account_invoice_ids_count", string="Invoice Count"
)
close_reason_id = fields.Many2one(
comodel_name="sale.subscription.close.reason", string="Close Reason"
)
crm_team_id = fields.Many2one(comodel_name="crm.team", string="Sale team")
to_renew = fields.Boolean(default=False, string="To renew")
def cron_subscription_management(self):
today = date.today()
for subscription in self.search([]):
if subscription.in_progress:
if (
subscription.recurring_next_date == today
and subscription.sale_subscription_line_ids
):
try:
subscription.generate_invoice()
except Exception:
logger.exception("Error on subscription invoice generate")
if not subscription.recurring_rule_boundary:
if subscription.date == today:
subscription.action_close_subscription()
else:
if subscription.date_start == today:
subscription.action_start_subscription()
subscription.generate_invoice()
@api.depends("sale_subscription_line_ids")
def _compute_total(self):
for record in self:
recurring_total = amount_tax = 0.0
for order_line in record.sale_subscription_line_ids:
recurring_total += order_line.price_subtotal
amount_tax += order_line.amount_tax_line_amount
record.update(
{
"recurring_total": recurring_total,
"amount_tax": amount_tax,
"amount_total": recurring_total + amount_tax,
}
)
@api.depends("template_id", "code")
def _compute_name(self):
for record in self:
template_code = record.template_id.code if record.template_id.code else ""
code = record.code if record.code else ""
slash = "/" if template_code and code else ""
record.name = "{}{}{}".format(template_code, slash, code)
@api.depends("template_id", "date_start")
def _compute_rule_boundary(self):
for record in self:
if record.template_id.recurring_rule_boundary == "unlimited":
record.date = False
record.recurring_rule_boundary = True
else:
record.date = (
relativedelta(months=+record.template_id.recurring_rule_count)
+ record.date_start
)
record.recurring_rule_boundary = False
@api.depends("template_id")
def _compute_terms(self):
for record in self:
record.terms = record.template_id.description
@api.onchange("template_id", "date_start")
def _onchange_template_id(self):
today = date.today()
if self.date_start:
today = self.date_start
if self.template_id and self.account_invoice_ids_count > 0:
self.calculate_recurring_next_date(self.recurring_next_date)
else:
self.calculate_recurring_next_date(today)
def calculate_recurring_next_date(self, start_date):
if self.account_invoice_ids_count == 0:
self.recurring_next_date = date.today()
else:
type_interval = self.template_id.recurring_rule_type
interval = int(self.template_id.recurring_interval)
self.recurring_next_date = start_date + relativedelta(
**{type_interval: interval}
)
@api.onchange("partner_id")
def onchange_partner_id(self):
self.pricelist_id = self.partner_id.property_product_pricelist
@api.onchange("partner_id", "company_id")
def onchange_partner_id_fpos(self):
self.fiscal_position_id = (
self.env["account.fiscal.position"]
.with_company(self.company_id)
.get_fiscal_position(self.partner_id.id)
)
def action_start_subscription(self):
self.close_reason_id = False
in_progress_stage = self.env["sale.subscription.stage"].search(
[("type", "=", "in_progress")], limit=1
)
self.stage_id = in_progress_stage
def action_close_subscription(self):
self.recurring_next_date = False
return {
"view_type": "form",
"view_mode": "form",
"res_model": "close.reason.wizard",
"type": "ir.actions.act_window",
"target": "new",
"res_id": False,
}
def _prepare_sale_order(self, line_ids=False):
self.ensure_one()
return {
"partner_id": self.partner_id.id,
"fiscal_position_id": self.fiscal_position_id.id,
"date_order": datetime.now(),
"payment_term_id": self.partner_id.property_payment_term_id.id,
"user_id": self.user_id.id,
"origin": self.name,
"order_line": line_ids,
}
def _prepare_account_move(self, line_ids):
self.ensure_one()
values = {
"partner_id": self.partner_id.id,
"invoice_date": self.recurring_next_date,
"invoice_payment_term_id": self.partner_id.property_payment_term_id.id,
"invoice_origin": self.name,
"invoice_user_id": self.user_id.id,
"partner_bank_id": self.company_id.partner_id.bank_ids[:1].id,
"invoice_line_ids": line_ids,
}
if self.journal_id:
values["journal_id"] = self.journal_id.id
return values
def create_invoice(self):
if not self.env["account.move"].check_access_rights("create", False):
try:
self.check_access_rights("write")
self.check_access_rule("write")
except AccessError:
return self.env["account.move"]
line_ids = []
for line in self.sale_subscription_line_ids:
line_values = line._prepare_account_move_line()
line_ids.append((0, 0, line_values))
invoice_values = self._prepare_account_move(line_ids)
invoice_id = (
self.env["account.move"]
.sudo()
.with_context(default_move_type="out_invoice", journal_type="sale")
.create(invoice_values)
)
self.write({"invoice_ids": [(4, invoice_id.id)]})
return invoice_id
def create_sale_order(self):
if not self.env["sale.order"].check_access_rights("create", False):
try:
self.check_access_rights("write")
self.check_access_rule("write")
except AccessError:
return self.env["sale.order"]
line_ids = []
for line in self.sale_subscription_line_ids:
line_values = line._prepare_sale_order_line()
line_ids.append((0, 0, line_values))
values = self._prepare_sale_order(line_ids)
order_id = self.env["sale.order"].sudo().create(values)
self.write({"sale_order_ids": [(4, order_id.id)]})
return order_id
def generate_invoice(self):
invoice_number = ""
msg_static = _("Created invoice with reference")
if self.template_id.invoicing_mode in ["draft", "invoice", "invoice_send"]:
invoice = self.create_invoice()
if self.template_id.invoicing_mode != "draft":
invoice.action_post()
if self.template_id.invoicing_mode == "invoice_send":
mail_template = self.template_id.invoice_mail_template_id
invoice.with_context(force_send=True).message_post_with_template(
mail_template.id,
composition_mode="comment",
email_layout_xmlid="mail.mail_notification_paynow",
)
invoice_number = invoice.name
message_body = (
"<b>%s</b> <a href=# data-oe-model=account.move data-oe-id=%d>%s</a>"
% (msg_static, invoice.id, invoice_number)
)
if self.template_id.invoicing_mode == "sale_and_invoice":
order_id = self.create_sale_order()
order_id.action_done()
new_invoice = order_id._create_invoices()
new_invoice.action_post()
new_invoice.invoice_origin = order_id.name + ", " + self.name
invoice_number = new_invoice.name
message_body = (
"<b>%s</b> <a href=# data-oe-model=account.move data-oe-id=%d>%s</a>"
% (msg_static, new_invoice.id, invoice_number)
)
if not invoice_number:
invoice_number = _("To validate")
message_body = "<b>%s</b> %s" % (msg_static, invoice_number)
self.calculate_recurring_next_date(self.recurring_next_date)
self.message_post(body=message_body)
def manual_invoice(self):
invoice_id = self.create_invoice()
self.calculate_recurring_next_date(self.recurring_next_date)
context = dict(self.env.context)
context["form_view_initial_mode"] = "edit"
return {
"name": self.name,
"views": [
(self.env.ref("account.view_move_form").id, "form"),
(self.env.ref("account.view_move_tree").id, "tree"),
],
"view_type": "form",
"view_mode": "form",
"res_model": "account.move",
"res_id": invoice_id.id,
"type": "ir.actions.act_window",
"context": context,
}
@api.depends("invoice_ids", "sale_order_ids.invoice_ids")
def _compute_account_invoice_ids_count(self):
for record in self:
record.account_invoice_ids_count = len(self.invoice_ids) + len(
self.sale_order_ids.invoice_ids
)
def action_view_account_invoice_ids(self):
return {
"name": self.name,
"views": [
(self.env.ref("account.view_move_tree").id, "tree"),
(self.env.ref("account.view_move_form").id, "form"),
],
"view_type": "form",
"view_mode": "tree,form",
"res_model": "account.move",
"type": "ir.actions.act_window",
"domain": [
("id", "in", self.invoice_ids.ids + self.sale_order_ids.invoice_ids.ids)
],
"context": self.env.context,
}
def _compute_sale_order_ids_count(self):
data = self.env["sale.order"].read_group(
domain=[("order_subscription_id", "in", self.ids)],
fields=["order_subscription_id"],
groupby=["order_subscription_id"],
)
count_dict = {
item["order_subscription_id"][0]: item["order_subscription_id_count"]
for item in data
}
for record in self:
record.sale_order_ids_count = count_dict.get(record.id, 0)
def action_view_sale_order_ids(self):
active_ids = self.sale_order_ids.ids
return {
"name": self.name,
"view_type": "form",
"view_mode": "tree,form",
"res_model": "sale.order",
"type": "ir.actions.act_window",
"domain": [("id", "in", active_ids)],
"context": self.env.context,
}
def _check_dates(self, start, next_invoice):
if start and next_invoice:
date_start = start
date_next_invoice = next_invoice
if not isinstance(date_start, date) and not isinstance(
date_next_invoice, date
):
date_start = fields.Date.to_date(start)
date_next_invoice = fields.Date.to_date(next_invoice)
if date_start > date_next_invoice:
return True
return False
def write(self, values):
res = super().write(values)
if "stage_id" in values:
for record in self:
if record.stage_id:
if record.stage_id.type == "in_progress":
record.in_progress = True
record.date_start = date.today()
elif record.stage_id.type == "post":
record.close_reason_id = False
record.in_progress = False
else:
record.in_progress = False
return res
@api.model
def create(self, values):
if "recurring_rule_boundary" in values:
if not values["recurring_rule_boundary"]:
template_id = self.env["sale.subscription.template"].search(
[("id", "=", values["template_id"])]
)
date_start = values["date_start"]
if not isinstance(values["date_start"], date):
date_start = fields.Date.to_date(values["date_start"])
values["date"] = template_id._get_date(date_start)
if "date_start" in values and "recurring_next_date" in values:
res = self._check_dates(values["date_start"], values["recurring_next_date"])
if res:
values["date_start"] = values["recurring_next_date"]
values["stage_id"] = (
self.env["sale.subscription.stage"]
.search([("type", "=", "pre")], order="sequence desc")[-1]
.id
)
return super(SaleSubscription, self).create(values)

View File

@@ -0,0 +1,10 @@
# Copyright 2023 Domatix - Carlos Martínez
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from odoo import fields, models
class SaleSubscriptionCloseReason(models.Model):
_name = "sale.subscription.close.reason"
_description = "Close reason model"
name = fields.Char(required=True)

View File

@@ -0,0 +1,322 @@
# Copyright 2023 Domatix - Carlos Martínez
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from odoo import api, fields, models
from odoo.tools.misc import get_lang
class SaleSubscriptionLine(models.Model):
_name = "sale.subscription.line"
_description = "Subscription lines added to a given subscription"
product_id = fields.Many2one(
comodel_name="product.product",
domain=[("sale_ok", "=", True)],
string="Product",
)
currency_id = fields.Many2one(
"res.currency",
related="sale_subscription_id.currency_id",
store=True,
readonly=True,
)
name = fields.Char(
string="Description", compute="_compute_name", store=True, readonly=False
)
product_uom_qty = fields.Float(default=1.0, string="Quantity")
price_unit = fields.Float(
string="Unit price", compute="_compute_price_unit", store=True, readonly=False
)
discount = fields.Float(
string="Discount (%)", compute="_compute_discount", store=True, readonly=False
)
tax_ids = fields.Many2many(
comodel_name="account.tax",
relation="subscription_line_tax",
column1="subscription_line_id",
column2="tax_id",
string="Taxes",
compute="_compute_tax_ids",
store=True,
readonly=False,
)
@api.depends("product_id", "price_unit", "product_uom_qty", "discount", "tax_ids")
def _compute_subtotal(self):
for record in self:
price = record.price_unit * (1 - (record.discount or 0.0) / 100.0)
taxes = record.tax_ids.compute_all(
price,
record.currency_id,
record.product_uom_qty,
product=record.product_id,
partner=record.sale_subscription_id.partner_id,
)
record.update(
{
"amount_tax_line_amount": sum(
t.get("amount", 0.0) for t in taxes.get("taxes", [])
),
"price_total": taxes["total_included"],
"price_subtotal": taxes["total_excluded"],
}
)
price_subtotal = fields.Monetary(
string="Subtotal", readonly="True", compute=_compute_subtotal, store=True
)
price_total = fields.Monetary(
string="Total", readonly="True", compute=_compute_subtotal, store=True
)
amount_tax_line_amount = fields.Float(
string="Taxes Amount", compute="_compute_subtotal", store=True
)
sale_subscription_id = fields.Many2one(
comodel_name="sale.subscription", string="Subscription"
)
company_id = fields.Many2one(
related="sale_subscription_id.company_id",
string="Company",
store=True,
index=True,
)
@api.depends("product_id")
def _compute_name(self):
for record in self:
if not record.product_id:
record.name = False
lang = get_lang(self.env, record.sale_subscription_id.partner_id.lang).code
product = record.product_id.with_context(lang=lang)
record.name = product.with_context(
lang=lang
).get_product_multiline_description_sale()
@api.depends("product_id", "sale_subscription_id.fiscal_position_id")
def _compute_tax_ids(self):
for line in self:
fpos = (
line.sale_subscription_id.fiscal_position_id
or line.sale_subscription_id.fiscal_position_id.get_fiscal_position(
line.sale_subscription_id.partner_id.id
)
)
# If company_id is set, always filter taxes by the company
taxes = line.product_id.taxes_id.filtered(
lambda t: t.company_id == line.env.company
)
line.tax_ids = fpos.map_tax(taxes)
@api.depends(
"product_id",
"sale_subscription_id.partner_id",
"sale_subscription_id.pricelist_id",
)
def _compute_price_unit(self):
for record in self:
if not record.product_id:
continue
if (
record.sale_subscription_id.pricelist_id
and record.sale_subscription_id.partner_id
):
product = record.product_id.with_context(
partner=record.sale_subscription_id.partner_id,
quantity=record.product_uom_qty,
date=fields.datetime.now(),
pricelist=record.sale_subscription_id.pricelist_id.id,
uom=record.product_id.uom_id.id,
)
record.price_unit = product._get_tax_included_unit_price(
record.company_id,
record.sale_subscription_id.currency_id,
fields.datetime.now(),
"sale",
fiscal_position=record.sale_subscription_id.fiscal_position_id,
product_price_unit=record._get_display_price(product),
product_currency=record.sale_subscription_id.currency_id,
)
@api.depends(
"product_id",
"price_unit",
"product_uom_qty",
"tax_ids",
"sale_subscription_id.partner_id",
"sale_subscription_id.pricelist_id",
)
def _compute_discount(self):
for record in self:
if not (
record.product_id
and record.product_id.uom_id
and record.sale_subscription_id.partner_id
and record.sale_subscription_id.pricelist_id
and record.sale_subscription_id.pricelist_id.discount_policy
== "without_discount"
and self.env.user.has_group("product.group_discount_per_so_line")
):
record.discount = 0.0
continue
record.discount = 0.0
product = record.product_id.with_context(
lang=record.sale_subscription_id.partner_id.lang,
partner=record.sale_subscription_id.partner_id,
quantity=record.product_uom_qty,
date=fields.Datetime.now(),
pricelist=record.sale_subscription_id.pricelist_id.id,
uom=record.product_id.uom_id.id,
fiscal_position=record.sale_subscription_id.fiscal_position_id
or self.env.context.get("fiscal_position"),
)
price, rule_id = record.sale_subscription_id.pricelist_id.with_context(
partner_id=record.sale_subscription_id.partner_id.id,
date=fields.Datetime.now(),
uom=record.product_id.uom_id.id,
).get_product_price_rule(
record.product_id,
record.product_uom_qty or 1.0,
record.sale_subscription_id.partner_id,
)
new_list_price, currency = record.with_context(
partner_id=record.sale_subscription_id.partner_id.id,
date=fields.Datetime.now(),
uom=record.product_id.uom_id.id,
)._get_real_price_currency(
product, rule_id, record.product_uom_qty, record.product_id.uom_id
)
if new_list_price != 0:
if record.sale_subscription_id.pricelist_id.currency_id != currency:
new_list_price = currency._convert(
new_list_price,
record.sale_subscription_id.pricelist_id.currency_id,
record.sale_subscription_id.company_id or self.env.company,
fields.Date.today(),
)
discount = (new_list_price - price) / new_list_price * 100
if (discount > 0 and new_list_price > 0) or (
discount < 0 and new_list_price < 0
):
record.discount = discount
def _get_real_price_currency(self, product, rule_id, qty, uom):
PricelistItem = self.env["product.pricelist.item"]
field_name = "lst_price"
currency_id = None
product_currency = product.currency_id
if rule_id:
pricelist_item = PricelistItem.browse(rule_id)
if pricelist_item.pricelist_id.discount_policy == "without_discount":
while (
pricelist_item.base == "pricelist"
and pricelist_item.base_pricelist_id
and pricelist_item.base_pricelist_id.discount_policy
== "without_discount"
):
_price, rule_id = pricelist_item.base_pricelist_id.with_context(
uom=uom.id
).get_product_price_rule(
product, qty, self.sale_subscription_id.partner_id
)
pricelist_item = PricelistItem.browse(rule_id)
if pricelist_item.base == "standard_price":
field_name = "standard_price"
product_currency = product.cost_currency_id
elif (
pricelist_item.base == "pricelist" and pricelist_item.base_pricelist_id
):
field_name = "price"
product = product.with_context(
pricelist=pricelist_item.base_pricelist_id.id
)
product_currency = pricelist_item.base_pricelist_id.currency_id
currency_id = pricelist_item.pricelist_id.currency_id
if not currency_id:
currency_id = product_currency
cur_factor = 1.0
else:
if currency_id.id == product_currency.id:
cur_factor = 1.0
else:
cur_factor = currency_id._get_conversion_rate(
product_currency,
currency_id,
self.company_id or self.env.company,
fields.Date.today(),
)
product_uom = self.env.context.get("uom") or product.uom_id.id
if uom and uom.id != product_uom:
# the unit price is in a different uom
uom_factor = uom._compute_price(1.0, product.uom_id)
else:
uom_factor = 1.0
return product[field_name] * uom_factor * cur_factor, currency_id
def _get_display_price(self, product):
if self.sale_subscription_id.pricelist_id.discount_policy == "with_discount":
return product.with_context(
pricelist=self.sale_subscription_id.pricelist_id.id,
uom=self.product_id.uom_id.id,
).price
final_price, rule_id = self.sale_subscription_id.pricelist_id.with_context(
partner_id=self.sale_subscription_id.partner_id.id,
date=fields.Datetime.now(),
uom=self.product_id.uom_id.id,
).get_product_price_rule(
product or self.product_id,
self.product_uom_qty or 1.0,
self.sale_subscription_id.partner_id,
)
base_price, currency = self.with_context(
partner_id=self.sale_subscription_id.partner_id.id,
date=fields.Datetime.now(),
uom=self.product_id.uom_id.id,
)._get_real_price_currency(
product, rule_id, self.product_uom_qty, self.product_id.uom_id
)
if currency != self.sale_subscription_id.pricelist_id.currency_id:
base_price = currency._convert(
base_price,
self.sale_subscription_id.pricelist_id.currency_id,
self.sale_subscription_id.company_id or self.env.company,
fields.Date.today(),
)
return max(base_price, final_price)
def _prepare_sale_order_line(self):
self.ensure_one()
return {
"product_id": self.product_id.id,
"name": self.name,
"product_uom_qty": self.product_uom_qty,
"price_unit": self.price_unit,
"discount": self.discount,
"price_subtotal": self.price_subtotal,
"tax_id": self.tax_ids,
"product_uom": self.product_id.uom_id.id,
}
def _prepare_account_move_line(self):
self.ensure_one()
account = (
self.product_id.property_account_income_id
or self.product_id.categ_id.property_account_income_categ_id
)
return {
"product_id": self.product_id.id,
"name": self.name,
"quantity": self.product_uom_qty,
"price_unit": self.price_unit,
"discount": self.discount,
"price_subtotal": self.price_subtotal,
"tax_ids": [(6, 0, self.tax_ids.ids)],
"product_uom_id": self.product_id.uom_id.id,
"account_id": account.id,
}

View File

@@ -0,0 +1,29 @@
# Copyright 2023 Domatix - Carlos Martínez
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
class SaleSubscriptionStage(models.Model):
_name = "sale.subscription.stage"
_description = "Subscription stage"
_order = "sequence, name, id"
name = fields.Char(required=True, translate=True)
sequence = fields.Integer()
display_name = fields.Char(string="Display name")
in_progress = fields.Boolean(string="In progress", default=False)
fold = fields.Boolean(string="Kanban folded")
description = fields.Text(translate=True)
type = fields.Selection(
[("pre", "Ready to start"), ("in_progress", "In progress"), ("post", "Closed")],
default="pre",
)
@api.constrains("type")
def _check_lot_product(self):
post_stages = self.env["sale.subscription.stage"].search(
[("type", "=", "post")]
)
if len(post_stages) > 1:
raise ValidationError(_("There is already a Closed-type stage declared"))

View File

@@ -0,0 +1,10 @@
# Copyright 2023 Domatix - Carlos Martínez
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from odoo import fields, models
class SaleSubscriptionTag(models.Model):
_name = "sale.subscription.tag"
_description = "Tags for sale subscription"
name = fields.Char("Tag name", required=True)

View File

@@ -0,0 +1,102 @@
# Copyright 2023 Domatix - Carlos Martínez
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from dateutil.relativedelta import relativedelta
from odoo import api, fields, models
class SaleSubscriptionTemplate(models.Model):
_name = "sale.subscription.template"
_description = "Subscription templates"
name = fields.Char(required=True)
description = fields.Text(string="Terms and conditions")
recurring_interval = fields.Integer(string="Repeat every", default=1)
recurring_rule_type = fields.Selection(
[
("days", "Day(s)"),
("weeks", "Week(s)"),
("months", "Month(s)"),
("years", "Year(s)"),
],
string="Recurrence",
default="months",
)
recurring_rule_boundary = fields.Selection(
[("unlimited", "Forever"), ("limited", "Fixed")],
string="Duration",
default="unlimited",
)
invoicing_mode = fields.Selection(
default="draft",
string="Invoicing mode",
selection=[
("draft", "Draft"),
("invoice", "Invoice"),
("invoice_send", "Invoice & send"),
("sale_and_invoice", "Sale order & Invoice"),
],
)
code = fields.Char()
recurring_rule_count = fields.Integer(default=1, string="Rule count")
invoice_mail_template_id = fields.Many2one(
comodel_name="mail.template",
string="Invoice Email",
domain="[('model', '=', 'account.move')]",
)
product_ids = fields.One2many(
comodel_name="product.template",
inverse_name="subscription_template_id",
string="Products",
)
product_ids_count = fields.Integer(
compute="_compute_product_ids_count", string="product_ids"
)
subscription_ids = fields.One2many(
comodel_name="sale.subscription",
inverse_name="template_id",
string="Subscriptions",
)
subscription_count = fields.Integer(
compute="_compute_subscription_count", string="subscription_ids"
)
def _compute_subscription_count(self):
data = self.env["sale.subscription"].read_group(
domain=[("template_id", "in", self.ids)],
fields=["template_id"],
groupby=["template_id"],
)
count_dict = {
item["template_id"][0]: item["template_id_count"] for item in data
}
for record in self:
record.subscription_count = count_dict.get(record.id, 0)
def action_view_subscription_ids(self):
return {
"name": self.name,
"view_mode": "tree,form",
"res_model": "sale.subscription",
"type": "ir.actions.act_window",
"domain": [("id", "in", self.subscription_ids.ids)],
}
def _get_date(self, date_start):
self.ensure_one()
return relativedelta(months=+self.recurring_rule_count) + date_start
@api.depends("product_ids")
def _compute_product_ids_count(self):
for record in self:
record.product_ids_count = len(self.product_ids)
def action_view_product_ids(self):
return {
"name": self.name,
"view_type": "form",
"view_mode": "tree,form",
"res_model": "product.template",
"type": "ir.actions.act_window",
"domain": [("id", "in", self.product_ids.ids)],
}

View File

@@ -0,0 +1 @@
* Carlos Martínez <carlos@domatix.com>

View File

@@ -0,0 +1 @@
This module allows creating subscriptions that generate recurring invoices or orders. It also enables the sale of products that generate subscriptions.

View File

@@ -0,0 +1,2 @@
* Refactor all the onchanges that have business logic to computed write-able fields when possible. Keep onchanges only for UI purposes.
* Add tests.

View File

@@ -0,0 +1,13 @@
To make a subscription:
#. Go to *Subscriptions > Configuration > Subscription templates*.
#. Create the templates you consider, choosing the billing frequency: daily, monthly... and the method of creating the invoice and/or order.
#. Go to *Subscription > Subscriptions*.
#. Create a subscription and indicate the start date. When the *Subscriptions Management* cron job is executed, the subscription will begin and the first invoice will be created if the execution date matches the start date. The invoice will also be created when the execution date matches the next invoice date. Additionally, you can manually change the subscription status and create an invoice.
#. The cron job will also end the subscription if its end date has been reached.
To create subscriptions with the sale of a product:
#. Go to *Subscriptions > Subscriptions > Products*.
#. Create the product and in the sales tab, complete the fields *Subscribable product* and *Subscription template*
#. Create a sales order with the product and confirm it.

View File

@@ -0,0 +1,8 @@
id,name,model_id/id,group_id/id,perm_read,perm_write,perm_create,perm_unlink
access_custom_sale_subscription_template,sale.subscription.template,model_sale_subscription_template,sales_team.group_sale_salesman,1,1,1,1
access_custom_sale_subscription,sale.subscription,model_sale_subscription,sales_team.group_sale_salesman,1,1,1,1
access_custom_sale_subscription_close_reason,sale.subscription.close.reason,model_sale_subscription_close_reason,sales_team.group_sale_salesman,1,1,1,1
access_custom_sale_subscription_stage,sale.subscription.stage,model_sale_subscription_stage,sales_team.group_sale_salesman,1,1,1,1
access_custom_sale_subscription_line,sale.subscription.line,model_sale_subscription_line,sales_team.group_sale_salesman,1,1,1,1
access_custom_sale_subscription_tag,sale.subscription.tag,model_sale_subscription_tag,sales_team.group_sale_salesman,1,1,1,1
access_close_subscription,Close subscription access,model_close_reason_wizard,sales_team.group_sale_salesman,1,1,1,1
1 id name model_id/id group_id/id perm_read perm_write perm_create perm_unlink
2 access_custom_sale_subscription_template sale.subscription.template model_sale_subscription_template sales_team.group_sale_salesman 1 1 1 1
3 access_custom_sale_subscription sale.subscription model_sale_subscription sales_team.group_sale_salesman 1 1 1 1
4 access_custom_sale_subscription_close_reason sale.subscription.close.reason model_sale_subscription_close_reason sales_team.group_sale_salesman 1 1 1 1
5 access_custom_sale_subscription_stage sale.subscription.stage model_sale_subscription_stage sales_team.group_sale_salesman 1 1 1 1
6 access_custom_sale_subscription_line sale.subscription.line model_sale_subscription_line sales_team.group_sale_salesman 1 1 1 1
7 access_custom_sale_subscription_tag sale.subscription.tag model_sale_subscription_tag sales_team.group_sale_salesman 1 1 1 1
8 access_close_subscription Close subscription access model_close_reason_wizard sales_team.group_sale_salesman 1 1 1 1

View File

@@ -0,0 +1,439 @@
<?xml version="1.0" encoding="utf-8" ?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="generator" content="Docutils: http://docutils.sourceforge.net/" />
<title>Subscription management</title>
<style type="text/css">
/*
:Author: David Goodger (goodger@python.org)
:Id: $Id: html4css1.css 7952 2016-07-26 18:15:59Z milde $
:Copyright: This stylesheet has been placed in the public domain.
Default cascading style sheet for the HTML output of Docutils.
See http://docutils.sf.net/docs/howto/html-stylesheets.html for how to
customize this style sheet.
*/
/* used to remove borders from tables and images */
.borderless, table.borderless td, table.borderless th {
border: 0 }
table.borderless td, table.borderless th {
/* Override padding for "table.docutils td" with "! important".
The right padding separates the table cells. */
padding: 0 0.5em 0 0 ! important }
.first {
/* Override more specific margin styles with "! important". */
margin-top: 0 ! important }
.last, .with-subtitle {
margin-bottom: 0 ! important }
.hidden {
display: none }
.subscript {
vertical-align: sub;
font-size: smaller }
.superscript {
vertical-align: super;
font-size: smaller }
a.toc-backref {
text-decoration: none ;
color: black }
blockquote.epigraph {
margin: 2em 5em ; }
dl.docutils dd {
margin-bottom: 0.5em }
object[type="image/svg+xml"], object[type="application/x-shockwave-flash"] {
overflow: hidden;
}
/* Uncomment (and remove this text!) to get bold-faced definition list terms
dl.docutils dt {
font-weight: bold }
*/
div.abstract {
margin: 2em 5em }
div.abstract p.topic-title {
font-weight: bold ;
text-align: center }
div.admonition, div.attention, div.caution, div.danger, div.error,
div.hint, div.important, div.note, div.tip, div.warning {
margin: 2em ;
border: medium outset ;
padding: 1em }
div.admonition p.admonition-title, div.hint p.admonition-title,
div.important p.admonition-title, div.note p.admonition-title,
div.tip p.admonition-title {
font-weight: bold ;
font-family: sans-serif }
div.attention p.admonition-title, div.caution p.admonition-title,
div.danger p.admonition-title, div.error p.admonition-title,
div.warning p.admonition-title, .code .error {
color: red ;
font-weight: bold ;
font-family: sans-serif }
/* Uncomment (and remove this text!) to get reduced vertical space in
compound paragraphs.
div.compound .compound-first, div.compound .compound-middle {
margin-bottom: 0.5em }
div.compound .compound-last, div.compound .compound-middle {
margin-top: 0.5em }
*/
div.dedication {
margin: 2em 5em ;
text-align: center ;
font-style: italic }
div.dedication p.topic-title {
font-weight: bold ;
font-style: normal }
div.figure {
margin-left: 2em ;
margin-right: 2em }
div.footer, div.header {
clear: both;
font-size: smaller }
div.line-block {
display: block ;
margin-top: 1em ;
margin-bottom: 1em }
div.line-block div.line-block {
margin-top: 0 ;
margin-bottom: 0 ;
margin-left: 1.5em }
div.sidebar {
margin: 0 0 0.5em 1em ;
border: medium outset ;
padding: 1em ;
background-color: #ffffee ;
width: 40% ;
float: right ;
clear: right }
div.sidebar p.rubric {
font-family: sans-serif ;
font-size: medium }
div.system-messages {
margin: 5em }
div.system-messages h1 {
color: red }
div.system-message {
border: medium outset ;
padding: 1em }
div.system-message p.system-message-title {
color: red ;
font-weight: bold }
div.topic {
margin: 2em }
h1.section-subtitle, h2.section-subtitle, h3.section-subtitle,
h4.section-subtitle, h5.section-subtitle, h6.section-subtitle {
margin-top: 0.4em }
h1.title {
text-align: center }
h2.subtitle {
text-align: center }
hr.docutils {
width: 75% }
img.align-left, .figure.align-left, object.align-left, table.align-left {
clear: left ;
float: left ;
margin-right: 1em }
img.align-right, .figure.align-right, object.align-right, table.align-right {
clear: right ;
float: right ;
margin-left: 1em }
img.align-center, .figure.align-center, object.align-center {
display: block;
margin-left: auto;
margin-right: auto;
}
table.align-center {
margin-left: auto;
margin-right: auto;
}
.align-left {
text-align: left }
.align-center {
clear: both ;
text-align: center }
.align-right {
text-align: right }
/* reset inner alignment in figures */
div.align-right {
text-align: inherit }
/* div.align-center * { */
/* text-align: left } */
.align-top {
vertical-align: top }
.align-middle {
vertical-align: middle }
.align-bottom {
vertical-align: bottom }
ol.simple, ul.simple {
margin-bottom: 1em }
ol.arabic {
list-style: decimal }
ol.loweralpha {
list-style: lower-alpha }
ol.upperalpha {
list-style: upper-alpha }
ol.lowerroman {
list-style: lower-roman }
ol.upperroman {
list-style: upper-roman }
p.attribution {
text-align: right ;
margin-left: 50% }
p.caption {
font-style: italic }
p.credits {
font-style: italic ;
font-size: smaller }
p.label {
white-space: nowrap }
p.rubric {
font-weight: bold ;
font-size: larger ;
color: maroon ;
text-align: center }
p.sidebar-title {
font-family: sans-serif ;
font-weight: bold ;
font-size: larger }
p.sidebar-subtitle {
font-family: sans-serif ;
font-weight: bold }
p.topic-title {
font-weight: bold }
pre.address {
margin-bottom: 0 ;
margin-top: 0 ;
font: inherit }
pre.literal-block, pre.doctest-block, pre.math, pre.code {
margin-left: 2em ;
margin-right: 2em }
pre.code .ln { color: grey; } /* line numbers */
pre.code, code { background-color: #eeeeee }
pre.code .comment, code .comment { color: #5C6576 }
pre.code .keyword, code .keyword { color: #3B0D06; font-weight: bold }
pre.code .literal.string, code .literal.string { color: #0C5404 }
pre.code .name.builtin, code .name.builtin { color: #352B84 }
pre.code .deleted, code .deleted { background-color: #DEB0A1}
pre.code .inserted, code .inserted { background-color: #A3D289}
span.classifier {
font-family: sans-serif ;
font-style: oblique }
span.classifier-delimiter {
font-family: sans-serif ;
font-weight: bold }
span.interpreted {
font-family: sans-serif }
span.option {
white-space: nowrap }
span.pre {
white-space: pre }
span.problematic {
color: red }
span.section-subtitle {
/* font-size relative to parent (h1..h6 element) */
font-size: 80% }
table.citation {
border-left: solid 1px gray;
margin-left: 1px }
table.docinfo {
margin: 2em 4em }
table.docutils {
margin-top: 0.5em ;
margin-bottom: 0.5em }
table.footnote {
border-left: solid 1px black;
margin-left: 1px }
table.docutils td, table.docutils th,
table.docinfo td, table.docinfo th {
padding-left: 0.5em ;
padding-right: 0.5em ;
vertical-align: top }
table.docutils th.field-name, table.docinfo th.docinfo-name {
font-weight: bold ;
text-align: left ;
white-space: nowrap ;
padding-left: 0 }
/* "booktabs" style (no vertical lines) */
table.docutils.booktabs {
border: 0px;
border-top: 2px solid;
border-bottom: 2px solid;
border-collapse: collapse;
}
table.docutils.booktabs * {
border: 0px;
}
table.docutils.booktabs th {
border-bottom: thin solid;
text-align: left;
}
h1 tt.docutils, h2 tt.docutils, h3 tt.docutils,
h4 tt.docutils, h5 tt.docutils, h6 tt.docutils {
font-size: 100% }
ul.auto-toc {
list-style-type: none }
</style>
</head>
<body>
<div class="document" id="subscription-management">
<h1 class="title">Subscription management</h1>
<!-- !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! source digest: sha256:7118b690b12a3d475a89b42c8caf9802168a5c9de8d668e84db674bf9fe382fa
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->
<p><a class="reference external" href="https://odoo-community.org/page/development-status"><img alt="Beta" src="https://img.shields.io/badge/maturity-Beta-yellow.png" /></a> <a class="reference external" href="http://www.gnu.org/licenses/agpl-3.0-standalone.html"><img alt="License: AGPL-3" src="https://img.shields.io/badge/licence-AGPL--3-blue.png" /></a> <a class="reference external" href="https://github.com/OCA/sale-workflow/tree/15.0/subscription_oca"><img alt="OCA/sale-workflow" src="https://img.shields.io/badge/github-OCA%2Fsale--workflow-lightgray.png?logo=github" /></a> <a class="reference external" href="https://translation.odoo-community.org/projects/sale-workflow-15-0/sale-workflow-15-0-subscription_oca"><img alt="Translate me on Weblate" src="https://img.shields.io/badge/weblate-Translate%20me-F47D42.png" /></a> <a class="reference external" href="https://runboat.odoo-community.org/builds?repo=OCA/sale-workflow&amp;target_branch=15.0"><img alt="Try me on Runboat" src="https://img.shields.io/badge/runboat-Try%20me-875A7B.png" /></a></p>
<p>This module allows creating subscriptions that generate recurring invoices or orders. It also enables the sale of products that generate subscriptions.</p>
<p><strong>Table of contents</strong></p>
<div class="contents local topic" id="contents">
<ul class="simple">
<li><a class="reference internal" href="#usage" id="id1">Usage</a></li>
<li><a class="reference internal" href="#bug-tracker" id="id2">Bug Tracker</a></li>
<li><a class="reference internal" href="#credits" id="id3">Credits</a><ul>
<li><a class="reference internal" href="#authors" id="id4">Authors</a></li>
<li><a class="reference internal" href="#contributors" id="id5">Contributors</a></li>
<li><a class="reference internal" href="#maintainers" id="id6">Maintainers</a></li>
</ul>
</li>
</ul>
</div>
<div class="section" id="usage">
<h1><a class="toc-backref" href="#id1">Usage</a></h1>
<p>To make a subscription:</p>
<ol class="arabic simple">
<li>Go to <em>Subscriptions &gt; Configuration &gt; Subscription templates</em>.</li>
<li>Create the templates you consider, choosing the billing frequency: daily, monthly… and the method of creating the invoice and/or order.</li>
<li>Go to <em>Subscription &gt; Subscriptions</em>.</li>
<li>Create a subscription and indicate the start date. When the <em>Subscriptions Management</em> cron job is executed, the subscription will begin and the first invoice will be created if the execution date matches the start date. The invoice will also be created when the execution date matches the next invoice date. Additionally, you can manually change the subscription status and create an invoice.</li>
<li>The cron job will also end the subscription if its end date has been reached.</li>
</ol>
<p>To create subscriptions with the sale of a product:</p>
<ol class="arabic simple">
<li>Go to <em>Subscriptions &gt; Subscriptions &gt; Products</em>.</li>
<li>Create the product and in the sales tab, complete the fields <em>Subscribable product</em> and <em>Subscription template</em></li>
<li>Create a sales order with the product and confirm it.</li>
</ol>
</div>
<div class="section" id="bug-tracker">
<h1><a class="toc-backref" href="#id2">Bug Tracker</a></h1>
<p>Bugs are tracked on <a class="reference external" href="https://github.com/OCA/sale-workflow/issues">GitHub Issues</a>.
In case of trouble, please check there if your issue has already been reported.
If you spotted it first, help us to smash it by providing a detailed and welcomed
<a class="reference external" href="https://github.com/OCA/sale-workflow/issues/new?body=module:%20subscription_oca%0Aversion:%2015.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**">feedback</a>.</p>
<p>Do not contact contributors directly about support or help with technical issues.</p>
</div>
<div class="section" id="credits">
<h1><a class="toc-backref" href="#id3">Credits</a></h1>
<div class="section" id="authors">
<h2><a class="toc-backref" href="#id4">Authors</a></h2>
<ul class="simple">
<li>Domatix</li>
</ul>
</div>
<div class="section" id="contributors">
<h2><a class="toc-backref" href="#id5">Contributors</a></h2>
<ul class="simple">
<li>Carlos Martínez &lt;<a class="reference external" href="mailto:carlos&#64;domatix.com">carlos&#64;domatix.com</a>&gt;</li>
</ul>
</div>
<div class="section" id="maintainers">
<h2><a class="toc-backref" href="#id6">Maintainers</a></h2>
<p>This module is maintained by the OCA.</p>
<a class="reference external image-reference" href="https://odoo-community.org"><img alt="Odoo Community Association" src="https://odoo-community.org/logo.png" /></a>
<p>OCA, or the Odoo Community Association, is a nonprofit organization whose
mission is to support the collaborative development of Odoo features and
promote its widespread use.</p>
<p>This module is part of the <a class="reference external" href="https://github.com/OCA/sale-workflow/tree/15.0/subscription_oca">OCA/sale-workflow</a> project on GitHub.</p>
<p>You are welcome to contribute. To learn how please visit <a class="reference external" href="https://odoo-community.org/page/Contribute">https://odoo-community.org/page/Contribute</a>.</p>
</div>
</div>
</div>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -0,0 +1,20 @@
<?xml version="1.0" ?>
<odoo>
<record id="product_template_form_view" model="ir.ui.view">
<field name="name">product.template.sub.form</field>
<field name="model">product.template</field>
<field name="priority" eval="8" />
<field name="inherit_id" ref="product.product_template_form_view" />
<field name="arch" type="xml">
<xpath expr="//page[@name='sales']" position="inside">
<group>
<field name="subscribable" />
<field
name="subscription_template_id"
attrs="{'invisible': [('subscribable', '=', False)]}"
/>
</group>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<!-- Inherit Form View to Modify it -->
<record id="res_partner_view_form" model="ir.ui.view">
<field name="name">res.partner.form</field>
<field name="model">res.partner</field>
<field name="inherit_id" ref="base.view_partner_form" />
<field name="arch" type="xml">
<button name="action_view_partner_invoices" position="after">
<field name="subscription_ids" invisible="1" />
<button
type="object"
class="oe_stat_button"
icon="fa-recycle"
name="action_view_subscription_ids"
attrs="{'invisible': [('subscription_ids', '=', False)]}"
>
<div class="o_form_field o_stat_info">
<span class="o_stat_value">
<field name="subscription_count" />
</span>
<span class="o_stat_text">Subscriptions</span>
</div>
</button>
</button>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding='UTF-8' ?>
<odoo>
<record id="view_sale_order_form" model="ir.ui.view">
<field name="name">view.sale_order.form</field>
<field name="model">sale.order</field>
<field name="inherit_id" ref="sale.view_order_form" />
<field name="arch" type="xml">
<div class="oe_button_box" name="button_box">
<button
name="action_view_subscriptions"
attrs="{'invisible': [('subscriptions_count', '=', 0)]}"
type="object"
class="oe_stat_button"
icon="fa-pencil-square-o"
>
<field
name="subscriptions_count"
widget="statinfo"
string="Subscriptions"
/>
</button>
</div>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,73 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="view_subscription_stage_form" model="ir.ui.view">
<field name="name">view.subscription.stage.form</field>
<field name="model">sale.subscription.stage</field>
<field name="arch" type="xml">
<form string="Subscription stage">
<sheet>
<group name="main">
<group>
<field name="name" />
<field name="in_progress" invisible="True" />
<field
name="sequence"
help="
This field dictates the stages' visual order on the Kanban and form view. Although is purely visual, mind that if the order isn't
consequent with your needs, you could have a 'Closed'-type stage before a 'Ready to start' one.
"
/>
<field
name="type"
help="Stages define the life-cycle of a given subscription; this is,
a subscription can be a 'Ready to start', 'In progress' or 'Closed' type of stage.
Bear in mind that there can only be one 'Closed'-type stage."
/>
<field name="fold" />
</group>
</group>
<group>
<field
name="description"
placeholder="Add new description..."
nolabel="1"
/>
</group>
</sheet>
</form>
</field>
</record>
<record id="view_subscription_stage_tree" model="ir.ui.view">
<field name="name">view.subscription.stage.tree</field>
<field name="model">sale.subscription.stage</field>
<field name="arch" type="xml">
<tree>
<field name="name" />
</tree>
</field>
</record>
<record id="subscription_stage_action" model="ir.actions.act_window">
<field name="name">Susbcription stages</field>
<field name="res_model">sale.subscription.stage</field>
<field name="view_mode">tree,form</field>
<field name="help" type="html">
<p class="oe_view_nocontent_create">
Click to create a new subscription stage.
</p>
</field>
</record>
<menuitem
id="subscription_stage_menu"
parent="sale_subscription_configuration_menu"
action="subscription_stage_action"
sequence="20"
name="Subscription stages"
/>
</odoo>

View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="view_sale_subscription_tag_tree" model="ir.ui.view">
<field name="name">view.sale.subscription.tag.tree</field>
<field name="model">sale.subscription.tag</field>
<field name="arch" type="xml">
<tree editable="top">
<field name="name" />
</tree>
</field>
</record>
<record id="subscription_tag_action" model="ir.actions.act_window">
<field name="name">Tags</field>
<field name="res_model">sale.subscription.tag</field>
<field name="view_mode">tree</field>
</record>
<menuitem
id="subscription_tag_menu"
parent="sale_subscription_configuration_menu"
action="subscription_tag_action"
sequence="40"
name="Subscription tags"
/>
</odoo>

View File

@@ -0,0 +1,135 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="sale_subscription_template_form" model="ir.ui.view">
<field name="name">sale.subscription.template.form</field>
<field name="model">sale.subscription.template</field>
<field name="arch" type="xml">
<form>
<sheet>
<group>
<div class="oe_button_box" name="button_box">
<button
name="action_view_product_ids"
type="object"
class="oe_stat_button"
icon="fa-pencil-square-o"
>
<field
name="product_ids_count"
widget="statinfo"
string="Products"
/>
</button>
<button
name="action_view_subscription_ids"
type="object"
class="oe_stat_button"
icon="fa-pencil-square-o"
>
<field
name="subscription_count"
widget="statinfo"
string="Subscriptions"
/>
</button>
</div>
</group>
<div class="oe_title">
<h1>
<field
name="name"
placeholder="e.g. Monthly Subscription"
/>
</h1>
</div>
<notebook>
<page string="Invoicing">
<group>
<group name="left_group">
<label for="recurring_interval" string="Invoice" />
<div class="o_row oe_inline">
<span class="every">Every</span>
<field name="recurring_interval" colspan="2" />
<field
name="recurring_rule_type"
colspan="2"
required="1"
/>
</div>
<field
name="recurring_rule_boundary"
widget="radio"
options="{'horizontal':true}"
/>
<label
for="recurring_rule_count"
attrs="{'invisible': [('recurring_rule_boundary','=','unlimited')]}"
/>
<div
class="o_row "
attrs="{'invisible': [('recurring_rule_boundary','=','unlimited')]}"
>
<span class="mr-1">For</span>
<field
name="recurring_rule_count"
class="oe_inline"
/>
month(s)
</div>
<field name="invoicing_mode" widget="radio" />
<field
name="invoice_mail_template_id"
attrs="{'invisible': [('invoicing_mode','!=','invoice_send')], 'required': [('invoicing_mode', '=', 'invoice_send')]}"
/>
</group>
<group name="right_group">
<field name="code" readonly="0" />
</group>
</group>
</page>
<page string="Terms and Conditions">
<group>
<field
nolabel="1"
name="description"
placeholder="Terms and Conditions"
/>
</group>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<record id="sale_subscription_template_tree" model="ir.ui.view">
<field name="name">sale.subscription.template.tree</field>
<field name="model">sale.subscription.template</field>
<field name="arch" type="xml">
<tree>
<field name="name" />
<field name="recurring_interval" />
<field name="recurring_rule_type" />
</tree>
</field>
</record>
<record id="sale_subscription_template_act_window" model="ir.actions.act_window">
<field name="name">Subscription templates</field>
<field name="res_model">sale.subscription.template</field>
<field name="view_mode">tree,form</field>
</record>
<menuitem
id="sale_template_subscription_menu"
parent="sale_subscription_configuration_menu"
action="sale_subscription_template_act_window"
sequence="2"
/>
</odoo>

View File

@@ -0,0 +1,473 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="sale_subscription_form" model="ir.ui.view">
<field name="name">sale.subscription.form</field>
<field name="model">sale.subscription</field>
<field name="arch" type="xml">
<form string="New subscription">
<header>
<button
string="Create Invoice"
name="manual_invoice"
type="object"
class="btn-primary"
/>
<button
string="Close subscription"
class="btn-danger"
name="action_close_subscription"
type="object"
help="A given subscription can be marked as closed when, for example, renewal is not desired."
attrs="{'invisible': [('in_progress', '=', False)]}"
/>
<field
name="stage_id"
widget="statusbar"
clickable="1"
options="{'fold_field': 'fold'}"
/>
</header>
<sheet>
<div class="oe_button_box" name="button_box">
<button
name="action_view_sale_order_ids"
type="object"
attrs="{'invisible': [('sale_order_ids_count', '=', 0)]}"
class="oe_stat_button"
icon="fa-pencil-square-o"
>
<field
name="sale_order_ids_count"
widget="statinfo"
string="Sales"
/>
</button>
<button
name="action_view_account_invoice_ids"
attrs="{'invisible': [('account_invoice_ids_count', '=', 0)]}"
type="object"
class="oe_stat_button"
icon="fa-pencil-square-o"
>
<field
name="account_invoice_ids_count"
widget="statinfo"
string="Invoices"
/>
</button>
</div>
<widget
name="web_ribbon"
text="Archived"
bg_color="bg-danger"
attrs="{'invisible': [('active', '=', True)]}"
/>
<div class="oe_title">
<h1 class="flex-fill d-flex flex-row">
<field name="name" force_save="1" />
</h1>
</div>
<group>
<group name="left_group">
<field name="active" invisible="1" />
<field name="in_progress" invisible="True" />
<field name="partner_id" />
<field name="currency_id" invisible="1" />
<field name="pricelist_id" />
<field
name="date_start"
attrs="{'readonly':['|',('active','=',False), ('in_progress', '=', True)]}"
/>
<field
name="date"
attrs="{'invisible':[('recurring_rule_boundary', '=', True)]}"
/>
<field
name="close_reason_id"
attrs="{'invisible': [('active', '=', True)]}"
/>
<field name="recurring_rule_boundary" invisible="1" />
</group>
<group name="right_group">
<field name="template_id" />
<field name="crm_team_id" />
<field
name="recurring_next_date"
attrs="{'invisible': ['|', ('recurring_next_date', '=', False), ('in_progress', '=', False)]}"
/>
<field
name="company_id"
options="{'no_create': True}"
groups="base.group_multi_company"
/>
</group>
</group>
<notebook>
<page
string="Subscription lines"
name="subscription_lines_page"
>
<field name="sale_subscription_line_ids">
<tree editable="bottom">
<field name="product_id" required="True" />
<field
name="name"
required="True"
widget="section_and_note_text"
/>
<field name="currency_id" invisible="1" />
<field name="product_uom_qty" required="True" />
<field name="price_unit" required="True" />
<field name="discount" required="True" />
<field name="tax_ids" widget="many2many_tags" />
<field
name="price_subtotal"
options="{'currency_field': 'currency_id'}"
/>
<field
name="price_total"
options="{'currency_field': 'currency_id'}"
/>
</tree>
</field>
<group
class="oe_subtotal_footer oe_right"
colspan="2"
name="subscription_total"
>
<field
name="recurring_total"
widget="monetary"
options="{'currency_field': 'currency_id'}"
/>
<field
name="amount_tax"
widget="monetary"
options="{'currency_field': 'currency_id'}"
/>
<div
class="oe_subtotal_footer_separator oe_inline o_td_label"
>
<label for="amount_total" />
</div>
<field
name="amount_total"
nolabel="1"
class="oe_subtotal_footer_separator"
widget="monetary"
options="{'currency_field': 'currency_id'}"
/>
</group>
</page>
<page string="Misc" name="misc_page">
<group>
<field name="description" />
<field name="terms" />
</group>
</page>
<page string="Other info" name="other_info_page">
<group>
<group>
<field name="code" />
<field name="tag_ids" widget="many2many_tags" />
<field name="sale_order_id" />
</group>
<group>
<field
name="journal_id"
domain="[('type', '=', 'sale')]"
/>
<field name="fiscal_position_id" />
<field name="user_id" />
</group>
</group>
</page>
</notebook>
</sheet>
<div class="oe_chatter">
<field name="message_follower_ids" widget="mail_followers" />
<field name="activity_ids" widget="mail_activity" />
<field name="message_ids" widget="mail_thread" />
</div>
</form>
</field>
</record>
<record id="sale_subscription_tree" model="ir.ui.view">
<field name="name">sale.subscription.tree</field>
<field name="model">sale.subscription</field>
<field name="arch" type="xml">
<tree>
<field name="name" optional="show" />
<field name="partner_id" optional="show" />
<field name="recurring_next_date" optional="show" />
<field name="code" optional="show" />
<field name="user_id" optional="show" />
<field name="recurring_total" sum="Total subtotal" optional="show" />
<field name="amount_tax" sum="Total Tax" optional="show" />
<field name="amount_total" sum="Total" optional="show" />
<field name="template_id" optional="show" />
<field name="stage_str" optional="show" />
</tree>
</field>
</record>
<record id="sale_subscription_kanban" model="ir.ui.view">
<field name="name">sale.subscription.kanban</field>
<field name="model">sale.subscription</field>
<field name="arch" type="xml">
<kanban
default_group_by="stage_id"
default_order="write_date,sequence"
class="o_kanban_small_column o_opportunity_kanban"
>
<!-- <field name="stage_id" options='{"group_by_tooltip": {"requirements": "Description"}}'/> -->
<field name="stage_id" />
<field name="code" />
<field name="write_date" />
<field name="sequence" invisible="1" />
<field name="partner_id" />
<field name="user_id" />
<field name="recurring_total" />
<field name="currency_id" />
<field name="activity_state" />
<field name="tag_ids" />
<field name="amount_total" />
<field name="activity_ids" />
<field name="image" />
<field name="color" />
<progressbar
field="activity_state"
colors="{&quot;planned&quot;: &quot;success&quot;, &quot;today&quot;: &quot;warning&quot;, &quot;overdue&quot;: &quot;danger&quot;}"
sum_field="recurring_total"
help="This bar allows to filter the opportunities based on scheduled activities."
/>
<templates>
<t t-name="kanban-box">
<div
t-attf-class="
#{!selection_mode ? kanban_color(record.color.raw_value) : ''}
oe_kanban_global_click_edit
oe_semantic_html_override
oe_kanban_card"
>
<div class="o_dropdown_kanban dropdown" modifiers="{}">
<a
role="button"
class="dropdown-toggle o-no-caret btn"
data-toggle="dropdown"
data-display="static"
href="#"
aria-label="Menú desplegable"
title="Menú desplegable"
modifiers="{}"
>
<span class="fa fa-ellipsis-v" modifiers="{}" />
</a>
<div class="dropdown-menu" role="menu" modifiers="{}">
<a
role="menuitem"
class="dropdown-item oe_kanban_action oe_kanban_action_a"
modifiers="{}"
data-type="edit"
href="#"
>
Edit
</a>
<a
role="menuitem"
class="dropdown-item oe_kanban_action oe_kanban_action_a"
modifiers="{}"
data-type="delete"
href="#"
>
Delete
</a>
<ul
class="oe_kanban_colorpicker"
data-field="color"
modifiers="{}"
/>
</div>
</div>
<div class="oe_kanban_content">
<div>
<field name="name" />
<strong>
<div>
<field
name="recurring_total"
widget="monetary"
options="{'currency_field': 'currency_id'}"
/>
</div>
</strong>
</div>
<div>
<field name="tag_ids" widget="many2many_tags" />
</div>
<div class="text-muted o_kanban_record_subtitle">
<t t-if="record.amount_total.raw_value">
<field
name="amount_total"
widget="monetary"
options="{'currency_field': 'company_currency'}"
/>
<span t-if="record.partner_id.value">,</span>
</t>
<span t-if="record.partner_id.value">
<t t-esc="record.partner_id.value" />
</span>
</div>
<div class="o_kanban_record_bottom">
<div class="oe_kanban_bottom_left">
<field
name="activity_ids"
widget="kanban_activity"
/>
</div>
<div class="oe_kanban_bottom_right">
<t t-set="unassigned">
<t t-esc="_t('Unassigned')" />
</t>
<img
t-att-src="kanban_image('res.users', 'image', record.user_id.raw_value)"
t-att-title="record.user_id.value || unassigned"
t-att-alt="record.user_id.value"
class="oe_kanban_avatar"
/>
</div>
</div>
</div>
</div>
</t>
</templates>
</kanban>
</field>
</record>
<record id="view_sale_order_pending_filter" model="ir.ui.view">
<field name="name">sale.order.pending.filter</field>
<field name="model">sale.subscription</field>
<field name="arch" type="xml">
<search>
<field name="to_renew" />
<filter
string="Pending subscriptions"
name="pendingsubs"
domain="[('to_renew','=', True)]"
/>
</search>
</field>
</record>
<record id="view_product_suscribable_filter" model="ir.ui.view">
<field name="name">product.suscribable.filter</field>
<field name="model">product.template</field>
<field name="arch" type="xml">
<search>
<field name="subscribable" />
<filter
string="Suscribable products"
name="subsproducts"
domain="[('subscribable','=', True)]"
/>
</search>
</field>
</record>
<record id="view_subscription_close_reason_tree" model="ir.ui.view">
<field name="name">view.subscription.close.reason.tree</field>
<field name="model">sale.subscription.close.reason</field>
<field name="arch" type="xml">
<tree editable="bottom">
<field name="name" />
</tree>
</field>
</record>
<record id="subscription_close_reason_action" model="ir.actions.act_window">
<field name="name">Close reasons</field>
<field name="res_model">sale.subscription.close.reason</field>
<field name="view_mode">tree</field>
</record>
<record id="sale_subscription_action" model="ir.actions.act_window">
<field name="name">Subscriptions</field>
<field name="res_model">sale.subscription</field>
<field name="view_mode">tree,kanban,form</field>
</record>
<record id="subscription_product_template_action" model="ir.actions.act_window">
<field name="name">Products</field>
<field name="res_model">product.template</field>
<field name="view_mode">tree,form</field>
<field name="context">
{'search_default_subsproducts': True, "default_type": "service", "default_subscribable": True}
</field>
</record>
<menuitem
id="sale_subscription_root"
groups="sales_team.group_sale_salesman_all_leads"
name="Subscriptions"
web_icon="subscription_oca,static/img/icon.png"
sequence="7"
/>
<menuitem
id="sale_subscription_configuration_menu"
parent="sale_subscription_root"
sequence="20"
name="Configuration"
/>
<menuitem
id="subscription_menu"
parent="sale_subscription_root"
name="Subscriptions"
sequence="1"
/>
<menuitem
id="sale_subscription_menu"
parent="subscription_menu"
action="sale_subscription_action"
sequence="1"
/>
<menuitem
id="product_subscription_menu"
parent="subscription_menu"
action="subscription_product_template_action"
sequence="3"
/>
<menuitem
id="subscription_close_reason_menu"
parent="sale_subscription_configuration_menu"
action="subscription_close_reason_action"
sequence="30"
name="Close reasons"
/>
</odoo>

View File

@@ -0,0 +1 @@
from . import close_subscription_wizard

View File

@@ -0,0 +1,25 @@
# Copyright 2023 Domatix - Carlos Martínez
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from odoo import fields, models
class CloseSubscriptionWizard(models.TransientModel):
_name = "close.reason.wizard"
_description = "Close reason wizard"
close_reason_id = fields.Many2one(
comodel_name="sale.subscription.close.reason", string="Reason"
)
def button_confirm(self):
sale_subscription = self.env["sale.subscription"].browse(
self.env.context["active_id"]
)
sale_subscription.close_reason_id = self.close_reason_id.id
stage = sale_subscription.stage_id
closed_stage = self.env["sale.subscription.stage"].search(
[("type", "=", "post")], limit=1
)
if stage != closed_stage:
sale_subscription.stage_id = closed_stage
sale_subscription.active = False

View File

@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="close_reason_wizard_view" model="ir.ui.view">
<field name="name">close.reason.wizard.view</field>
<field name="model">close.reason.wizard</field>
<field name="arch" type="xml">
<form string="Close reason">
<group>
<field name="close_reason_id" />
</group>
<footer>
<button
name="button_confirm"
type="object"
class="oe_highlight"
string="Confirm"
/>
<button special="cancel" string="Cancel" class="oe_link" />
</footer>
</form>
</field>
</record>
<record id="close_reason_wizard_act_window" model="ir.actions.act_window">
<field name="name">Close reason</field>
<field name="res_model">close.reason.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
</odoo>