Skip to content

Commit c8c1a38

Browse files
authored
add account_invoice_send_on_reconcile (#219)
1 parent 7b04a64 commit c8c1a38

File tree

8 files changed

+465
-0
lines changed

8 files changed

+465
-0
lines changed
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# Account Invoice Send on Reconcile
2+
3+
## Overview
4+
5+
This Odoo 15.0 module automatically sends customer invoice emails when bank payments are
6+
reconciled with invoices from bank statements.
7+
8+
## Features
9+
10+
- **Automatic Invoice Sending**: When reconciling bank statement lines with customer
11+
invoices, the module automatically sends the invoice email to the customer
12+
- **Duplicate Prevention**: Uses the `is_move_sent` flag to prevent sending duplicate
13+
emails
14+
- **Smart Filtering**: Only sends for:
15+
- Posted customer invoices
16+
- Fully paid invoices
17+
- Invoices not already marked as sent
18+
- **Error Handling**: Email sending errors are logged but don't break the reconciliation
19+
process
20+
21+
## Configuration
22+
23+
No configuration required. The module works automatically once installed.
24+
25+
## Usage
26+
27+
1. Navigate to Accounting > Dashboard > Bank
28+
2. Import or create bank statement lines
29+
3. Reconcile statement lines with customer invoices
30+
4. The module automatically sends invoice emails for reconciled, fully-paid invoices
31+
5. Check the invoice's "Sent" status to confirm
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
2+
3+
from . import models
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"name": "Account Invoice Send on Reconcile",
3+
"version": "15.0.1.0.0",
4+
"category": "Accounting",
5+
"author": "Nitrokey GmbH",
6+
"license": "AGPL-3",
7+
"website": "https://github.com/OCA/server-tools",
8+
"summary": "Automatically send invoice emails when reconciling bank payments",
9+
"depends": [
10+
"account",
11+
"payment",
12+
],
13+
"data": [],
14+
"installable": True,
15+
"auto_install": False,
16+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
2+
3+
from . import account_move_line
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
2+
3+
import logging
4+
5+
from odoo import models
6+
7+
_logger = logging.getLogger(__name__)
8+
9+
10+
class AccountMoveLine(models.Model):
11+
_inherit = "account.move.line"
12+
13+
def reconcile(self):
14+
"""Override to automatically send invoice emails after reconciliation.
15+
16+
This method is called when move lines are reconciled, including
17+
bank statement reconciliation and payment register.
18+
"""
19+
# Call parent method to perform standard reconciliation
20+
res = super().reconcile()
21+
22+
# Get all invoices from the reconciled move lines
23+
invoices = self.mapped("move_id").filtered(
24+
lambda m: m.is_invoice(include_receipts=True)
25+
)
26+
27+
for invoice in invoices:
28+
# Skip if not a valid invoice for sending
29+
if not self._should_send_invoice_after_reconciliation(invoice):
30+
continue
31+
32+
# Send invoice email with error handling
33+
try:
34+
_logger.info(
35+
"Sending invoice email for %s after reconciliation",
36+
invoice.name,
37+
)
38+
39+
# Get email context from action_invoice_sent
40+
action_dict = invoice.action_invoice_sent()
41+
if action_dict and action_dict.get("context"):
42+
email_ctx = action_dict["context"]
43+
invoice.with_context(**email_ctx).message_post_with_template(
44+
email_ctx.get("default_template_id")
45+
)
46+
47+
# Mark invoice as sent
48+
invoice.write({"is_move_sent": True})
49+
50+
_logger.info("Successfully sent invoice email for %s", invoice.name)
51+
except Exception as e:
52+
# Log error but don't break reconciliation process
53+
_logger.error(
54+
"Failed to send invoice email for %s: %s",
55+
invoice.name,
56+
str(e),
57+
exc_info=True,
58+
)
59+
60+
return res
61+
62+
def _should_send_invoice_after_reconciliation(self, invoice):
63+
"""Determine if invoice should be sent after bank statement reconciliation.
64+
65+
Checks multiple conditions:
66+
- Invoice must be posted
67+
- Must be customer invoice (out_invoice only)
68+
- Must be fully paid
69+
- Must not already be sent
70+
71+
Args:
72+
invoice: account.move record
73+
74+
Returns:
75+
bool: True if invoice should be sent, False otherwise
76+
"""
77+
# Check basic invoice conditions
78+
if invoice.state != "posted":
79+
return False
80+
81+
if invoice.move_type != "out_invoice":
82+
return False
83+
84+
if invoice.payment_state != "paid":
85+
return False
86+
87+
if invoice.is_move_sent:
88+
return False
89+
90+
return True
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
2+
3+
from . import test_invoice_send_on_reconcile
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
2+
3+
import logging
4+
5+
from odoo.tests.common import TransactionCase
6+
7+
_logger = logging.getLogger(__name__)
8+
9+
10+
class TestCommon(TransactionCase):
11+
"""Base test class with tracking disabled."""
12+
13+
@classmethod
14+
def setUpClass(cls):
15+
super().setUpClass()
16+
cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True))
17+
18+
19+
class TestInvoiceSendOnReconcileMixin:
20+
"""Mixin with helper methods for invoice send on reconcile tests."""
21+
22+
def create_partner(self, name="Test Partner", email="[email protected]"):
23+
"""Create a test partner with email."""
24+
return self.env["res.partner"].create(
25+
{
26+
"name": name,
27+
"email": email,
28+
}
29+
)
30+
31+
def create_product(self, name="Test Product", price=100.0):
32+
"""Create a test product."""
33+
return self.env["product.product"].create(
34+
{
35+
"name": name,
36+
"list_price": price,
37+
"type": "service",
38+
}
39+
)
40+
41+
def create_invoice(self, partner, product, amount=100.0, move_type="out_invoice"):
42+
"""Create a customer invoice.
43+
44+
Args:
45+
partner: res.partner record
46+
product: product.product record
47+
amount: invoice amount
48+
move_type: 'out_invoice', 'out_refund', 'in_invoice', etc.
49+
50+
Returns:
51+
account.move record
52+
"""
53+
invoice = self.env["account.move"].create(
54+
{
55+
"partner_id": partner.id,
56+
"move_type": move_type,
57+
"invoice_line_ids": [
58+
(
59+
0,
60+
0,
61+
{
62+
"product_id": product.id,
63+
"name": product.name,
64+
"quantity": 1,
65+
"price_unit": amount,
66+
},
67+
)
68+
],
69+
}
70+
)
71+
return invoice
72+
73+
def create_bank_journal(self, name="Bank", code="BNK1"):
74+
"""Create a bank journal for payments."""
75+
return self.env["account.journal"].create(
76+
{
77+
"name": name,
78+
"code": code,
79+
"type": "bank",
80+
}
81+
)
82+
83+
def reconcile_invoice_with_payment(self, invoice, journal=None):
84+
"""Reconcile invoice with a payment using account.payment.register wizard.
85+
86+
Args:
87+
invoice: account.move record (must be posted)
88+
journal: account.journal record (optional, uses first bank journal if not provided)
89+
90+
Returns:
91+
dict: Result from _reconcile_payments
92+
"""
93+
if journal is None:
94+
journal = self.env["account.journal"].search(
95+
[("type", "=", "bank")], limit=1
96+
)
97+
if not journal:
98+
journal = self.create_bank_journal()
99+
100+
# Create payment register wizard
101+
ctx = {
102+
"active_model": "account.move",
103+
"active_ids": invoice.ids,
104+
}
105+
106+
payment_register = (
107+
self.env["account.payment.register"]
108+
.with_context(**ctx)
109+
.create(
110+
{
111+
"journal_id": journal.id,
112+
}
113+
)
114+
)
115+
116+
# Process the payment (this calls _reconcile_payments internally)
117+
result = payment_register.action_create_payments()
118+
119+
return result
120+
121+
def get_sent_mail_count(self, invoice):
122+
"""Get count of sent mails for an invoice.
123+
124+
Args:
125+
invoice: account.move record
126+
127+
Returns:
128+
int: Number of mail.mail records for this invoice
129+
"""
130+
return self.env["mail.mail"].search_count(
131+
[
132+
("model", "=", "account.move"),
133+
("res_id", "=", invoice.id),
134+
]
135+
)
136+
137+
def create_payment_acquirer(self, provider="paypal"):
138+
"""Create a payment acquirer for testing online payments.
139+
140+
Args:
141+
provider: Provider name (paypal, stripe, etc.)
142+
143+
Returns:
144+
payment.acquirer record
145+
"""
146+
vals = {
147+
"name": f"Test {provider.title()}",
148+
"provider": provider,
149+
"state": "test",
150+
}
151+
152+
# Add provider-specific required fields
153+
if provider == "paypal":
154+
vals["paypal_email_account"] = "[email protected]"
155+
156+
return self.env["payment.acquirer"].create(vals)

0 commit comments

Comments
 (0)