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
127 changes: 127 additions & 0 deletions contract_invoice_align_start/README.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
.. image:: https://odoo-community.org/readme-banner-image
:target: https://odoo-community.org/get-involved?utm_source=readme
:alt: Odoo Community Association

============================
Contract Invoice Align Start
============================

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

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

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

Contract Invoice Align Start billing cycle
==========================================

This module extends the **Contract** module to verify alignment of the
start date to the beginning of the configured period. It creates a
specific first period (dummy period) to align the next periods to the
first day of the recurring interval. For example, for a monthly
recurrence starting on January 15th, the first invoice will be for
January 15th-31st, and subsequent invoices will cover the full month
(February 1st-28th, etc.). Proration is automatically applied to the
first partial period.

**Table of contents**

.. contents::
:local:

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

This module aligns the contract invoicing cycle to the beginning of the
period (usually the month).

**Standard Behavior (Without this module):** If a contract starts on
January 15th with a monthly recurrence, the billing periods are:

- January 15th to February 14th
- February 15th to March 14th
- etc.

**Aligned Behavior (With this module):** If "Align Billing Cycle to
First Day of Month" is enabled:

- **First Period:** January 15th to January 31st (Prorated)
- **Second Period:** February 1st to February 28th
- **Subsequent Periods:** Full months starting from the 1st.

This ensures that invoices are generated for standard calendar months,
which is often preferred for accounting and subscription management.

Usage
=====

To use this module:

1. Go to **Accounting > Customers > Contracts**.
2. Create or select a contract.
3. In the **Invoicing** section, check **Align Billing Cycle to First
Day of Month**.
4. Set the **Start Date** (e.g., mid-month).
5. The first invoice generated will cover the partial period until the
end of that month, with the quantity prorated accordingly.
6. Subsequent invoices will cover full calendar months.

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_align_start%0Aversion:%2019.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/19.0/contract_invoice_align_start>`_ 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_align_start/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import models
16 changes: 16 additions & 0 deletions contract_invoice_align_start/__manifest__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"name": "Contract Invoice Align Start",
"summary": "Align contract invoicing to the beginning of the month",
"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",
],
"installable": True,
}
3 changes: 3 additions & 0 deletions contract_invoice_align_start/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
68 changes: 68 additions & 0 deletions contract_invoice_align_start/models/contract.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# 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",
"recurring_next_date",
"align_billing_cycle",
)
def _compute_next_period_date_end(self):
for rec in self:
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,
align_billing_cycle=rec.align_billing_cycle,
)

@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",
"align_billing_cycle",
)
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")
if (
contract._origin
and contract._origin.date_start != contract.date_start
or not recurring_next_date
):
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,
align_billing_cycle=contract.align_billing_cycle,
)
else:
contract.recurring_next_date = min(recurring_next_date)
109 changes: 109 additions & 0 deletions contract_invoice_align_start/models/contract_line.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# Copyright 2025 bosd
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).

from calendar import monthrange

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.align_billing_cycle",
"recurring_next_date",
)
def _compute_next_period_date_end(self):
for rec in self:
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,
align_billing_cycle=rec.contract_id.align_billing_cycle,
)

@api.depends(
"next_period_date_start",
"recurring_invoicing_type",
"recurring_invoicing_offset",
"recurring_rule_type",
"recurring_interval",
"date_end",
"contract_id.align_billing_cycle",
)
def _compute_recurring_next_date(self):
for rec in self:
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,
align_billing_cycle=rec.contract_id.align_billing_cycle,
)

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
# Calculate First Date Invoiced
first_date_invoiced = (
last_date_invoiced + relativedelta(days=1)
if last_date_invoiced
else self.date_start
)

# Calculate Last Date Invoiced (Period End)
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,
align_billing_cycle=self.contract_id.align_billing_cycle,
)
return first_date_invoiced, last_date_invoiced, recurring_next_date

def _prepare_invoice_line(self):
"""Override to implement proration if alignment is active."""
self.ensure_one()
res = super()._prepare_invoice_line()

# Check if proration is needed
if (
self.contract_id.align_billing_cycle
and self.recurring_rule_type == "monthly"
):
period_start = self.next_period_date_start
period_end = self.next_period_date_end

if period_start and period_end:
_, days_in_month = monthrange(period_start.year, period_start.month)
actual_days = (period_end - period_start).days + 1

if actual_days < days_in_month:
ratio = actual_days / days_in_month
current_qty = res.get("quantity", self.quantity)
new_qty = current_qty * ratio
description = res.get("name", "")
description += f" (Prorated: {actual_days}/{days_in_month} days)"
res.update({"quantity": new_qty, "name": description})

return res
Loading
Loading