From a02fb6a9df1ede6f657e3b29fa749c2addcf9bce Mon Sep 17 00:00:00 2001 From: Chris Mann Date: Mon, 15 Jun 2026 16:34:33 +0100 Subject: [PATCH] [IMP] subscription_oca: add automatic payment --- subscription_oca/README.rst | 138 ++++- subscription_oca/__manifest__.py | 9 +- subscription_oca/models/__init__.py | 1 + .../models/payment_transaction.py | 80 +++ subscription_oca/models/sale_order.py | 18 + subscription_oca/models/sale_subscription.py | 304 ++++++++-- .../models/sale_subscription_line.py | 1 + .../models/sale_subscription_template.py | 7 + subscription_oca/readme/CONTRIBUTORS.md | 2 + subscription_oca/readme/USAGE.md | 113 ++++ .../static/description/index.html | 196 ++++++- .../tests/test_subscription_oca.py | 538 +++++++++++++++++- .../sale_subscription_template_views.xml | 1 + .../views/sale_subscription_views.xml | 10 + 14 files changed, 1342 insertions(+), 76 deletions(-) create mode 100644 subscription_oca/models/payment_transaction.py diff --git a/subscription_oca/README.rst b/subscription_oca/README.rst index 57a6d31363..94da811921 100644 --- a/subscription_oca/README.rst +++ b/subscription_oca/README.rst @@ -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 ======================= @@ -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 @@ -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 ====================== @@ -96,6 +220,7 @@ Authors * Domatix * Onestein +* Open User Systems Contributors ------------ @@ -113,6 +238,9 @@ Contributors - Alberto Martínez - Dennis Sluijk +- `Open User Systems `__: + + - Chris Mann Maintainers ----------- diff --git a/subscription_oca/__manifest__.py b/subscription_oca/__manifest__.py index 583ac368f9..52a7e2ee95 100644 --- a/subscription_oca/__manifest__.py +++ b/subscription_oca/__manifest__.py @@ -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", @@ -25,5 +27,4 @@ "security/ir.model.access.csv", ], "installable": True, - "application": True, } diff --git a/subscription_oca/models/__init__.py b/subscription_oca/models/__init__.py index 6fa4481885..ebba7691cd 100644 --- a/subscription_oca/models/__init__.py +++ b/subscription_oca/models/__init__.py @@ -1,4 +1,5 @@ from . import account_move +from . import payment_transaction from . import product_template from . import res_partner from . import sale_order diff --git a/subscription_oca/models/payment_transaction.py b/subscription_oca/models/payment_transaction.py new file mode 100644 index 0000000000..4e28cb1a83 --- /dev/null +++ b/subscription_oca/models/payment_transaction.py @@ -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 + ) diff --git a/subscription_oca/models/sale_order.py b/subscription_oca/models/sale_order.py index 43d4f9bbea..a8931d6fd0 100644 --- a/subscription_oca/models/sale_order.py +++ b/subscription_oca/models/sale_order.py @@ -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): """ diff --git a/subscription_oca/models/sale_subscription.py b/subscription_oca/models/sale_subscription.py index 114db35968..8d9dc7181d 100644 --- a/subscription_oca/models/sale_subscription.py +++ b/subscription_oca/models/sale_subscription.py @@ -7,7 +7,7 @@ from markupsafe import Markup from odoo import Command, api, fields, models -from odoo.exceptions import AccessError +from odoo.exceptions import AccessError, ValidationError logger = logging.getLogger(__name__) @@ -57,13 +57,19 @@ class SaleSubscription(models.Model): pricelist_id = fields.Many2one( comodel_name="product.pricelist", required=True, string="Pricelist" ) - recurring_next_date = fields.Date(string="Next invoice date", default=date.today()) + recurring_next_date = fields.Date( + string="Next invoice date", + default=lambda self: fields.Date.context_today(self), + ) 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_start = fields.Date( + string="Start date", + default=lambda self: fields.Date.context_today(self), + ) date = fields.Date( string="Finish date", compute="_compute_rule_boundary", @@ -104,6 +110,65 @@ class SaleSubscription(models.Model): store=True, ondelete="restrict", ) + invoicing_mode = fields.Selection(related="template_id.invoicing_mode") + auto_create_payment = fields.Boolean(related="template_id.auto_create_payment") + payment_token_id = fields.Many2one( + comodel_name="payment.token", + string="Payment Token", + domain="[('partner_id', '=', partner_id)]", + help="Saved payment method used to charge this subscription " + "automatically when automatic payment is enabled.", + ) + payment_exception = fields.Boolean( + copy=False, + help="Set when an automatic payment fails. The scheduled job skips " + "subscriptions in this state until it is cleared, once the payment " + "method has been fixed.", + tracking=True, + ) + + @api.onchange("partner_id") + def _onchange_partner_id_payment_token(self): + """Suggest the partner's most recent token without overriding a manual + choice. Only relevant when the template enables automatic payment.""" + for record in self: + if not record.auto_create_payment: + continue + if ( + record.payment_token_id + and record.payment_token_id.partner_id.commercial_partner_id + == record.partner_id.commercial_partner_id + ): + continue + record.payment_token_id = ( + record.env["payment.token"] + .sudo() + .search( + [ + ("partner_id", "=", record.partner_id.id), + ("company_id", "=", record.company_id.id), + ], + limit=1, + order="write_date desc", + ) + ) + + @api.constrains("payment_token_id", "partner_id") + def _check_payment_token_partner(self): + for record in self: + if not record.payment_token_id: + continue + if ( + record.payment_token_id.partner_id.commercial_partner_id + != record.partner_id.commercial_partner_id + ): + raise ValidationError( + self.env._( + "Payment token '%s' belongs to a different partner " + "and cannot be used for this subscription." + ) + % record.payment_token_id.display_name + ) @api.model def _read_group_stage_ids(self, stages, domain): @@ -145,9 +210,15 @@ def cron_subscription_management(self): if ( subscription.recurring_next_date <= today and subscription.sale_subscription_line_ids + and not subscription.payment_exception ): try: - subscription.generate_invoice() + # Isolate each subscription so a failure (e.g. a + # rejected charge) rolls back only its own changes and + # never leaves a half-processed invoice behind for the + # rest of the batch. + with self.env.cr.savepoint(): + subscription.generate_invoice() except Exception: logger.exception("Error on subscription invoice generate") if ( @@ -326,43 +397,105 @@ def create_sale_order(self): self.write({"sale_order_ids": [Command.link(order_id.id)]}) return order_id + def _invoice_chatter_link(self, msg_static, invoice): + return ( + f"{msg_static} " + f"" + f"{invoice.display_name}" + ) + + def _send_invoice_email(self, invoice): + mail_template = self.template_id.invoice_mail_template_id + self.env["account.move.send"]._generate_and_send_invoices( + invoice, mail_template=mail_template, sending_methods=["email"] + ) + + def _get_or_create_draft_invoice(self): + """Reuse a draft invoice left over from a previous failed automatic + payment (so retries don't pile up duplicates), but never reuse one that + already has an in-flight or successful transaction.""" + self.ensure_one() + for invoice in self.invoice_ids.filtered( + lambda m: m.state == "draft" and m.move_type == "out_invoice" + ): + if not invoice.transaction_ids.filtered( + lambda t: t.state in ("pending", "authorized", "done") + ): + return invoice + return self.create_invoice() + def generate_invoice(self): invoice_number = "" message_body = "" msg_static = self.env._("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() - mail_template = self.template_id.invoice_mail_template_id - self.env["account.move.send"]._generate_and_send_invoices( - invoice, mail_template=mail_template, sending_methods=["email"] - ) - invoice_number = invoice.name - message_body = ( - f"{msg_static} " - f"" - f"{invoice_number}" - "" - ) - - if self.template_id.invoicing_mode == "sale_and_invoice": + mode = self.template_id.invoicing_mode + auto_pay = self.template_id.auto_create_payment + payment_failed = False + auto_pay_invoice = self.env["account.move"] + + if mode in ["draft", "invoice", "invoice_send"]: + if auto_pay: + # Charge before posting: the invoice is kept in draft and the + # payment flow posts and reconciles it only on success, so a + # failed charge never leaves a posted invoice owed or burns an + # invoice number. + invoice = self._get_or_create_draft_invoice() + payment_failed = not self.create_payment(invoice) + auto_pay_invoice = invoice + if ( + invoice.state == "posted" + and mode in ("invoice", "invoice_send") + and invoice.payment_state in ("in_payment", "paid") + ): + self._send_invoice_email(invoice) + else: + invoice = self.create_invoice() + if mode != "draft": + invoice.action_post() + self._send_invoice_email(invoice) + invoice_number = invoice.name + message_body = self._invoice_chatter_link(msg_static, invoice) + + if mode == "sale_and_invoice": order_id = self.create_sale_order() order_id.action_confirm() order_id.action_lock() 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 = ( - "%s %s" - % (msg_static, new_invoice.id, invoice_number) - ) - if not invoice_number: - invoice_number = self.env._("To validate") - message_body = f"{msg_static} {invoice_number}" - self.calculate_recurring_next_date(self.recurring_next_date) - self.message_post(body=Markup(message_body)) + if auto_pay: + payment_failed = not self.create_payment(new_invoice) + auto_pay_invoice = new_invoice + else: + new_invoice.action_post() + if new_invoice.state == "posted": + invoice_number = new_invoice.name + message_body = self._invoice_chatter_link(msg_static, new_invoice) + + if auto_pay: + # Automatic payment posts its own chatter, so skip the generic + # "Created invoice" / "To validate" note: + # - a single "submitted; awaiting confirmation" note while the + # charge is still pending (the invoice stays draft), + # - a "confirmed" note with the real invoice number once the + # payment is captured (posted by the payment.transaction + # post-process hook), and + # - a failure note via _register_payment_failure. + if not payment_failed and auto_pay_invoice.state != "posted": + self.message_post( + body=self.env._( + "Automatic payment submitted; awaiting confirmation." + ) + ) + else: + if not invoice_number: + invoice_number = self.env._("To validate") + message_body = f"{msg_static} {invoice_number}" + self.message_post(body=Markup(message_body)) + + # Keep the schedule on the failed period so the next run (once the + # payment method is fixed) retries it instead of skipping ahead. + if not payment_failed: + self.calculate_recurring_next_date(self.recurring_next_date) def manual_invoice(self): invoice_id = self.create_invoice() @@ -487,3 +620,110 @@ def create(self, vals_list): .id ) return super().create(vals_list) + + def _payment_failure_activity_summary(self): + return self.env._("Subscription automatic payment failed") + + def _register_payment_failure(self, message): + """Flag the subscription, log a chatter note and schedule a to-do + activity (visible in list and kanban) so the failure is surfaced and + the scheduler stops retrying until it is resolved.""" + self.ensure_one() + self.payment_exception = True + self.message_post(body=message) + summary = self._payment_failure_activity_summary() + already_open = self.activity_ids.filtered(lambda a: a.summary == summary) + if not already_open: + self.activity_schedule( + "mail.mail_activity_data_todo", + summary=summary, + note=message, + user_id=self.user_id.id or self.env.uid, + ) + + def _clear_payment_failure(self): + """Clear the exception flag and resolve any open payment-failure + activity once a charge succeeds or is accepted.""" + self.ensure_one() + self.payment_exception = False + summary = self._payment_failure_activity_summary() + self.activity_ids.filtered(lambda a: a.summary == summary).unlink() + + def create_payment(self, invoice): + """Charge ``invoice`` against the subscription's saved token using an + offline (merchant-initiated) payment transaction. + + :return: ``True`` if the charge was captured or accepted for + asynchronous capture (e.g. SEPA direct debit), ``False`` on a hard + failure. + """ + self.ensure_one() + invoice.ensure_one() + token = self.payment_token_id + if not token: + self._register_payment_failure( + self.env._("No payment token found for partner %s") + % invoice.partner_id.display_name + ) + return False + provider = token.provider_id + if not provider.journal_id: + self._register_payment_failure( + self.env._("Payment provider %s has no payment journal configured.") + % provider.display_name + ) + return False + payment_transaction = self.env["payment.transaction"].sudo() + # The invoice is still in draft at charge time (charge-before-post), so + # it has no sequence number yet (its name is empty or the "/" + # placeholder). Fall back to the subscription's own reference for a + # stable, traceable prefix instead of a timestamp. + has_number = invoice.name and invoice.name != "/" + reference = payment_transaction._compute_reference( + provider.code, prefix=invoice.name if has_number else self.name + ) + transaction = payment_transaction.create( + { + "provider_id": provider.id, + "payment_method_id": token.payment_method_id.id, + "token_id": token.id, + "operation": "offline", + "reference": reference, + "amount": invoice.amount_total, + "currency_id": invoice.currency_id.id, + "partner_id": invoice.partner_id.id, + "invoice_ids": [Command.set(invoice.ids)], + } + ) + try: + transaction._send_payment_request() + except Exception: + logger.exception( + "Automatic payment request failed for subscription %s", self.id + ) + self._register_payment_failure( + self.env._( + "The automatic payment request could not be sent. " + "Please check the payment method." + ) + ) + return False + if transaction.state == "done": + # Skip the sale module's automatic invoice sending so this module + # stays the single authority on when the (paid) invoice is emailed. + transaction.with_context(skip_sale_auto_invoice_send=True)._post_process() + self._clear_payment_failure() + return True + if transaction.state in ("pending", "authorized"): + # Asynchronous capture: the charge has been submitted and the + # provider will confirm it later via webhook. The chatter note is + # posted by generate_invoice (and the confirmation note by the + # payment.transaction post-process hook) to avoid duplicate + # messages here. + self._clear_payment_failure() + return True + self._register_payment_failure( + self.env._("The automatic payment was declined (state: %s).") + % transaction.state + ) + return False diff --git a/subscription_oca/models/sale_subscription_line.py b/subscription_oca/models/sale_subscription_line.py index 2bd40bcdbd..9f766a319a 100644 --- a/subscription_oca/models/sale_subscription_line.py +++ b/subscription_oca/models/sale_subscription_line.py @@ -85,6 +85,7 @@ def _compute_name(self): for record in self: if not record.product_id: record.name = False + continue 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( diff --git a/subscription_oca/models/sale_subscription_template.py b/subscription_oca/models/sale_subscription_template.py index acf82a7b12..de7149d0d3 100644 --- a/subscription_oca/models/sale_subscription_template.py +++ b/subscription_oca/models/sale_subscription_template.py @@ -37,6 +37,13 @@ class SaleSubscriptionTemplate(models.Model): ("sale_and_invoice", "Sale order & Invoice"), ], ) + auto_create_payment = fields.Boolean( + string="Automatic payment", + help="Automatically charge the subscription's saved payment token. " + "The invoice is kept in draft and only posted once the payment " + "succeeds. Works with every invoicing mode; with Draft mode it " + "charges silently (posts on success without sending an email).", + ) code = fields.Char() recurring_rule_count = fields.Integer(default=1, string="Rule count") invoice_mail_template_id = fields.Many2one( diff --git a/subscription_oca/readme/CONTRIBUTORS.md b/subscription_oca/readme/CONTRIBUTORS.md index 885a89aa19..af1f717570 100644 --- a/subscription_oca/readme/CONTRIBUTORS.md +++ b/subscription_oca/readme/CONTRIBUTORS.md @@ -7,3 +7,5 @@ - Valentin Vinagre - Alberto Martínez - Dennis Sluijk \<\> +- [Open User Systems](https://www.openusersystems.com): + - Chris Mann \<\> diff --git a/subscription_oca/readme/USAGE.md b/subscription_oca/readme/USAGE.md index 707414ff28..3bb9971ab5 100644 --- a/subscription_oca/readme/USAGE.md +++ b/subscription_oca/readme/USAGE.md @@ -24,3 +24,116 @@ To create subscriptions with the sale of a product: 2. Create the product and in the sales tab, complete the fields *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. diff --git a/subscription_oca/static/description/index.html b/subscription_oca/static/description/index.html index 50e903fc3d..9698c9a94b 100644 --- a/subscription_oca/static/description/index.html +++ b/subscription_oca/static/description/index.html @@ -3,7 +3,7 @@ -README.rst +Subscription management -
+
+

