From c0dcd07fc474bd8475a107df9fab4692d8dad363 Mon Sep 17 00:00:00 2001 From: kanda999 Date: Sat, 16 May 2026 10:28:39 +0000 Subject: [PATCH 1/3] purchase_deposit_currency/ --- purchase_deposit_currency/__init__.py | 3 + purchase_deposit_currency/__manifest__.py | 20 ++++ purchase_deposit_currency/models/__init__.py | 5 + .../models/account_move.py | 28 ++++++ .../models/account_move_line.py | 74 +++++++++++++++ .../models/purchase_order_line.py | 32 +++++++ .../readme/DESCRIPTION.md | 92 +++++++++++++++++++ .../views/account_move_views.xml | 19 ++++ 8 files changed, 273 insertions(+) create mode 100644 purchase_deposit_currency/__init__.py create mode 100644 purchase_deposit_currency/__manifest__.py create mode 100644 purchase_deposit_currency/models/__init__.py create mode 100644 purchase_deposit_currency/models/account_move.py create mode 100644 purchase_deposit_currency/models/account_move_line.py create mode 100644 purchase_deposit_currency/models/purchase_order_line.py create mode 100644 purchase_deposit_currency/readme/DESCRIPTION.md create mode 100644 purchase_deposit_currency/views/account_move_views.xml diff --git a/purchase_deposit_currency/__init__.py b/purchase_deposit_currency/__init__.py new file mode 100644 index 00000000..31399946 --- /dev/null +++ b/purchase_deposit_currency/__init__.py @@ -0,0 +1,3 @@ +# Copyright 2026 Quartile Limited +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +from . import models diff --git a/purchase_deposit_currency/__manifest__.py b/purchase_deposit_currency/__manifest__.py new file mode 100644 index 00000000..59be9557 --- /dev/null +++ b/purchase_deposit_currency/__manifest__.py @@ -0,0 +1,20 @@ +# Copyright 2026 Quartile Limited +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +{ + "name": "Purchase Deposit Multi-Currency", + "version": "16.0.2.0.0", + "author": "Quartile Limited", + "website": "https://www.quartile.co", + "category": "Purchase", + "license": "AGPL-3", + "summary": "Let each vendor-bill line carry a manually-entered " + "company-currency amount so foreign-currency deposits and invoices " + "post the exact JPY (company-currency) value the user actually paid, " + "bypassing Odoo's exchange-rate conversion. The deposit value is " + "carried over to the deposit-offset line of the final invoice.", + "depends": ["purchase_deposit"], + "data": [ + "views/account_move_views.xml", + ], + "installable": True, +} diff --git a/purchase_deposit_currency/models/__init__.py b/purchase_deposit_currency/models/__init__.py new file mode 100644 index 00000000..7426afa3 --- /dev/null +++ b/purchase_deposit_currency/models/__init__.py @@ -0,0 +1,5 @@ +# Copyright 2026 Quartile Limited +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +from . import account_move +from . import account_move_line +from . import purchase_order_line diff --git a/purchase_deposit_currency/models/account_move.py b/purchase_deposit_currency/models/account_move.py new file mode 100644 index 00000000..4c8f59c0 --- /dev/null +++ b/purchase_deposit_currency/models/account_move.py @@ -0,0 +1,28 @@ +# Copyright 2026 Quartile Limited +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +from odoo import models + + +class AccountMove(models.Model): + _inherit = "account.move" + + def action_post(self): + """When a deposit vendor bill is posted, capture the company-currency + value of its deposit line and store it on the linked PO deposit + line. The standard purchase_deposit offset on the final invoice then + re-applies that value (see + ``purchase.order.line._prepare_account_move_line``). + """ + deposit_writes = [] + for move in self: + for line in move.line_ids: + po_line = line.purchase_line_id + if not po_line or not po_line.is_deposit: + continue + amount = line.company_amount or abs(line.balance) + if amount: + deposit_writes.append((po_line, amount)) + res = super().action_post() + for po_line, amount in deposit_writes: + po_line.deposit_company_amount = amount + return res diff --git a/purchase_deposit_currency/models/account_move_line.py b/purchase_deposit_currency/models/account_move_line.py new file mode 100644 index 00000000..6ae2023a --- /dev/null +++ b/purchase_deposit_currency/models/account_move_line.py @@ -0,0 +1,74 @@ +# Copyright 2026 Quartile Limited +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +from odoo import api, fields, models +from odoo.tools.float_utils import float_is_zero + + +class AccountMoveLine(models.Model): + _inherit = "account.move.line" + + is_deposit_line = fields.Boolean( + related="purchase_line_id.is_deposit", + store=True, + string="Is Deposit Line", + help="True when this account move line is the deposit (or deposit " + "offset) line for a purchase order. The Company Currency Amount " + "override is only honoured on deposit lines.", + ) + company_amount = fields.Monetary( + string="Company Currency Amount", + currency_field="company_currency_id", + help="Manually-entered company-currency value of this line. " + "When set on a deposit line (non-zero), the line's balance is " + "forced to this value, bypassing the standard amount_currency × " + "exchange_rate calculation. Useful for foreign-currency deposits " + "where you actually paid an exact JPY amount that doesn't match " + "today's exchange rate. Ignored on non-deposit lines.", + ) + + @api.onchange("company_amount") + def _onchange_company_amount(self): + for line in self: + if not line.company_amount: + continue + line._apply_company_amount_override() + + def _apply_company_amount_override(self): + """Force the line's balance to ``company_amount`` with the sign that + matches the line's intended direction. The companion AP / receivable + line is auto-balanced by Odoo from the sum of the other lines. + + Defence-in-depth: the override is only honoured on deposit lines + (``is_deposit_line``); on any other line the value is ignored. + """ + self.ensure_one() + if not self.company_amount: + return + if not self.is_deposit_line: + return + rounding = self.company_currency_id.rounding + amount_currency_positive = self.amount_currency >= 0 + target = abs(self.company_amount) + signed_balance = target if amount_currency_positive else -target + if float_is_zero(self.balance - signed_balance, precision_rounding=rounding): + return + self.balance = signed_balance + + def write(self, vals): + res = super().write(vals) + if ( + "company_amount" in vals + or "amount_currency" in vals + or "price_unit" in vals + or "quantity" in vals + ): + for line in self.filtered("company_amount"): + line._apply_company_amount_override() + return res + + @api.model_create_multi + def create(self, vals_list): + lines = super().create(vals_list) + for line in lines.filtered("company_amount"): + line._apply_company_amount_override() + return lines diff --git a/purchase_deposit_currency/models/purchase_order_line.py b/purchase_deposit_currency/models/purchase_order_line.py new file mode 100644 index 00000000..402bc853 --- /dev/null +++ b/purchase_deposit_currency/models/purchase_order_line.py @@ -0,0 +1,32 @@ +# Copyright 2026 Quartile Limited +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +from odoo import fields, models + + +class PurchaseOrderLine(models.Model): + _inherit = "purchase.order.line" + + company_currency_id = fields.Many2one( + "res.currency", + related="company_id.currency_id", + string="Company Currency", + readonly=True, + ) + deposit_company_amount = fields.Monetary( + string="Deposit Company-Currency Amount", + currency_field="company_currency_id", + copy=False, + help="Company-currency value carried over from the deposit vendor " + "bill. Used to populate ``company_amount`` on the negative " + "deposit-offset line of the final invoice so the JPY amount " + "matches what was actually paid.", + ) + + def _prepare_account_move_line(self, move=False): + res = super()._prepare_account_move_line(move=move) + if self.is_deposit and self.deposit_company_amount: + # purchase_deposit flips quantity to -1 for the offset line. + # The corresponding company-currency value must also flip + # so balance lands at - on the final invoice. + res["company_amount"] = -self.deposit_company_amount + return res diff --git a/purchase_deposit_currency/readme/DESCRIPTION.md b/purchase_deposit_currency/readme/DESCRIPTION.md new file mode 100644 index 00000000..7dfc4f14 --- /dev/null +++ b/purchase_deposit_currency/readme/DESCRIPTION.md @@ -0,0 +1,92 @@ +# Purchase Deposit Multi-Currency + +Adds a per-line **Company Currency Amount (会社通貨価格)** override on +vendor bills, plus automatic propagation of that value from a deposit +bill to the deposit-offset line of the final invoice. + +## Why + +When the purchase order is in a foreign currency, the user often pays +an exact JPY (company-currency) amount that does not match Odoo's +configured exchange rate at the deposit date. For example, paying +**¥4000** as a deposit on a **USD 100** purchase order, even though +USD 30 at today's rate would convert to ¥3300. + +Odoo's standard behaviour would record ¥3300 in the journal (= $30 × +rate). This module lets the user enter ¥4000 directly on the bill +line, regardless of the rate. + +## What it does + +1. **`Company Currency Amount` field on deposit lines only.** + - Optional column in the vendor-bill line tree. + - **Editable only on deposit lines** (where the underlying PO line + has ``is_deposit = True``). Read-only on regular product / tax / + AP lines for safety. The scope can be widened later if needed. + - Leave blank to use the standard rate-based conversion. + - Enter a value to force the deposit line's ``balance`` + (debit/credit) to that company-currency amount. The foreign + currency ``amount_currency`` stays at ``price_unit × quantity``; + only the company-currency side is replaced. + - The companion AP / payable line is auto-balanced by Odoo from + the remaining lines, so the journal still nets to zero. + +2. **Deposit → final invoice propagation.** + - When a deposit vendor bill (created by ``purchase_deposit``'s + *Register Deposit* wizard) is posted, its line's + ``company_amount`` is captured and stored on the corresponding + PO deposit line in a new ``deposit_company_amount`` field. + - When the user later runs the standard "Create Bill" on the PO, + ``purchase_deposit`` adds a negative-quantity offset line for + the deposit; this module sets ``company_amount`` on that line + to the negated stored value. + - End result: the offset line's JPY balance exactly mirrors the + deposit bill's JPY balance, so the deposit account closes out + cleanly even when the exchange rate has moved between deposit + posting and final invoice creation. + +3. **Deposit-line scope only (initial release).** The override is + intentionally restricted to deposit lines. If a future requirement + needs direct override on regular product lines of the final + invoice, remove the ``is_deposit_line`` gate in + ``_apply_company_amount_override`` and the corresponding + ``readonly`` attribute on the view. + +## Example + +``` +PO (USD): 1 × $100 +Receive: standard receipt at PO unit cost. + +Register Deposit wizard (standard purchase_deposit, no changes): + → Deposit bill in USD: 1 × $30 + → User overrides company_amount = 4000 (¥ direct input) + → Post deposit bill + Journal: Deposit account debit ¥4000 amount_currency $30 + AP credit ¥4000 amount_currency -$30 + → PO deposit line now stores deposit_company_amount = ¥4000 + +Standard "Create Bill" on the PO: + → Final invoice in USD: + Product line: $100 company_amount = ¥11000 (auto rate) + Deposit offset: -$30 company_amount = -¥4000 (propagated) + AP line: -$70 balance = -¥7000 (auto-balanced) + → Optionally, the user can further override company_amount on the + product line of the final invoice (e.g. ¥10800 instead of the + rate-based ¥11000). +``` + +The user pays ¥4000 to the deposit bill and ¥7000 (or whatever the +USD $70 actually settles at) to the final bill. Exchange-rate +differences at payment time are recorded in Odoo's standard +exchange-diff journal. + +## Notes + +- A ``company_amount`` of zero / empty is treated as "no override" — + Odoo's rate-based conversion applies as usual. +- The override only affects the line it's set on; AP and tax lines + are auto-balanced and do not need a separate override. +- Tax-exclusive vs tax-inclusive behaviour is unchanged: the foreign + currency total (and tax computation) is still driven by + ``price_unit × quantity``. diff --git a/purchase_deposit_currency/views/account_move_views.xml b/purchase_deposit_currency/views/account_move_views.xml new file mode 100644 index 00000000..2eda97e4 --- /dev/null +++ b/purchase_deposit_currency/views/account_move_views.xml @@ -0,0 +1,19 @@ + + + + account.move.form.company.amount + account.move + + + + + + + + + From 34c446725c68d158447e748247b23fcffca344a0 Mon Sep 17 00:00:00 2001 From: kanda999 Date: Mon, 18 May 2026 11:09:58 +0000 Subject: [PATCH 2/3] upd purchase_deposit_currency --- purchase_deposit_currency/__manifest__.py | 2 +- .../models/account_move_line.py | 38 +++++++------ .../readme/DESCRIPTION.md | 55 +++++++++++++------ .../views/account_move_views.xml | 4 +- 4 files changed, 60 insertions(+), 39 deletions(-) diff --git a/purchase_deposit_currency/__manifest__.py b/purchase_deposit_currency/__manifest__.py index 59be9557..d8527dfb 100644 --- a/purchase_deposit_currency/__manifest__.py +++ b/purchase_deposit_currency/__manifest__.py @@ -2,7 +2,7 @@ # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). { "name": "Purchase Deposit Multi-Currency", - "version": "16.0.2.0.0", + "version": "16.0.2.1.0", "author": "Quartile Limited", "website": "https://www.quartile.co", "category": "Purchase", diff --git a/purchase_deposit_currency/models/account_move_line.py b/purchase_deposit_currency/models/account_move_line.py index 6ae2023a..3f512024 100644 --- a/purchase_deposit_currency/models/account_move_line.py +++ b/purchase_deposit_currency/models/account_move_line.py @@ -7,23 +7,15 @@ class AccountMoveLine(models.Model): _inherit = "account.move.line" - is_deposit_line = fields.Boolean( - related="purchase_line_id.is_deposit", - store=True, - string="Is Deposit Line", - help="True when this account move line is the deposit (or deposit " - "offset) line for a purchase order. The Company Currency Amount " - "override is only honoured on deposit lines.", - ) company_amount = fields.Monetary( string="Company Currency Amount", currency_field="company_currency_id", help="Manually-entered company-currency value of this line. " - "When set on a deposit line (non-zero), the line's balance is " - "forced to this value, bypassing the standard amount_currency × " - "exchange_rate calculation. Useful for foreign-currency deposits " - "where you actually paid an exact JPY amount that doesn't match " - "today's exchange rate. Ignored on non-deposit lines.", + "When set (non-zero), the line's balance is forced to this value, " + "bypassing the standard amount_currency × exchange_rate " + "calculation. Useful for foreign-currency vendor bills where you " + "actually paid an exact JPY amount that doesn't match today's " + "exchange rate.", ) @api.onchange("company_amount") @@ -37,15 +29,10 @@ def _apply_company_amount_override(self): """Force the line's balance to ``company_amount`` with the sign that matches the line's intended direction. The companion AP / receivable line is auto-balanced by Odoo from the sum of the other lines. - - Defence-in-depth: the override is only honoured on deposit lines - (``is_deposit_line``); on any other line the value is ignored. """ self.ensure_one() if not self.company_amount: return - if not self.is_deposit_line: - return rounding = self.company_currency_id.rounding amount_currency_positive = self.amount_currency >= 0 target = abs(self.company_amount) @@ -72,3 +59,18 @@ def create(self, vals_list): for line in lines.filtered("company_amount"): line._apply_company_amount_override() return lines + + def _get_gross_unit_price(self): + # Make purchase_stock's price-diff logic (which divides by currency_rate) + # see the company_amount override, so SVL/AML adjustments are generated. + res = super()._get_gross_unit_price() + if ( + self.company_amount + and self.quantity + and self.currency_rate + and self.currency_id != self.company_currency_id + and self.move_id.move_type in ("in_invoice", "in_refund") + ): + sign = -1 if self.move_id.move_type == "in_refund" else 1 + return abs(self.company_amount) / self.quantity * self.currency_rate * sign + return res diff --git a/purchase_deposit_currency/readme/DESCRIPTION.md b/purchase_deposit_currency/readme/DESCRIPTION.md index 7dfc4f14..44c311c5 100644 --- a/purchase_deposit_currency/readme/DESCRIPTION.md +++ b/purchase_deposit_currency/readme/DESCRIPTION.md @@ -18,20 +18,29 @@ line, regardless of the rate. ## What it does -1. **`Company Currency Amount` field on deposit lines only.** - - Optional column in the vendor-bill line tree. - - **Editable only on deposit lines** (where the underlying PO line - has ``is_deposit = True``). Read-only on regular product / tax / - AP lines for safety. The scope can be widened later if needed. +1. **`Company Currency Amount` field on every vendor-bill line.** + - Optional column in the vendor-bill line tree (in_invoice / + in_refund only). - Leave blank to use the standard rate-based conversion. - - Enter a value to force the deposit line's ``balance`` - (debit/credit) to that company-currency amount. The foreign - currency ``amount_currency`` stays at ``price_unit × quantity``; - only the company-currency side is replaced. + - Enter a value to force the line's ``balance`` (debit/credit) to + that company-currency amount. The foreign currency + ``amount_currency`` stays at ``price_unit × quantity``; only the + company-currency side is replaced. - The companion AP / payable line is auto-balanced by Odoo from the remaining lines, so the journal still nets to zero. -2. **Deposit → final invoice propagation.** +2. **Stock valuation adjustment for product lines.** + - When the overridden line is a stockable product valued in real + time, the override also drives ``purchase_stock``'s price-diff + logic. The hook is ``_get_gross_unit_price``: it returns a + foreign-currency unit price that, divided by the date-based + ``currency_rate``, yields exactly ``company_amount / quantity``. + - As a result, both the price-difference AML (stock_in vs. + expense) and the corresponding ``stock.valuation.layer`` + adjustment reflect the user-entered JPY value rather than the + rate-converted one. + +3. **Deposit → final invoice propagation.** - When a deposit vendor bill (created by ``purchase_deposit``'s *Register Deposit* wizard) is posted, its line's ``company_amount`` is captured and stored on the corresponding @@ -45,13 +54,6 @@ line, regardless of the rate. cleanly even when the exchange rate has moved between deposit posting and final invoice creation. -3. **Deposit-line scope only (initial release).** The override is - intentionally restricted to deposit lines. If a future requirement - needs direct override on regular product lines of the final - invoice, remove the ``is_deposit_line`` gate in - ``_apply_company_amount_override`` and the corresponding - ``readonly`` attribute on the view. - ## Example ``` @@ -81,6 +83,25 @@ USD $70 actually settles at) to the final bill. Exchange-rate differences at payment time are recorded in Odoo's standard exchange-diff journal. +## Direct override on a product line + +When a regular product line on a vendor bill is overridden — e.g. +the PO is USD 100 / qty 1 and the user enters +``company_amount = ¥17000`` instead of the rate-converted ¥15000 — +``_get_gross_unit_price`` makes the price-diff logic see the JPY +override: + +``` +Receipt SVL : qty 1, value ¥15000 (rate-based at receipt date) +Vendor bill : $100, company_amount = ¥17000 + Journal: stock_in debit ¥15000 + expense debit ¥2000 + AP credit ¥17000 amount_currency -$100 + SVL adj : value +¥2000 on the receipt layer +``` + +The stock valuation now reflects the actual JPY paid. + ## Notes - A ``company_amount`` of zero / empty is treated as "no override" — diff --git a/purchase_deposit_currency/views/account_move_views.xml b/purchase_deposit_currency/views/account_move_views.xml index 2eda97e4..1be8f4cd 100644 --- a/purchase_deposit_currency/views/account_move_views.xml +++ b/purchase_deposit_currency/views/account_move_views.xml @@ -6,12 +6,10 @@ - From ac2a819c47dc528171361ffc956cf9843eb0069c Mon Sep 17 00:00:00 2001 From: kanda999 Date: Tue, 23 Jun 2026 08:38:30 +0000 Subject: [PATCH 3/3] upd --- purchase_deposit_currency/__manifest__.py | 2 +- .../models/account_move.py | 88 ++++++- .../models/account_move_line.py | 18 ++ .../readme/DESCRIPTION.md | 37 ++- purchase_deposit_currency/tests/__init__.py | 3 + .../tests/test_purchase_deposit_currency.py | 217 ++++++++++++++++++ 6 files changed, 353 insertions(+), 12 deletions(-) create mode 100644 purchase_deposit_currency/tests/__init__.py create mode 100644 purchase_deposit_currency/tests/test_purchase_deposit_currency.py diff --git a/purchase_deposit_currency/__manifest__.py b/purchase_deposit_currency/__manifest__.py index d8527dfb..1862ba36 100644 --- a/purchase_deposit_currency/__manifest__.py +++ b/purchase_deposit_currency/__manifest__.py @@ -2,7 +2,7 @@ # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). { "name": "Purchase Deposit Multi-Currency", - "version": "16.0.2.1.0", + "version": "16.0.2.2.0", "author": "Quartile Limited", "website": "https://www.quartile.co", "category": "Purchase", diff --git a/purchase_deposit_currency/models/account_move.py b/purchase_deposit_currency/models/account_move.py index 4c8f59c0..2ffa1fc0 100644 --- a/purchase_deposit_currency/models/account_move.py +++ b/purchase_deposit_currency/models/account_move.py @@ -1,11 +1,97 @@ # Copyright 2026 Quartile Limited # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). -from odoo import models +from odoo import api, models class AccountMove(models.Model): _inherit = "account.move" + def _moves_needing_deposit_company_adjustment(self): + return self.filtered( + lambda m: m.move_type in ("in_invoice", "in_refund") + and m.currency_id != m.company_id.currency_id + and m.line_ids.filtered( + lambda l: l.display_type == "product" + and l.purchase_line_id.is_deposit + and l.purchase_line_id.deposit_company_amount + and l.quantity < 0 + ) + ) + + def _apply_deposit_company_adjustment(self): + """Push the deposit's exchange-rate difference onto the product + line(s) of the final invoice. + + The deposit-offset line's company-currency balance is pinned to the + JPY actually paid (``deposit_company_amount``), while the product + lines are booked at the current rate. The gap between the deposit + valued at the current rate and the JPY actually paid is the + rate-difference; it belongs in the goods' acquisition cost. We absorb + it into the product lines' ``company_amount`` so the goods value (and + SVL) reflects the true cost and the auto-balanced payable equals the + remaining foreign amount at the current rate. + """ + for move in self: + company_currency = move.company_id.currency_id + offset_lines = move.line_ids.filtered( + lambda l: l.display_type == "product" + and l.purchase_line_id.is_deposit + and l.purchase_line_id.deposit_company_amount + and l.quantity < 0 + ) + # delta = deposit-at-current-rate - JPY actually paid (pinned + # balance). Signed, so it works for in_invoice and in_refund. + total_delta = sum( + offset._deposit_natural_balance() - offset.balance + for offset in offset_lines + ) + if company_currency.is_zero(total_delta): + continue + product_lines = move.line_ids.filtered( + lambda l: l.display_type == "product" + and l.purchase_line_id + and not l.purchase_line_id.is_deposit + # Auto-fill empty lines and lines we filled before (rate may + # have changed); never clobber a manual override. + and (not l.company_amount or l.deposit_amount_adjusted) + ) + if not product_lines: + continue + naturals = {l.id: l._deposit_natural_balance() for l in product_lines} + total_weight = sum(abs(v) for v in naturals.values()) + line_count = len(product_lines) + remaining = total_delta + for idx, line in enumerate(product_lines): + if idx < line_count - 1: + if total_weight: + weight = abs(naturals[line.id]) / total_weight + raw_share = total_delta * weight + else: + raw_share = total_delta / line_count + share = company_currency.round(raw_share) + remaining -= share + else: + # last line absorbs the rounding remainder + share = company_currency.round(remaining) + target = company_currency.round(naturals[line.id] + share) + line.with_context(skip_deposit_company_adjustment=True).write( + {"company_amount": target, "deposit_amount_adjusted": True} + ) + + @api.model_create_multi + def create(self, vals_list): + moves = super().create(vals_list) + target = moves._moves_needing_deposit_company_adjustment() + target._apply_deposit_company_adjustment() + return moves + + def write(self, vals): + res = super().write(vals) + if not self.env.context.get("skip_deposit_company_adjustment"): + target = self._moves_needing_deposit_company_adjustment() + target._apply_deposit_company_adjustment() + return res + def action_post(self): """When a deposit vendor bill is posted, capture the company-currency value of its deposit line and store it on the linked PO deposit diff --git a/purchase_deposit_currency/models/account_move_line.py b/purchase_deposit_currency/models/account_move_line.py index 3f512024..e0e43be8 100644 --- a/purchase_deposit_currency/models/account_move_line.py +++ b/purchase_deposit_currency/models/account_move_line.py @@ -17,6 +17,24 @@ class AccountMoveLine(models.Model): "actually paid an exact JPY amount that doesn't match today's " "exchange rate.", ) + deposit_amount_adjusted = fields.Boolean( + copy=False, + help="Set automatically when this product line's company_amount was " + "filled by the deposit rate-difference adjustment on the final " + "invoice. Lets the value be recomputed when the rate changes without " + "mistaking it for a manual override.", + ) + + def _deposit_natural_balance(self): + """Rate-based company-currency value of this line, i.e. what + ``balance`` would be without any ``company_amount`` override. Used by + the deposit rate-difference adjustment so its computation stays + idempotent regardless of overrides already applied. + """ + self.ensure_one() + if not self.currency_rate: + return self.balance + return self.amount_currency / self.currency_rate @api.onchange("company_amount") def _onchange_company_amount(self): diff --git a/purchase_deposit_currency/readme/DESCRIPTION.md b/purchase_deposit_currency/readme/DESCRIPTION.md index 44c311c5..7c513383 100644 --- a/purchase_deposit_currency/readme/DESCRIPTION.md +++ b/purchase_deposit_currency/readme/DESCRIPTION.md @@ -54,6 +54,22 @@ line, regardless of the rate. cleanly even when the exchange rate has moved between deposit posting and final invoice creation. +4. **Rate-difference absorbed by the product line(s).** + - Because the offset line is pinned to the JPY actually paid while + the product lines are booked at the *current* rate, there is a + rate-difference (the deposit valued at today's rate vs. the JPY + actually paid). This difference is part of the goods' + acquisition cost, so the module pushes it onto the product + line(s) via their ``company_amount`` (distributed proportionally + when there is more than one). + - End result: the product line reflects the **true cost** + (deposit paid + remaining foreign amount × current rate), and + the auto-balanced payable equals exactly the **remaining** + foreign amount converted at the current rate. + - The value is recomputed if the rate changes (e.g. the invoice + date is edited) and never overwrites a ``company_amount`` the + user entered manually on a product line. + ## Example ``` @@ -68,20 +84,21 @@ Register Deposit wizard (standard purchase_deposit, no changes): AP credit ¥4000 amount_currency -$30 → PO deposit line now stores deposit_company_amount = ¥4000 -Standard "Create Bill" on the PO: +Standard "Create Bill" on the PO (current rate USD 1 = ¥160): → Final invoice in USD: - Product line: $100 company_amount = ¥11000 (auto rate) + Product line: $100 company_amount = ¥15200 (auto adj.) Deposit offset: -$30 company_amount = -¥4000 (propagated) - AP line: -$70 balance = -¥7000 (auto-balanced) - → Optionally, the user can further override company_amount on the - product line of the final invoice (e.g. ¥10800 instead of the - rate-based ¥11000). + AP line: -$70 balance = -¥11200 (auto-balanced) + → Product line = ¥4000 (deposit paid) + $70 × 160 (¥11200) = ¥15200, + i.e. the rate-difference (deposit at 160 = ¥4800 vs. ¥4000 paid = + ¥800) is subtracted from $100 × 160 = ¥16000 → ¥15200. + → The user may still override company_amount manually on the product + line; a manual value is never overwritten by the auto adjustment. ``` -The user pays ¥4000 to the deposit bill and ¥7000 (or whatever the -USD $70 actually settles at) to the final bill. Exchange-rate -differences at payment time are recorded in Odoo's standard -exchange-diff journal. +The user pays ¥4000 to the deposit bill and ¥11200 (USD $70 at the +current rate) to the final bill. Exchange-rate differences at payment +time are recorded in Odoo's standard exchange-diff journal. ## Direct override on a product line diff --git a/purchase_deposit_currency/tests/__init__.py b/purchase_deposit_currency/tests/__init__.py new file mode 100644 index 00000000..37f7bebb --- /dev/null +++ b/purchase_deposit_currency/tests/__init__.py @@ -0,0 +1,3 @@ +# Copyright 2026 Quartile Limited +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +from . import test_purchase_deposit_currency diff --git a/purchase_deposit_currency/tests/test_purchase_deposit_currency.py b/purchase_deposit_currency/tests/test_purchase_deposit_currency.py new file mode 100644 index 00000000..437d5ab8 --- /dev/null +++ b/purchase_deposit_currency/tests/test_purchase_deposit_currency.py @@ -0,0 +1,217 @@ +# Copyright 2026 Quartile Limited +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from odoo import fields +from odoo.tests.common import Form, TransactionCase + + +class TestPurchaseDepositCurrency(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.company = cls.env["res.company"].create( + { + "name": "test company", + "currency_id": cls.env.ref("base.JPY").id, + "country_id": cls.env.ref("base.jp").id, + } + ) + cls.env.user.company_id = cls.company + cls.currency_usd = cls.env.ref("base.USD") + cls.currency_usd.active = True + Rate = cls.env["res.currency.rate"] + Rate.create( + { + "name": "2025-10-01", + "currency_id": cls.currency_usd.id, + "company_id": cls.company.id, + "rate": 1 / 150.0, + } + ) + # Latest rate, applied to the final invoice (USD 1 = JPY 160). + Rate.create( + { + "name": "2025-11-01", + "currency_id": cls.currency_usd.id, + "company_id": cls.company.id, + "rate": 1 / 160.0, + } + ) + Account = cls.env["account.account"] + account_payable = Account.create( + { + "code": "TEST1", + "name": "Payable", + "reconcile": True, + "account_type": "liability_payable", + "company_id": cls.company.id, + } + ) + account_expense = Account.create( + { + "code": "TEST2", + "name": "Expense", + "account_type": "expense", + "company_id": cls.company.id, + } + ) + stock_valuation = Account.create( + { + "code": "TEST3", + "name": "Stock Valuation", + "account_type": "asset_current", + "company_id": cls.company.id, + } + ) + stock_input = Account.create( + { + "code": "TEST4", + "name": "Stock Input", + "account_type": "asset_current", + "company_id": cls.company.id, + } + ) + stock_output = Account.create( + { + "code": "TEST5", + "name": "Stock Output", + "account_type": "asset_current", + "company_id": cls.company.id, + } + ) + cls.vendor = cls.env["res.partner"].create( + { + "name": "test partner", + "property_account_payable_id": account_payable.id, + "company_id": cls.company.id, + } + ) + stock_journal = cls.env["account.journal"].create( + { + "code": "Valuation", + "name": "Valuation Journal", + "type": "general", + "company_id": cls.company.id, + } + ) + cls.category = cls.env["product.category"].create( + { + "name": "Deposit Test Category", + "property_valuation": "real_time", + "property_cost_method": "fifo", + "property_account_expense_categ_id": account_expense.id, + "property_stock_valuation_account_id": stock_valuation.id, + "property_stock_account_input_categ_id": stock_input.id, + "property_stock_account_output_categ_id": stock_output.id, + "property_stock_journal": stock_journal.id, + } + ) + cls.product = cls.env["product.product"].create( + { + "name": "Deposit Test Product", + "type": "product", + "categ_id": cls.category.id, + "company_id": cls.company.id, + } + ) + cls.account_deposit = Account.create( + { + "name": "Purchase Deposit", + "code": "TEST6", + "account_type": "asset_current", + "company_id": cls.company.id, + } + ) + cls.journal = cls.env["account.journal"].create( + { + "code": "TP", + "name": "Test Purchase", + "type": "purchase", + "company_id": cls.company.id, + } + ) + + def _create_purchase_order(self): + with Form(self.env["purchase.order"]) as po_form: + po_form.partner_id = self.vendor + po_form.date_order = fields.Date.from_string("2025-10-01") + po_form.company_id = self.company + po_form.currency_id = self.currency_usd + with po_form.order_line.new() as line: + line.product_id = self.product + line.product_qty = 1.0 + line.price_unit = 100.0 + po = po_form.save() + po.button_confirm() + return po + + def _create_advance_payment(self, po): + wizard_env = self.env["purchase.advance.payment.inv"].with_context( + active_id=po.id, + active_ids=po.ids, + active_model="purchase.order", + create_bills=True, + ) + with Form(wizard_env) as advance_form: + advance_form.advance_payment_method = "percentage" + advance_form.amount = 30 + advance_form.deposit_account_id = self.account_deposit + wizard = advance_form.save() + wizard.create_invoices() + + def test_deposit_rate_difference_lands_on_product_line(self): + """USD 100 PO, USD 30 deposit paid as JPY 3900 (manual override), + final invoice at rate 160. The product line must carry the true cost + (3900 + 70*160 = 15100) and the payable must be the remaining USD 70 + at the current rate (-11200). + """ + po = self._create_purchase_order() + self._create_advance_payment(po) + deposit_bill = po.invoice_ids + deposit_bill.invoice_date = fields.Date.from_string("2025-10-01") + deposit_line = deposit_bill.line_ids.filtered( + lambda l: l.purchase_line_id.is_deposit and l.quantity > 0 + ) + self.assertTrue(deposit_line, "Deposit bill should have a deposit line.") + # Manually enter the JPY actually paid for the deposit (not 30*rate). + deposit_line.company_amount = 3900 + self.assertEqual(deposit_line.balance, 3900) + deposit_bill.action_post() + deposit_po_line = po.order_line.filtered("is_deposit") + self.assertEqual(deposit_po_line.deposit_company_amount, 3900) + + # Receive the goods, then create the final bill. + po.picking_ids.move_ids.write({"quantity_done": 1}) + po.picking_ids.button_validate() + res = po.with_context(create_bill=True).action_create_invoice() + bill = self.env["account.move"].browse(res["res_id"]) + bill.invoice_date = fields.Date.from_string("2025-11-01") + + offset_line = bill.line_ids.filtered( + lambda l: l.purchase_line_id.is_deposit and l.quantity < 0 + ) + product_line = bill.line_ids.filtered( + lambda l: l.display_type == "product" + and not l.purchase_line_id.is_deposit + ) + payable_line = bill.line_ids.filtered( + lambda l: l.account_id.account_type == "liability_payable" + ) + + # Offset line still pinned to the JPY actually paid for the deposit. + self.assertEqual(offset_line.balance, -3900) + # Rate difference (160 vs the 130 effectively paid) lands on the goods. + self.assertTrue(product_line.deposit_amount_adjusted) + self.assertEqual(product_line.company_amount, 15100) + self.assertEqual(product_line.balance, 15100) + # Remaining USD 70 payable at the current rate. + self.assertEqual(payable_line.balance, -11200) + + # Posting must not disturb the balances. + bill.action_post() + self.assertEqual(offset_line.balance, -3900) + self.assertEqual(product_line.balance, 15100) + self.assertEqual(payable_line.balance, -11200) + # Stock valuation reflects the true acquisition cost. + svls = bill.line_ids.mapped("stock_valuation_layer_ids") + self.assertEqual(sum(svls.mapped("value")), -900.0)