Skip to content

Commit d4f93e7

Browse files
committed
[IMP] account_internal_transfer: sync amounts and rates for paired payments
Add synchronization logic for paired internal transfer payments: - Sync counterpart_currency_amount between paired payments for cross-currency transfers - Sync amounts when counterpart_currency_amount or rate fields change - Sync journal changes (swapped relationship between payment and paired) - Preserve exact manual rates by using inverted rate (1/rate) instead of calculating from rounded amounts - Add comprehensive test suite covering amount sync, rate sync, and edge cases - Prevent recursion with skip_paired_payment_update context flag Technical details: - Enhanced write() method to detect paired payment changes - Support for payment_pro module fields (counterpart_rate, amount_exact, etc.) - Use float_compare for rate comparison to handle floating-point precision - Handle both payment_pro enabled/disabled scenarios Change note: Se implementó la sincronización automática de montos y tipos de cambio en transferencias internas entre cuentas con diferentes monedas. Cuando se modifica el monto convertido o el tipo de cambio en un pago, el sistema ahora actualiza automáticamente el pago pareado con los valores correspondientes. Además, cuando se establece un tipo de cambio manual (ej. 1500 ARS/USD), ambos movimientos muestran exactamente la misma tasa, evitando valores con decimales generados por redondeo.
1 parent c5c65cf commit d4f93e7

3 files changed

Lines changed: 280 additions & 4 deletions

File tree

account_internal_transfer/models/account_payment.py

Lines changed: 56 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from odoo import _, api, fields, models
22
from odoo.exceptions import UserError, ValidationError
33
from odoo.fields import Domain
4+
from odoo.tools import float_compare
45

56

67
class AccountPayment(models.Model):
@@ -249,14 +250,65 @@ def write(self, vals):
249250
if self.env.context.get("skip_paired_payment_update"):
250251
return res
251252

252-
# Update paired payment when amount or journal changes
253+
# Update paired payment when amount, rate or journal changes
253254
for payment in self.filtered(lambda p: p.is_internal_transfer and p.paired_internal_transfer_payment_id):
254255
paired_payment = payment.paired_internal_transfer_payment_id
255256
updates = {}
256257

257-
# Sync amount
258-
if "amount" in vals and payment.amount != paired_payment.amount:
259-
updates["amount"] = payment.amount
258+
# Sync amounts: use counterpart_currency_amount for proper cross-currency conversion
259+
amount_fields_changed = any(
260+
f in vals
261+
for f in [
262+
"amount",
263+
"counterpart_currency_amount",
264+
"user_accounting_rate",
265+
"user_counterpart_rate",
266+
"accounting_rate",
267+
"counterpart_rate",
268+
]
269+
)
270+
271+
use_payment_pro = getattr(payment.company_id, "use_payment_pro", False)
272+
if amount_fields_changed and use_payment_pro:
273+
counterpart_amount = getattr(payment, "counterpart_currency_amount", False)
274+
if counterpart_amount:
275+
converted_amount = abs(counterpart_amount)
276+
source_amount = getattr(payment, "amount_exact", False) or payment.amount
277+
if converted_amount != paired_payment.amount:
278+
updates["amount"] = converted_amount
279+
if "amount_exact" in paired_payment._fields:
280+
updates["amount_exact"] = converted_amount
281+
if source_amount != getattr(paired_payment, "counterpart_currency_amount", 0):
282+
updates["counterpart_currency_amount"] = source_amount
283+
# Set rate: use inverted rate if explicitly set, otherwise calculate from amounts
284+
if converted_amount and "counterpart_rate" in paired_payment._fields:
285+
source_rate = getattr(payment, "counterpart_rate", False)
286+
if source_rate:
287+
# Use inverted rate to preserve exact rate value (avoid rounding issues)
288+
updates["counterpart_rate"] = 1.0 / source_rate
289+
else:
290+
# Calculate from amounts when no explicit rate was set
291+
updates["counterpart_rate"] = source_amount / converted_amount
292+
elif "amount" in vals:
293+
# Fallback: sync basic amount when counterpart_currency_amount is not available
294+
if payment.amount != paired_payment.amount:
295+
updates["amount"] = payment.amount
296+
elif "amount" in vals:
297+
if payment.amount != paired_payment.amount:
298+
updates["amount"] = payment.amount
299+
300+
# Sync rates: invert counterpart_rate because paired payment has swapped currencies
301+
rate_fields_changed = any(
302+
f in vals
303+
for f in ["user_accounting_rate", "user_counterpart_rate", "accounting_rate", "counterpart_rate"]
304+
)
305+
counterpart_rate = getattr(payment, "counterpart_rate", False)
306+
if rate_fields_changed and use_payment_pro and counterpart_rate:
307+
inverse_rate = 1.0 / counterpart_rate
308+
current_paired_rate = getattr(paired_payment, "counterpart_rate", 0)
309+
# Use float_compare with 6 decimal precision to avoid floating-point comparison issues
310+
if float_compare(inverse_rate, current_paired_rate, precision_digits=6) != 0:
311+
updates["counterpart_rate"] = inverse_rate
260312

261313
# Sync journals (swapped relationship)
262314
if "journal_id" in vals and payment.journal_id != paired_payment.destination_journal_id:
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from . import test_account_payment
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
from odoo.tests import TransactionCase, tagged
2+
3+
4+
@tagged("post_install", "-at_install")
5+
class TestAccountPaymentInternalTransfer(TransactionCase):
6+
"""Test internal transfer payment synchronization for amounts and rates"""
7+
8+
@classmethod
9+
def setUpClass(cls):
10+
super().setUpClass()
11+
12+
# Use existing currency and journals to minimize data creation
13+
cls.company = cls.env.company
14+
cls.usd = cls.env.ref("base.USD")
15+
cls.eur = cls.env.ref("base.EUR")
16+
17+
# Check if payment_pro is available
18+
cls.has_payment_pro = "use_payment_pro" in cls.env["res.company"]._fields
19+
20+
# Get or create minimal journals
21+
cls.journal_usd = cls.env["account.journal"].search(
22+
[
23+
("type", "in", ["bank", "cash"]),
24+
("company_id", "=", cls.company.id),
25+
],
26+
limit=1,
27+
)
28+
29+
cls.journal_eur = cls.env["account.journal"].search(
30+
[
31+
("type", "in", ["bank", "cash"]),
32+
("company_id", "=", cls.company.id),
33+
("id", "!=", cls.journal_usd.id),
34+
],
35+
limit=1,
36+
)
37+
38+
# Create second journal only if needed
39+
if not cls.journal_eur:
40+
cls.journal_eur = cls.env["account.journal"].create(
41+
{
42+
"name": "Test Bank 2",
43+
"type": "bank",
44+
"code": "TB2",
45+
}
46+
)
47+
48+
def _create_paired_payments(self, amount=1000.0):
49+
"""Helper to create paired payments for testing"""
50+
payment = self.env["account.payment"].create(
51+
{
52+
"payment_type": "outbound",
53+
"amount": amount,
54+
"journal_id": self.journal_usd.id,
55+
"is_internal_transfer": True,
56+
"destination_journal_id": self.journal_eur.id,
57+
}
58+
)
59+
60+
paired_payment = self.env["account.payment"].create(
61+
{
62+
"payment_type": "inbound",
63+
"amount": amount,
64+
"journal_id": self.journal_eur.id,
65+
"is_internal_transfer": True,
66+
"destination_journal_id": self.journal_usd.id,
67+
"paired_internal_transfer_payment_id": payment.id,
68+
}
69+
)
70+
payment.paired_internal_transfer_payment_id = paired_payment
71+
72+
return payment, paired_payment
73+
74+
def test_amount_sync_basic(self):
75+
"""Test basic amount synchronization (works with or without payment_pro)"""
76+
payment, paired_payment = self._create_paired_payments()
77+
78+
# Update amount on source payment
79+
payment.write({"amount": 1500.0})
80+
81+
# Verify paired payment amount was synced
82+
self.assertEqual(paired_payment.amount, 1500.0, "Paired payment amount should sync with source payment amount")
83+
84+
def test_amount_sync_with_counterpart_currency(self):
85+
"""Test amount synchronization with counterpart_currency_amount (payment_pro)"""
86+
if not self.has_payment_pro:
87+
self.skipTest("account_payment_pro module is not installed")
88+
89+
if "counterpart_currency_amount" not in self.env["account.payment"]._fields:
90+
self.skipTest("counterpart_currency_amount field not available")
91+
92+
# Enable payment_pro
93+
self.company.use_payment_pro = True
94+
95+
payment, paired_payment = self._create_paired_payments()
96+
97+
# Update with counterpart_currency_amount (cross-currency transfer)
98+
payment.write(
99+
{
100+
"counterpart_currency_amount": 850.0,
101+
}
102+
)
103+
104+
# Verify paired payment received the converted amount
105+
self.assertEqual(paired_payment.amount, 850.0, "Paired payment amount should match counterpart_currency_amount")
106+
107+
# Verify reverse sync
108+
self.assertEqual(
109+
paired_payment.counterpart_currency_amount,
110+
1000.0,
111+
"Paired payment counterpart_currency_amount should be source payment amount",
112+
)
113+
114+
def test_rate_sync_with_inverted_counterpart_rate(self):
115+
"""Test rate synchronization with inverted counterpart_rate (payment_pro)"""
116+
if not self.has_payment_pro:
117+
self.skipTest("account_payment_pro module is not installed")
118+
119+
if "counterpart_rate" not in self.env["account.payment"]._fields:
120+
self.skipTest("counterpart_rate field not available")
121+
122+
# Enable payment_pro
123+
self.company.use_payment_pro = True
124+
125+
payment, paired_payment = self._create_paired_payments()
126+
127+
# Set a counterpart_rate on the source payment
128+
payment.write(
129+
{
130+
"counterpart_rate": 1.2,
131+
}
132+
)
133+
134+
# Verify paired payment has inverted rate (1/1.2)
135+
expected_inverse_rate = 1.0 / 1.2
136+
self.assertAlmostEqual(
137+
paired_payment.counterpart_rate,
138+
expected_inverse_rate,
139+
places=4,
140+
msg="Paired payment counterpart_rate should be inverted (1/rate)",
141+
)
142+
143+
def test_journal_sync_swapped_relationship(self):
144+
"""Test journal synchronization maintains swapped relationship"""
145+
payment, paired_payment = self._create_paired_payments()
146+
147+
# Create minimal third journal for testing
148+
new_journal = self.env["account.journal"].create(
149+
{
150+
"name": "Test Bank 3",
151+
"type": "bank",
152+
"code": "TB3",
153+
}
154+
)
155+
156+
# Change destination_journal on source payment
157+
payment.write(
158+
{
159+
"destination_journal_id": new_journal.id,
160+
}
161+
)
162+
163+
# Verify paired payment's journal_id was updated (swapped relationship)
164+
self.assertEqual(
165+
paired_payment.journal_id.id,
166+
new_journal.id,
167+
"Paired payment journal_id should sync with source destination_journal_id",
168+
)
169+
170+
def test_no_recursion_with_context(self):
171+
"""Test that skip_paired_payment_update context prevents recursion"""
172+
payment, paired_payment = self._create_paired_payments()
173+
174+
# Store original amount
175+
original_amount = paired_payment.amount
176+
177+
# Update payment with skip context
178+
payment.with_context(skip_paired_payment_update=True).write(
179+
{
180+
"amount": 2000.0,
181+
}
182+
)
183+
184+
# Verify paired payment was NOT updated
185+
self.assertEqual(
186+
paired_payment.amount,
187+
original_amount,
188+
"Paired payment should not be updated when skip_paired_payment_update context is set",
189+
)
190+
191+
def test_rate_fields_trigger_sync(self):
192+
"""Test that rate fields trigger synchronization (payment_pro)"""
193+
if not self.has_payment_pro:
194+
self.skipTest("account_payment_pro module is not installed")
195+
196+
if "counterpart_rate" not in self.env["account.payment"]._fields:
197+
self.skipTest("counterpart_rate field not available")
198+
199+
# Enable payment_pro
200+
self.company.use_payment_pro = True
201+
202+
payment, paired_payment = self._create_paired_payments()
203+
204+
# Set initial counterpart_rate
205+
payment.write({"counterpart_rate": 2.0})
206+
207+
# Verify inverse rate was set on paired payment
208+
self.assertAlmostEqual(
209+
paired_payment.counterpart_rate,
210+
0.5, # 1/2.0
211+
places=4,
212+
msg="Counterpart rate should be inverted on paired payment",
213+
)
214+
215+
# Update rate and verify sync continues to work
216+
payment.write({"counterpart_rate": 1.5})
217+
218+
self.assertAlmostEqual(
219+
paired_payment.counterpart_rate,
220+
1.0 / 1.5,
221+
places=4,
222+
msg="Changing counterpart_rate should trigger sync with inverted rate",
223+
)

0 commit comments

Comments
 (0)