Skip to content

contract_line_successor: stop() crashes over XML-RPC; gaps in public API for the close+successor+override pattern #1436

Description

@flozano

Module

contract_line_successor, verified against 18.0.1.0.1.9 on the
current 18.0 branch.

Summary

Two related findings when calling the public surface of
contract.line from an external client over XML-RPC / JSON-RPC:

  1. Concrete bug: stop(date_end) crashes with TypeError on a
    plain ISO-string date argument — reproducible deterministically.
  2. API-design observation: the natural "close + successor with
    overrides" flow that the bundled contract_price_revision wizard
    implements has no public-method equivalent — the wizard reaches
    into _prepare_value_for_plan_successor, so external callers that
    want the same pattern have to either inherit the model or replicate
    the helper. A small public-API surface here would let RPC callers
    use the addon without leaning on a leading-underscore method.

1. Reproducing the stop() TypeError

The public model method contract.line.stop(date_end) receives
date_end as an ISO string when called over XML-RPC, and the first
comparison against the field value crashes.

import odoorpc
o = odoorpc.ODOO("localhost", port=8069)
o.login("db", "admin", "admin")

line_id = o.env["contract.line"].search(
    [("is_stop_allowed", "=", True)], limit=1,
)[0]

o.execute_kw(
    "contract.line", "stop",
    [[line_id], "2026-08-31"],
)

Result:

odoorpc.error.RPCError: '<' not supported between instances of
'str' and 'datetime.date'

Originating at the date comparisons in stop():

def stop(self, date_end, manual_renew_needed=False, post_message=True):
    ...
    for rec in self:
        if date_end < rec.date_start:                       # str < date
            rec.cancel()
        else:
            if not rec.date_end or rec.date_end > date_end: # date > str
                ...

Confirmed: the crash happens before any write, so the
contract.line row is left untouched — no partial state on the
RPC error path.

Root cause

XML-RPC / JSON-RPC marshalls Python datetime.date arguments to
ISO strings on the wire. The ORM coerces date values on field
assignment (write() / create()), but it does not coerce
positional method arguments back to date. So the public method
receives str where it expects date, and the first comparison
against a fields.Date value crashes.

The bundled wizards work fine because they construct date objects
in Python and call stop() in the same transaction.

Suggested fix

A single coercion at the entry of stop():

def stop(self, date_end, manual_renew_needed=False, post_message=True):
    date_end = fields.Date.to_date(date_end)
    ...

fields.Date.to_date is a no-op when the argument already is a
date, so in-process callers are unaffected.

2. Related observation: plan_successor() has the same root

cause but is hard to reach via RPC

plan_successor() also takes positional date arguments and calls
get_next_invoice_date internally with them — get_next_invoice_date
does relativedelta arithmetic that would fail the same way.

In practice, plan_successor()'s validator
(is_plan_successor_allowed) gates the path: it requires
not is_auto_renew and date_end >= context["date_start"]. With
typical contract lines (auto-renew=True, open-ended date_end) the
validator raises ValidationError before the date arithmetic runs,
so the TypeError is masked. The bug is still present in the code
path; it's just hard to surface with normal data.

The same single-line coercion at the entry of plan_successor() (or
inside _prepare_value_for_plan_successor's top, behind the gate)
would close it.

3. API observation: the "close + successor + override" pattern

needs a public entry point

The bundled contract_price_revision wizard implements a useful
pattern that external callers reasonably want to invoke:

# contract_price_revision/wizards/contract_price_revision.py
date_end = self._get_old_line_date_end(line)
line.stop(date_end)
new_line = line.copy(self._get_new_line_value(line))
line.update({"successor_contract_line_id": new_line.id})

This composes stop() (public) with _prepare_value_for_plan_successor
(leading-underscore) and copy(vals) (public). The result is "close
the predecessor, create a successor with arbitrary overrides on top
of the OCA-prepared vals, link them bidirectionally".

plan_successor() is the closest public method, but its semantic is
different — it extends a contract line with a new period within or
overlapping
the existing window (validator requires
line.date_end >= new.date_start). The wizard's close-then-restart
case doesn't fit that semantic, which is why the wizard bypasses
plan_successor() and uses the leading-underscore helper directly.

That leaves external callers (who can't inherit the model) without a
public way to invoke this flow. They have to either:

  • Build the close+create+link sequence themselves, losing OCA's
    full field-copy via _convert_to_write(self.read()[0]),
    get_next_invoice_date for recurring_next_date, the
    predecessor link, the chatter audit message, and the
    is_plan_successor_allowed / is_stop_allowed validators; or
  • Wrap the model in a local _inherit addon (which is what we
    ended up doing) to call the leading-underscore helpers in-process.

Suggestion (open to direction)

Either of these would help external callers:

  • (a) Promote _prepare_value_for_plan_successor to a documented
    extension point.
    Add the same fields.Date.to_date coercion at
    the top, and note in its docstring that it's the supported way for
    addons (or, via RPC, for thin wrappers) to compute successor vals.
  • (b) Add a public wrapper method, e.g.
    succeed_line(date_start, date_end, is_auto_renew, overrides=None, post_message=True) that does `stop() + _prepare_value_for_plan_successor
    • .copy(overrides on top) + bidirectional linkin one call, with ISO-string coercion at the entry. Thecontract_price_revision`
      wizard could then call this instead of duplicating the sequence.

Happy to submit a PR for any of the above (the stop() coercion is
trivially low-risk; the public-wrapper option is more design-touch
and benefits from a maintainer steer). Just wanted to surface the
findings first.

Thank you for the addon — it's the right shape for what we needed,
and the only friction was on the RPC entry path.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions