Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 112 additions & 0 deletions contract_invoice_offset/README.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
=======================
Contract Invoice Offset
=======================

..
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! source digest: sha256:cc0cdda8117975e2abacbfce602f28372973e27343551ddfcdb69fb65024eb5d
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!

.. |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/18.0/contract_invoice_offset
:alt: OCA/contract
.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png
:target: https://translation.odoo-community.org/projects/contract-18-0/contract-18-0-contract_invoice_offset
: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=18.0
:alt: Try me on Runboat

|badge1| |badge2| |badge3| |badge4| |badge5|

This module extends the **Contract** module to provide flexible
invoicing offsets. It allows you to define offsets in Days, Weeks,
Months, or Years, enabling scenarios such as **Pre-paid + 1 month in
advance** (e.g. Invoicing January's service in December).

**Table of contents**

.. contents::
:local:

Use Cases / Context
===================

In the standard behavior of the Contract module, invoicing offsets are
restricted to a fixed number of days. While this works for many cases,
it is difficult to configure reliable "in advance" invoicing for
intervals like months, given the varying number of days in each month.

In some business scenarios, a contract must be invoiced exactly one
month in advance (e.g., invoicing January service in December) or with a
specific delay in weeks or years.

This module introduces flexible invoicing offsets, allowing users to
define offsets in Days, Weeks, Months, or Years, ensuring the invoice
date is logically consistent with the billing period regardless of
calendar variations.

Usage
=====

To use this module:

1. Go to **Accounting > Customers > Contracts**.
2. Select or create a contract.
3. In the **Invoicing** section:

- Set **Invoicing type** (e.g., Pre-paid).
- Set **Invoicing Offset Type** (e.g., Months).
- Set **Invoicing Offset Value** (e.g., -1 for 1 month in advance).

4. The **Date of Next Invoice** will be calculated based on your
configuration.

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:%20contract_invoice_offset%0Aversion:%2018.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
-------

* bosd

Contributors
------------

- bosd

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/18.0/contract_invoice_offset>`_ project on GitHub.

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.
1 change: 1 addition & 0 deletions contract_invoice_offset/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import models
19 changes: 19 additions & 0 deletions contract_invoice_offset/__manifest__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"name": "Contract Invoice Offset",
"summary": "Flexible invoicing offsets (e.g. 1 month in advance)",
"version": "19.0.1.0.0",
"category": "Accounting",
"website": "https://github.com/OCA/contract",
"author": "bosd, Odoo Community Association (OCA)",
"license": "AGPL-3",
"depends": [
"contract",
],
"data": [
"views/contract_view.xml",
],
"demo": [
"demo/contract_demo.xml",
],
"installable": True,
}
52 changes: 52 additions & 0 deletions contract_invoice_offset/demo/contract_demo.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="contract_demo_offset_advance" model="contract.contract">
<field name="name">Demo Contract: 1 Month Advance</field>
<field name="partner_id" ref="base.res_partner_2" />
<field name="recurring_interval">1</field>
<field name="recurring_rule_type">monthly</field>
<field name="date_start" eval="time.strftime('%Y-%m-01')" />
<field name="recurring_invoicing_type">pre-paid</field>
<field name="invoicing_offset_type">months</field>
<field name="invoicing_offset_value">-1</field>
<field
name="contract_line_ids"
eval="[
(0, 0, {
'name': 'Monthly Service (Advance)',
'product_id': ref('product.product_product_4'),
'quantity': 1,
'price_unit': 100.0,
'recurring_interval': 1,
'recurring_rule_type': 'monthly',
'date_start': time.strftime('%Y-%m-01'),
})
]"
/>
</record>

<record id="contract_demo_offset_delay" model="contract.contract">
<field name="name">Demo Contract: 2 Weeks Delay</field>
<field name="partner_id" ref="base.res_partner_3" />
<field name="recurring_interval">1</field>
<field name="recurring_rule_type">monthly</field>
<field name="date_start" eval="time.strftime('%Y-%m-01')" />
<field name="recurring_invoicing_type">post-paid</field>
<field name="invoicing_offset_type">weeks</field>
<field name="invoicing_offset_value">2</field>
<field
name="contract_line_ids"
eval="[
(0, 0, {
'name': 'Monthly Service (Delayed)',
'product_id': ref('product.product_product_5'),
'quantity': 1,
'price_unit': 150.0,
'recurring_interval': 1,
'recurring_rule_type': 'monthly',
'date_start': time.strftime('%Y-%m-01'),
})
]"
/>
</record>
</odoo>
3 changes: 3 additions & 0 deletions contract_invoice_offset/models/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from . import contract_recurring_mixin
from . import contract
from . import contract_line
87 changes: 87 additions & 0 deletions contract_invoice_offset/models/contract.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# Copyright 2025 bosd
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).

from odoo import api, models


class ContractContract(models.Model):
_inherit = "contract.contract"

@api.depends(
"next_period_date_start",
"recurring_invoicing_type",
"recurring_invoicing_offset",
"recurring_rule_type",
"recurring_interval",
"date_end",
"contract_line_ids.recurring_next_date",
"contract_line_ids.is_canceled",
"invoicing_offset_type",
"invoicing_offset_value",
)
def _compute_recurring_next_date(self):
for contract in self:
recurring_next_date = contract.contract_line_ids.filtered(
lambda line: (
line.recurring_next_date
and not line.is_canceled
and (not line.display_type or line.is_recurring_note)
)
).mapped("recurring_next_date")
# we give priority to computation from date_start if modified
if (
contract._origin
and contract._origin.date_start != contract.date_start
or not recurring_next_date
):
kwargs = {
"invoicing_offset_type": contract.invoicing_offset_type,
"invoicing_offset_value": contract.invoicing_offset_value,
}
if hasattr(contract, "align_billing_cycle"):
kwargs["align_billing_cycle"] = contract.align_billing_cycle

contract.recurring_next_date = self.get_next_invoice_date(
contract.next_period_date_start,
contract.recurring_invoicing_type,
contract.recurring_invoicing_offset,
contract.recurring_rule_type,
contract.recurring_interval,
max_date_end=contract.date_end,
**kwargs,
)
else:
contract.recurring_next_date = min(recurring_next_date)

@api.depends(
"next_period_date_start",
"recurring_invoicing_type",
"recurring_invoicing_offset",
"recurring_rule_type",
"recurring_interval",
"date_end",
"recurring_next_date",
"invoicing_offset_type",
"invoicing_offset_value",
)
def _compute_next_period_date_end(self):
"""Compute the end date of the next billing period."""
# Override to pass offset settings
for rec in self:
kwargs = {
"invoicing_offset_type": rec.invoicing_offset_type,
"invoicing_offset_value": rec.invoicing_offset_value,
}
if hasattr(rec, "align_billing_cycle"):
kwargs["align_billing_cycle"] = rec.align_billing_cycle

rec.next_period_date_end = self.get_next_period_date_end(
rec.next_period_date_start,
rec.recurring_rule_type,
rec.recurring_interval,
max_date_end=rec.date_end,
next_invoice_date=rec.recurring_next_date,
recurring_invoicing_type=rec.recurring_invoicing_type,
recurring_invoicing_offset=rec.recurring_invoicing_offset,
**kwargs,
)
113 changes: 113 additions & 0 deletions contract_invoice_offset/models/contract_line.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
# Copyright 2025 bosd
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).

from dateutil.relativedelta import relativedelta

from odoo import api, models


class ContractLine(models.Model):
_inherit = "contract.line"

@api.depends(
"next_period_date_start",
"recurring_invoicing_type",
"recurring_invoicing_offset",
"recurring_rule_type",
"recurring_interval",
"date_end",
"contract_id.invoicing_offset_type",
"contract_id.invoicing_offset_value",
"recurring_next_date",
)
def _compute_next_period_date_end(self):
for rec in self:
kwargs = {
"invoicing_offset_type": rec.contract_id.invoicing_offset_type,
"invoicing_offset_value": rec.contract_id.invoicing_offset_value,
}
if hasattr(rec.contract_id, "align_billing_cycle"):
kwargs["align_billing_cycle"] = rec.contract_id.align_billing_cycle

rec.next_period_date_end = self.get_next_period_date_end(
rec.next_period_date_start,
rec.recurring_rule_type,
rec.recurring_interval,
max_date_end=rec.date_end,
next_invoice_date=rec.recurring_next_date,
recurring_invoicing_type=rec.recurring_invoicing_type,
recurring_invoicing_offset=rec.recurring_invoicing_offset,
**kwargs,
)

@api.depends(
"next_period_date_start",
"recurring_invoicing_type",
"recurring_invoicing_offset",
"recurring_rule_type",
"recurring_interval",
"date_end",
"contract_id.invoicing_offset_type",
"contract_id.invoicing_offset_value",
)
def _compute_recurring_next_date(self):
# Override to look at contract_id for offset settings,
# since lines don't (typically) have these fields themselves set independently.
for rec in self:
kwargs = {
"invoicing_offset_type": rec.contract_id.invoicing_offset_type,
"invoicing_offset_value": rec.contract_id.invoicing_offset_value,
}
if hasattr(rec.contract_id, "align_billing_cycle"):
kwargs["align_billing_cycle"] = rec.contract_id.align_billing_cycle

rec.recurring_next_date = self.get_next_invoice_date(
rec.next_period_date_start,
rec.recurring_invoicing_type,
rec.recurring_invoicing_offset,
rec.recurring_rule_type,
rec.recurring_interval,
max_date_end=rec.date_end,
**kwargs,
)

@api.constrains("recurring_next_date", "date_start")
def _check_recurring_next_date_start_date(self):
# Filter out lines that have a negative offset (advance payment)
lines_to_check = self.filtered(
lambda line: line.contract_id.invoicing_offset_value >= 0
)

if lines_to_check:
super(ContractLine, lines_to_check)._check_recurring_next_date_start_date()
return

def _get_period_to_invoice(
self, last_date_invoiced, recurring_next_date, stop_at_date_end=True
):
self.ensure_one()
if not recurring_next_date:
return False, False, False
first_date_invoiced = (
last_date_invoiced + relativedelta(days=1)
if last_date_invoiced
else self.date_start
)
kwargs = {
"invoicing_offset_type": self.contract_id.invoicing_offset_type,
"invoicing_offset_value": self.contract_id.invoicing_offset_value,
}
if hasattr(self.contract_id, "align_billing_cycle"):
kwargs["align_billing_cycle"] = self.contract_id.align_billing_cycle

last_date_invoiced = self.get_next_period_date_end(
first_date_invoiced,
self.recurring_rule_type,
self.recurring_interval,
max_date_end=(self.date_end if stop_at_date_end else False),
next_invoice_date=recurring_next_date,
recurring_invoicing_type=self.recurring_invoicing_type,
recurring_invoicing_offset=self.recurring_invoicing_offset,
**kwargs,
)
return first_date_invoiced, last_date_invoiced, recurring_next_date
Loading
Loading