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

=======================
Subscription management
=======================
Expand All @@ -17,7 +13,7 @@ Subscription management
.. |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
.. |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
Expand Down Expand Up @@ -71,6 +67,134 @@ To create subscriptions with the sale of a product:
*Subscribable product* and *Subscription template*
3. Create a sales order with the product and confirm it.

Automatic payment
-----------------

Subscriptions can charge a customer's saved payment method (a *payment
token*) automatically on each billing run, so no manual collection step
is needed. This is intended for recurring merchant-initiated charges
(for example SEPA direct debit or a stored card via a tokenizing payment
provider).

The defining principle is **charge before posting**: the invoice is kept
in *draft* and is only posted (and reconciled) once the payment
succeeds. A failed charge therefore never leaves a posted invoice owed
by the customer and never consumes an invoice number.

Enabling it on a template
~~~~~~~~~~~~~~~~~~~~~~~~~

1. Go to *Subscriptions > Configuration > Subscription templates* and
open or create a template.
2. Pick an *Invoicing mode* (see the table below for how each one
behaves once automatic payment is on).
3. Tick *Automatic payment*.

*Automatic payment* is orthogonal to the invoicing mode and works with
**all** of them, including *Draft*.

Assigning the payment token
~~~~~~~~~~~~~~~~~~~~~~~~~~~

On a subscription whose template has *Automatic payment* enabled, a
*Payment Token* field appears. It can be set in three ways:

- **Manually** - pick any saved token belonging to the customer.
- **Suggested automatically** - when you select the partner, the most
recent token saved for that partner (in the subscription's company) is
proposed. A token you set manually is never silently overwritten.
- **Carried over from a sale** - see *Onboarding from eCommerce* below.

A token belonging to a different commercial partner cannot be assigned;
this is enforced by a constraint.

What happens on each billing run
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

When the *Subscriptions Management* cron job (or a manual run) generates
an invoice for a subscription with *Automatic payment*:

1. A **draft** invoice is created (or a draft left over from a previous
failed attempt is reused, so retries never pile up duplicates).
2. An offline payment transaction is created against the saved token and
submitted to the provider.
3. **On success** the invoice is posted, reconciled with the payment,
and - depending on the invoicing mode - emailed to the customer as a
paid document. The customer never receives an "amount due" document
for money already taken.
4. **On asynchronous capture** (e.g. direct debit) the transaction is
left *pending* and the invoice stays draft; it is posted later when
the provider confirms the charge via webhook. The subscription keeps
billing normally.
5. **On failure** the invoice stays draft, the subscription is flagged
(see *Payment failures*) and the next invoice date is **not**
advanced, so the same period is retried once the issue is fixed.

Invoicing mode behaviour with automatic payment
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

+------------------------+---------------------------------------------+
| Invoicing mode | On a successful charge |
+========================+=============================================+
| *Draft* | Invoice posted, **no email** (silent |
| | background billing) |
+------------------------+---------------------------------------------+
| *Invoice* | Invoice posted, paid invoice emailed |
+------------------------+---------------------------------------------+
| *Invoice & send* | Invoice posted, paid invoice emailed |
+------------------------+---------------------------------------------+
| *Sale order & Invoice* | Sale order confirmed, invoice posted (no |
| | email) |
+------------------------+---------------------------------------------+

Use cases
~~~~~~~~~

- **Stored-card billing (synchronous)** - the charge is captured
immediately; the invoice is posted, reconciled and emailed in the same
run.
- **Direct debit / asynchronous capture** - the charge is *submitted*
and the provider confirms it later via webhook; the invoice is posted
on confirmation.
- **Silent background billing** - use *Draft* mode with *Automatic
payment* to collect and post without ever emailing the customer.
- **Onboarding from a webshop sale** - see below.

Payment failures
~~~~~~~~~~~~~~~~

If a charge cannot be collected (no token, a misconfigured provider, or
the provider rejects it outright) the subscription is:

- flagged with *Payment Exception*,
- given a **to-do activity** (visible in the list and kanban views) so a
salesperson is alerted, and
- left with its draft invoice and unchanged next-invoice date.

While the flag is set, the cron job **skips** the subscription, so a
broken payment method does not generate repeated invoices or charges.
Once the payment method has been fixed, clear *Payment Exception* (the
activity is resolved automatically on the next successful charge) and
the subscription resumes. Integrations that manage their own retries
(for example a direct-debit provider) can set or clear this flag through
the same field.

Onboarding from eCommerce
~~~~~~~~~~~~~~~~~~~~~~~~~

A customer's first token is typically captured when they buy a
subscription product online and pay with a tokenizing provider. When the
sale order is confirmed:

1. A subscription is created from the order's subscribable products (via
their *Subscription template*), as usual.
2. If that template has *Automatic payment* enabled, the token saved
during the order's online payment is copied onto the new subscription
automatically.

From the next billing cycle onward the subscription charges that token
without any manual setup.

Known issues / Roadmap
======================