Subscription management

- - -Odoo Community Association - -
-

Subscription management

-

Beta License: AGPL-3 OCA/contract Translate me on Weblate Try me on Runboat

+

Beta License: AGPL-3 OCA/contract Translate me on Weblate Try me on Runboat

This module allows creating subscriptions that generate recurring invoices or orders. It also enables the sale of products that generate subscriptions.

Table of contents

-

Usage

+

Usage

To make a subscription:

  1. Go to Subscriptions > Configuration > Subscription templates.
  2. @@ -421,9 +428,146 @@

    Usage

    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. +
  3. Pick an Invoicing mode (see the table below for how each one +behaves once automatic payment is on).
  4. +
  5. Tick Automatic payment.
  6. +
+

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. +
  3. An offline payment transaction is created against the saved token and +submitted to the provider.
  4. +
  5. 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.
  6. +
  7. 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.
  8. +
  9. 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.
  10. +
+
+
+

Invoicing mode behaviour with automatic payment

+ ++++ + + + + + + + + + + + + + + + + + + + +
Invoicing modeOn a successful charge
DraftInvoice posted, no email (silent +background billing)
InvoiceInvoice posted, paid invoice emailed
Invoice & sendInvoice posted, paid invoice emailed
Sale order & InvoiceSale 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. +
  3. If that template has Automatic payment enabled, the token saved +during the order’s online payment is copied onto the new subscription +automatically.
  4. +