Expand All @@ -96,6 +220,7 @@ Authors

* Domatix
* Onestein
* Open User Systems

Contributors
------------
Expand All @@ -113,6 +238,9 @@ Contributors
- Alberto Martínez

- Dennis Sluijk <d.sluijk@onestein.nl>
- `Open User Systems <https://www.openusersystems.com>`__:

- Chris Mann <chrisandrewmann@gmail.com>

Maintainers
-----------
Expand Down
9 changes: 5 additions & 4 deletions subscription_oca/__manifest__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@
{
"name": "Subscription management",
"summary": "Generate recurring invoices.",
"version": "18.0.1.0.0",
"version": "18.0.2.0.0",
"development_status": "Beta",
"category": "Subscription Management",
"website": "https://github.com/OCA/contract",
"license": "AGPL-3",
"author": "Domatix, Onestein, Odoo Community Association (OCA)",
"depends": ["sale_management", "account"],
"author": "Domatix, Onestein, Open User Systems, "
"Odoo Community Association (OCA)",
"maintainers": [],
"depends": ["sale_management", "account", "account_payment"],
"data": [
"views/product_template_views.xml",
"views/account_move_views.xml",
Expand All @@ -25,5 +27,4 @@
"security/ir.model.access.csv",
],
"installable": True,
"application": True,
}
1 change: 1 addition & 0 deletions subscription_oca/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from . import account_move
from . import payment_transaction
from . import product_template
from . import res_partner
from . import sale_order
Expand Down
80 changes: 80 additions & 0 deletions subscription_oca/models/payment_transaction.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# Copyright 2026 Open User Systems
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from markupsafe import Markup

from odoo import models


class PaymentTransaction(models.Model):
_inherit = "payment.transaction"

def _post_process(self):
res = super()._post_process()
self._subscription_oca_log_confirmation()
return res

def _set_canceled(self, state_message=None, extra_allowed_states=()):
res = super()._set_canceled(
state_message=state_message, extra_allowed_states=extra_allowed_states
)
self._subscription_oca_flag_cancellation()
return res

def _subscription_oca_log_confirmation(self):
"""Post a closing chatter note on the subscription once an automatic
payment is captured and its invoice is posted, with the real invoice
number. Fires for both the synchronous ``done`` result and the
asynchronous webhook confirmation, so a pending charge always gets a
follow-up instead of a perpetual "awaiting confirmation".
"""
for transaction in self.filtered(
lambda t: t.state == "done" and t.operation == "offline"
):
posted = transaction.invoice_ids.filtered(
lambda m: m.state == "posted" and m.name
)
for invoice in posted:
for subscription in invoice.subscription_id.filtered(
"auto_create_payment"
):
label = subscription.env._(
"Automatic payment confirmed for invoice"
)
already = subscription.message_ids.filtered(
lambda m, lbl=label, name=invoice.name: lbl in (m.body or "")
and name in (m.body or "")
)
if already:
continue
subscription.sudo().message_post(
body=Markup(subscription._invoice_chatter_link(label, invoice))
)

def _subscription_oca_flag_cancellation(self):
"""Surface a cancelled/reversed automatic payment on its subscription.

When an offline subscription charge is cancelled later (a permanent
direct-debit failure, or a chargeback that the payment framework
unreconciles), nothing otherwise tells the subscription: it keeps
looking paid and the scheduler keeps billing. Flag the subscription
(exception + to-do activity + chatter) so the event is not missed.

Only the subscription's own status is touched: the invoice is left to
the payment framework (a draft stays draft; a posted invoice is
unreconciled when its payment is cancelled), the stage is left alone,
and the next-invoice date is not reset (a posted invoice already stands
as the receivable for that period).
"""
for transaction in self.filtered(lambda t: t.operation != "refund"):
subscriptions = transaction.invoice_ids.subscription_id.filtered(
"auto_create_payment"
)
for subscription in subscriptions:
subscription.sudo()._register_payment_failure(
self.env._(
"The automatic payment (%s) was cancelled or reversed. "
"Any posted invoice for this period is no longer paid "
"and may need to be followed up or refunded."
)
% transaction.reference
)
18 changes: 18 additions & 0 deletions subscription_oca/models/sale_order.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,24 @@ def create_subscription(self, lines, subscription_tmpl):
subscription_tmpl.recurring_rule_type,
subscription_tmpl.recurring_interval,
)
if subscription_tmpl.auto_create_payment:
self._assign_subscription_payment_token(rec)
return rec
return self.env["sale.subscription"]

def _assign_subscription_payment_token(self, subscription):
"""Carry the token saved during the order's online payment over to the
subscription, so recurring charges work without manual setup."""
self.ensure_one()
token = (
self.transaction_ids.filtered(
lambda tx: tx.state in ("done", "authorized") and tx.token_id
)
.sorted("last_state_change", reverse=True)[:1]
.token_id
)
if token:
subscription.payment_token_id = token

def group_subscription_lines(self):
"""
Expand Down
Loading
Loading