+

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

+
+
-

Known issues / Roadmap

+

Known issues / Roadmap

  • Refactor all the onchanges that have business logic to computed write-able fields when possible. Keep onchanges only for UI purposes.
  • @@ -431,7 +575,7 @@

    Known issues / Roadmap

-

Bug Tracker

+

Bug Tracker

Bugs are tracked on GitHub 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 @@ -439,16 +583,17 @@

Bug Tracker

Do not contact contributors directly about support or help with technical issues.

-

Credits

+

Credits

-

Authors

+

Authors

  • Domatix
  • Onestein
  • +
  • Open User Systems
-

Contributors

+

Contributors

  • Dennis Sluijk <d.sluijk@onestein.nl>
  • +
  • Open User Systems: +
  • -

    Maintainers

    +

    Maintainers

    This module is maintained by the OCA.

    Odoo Community Association @@ -479,6 +628,5 @@

    Maintainers

    -
    diff --git a/subscription_oca/tests/test_subscription_oca.py b/subscription_oca/tests/test_subscription_oca.py index 0d9ca7afca..c4145fe8c1 100644 --- a/subscription_oca/tests/test_subscription_oca.py +++ b/subscription_oca/tests/test_subscription_oca.py @@ -344,17 +344,40 @@ def test_subscription_oca_sub_lines(self): move_res = self.sub_line._prepare_account_move_line() self.assertIsInstance(move_res, dict) - @patch( - "odoo.addons.subscription_oca.models.sale_subscription." - "SaleSubscription.generate_invoice" - ) - def test_subscription_oca_sub_cron_error(self, generate_invoice_patch): - # Simulate something failing in generating an invoice, - # we expect something being logged - generate_invoice_patch.side_effect = exceptions.UserError("Error") - with mute_logger("odoo.addons.subscription_oca.models.sale_subscription"): - with self.assertRaises(exceptions.UserError): - self.sub1.cron_subscription_management() + def test_subscription_oca_sub_cron_error(self): + # The cron wraps each subscription in a savepoint and logs errors rather + # than re-raising, so one failure never aborts the rest of the batch. + sub = self.create_sub( + { + "date_start": fields.Date.today() - relativedelta(days=10), + "in_progress": True, + } + ) + self.create_sub_line(sub) + sub.recurring_next_date = fields.Date.today() - relativedelta(days=1) + SaleSubscription = type(self.env["sale.subscription"]) + # Limit the cron to only our test subscription so the mock's side-effect + # doesn't fire on other in-progress subscriptions that call + # generate_invoice() outside the savepoint path. + with patch.object( + SaleSubscription, + "search", + return_value=sub, + ): + with patch.object( + SaleSubscription, + "generate_invoice", + side_effect=exceptions.UserError("Error"), + ): + with mute_logger( + "odoo.addons.subscription_oca.models.sale_subscription" + ): + sub.cron_subscription_management() + # No invoice should have been created (the savepoint rolled it back). + self.assertEqual( + self.env["account.move"].search_count([("subscription_id", "=", sub.id)]), + 0, + ) def test_subscription_oca_sub_cron(self): # sale.subscription @@ -683,3 +706,496 @@ def _collect_all_sub_test_results(self, subscription): ) test_res.append(group_stage_ids) return test_res + + # ------------------------------------------------------------------ + # Automatic payment feature + # ------------------------------------------------------------------ + def _prepare_provider(self): + bank_journal = self.env["account.journal"].search( + [ + ("type", "=", "bank"), + ("company_id", "=", self.env.ref("base.main_company").id), + ], + limit=1, + ) + if not bank_journal: + bank_journal = self.env["account.journal"].create( + {"name": "Test Bank", "type": "bank", "code": "TBNKX"} + ) + provider = self.env["payment.provider"].create( + {"name": "Test Provider", "state": "test"} + ) + provider.journal_id = bank_journal + # Wire an inbound payment method line so a successful transaction can + # create and reconcile a payment during post-processing. + if not provider.journal_id.inbound_payment_method_line_ids.filtered( + lambda line: line.payment_provider_id == provider + ): + self.env["account.payment.method.line"].create( + { + "name": "Test inbound line", + "payment_method_id": self.env.ref( + "account.account_payment_method_manual_in" + ).id, + "payment_provider_id": provider.id, + "journal_id": provider.journal_id.id, + } + ) + return provider + + def _create_payment_token(self, partner, provider): + method = self.env["payment.method"].create( + { + "name": "Test Method", + "code": "none", + "provider_ids": [Command.set(provider.ids)], + "support_tokenization": True, + } + ) + return self.env["payment.token"].create( + { + "partner_id": partner.id, + "provider_id": provider.id, + "provider_ref": f"tok-{partner.id}", + "payment_method_id": method.id, + } + ) + + def _post_subscription_invoice(self, subscription): + invoice = subscription.create_invoice() + invoice.action_post() + return invoice + + def test_payment_no_token(self): + invoice = self._post_subscription_invoice(self.sub1) + self.assertFalse(self.sub1.create_payment(invoice)) + self.assertTrue(self.sub1.payment_exception) + msgs = self.sub1.message_ids.filtered( + lambda m: "No payment token found" in (m.body or "") + ) + self.assertEqual(len(msgs), 1) + + def test_payment_success(self): + provider = self._prepare_provider() + self.sub1.payment_token_id = self._create_payment_token(self.partner, provider) + invoice = self._post_subscription_invoice(self.sub1) + tx_model = type(self.env["payment.transaction"]) + + def _fake_send(tx): + tx.state = "done" + + with ( + patch.object(tx_model, "_send_payment_request", _fake_send), + patch.object(tx_model, "_post_process", lambda tx: None), + ): + self.assertTrue(self.sub1.create_payment(invoice)) + self.assertFalse(self.sub1.payment_exception) + + def test_payment_pending_async(self): + provider = self._prepare_provider() + self.sub1.payment_token_id = self._create_payment_token(self.partner, provider) + invoice = self._post_subscription_invoice(self.sub1) + tx_model = type(self.env["payment.transaction"]) + + def _fake_send(tx): + tx.state = "pending" + + with patch.object(tx_model, "_send_payment_request", _fake_send): + self.assertTrue(self.sub1.create_payment(invoice)) + self.assertFalse(self.sub1.payment_exception) + self.assertEqual(invoice.transaction_ids.state, "pending") + # The "awaiting confirmation" note is posted by generate_invoice, not + # create_payment, so a direct charge does not duplicate it. + self.assertFalse( + self.sub1.message_ids.filtered( + lambda m: "awaiting confirmation" in (m.body or "") + ) + ) + + def test_payment_reference_falls_back_to_subscription(self): + # Charge-before-post: the invoice is still draft (no sequence number), + # so the payment reference must fall back to the subscription's own + # reference instead of an opaque timestamp. + provider = self._prepare_provider() + sub = self.create_sub({"code": "AUTOPAYREF"}) + self.create_sub_line(sub) + sub.payment_token_id = self._create_payment_token(self.partner, provider) + invoice = sub.create_invoice() + self.assertEqual(invoice.state, "draft") + tx_model = type(self.env["payment.transaction"]) + + def _fake_send(tx): + tx.state = "pending" + + with patch.object(tx_model, "_send_payment_request", _fake_send): + self.assertTrue(sub.create_payment(invoice)) + transaction = invoice.transaction_ids + self.assertEqual(len(transaction), 1) + self.assertIn("AUTOPAYREF", transaction.reference) + + def test_generate_invoice_auto_payment_pending_message(self): + # While the charge is pending the invoice stays draft: post a single + # clean "submitted; awaiting confirmation" note - no "Draft Invoice" + # display name, no "To validate", no bare "False". + provider = self._prepare_provider() + template = self.create_sub_template( + {"invoicing_mode": "invoice", "auto_create_payment": True} + ) + sub = self.create_sub({"template_id": template.id}) + self.create_sub_line(sub) + sub.payment_token_id = self._create_payment_token(self.partner, provider) + tx_model = type(self.env["payment.transaction"]) + + def _fake_send(tx): + tx.state = "pending" + + with patch.object(tx_model, "_send_payment_request", _fake_send): + sub.generate_invoice() + invoice = sub.invoice_ids + self.assertEqual(invoice.state, "draft") + awaiting = sub.message_ids.filtered( + lambda m: "awaiting confirmation" in (m.body or "") + ) + self.assertEqual(len(awaiting), 1) + self.assertNotIn("Draft Invoice", awaiting[0].body) + self.assertNotIn("False", awaiting[0].body) + self.assertFalse( + sub.message_ids.filtered(lambda m: "To validate" in (m.body or "")) + ) + + def test_transaction_cancel_flags_subscription(self): + # An accepted (pending) automatic payment that the provider later + # cancels or reverses (permanent failure / chargeback) must surface on + # the subscription: exception flag + to-do activity. Stage and next + # invoice date are intentionally left untouched. + provider = self._prepare_provider() + template = self.create_sub_template( + {"invoicing_mode": "invoice", "auto_create_payment": True} + ) + sub = self.create_sub({"template_id": template.id}) + self.create_sub_line(sub) + sub.payment_token_id = self._create_payment_token(self.partner, provider) + invoice = sub.create_invoice() + tx_model = type(self.env["payment.transaction"]) + + def _fake_send(tx): + tx.state = "pending" + + with patch.object(tx_model, "_send_payment_request", _fake_send): + self.assertTrue(sub.create_payment(invoice)) + self.assertFalse(sub.payment_exception) + stage_before = sub.stage_id + next_date_before = sub.recurring_next_date + # Provider cancels the transaction afterwards. + invoice.transaction_ids._set_canceled() + self.assertTrue(sub.payment_exception) + self.assertTrue( + sub.activity_ids.filtered( + lambda a: a.summary == sub._payment_failure_activity_summary() + ) + ) + # Stage and schedule are left as-is (posted invoice stands as the + # receivable; the exception flag stops further auto-billing). + self.assertEqual(sub.stage_id, stage_before) + self.assertEqual(sub.recurring_next_date, next_date_before) + + def test_payment_declined(self): + provider = self._prepare_provider() + self.sub1.payment_token_id = self._create_payment_token(self.partner, provider) + invoice = self._post_subscription_invoice(self.sub1) + tx_model = type(self.env["payment.transaction"]) + + def _fake_send(tx): + tx.state = "error" + + with patch.object(tx_model, "_send_payment_request", _fake_send): + self.assertFalse(self.sub1.create_payment(invoice)) + self.assertTrue(self.sub1.payment_exception) + + def test_payment_token_partner_constraint(self): + provider = self._prepare_provider() + token = self._create_payment_token(self.partner_2, provider) + with self.assertRaises(exceptions.ValidationError): + self.sub1.payment_token_id = token + + def test_onchange_suggest_token(self): + template = self.create_sub_template( + {"invoicing_mode": "invoice_send", "auto_create_payment": True} + ) + provider = self._prepare_provider() + token = self._create_payment_token(self.partner, provider) + sub = self.env["sale.subscription"].new( + { + "template_id": template.id, + "partner_id": self.partner.id, + "company_id": self.env.ref("base.main_company").id, + } + ) + sub._onchange_partner_id_payment_token() + self.assertEqual(sub.payment_token_id, token) + + def test_generate_invoice_auto_payment_no_token(self): + template = self.create_sub_template( + {"invoicing_mode": "invoice_send", "auto_create_payment": True} + ) + sub = self.create_sub({"template_id": template.id}) + self.create_sub_line(sub) + original_next_date = sub.recurring_next_date + move_send = type(self.env["account.move.send"]) + with patch.object( + move_send, "_generate_and_send_invoices", return_value=None + ) as mock_send: + sub.generate_invoice() + mock_send.assert_not_called() + self.assertTrue(sub.payment_exception) + # Invoice is kept in draft (never posted) and the schedule is not + # advanced, so the period is retried once the token is fixed. + self.assertEqual(len(sub.invoice_ids), 1) + self.assertEqual(sub.invoice_ids.state, "draft") + self.assertEqual(sub.recurring_next_date, original_next_date) + self.assertTrue( + sub.activity_ids.filtered( + lambda a: a.summary == sub._payment_failure_activity_summary() + ) + ) + + def test_auto_payment_reuses_failed_draft(self): + template = self.create_sub_template( + {"invoicing_mode": "invoice", "auto_create_payment": True} + ) + sub = self.create_sub({"template_id": template.id}) + self.create_sub_line(sub) + sub.generate_invoice() + first_invoice = sub.invoice_ids + self.assertEqual(len(first_invoice), 1) + self.assertEqual(first_invoice.state, "draft") + # Second run reuses the lingering failed draft instead of duplicating. + sub.generate_invoice() + self.assertEqual(sub.invoice_ids, first_invoice) + + def test_generate_invoice_auto_payment_success(self): + provider = self._prepare_provider() + template = self.create_sub_template( + {"invoicing_mode": "invoice_send", "auto_create_payment": True} + ) + sub = self.create_sub({"template_id": template.id}) + self.create_sub_line(sub) + sub.payment_token_id = self._create_payment_token(self.partner, provider) + original_next_date = sub.recurring_next_date + tx_model = type(self.env["payment.transaction"]) + move_send = type(self.env["account.move.send"]) + + def _fake_send(tx): + tx._set_done() + + with ( + patch.object(tx_model, "_send_payment_request", _fake_send), + patch.object( + move_send, "_generate_and_send_invoices", return_value=None + ) as mock_send, + ): + sub.generate_invoice() + invoice = sub.invoice_ids + self.assertEqual(len(invoice), 1) + self.assertEqual(invoice.state, "posted") + self.assertIn(invoice.payment_state, ("in_payment", "paid")) + mock_send.assert_called_once() + self.assertFalse(sub.payment_exception) + self.assertNotEqual(sub.recurring_next_date, original_next_date) + # A settled charge posts a closing confirmation note with the real + # invoice number, and leaves no dangling "awaiting confirmation". + confirmation = sub.message_ids.filtered( + lambda m: "Automatic payment confirmed for invoice" in (m.body or "") + and invoice.name in (m.body or "") + ) + self.assertEqual(len(confirmation), 1) + self.assertFalse( + sub.message_ids.filtered( + lambda m: "awaiting confirmation" in (m.body or "") + ) + ) + + def test_generate_invoice_draft_mode_auto_payment_silent(self): + provider = self._prepare_provider() + template = self.create_sub_template( + {"invoicing_mode": "draft", "auto_create_payment": True} + ) + sub = self.create_sub({"template_id": template.id}) + self.create_sub_line(sub) + sub.payment_token_id = self._create_payment_token(self.partner, provider) + tx_model = type(self.env["payment.transaction"]) + move_send = type(self.env["account.move.send"]) + + def _fake_send(tx): + tx._set_done() + + with ( + patch.object(tx_model, "_send_payment_request", _fake_send), + patch.object( + move_send, "_generate_and_send_invoices", return_value=None + ) as mock_send, + ): + sub.generate_invoice() + # Draft mode + automatic payment: the paid invoice is posted but no + # email is sent (silent background billing). + self.assertEqual(sub.invoice_ids.state, "posted") + mock_send.assert_not_called() + self.assertFalse(sub.payment_exception) + + def test_generate_invoice_without_auto_payment_sends_email(self): + template = self.create_sub_template({"invoicing_mode": "invoice_send"}) + sub = self.create_sub({"template_id": template.id}) + self.create_sub_line(sub) + move_send = type(self.env["account.move.send"]) + with patch.object( + move_send, "_generate_and_send_invoices", return_value=None + ) as mock_send: + sub.generate_invoice() + mock_send.assert_called_once() + self.assertFalse(sub.payment_exception) + + def test_cron_skips_payment_exception(self): + template = self.create_sub_template( + {"invoicing_mode": "invoice_send", "auto_create_payment": True} + ) + sub = self.create_sub( + { + "template_id": template.id, + "date_start": fields.Date.today() - relativedelta(days=10), + "in_progress": True, + "payment_exception": True, + } + ) + self.create_sub_line(sub) + sub.recurring_next_date = fields.Date.today() - relativedelta(days=1) + sub.cron_subscription_management() + self.assertEqual(len(sub.invoice_ids), 0) + + def test_so_subscription_token_handoff(self): + template = self.create_sub_template( + {"invoicing_mode": "invoice_send", "auto_create_payment": True} + ) + self.product_1.product_tmpl_id.write( + {"subscribable": True, "subscription_template_id": template.id} + ) + provider = self._prepare_provider() + token = self._create_payment_token(self.partner, provider) + so = self.env["sale.order"].create( + { + "partner_id": self.partner.id, + "order_line": [ + Command.create( + { + "product_id": self.product_1.id, + "product_uom_qty": 1, + } + ) + ], + } + ) + tx = self.env["payment.transaction"].create( + { + "provider_id": provider.id, + "payment_method_id": token.payment_method_id.id, + "token_id": token.id, + "operation": "online_token", + "reference": "SO-TEST-TOKEN", + "amount": 100.0, + "currency_id": so.currency_id.id, + "partner_id": self.partner.id, + "state": "done", + } + ) + so.transaction_ids = [Command.link(tx.id)] + so.with_context(uid=1).action_confirm() + subscription = so.subscription_ids + self.assertEqual(len(subscription), 1) + self.assertEqual(subscription.payment_token_id, token) + + def test_payment_no_journal_on_provider(self): + provider = self._prepare_provider() + provider.journal_id = False + token = self._create_payment_token(self.partner, provider) + self.sub1.payment_token_id = token + invoice = self._post_subscription_invoice(self.sub1) + self.assertFalse(self.sub1.create_payment(invoice)) + self.assertTrue(self.sub1.payment_exception) + msgs = self.sub1.message_ids.filtered( + lambda m: "no payment journal" in (m.body or "") + ) + self.assertEqual(len(msgs), 1) + + def test_payment_send_request_raises(self): + provider = self._prepare_provider() + self.sub1.payment_token_id = self._create_payment_token(self.partner, provider) + invoice = self._post_subscription_invoice(self.sub1) + tx_model = type(self.env["payment.transaction"]) + + def _raise(tx): + raise RuntimeError("network error") + + with ( + patch.object(tx_model, "_send_payment_request", _raise), + mute_logger("odoo.addons.subscription_oca.models.sale_subscription"), + ): + self.assertFalse(self.sub1.create_payment(invoice)) + self.assertTrue(self.sub1.payment_exception) + msgs = self.sub1.message_ids.filtered( + lambda m: "could not be sent" in (m.body or "") + ) + self.assertEqual(len(msgs), 1) + + def test_generate_invoice_sale_and_invoice_auto_payment(self): + provider = self._prepare_provider() + template = self.create_sub_template( + {"invoicing_mode": "sale_and_invoice", "auto_create_payment": True} + ) + sub = self.create_sub({"template_id": template.id}) + self.create_sub_line(sub) + sub.payment_token_id = self._create_payment_token(self.partner, provider) + sub.sale_subscription_line_ids.mapped("product_id").write( + {"invoice_policy": "order"} + ) + tx_model = type(self.env["payment.transaction"]) + + def _fake_send(tx): + tx._set_done() + + with ( + patch.object(tx_model, "_send_payment_request", _fake_send), + patch.object(tx_model, "_post_process", lambda tx: None), + ): + sub.generate_invoice() + self.assertFalse(sub.payment_exception) + + def test_onchange_token_skips_when_auto_pay_off(self): + template = self.create_sub_template({"invoicing_mode": "invoice_send"}) + provider = self._prepare_provider() + token = self._create_payment_token(self.partner, provider) + sub = self.env["sale.subscription"].new( + { + "template_id": template.id, + "partner_id": self.partner.id, + "payment_token_id": token.id, + "company_id": self.env.ref("base.main_company").id, + } + ) + sub._onchange_partner_id_payment_token() + self.assertEqual(sub.payment_token_id, token) + + def test_onchange_token_skips_when_token_matches_partner(self): + template = self.create_sub_template( + {"invoicing_mode": "invoice_send", "auto_create_payment": True} + ) + provider = self._prepare_provider() + token = self._create_payment_token(self.partner, provider) + sub = self.env["sale.subscription"].new( + { + "template_id": template.id, + "partner_id": self.partner.id, + "payment_token_id": token.id, + "company_id": self.env.ref("base.main_company").id, + } + ) + sub._onchange_partner_id_payment_token() + self.assertEqual(sub.payment_token_id, token) diff --git a/subscription_oca/views/sale_subscription_template_views.xml b/subscription_oca/views/sale_subscription_template_views.xml index 3190c89b82..1241634997 100644 --- a/subscription_oca/views/sale_subscription_template_views.xml +++ b/subscription_oca/views/sale_subscription_template_views.xml @@ -76,6 +76,7 @@ month(s)
    + + + + +