diff --git a/altinkaya_account/i18n/altinkaya_account.pot b/altinkaya_account/i18n/altinkaya_account.pot index fc1a6025d..ef04a33db 100644 --- a/altinkaya_account/i18n/altinkaya_account.pot +++ b/altinkaya_account/i18n/altinkaya_account.pot @@ -497,7 +497,21 @@ msgstr "" #. odoo-python #: code:addons/altinkaya_account/models/res_partner.py:0 #, python-format -msgid "KDV %s oranlı vergi tanımlanmamış!" +msgid "KFARK journal not found!" +msgstr "" + +#. module: altinkaya_account +#. odoo-python +#: code:addons/altinkaya_account/models/res_partner.py:0 +#, python-format +msgid "VAT tax with %s%% rate not found!" +msgstr "" + +#. module: altinkaya_account +#. odoo-python +#: code:addons/altinkaya_account/models/res_partner.py:0 +#, python-format +msgid "Currency difference for the following invoices:\n" msgstr "" #. module: altinkaya_account diff --git a/altinkaya_account/i18n/tr.po b/altinkaya_account/i18n/tr.po index 1670e2fd4..713cdef77 100644 --- a/altinkaya_account/i18n/tr.po +++ b/altinkaya_account/i18n/tr.po @@ -501,8 +501,22 @@ msgstr "Yevmiye Adı" #. odoo-python #: code:addons/altinkaya_account/models/res_partner.py:0 #, python-format -msgid "KDV %s oranlı vergi tanımlanmamış!" -msgstr "" +msgid "KFARK journal not found!" +msgstr "KFARK günlüğü bulunamadı!" + +#. module: altinkaya_account +#. odoo-python +#: code:addons/altinkaya_account/models/res_partner.py:0 +#, python-format +msgid "VAT tax with %s%% rate not found!" +msgstr "KDV %%%s oranlı vergi tanımlanmamış!" + +#. module: altinkaya_account +#. odoo-python +#: code:addons/altinkaya_account/models/res_partner.py:0 +#, python-format +msgid "Currency difference for the following invoices:\n" +msgstr "Aşağıdaki faturaların kur farkıdır:\n" #. module: altinkaya_account #: model_terms:ir.ui.view,arch_db:altinkaya_account.view_partner_form diff --git a/altinkaya_account/models/account_full_reconcile.py b/altinkaya_account/models/account_full_reconcile.py index 5576f7cf6..a808c4e77 100644 --- a/altinkaya_account/models/account_full_reconcile.py +++ b/altinkaya_account/models/account_full_reconcile.py @@ -73,3 +73,12 @@ def get_report_data(self): res = self.env.cr.dictfetchall() return res + + def unlink(self): + """When removing a full reconciliation, also remove partial reconciliations.""" + partial_rec_to_unlink = self.env["account.partial.reconcile"] + for rec in self: + partial_rec_to_unlink |= rec.partial_reconcile_ids + res = super().unlink() + partial_rec_to_unlink.unlink() + return res diff --git a/altinkaya_account/models/res_partner.py b/altinkaya_account/models/res_partner.py index f5795a699..72f6d646c 100644 --- a/altinkaya_account/models/res_partner.py +++ b/altinkaya_account/models/res_partner.py @@ -1,9 +1,13 @@ # Copyright 2025 Ismail Çağan Yılmaz (https://github.com/milleniumkid) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +import logging from odoo import Command, _, api, fields, models from odoo.exceptions import UserError +from odoo.tools import float_is_zero + +_logger = logging.getLogger(__name__) class ResPartner(models.Model): @@ -159,11 +163,145 @@ def action_generate_currency_diff_invoice(self): "context": self.env.context, } + def _get_aggregate_currency_difference(self): + """Aggregate currency difference calculation (accounting method). + + Calculates the difference between total TRY payments and + FIFO-matched invoices' TRY totals. + + Returns: + tuple: (net_currency_diff, matched_invoice_moves) + """ + self.ensure_one() + aml_obj = self.env["account.move.line"] + receivable_account = self.property_account_receivable_id + krfrk_journal = self.env.company.currency_exchange_journal_id + kfark_journal = self.env["account.journal"].search( + [("code", "=", "KFARK")], limit=1 + ) + + excluded_journal_ids = [krfrk_journal.id] + if kfark_journal: + excluded_journal_ids.append(kfark_journal.id) + + # All posted receivable AMLs (excluding KRFRK and KFARK) + all_amls = aml_obj.search( + [ + ("partner_id", "=", self.id), + ("account_id", "=", receivable_account.id), + ("move_id.state", "=", "posted"), + ("journal_id", "not in", excluded_journal_ids), + ], + order="date asc, id asc", + ) + + # Invoices (debit): debit > 0, amount_currency > 0 + invoice_amls = all_amls.filtered( + lambda l: l.debit > 0 and l.amount_currency > 0 + ) + # Payments (credit): credit > 0, amount_currency < 0 + payment_amls = all_amls.filtered( + lambda l: l.credit > 0 and l.amount_currency < 0 + ) + + if not payment_amls: + return 0.0, self.env["account.move"] + + # FIFO queue: remaining USD and TRY per invoice + invoices_queue = [] + for aml in invoice_amls: + invoices_queue.append( + { + "usd_remaining": aml.amount_currency, + "tl_remaining": aml.debit, + "move_id": aml.move_id, + } + ) + + # FIFO matching + total_payment_tl = 0.0 + total_matched_invoice_tl = 0.0 + inv_idx = 0 + matched_invoice_moves = self.env["account.move"] + + for aml in payment_amls: + payment_usd = abs(aml.amount_currency) + total_payment_tl += aml.credit + + usd_to_match = payment_usd + while usd_to_match > 0.005 and inv_idx < len(invoices_queue): + inv = invoices_queue[inv_idx] + if inv["usd_remaining"] <= 0.005: + inv_idx += 1 + continue + + match_usd = min(usd_to_match, inv["usd_remaining"]) + inv_tl_portion = inv["tl_remaining"] * ( + match_usd / inv["usd_remaining"] + ) + + total_matched_invoice_tl += inv_tl_portion + inv["usd_remaining"] -= match_usd + inv["tl_remaining"] -= inv_tl_portion + usd_to_match -= match_usd + + matched_invoice_moves |= inv["move_id"] + + if inv["usd_remaining"] <= 0.005: + inv_idx += 1 + + aggregate_diff = round(total_payment_tl - total_matched_invoice_tl, 2) + + # Deduct previously posted KFARK invoices + if kfark_journal: + posted_kfark_amls = aml_obj.search( + [ + ("partner_id", "=", self.id), + ("account_id", "=", receivable_account.id), + ("journal_id", "=", kfark_journal.id), + ("move_id.state", "=", "posted"), + ] + ) + already_invoiced = round( + sum(posted_kfark_amls.mapped("debit")) + - sum(posted_kfark_amls.mapped("credit")), + 2, + ) + else: + already_invoiced = 0.0 + + net_currency_diff = round(aggregate_diff - already_invoiced, 2) + _logger.info( + "KURFARK AGGREGATE [%s] " + "invoices=%d (%.2f TL), payments=%d (%.2f TL), " + "aggregate=%.2f, already_invoiced=%.2f, net=%.2f", + self.name, + len(invoice_amls), + total_matched_invoice_tl, + len(payment_amls), + total_payment_tl, + aggregate_diff, + already_invoiced, + net_currency_diff, + ) + return net_currency_diff, matched_invoice_moves + def calc_difference_invoice(self, date, payment_term, billing_point): + """Create currency difference invoice using aggregate method. + + Calculates directly from invoices and payments instead of KRFRK entries: + Currency diff = Sum(TRY payments) - Sum(FIFO matched invoices' TRY) + """ + self.ensure_one() inv_obj = self.env["account.move"] + aml_obj = self.env["account.move.line"] diff_inv_journal = self.env["account.journal"].search( [("code", "=", "KFARK")], limit=1 ) + if not diff_inv_journal: + raise UserError(_("KFARK journal not found!")) + + # Cancel existing draft KFARK invoices draft_dif_invs = inv_obj.search( [ ("state", "=", "draft"), @@ -173,100 +311,89 @@ def calc_difference_invoice(self, date, payment_term, billing_point): ] ) if draft_dif_invs: - for draft_inv in draft_dif_invs: - draft_inv.button_cancel() - - difference_aml_domain = self._get_difference_aml_domain() + draft_dif_invs.button_cancel() + + # Calculate aggregate currency difference + net_currency_diff, matched_invoices = self._get_aggregate_currency_difference() + _logger.info( + "KURFARK [%s] net_diff=%.2f, matched=%d", + self.name, + net_currency_diff, + len(matched_invoices), + ) - difference_amls = self.env["account.move.line"].search(difference_aml_domain) - if not difference_amls: + if abs(net_currency_diff) < 0.01: return False - if ( - difference_amls - and round( - ( - sum(difference_amls.mapped("debit")) - - sum(difference_amls.mapped("credit")) - ), - 2, + inv_type = "out_refund" if net_currency_diff < 0 else "out_invoice" + + # Current VAT rates (used in invoice lines) + current_kdv_rates = [20, 10] + # Map old rates to current rates (18→20, 8→10) + rate_mapping = {18: 20, 8: 10, 20: 20, 10: 10} + all_kdv_rates = list(rate_mapping.keys()) + + taxes_dict = {} + for kdv_rate in current_kdv_rates: + tax = self.env["account.tax"].search( + [ + ("type_tax_use", "=", "sale"), + ("amount", "=", kdv_rate), + ("include_base_amount", "=", False), + ], + limit=1, ) - < 0 - ): - inv_type = "out_refund" - else: - inv_type = "out_invoice" - if difference_amls: - # Get taxes - kdv_rates = [20, 10, 18, 8] - taxes_dict = {} - for kdv_rate in kdv_rates: - tax = self.env["account.tax"].search( - [ - ("type_tax_use", "=", "sale"), - ("amount", "=", kdv_rate), - ("include_base_amount", "=", False), - ], - limit=1, - ) - if tax: - taxes_dict[kdv_rate] = tax - else: - raise UserError(_("KDV %s oranlı vergi tanımlanmamış!") % kdv_rate) - - inv_ids = ( - difference_amls._all_reconciled_lines() - .filtered(lambda r: "invoice" in r.move_type) - .mapped("move_id") + if tax: + taxes_dict[kdv_rate] = tax + else: + raise UserError(_("VAT tax with %s%% rate not found!") % kdv_rate) + + # Tax distribution and invoice lines + inv_lines_to_create = [] + comment_einvoice = "" + + sale_invoices = matched_invoices.filtered(lambda m: m.is_invoice()) + if sale_invoices: + comment_einvoice = _("Currency difference for the following invoices:\n") + comment_einvoice += ", ".join( + inv_id.supplier_invoice_number or inv_id.name + for inv_id in sale_invoices ) - total_difference = sum(difference_amls.mapped("balance")) - - comment_einvoice = "Aşağıdaki faturaların kur farkıdır:\n" - inv_lines_to_create = [] + tax_lines = sale_invoices.mapped("tax_line_ids") - if len(inv_ids) > 0: - comment_einvoice += ", ".join( - inv_id.supplier_invoice_number - if inv_id.supplier_invoice_number - else inv_id.number - for inv_id in inv_ids + # Calculate TRY-based tax ratios + # Merge old rates (18, 8) into current rates (20, 10) + base_per_rate = {} + for rate in all_kdv_rates: + invoice_taxes = tax_lines.filtered( + lambda txl, r=rate: txl.tax_line_id.amount == r ) - - # Compute tax distribution - tax_lines = inv_ids.mapped("tax_line_ids") - distribution = {} - - for rate in kdv_rates: - invoice_taxes = tax_lines.filtered( - lambda txl: txl.tax_line_id.amount == rate - ) - - total_tax_amount = sum( - abs(bal) for bal in invoice_taxes.mapped("balance") + tax_tl = sum(abs(bal) for bal in invoice_taxes.mapped("balance")) + if tax_tl > 0: + current_rate = rate_mapping[rate] + base_tl = tax_tl / (rate / 100.0) + base_per_rate[current_rate] = ( + base_per_rate.get(current_rate, 0.0) + base_tl ) - tax_rate = round( - ( - total_tax_amount - / sum(inv_ids.mapped("amount_untaxed_signed")) - * 100 - / rate - ), - 4, - ) - if tax_rate > 0: - distribution[rate] = tax_rate + total_base_tl = sum(base_per_rate.values()) + if total_base_tl > 0: + distribution = { + rate: round(base_tl / total_base_tl, 4) + for rate, base_tl in base_per_rate.items() + } - for rate, tax_rate in distribution.items(): + for rate, tax_ratio in distribution.items(): + diff_account = self.env.company.currency_diff_inv_account_id inv_lines_to_create.append( { "name": _("Currency Difference"), "product_uom_id": 1, - "account_id": self.env.company.currency_diff_inv_account_id.id, # noqa + "account_id": diff_account.id, "price_unit": abs( round( - total_difference * tax_rate / (1 + rate / 100.0), + net_currency_diff * tax_ratio / (1 + rate / 100.0), 2, ) ), @@ -274,61 +401,225 @@ def calc_difference_invoice(self, date, payment_term, billing_point): } ) - else: - # If there is no invoice, then it is a difference between - # the exchange rate of the invoice and the payment - # Set the tax rate to 20% - comment_einvoice = "" - inv_lines_to_create.append( - { - "name": _("Currency Difference"), - "product_uom_id": 1, - "account_id": self.env.company.currency_diff_inv_account_id.id, # noqa - "price_unit": abs( - round( - total_difference / (1 + taxes_dict[20].amount / 100.0), - 2, - ), - ), - "tax_ids": [(6, False, [taxes_dict[20].id])], - } - ) - - dif_inv = inv_obj.create( + if not inv_lines_to_create: + # No matched invoices, default to 20% VAT + diff_account = self.env.company.currency_diff_inv_account_id + inv_lines_to_create.append( { - "partner_id": self.id, - "invoice_date": date, - "journal_id": diff_inv_journal.id, - "currency_id": self.env.company.currency_id.id, - "move_type": inv_type, - "billing_point_id": billing_point.id, - "invoice_payment_term_id": payment_term.id, - "comment_einvoice": comment_einvoice, - "line_ids": [(0, 0, line) for line in inv_lines_to_create], + "name": _("Currency Difference"), + "product_uom_id": 1, + "account_id": diff_account.id, + "price_unit": abs( + round( + net_currency_diff / (1 + taxes_dict[20].amount / 100.0), + 2, + ) + ), + "tax_ids": [(6, False, [taxes_dict[20].id])], } ) - difference_amls.write({"difference_checked": True}) + dif_inv = inv_obj.create( + { + "partner_id": self.id, + "invoice_date": date, + "journal_id": diff_inv_journal.id, + "currency_id": self.env.company.currency_id.id, + "move_type": inv_type, + "billing_point_id": billing_point.id, + "invoice_payment_term_id": payment_term.id, + "comment_einvoice": comment_einvoice, + "line_ids": [(0, 0, line) for line in inv_lines_to_create], + } + ) + + # Mark pending KRFRK entries and link to KFARK invoice + receivable_account = self.property_account_receivable_id + krfrk_journal = self.env.company.currency_exchange_journal_id + unchecked_krfrk = aml_obj.search( + [ + ("partner_id", "=", self.id), + ("account_id", "=", receivable_account.id), + ("journal_id", "=", krfrk_journal.id), + ("difference_checked", "=", False), + ("move_id.state", "=", "posted"), + ("move_id.reversal_move_id", "=", False), + ("move_id.reversed_entry_id", "=", False), + ] + ) + if unchecked_krfrk: + unchecked_krfrk.write({"difference_checked": True}) dif_inv.write( { - "currency_difference_line_ids": [(6, 0, difference_amls.ids)], + "currency_difference_line_ids": [(6, 0, unchecked_krfrk.ids)], + } + ) + + # Clear amount_currency on receivable line + self.env.cr.execute( + """ + UPDATE account_move_line + SET amount_currency = 0.0, currency_id = %s + WHERE move_id = %s AND account_id = %s + """, + ( + dif_inv.company_id.currency_id.id, + dif_inv.id, + dif_inv.partner_id.property_account_receivable_id.id, + ), + ) + dif_inv.line_ids.invalidate_recordset(["amount_currency", "currency_id"]) + return dif_inv + + def unreconcile_partners_amls(self): + """Remove reconciliation for partner's AMLs with currencies.""" + if ( + self.property_account_receivable_id.currency_id + and self.property_account_payable_id.currency_id + ): + reconciled_amls = self.env["account.move.line"].search( + [ + ("partner_id", "=", self.id), + ("full_reconcile_id", "!=", False), + ] + ) + if reconciled_amls: + reconciled_amls.remove_move_reconcile() + + def calc_currency_valuation(self, move_date): + """Calculate currency valuation for foreign partners.""" + query = """ + SELECT partner_id, + currency_id, + account_id, + SUM(try_debit) AS total_try_debit, + SUM(try_credit) AS total_try_credit, + SUM(amount_currency) AS total_currency_amount + FROM ( + SELECT + L.partner_id, + L.account_id, + CASE + WHEN (SUM(L.debit) - SUM(L.credit)) > 0 + THEN ROUND((SUM(L.debit) - SUM(L.credit)), 2) + ELSE 0.00 + END AS TRY_DEBIT, + CASE + WHEN SUM(L.debit) - SUM(L.credit) < 0 + THEN -1 * ROUND( + (SUM(L.debit) - SUM(L.credit)), 2 + ) + ELSE 0.00 + END AS TRY_CREDIT, + ROUND(SUM(L.amount_currency), 4) AS AMOUNT_CURRENCY, + L.currency_id AS CURRENCY_ID + FROM account_move_line AS L + LEFT JOIN account_account A + ON (L.account_id = A.id) + LEFT JOIN account_move AM + ON (L.move_id = AM.id) + LEFT JOIN account_journal AJ + ON (AM.journal_id = AJ.id) + LEFT JOIN account_account_type AT + ON (A.user_type_id = AT.id) + LEFT JOIN res_partner RP + ON (L.partner_id = RP.id) + WHERE L.DATE <= %s + AND L.partner_id IN %s + AND AT.type IN ('payable', 'receivable') + AND L.currency_id IS NOT NULL + AND L.currency_id != 31 -- TRY + AND RP.country_id != 224 -- Turkey + GROUP BY AJ.NAME, + A.code, + A.currency_id, + L.move_id, + AM.NAME, + L.DATE, + L.currency_id, + L.partner_id, + AJ.id, + L.account_id + ) sub + GROUP BY partner_id, currency_id, account_id; + """ + self.env.cr.execute(query, (move_date, tuple(self.ids))) + result = self.env.cr.dictfetchall() + rates = self.env["res.currency.rate"].search_read( + [("name", "=", move_date)], + ["currency_id", "tcmb_forex_buying"], + ) + if not rates: + raise UserError( + _("No exchange rate information found for the selected day!") + ) + rate_dict = {x["currency_id"][0]: x["tcmb_forex_buying"] for x in rates} + diff_journal = self.env["account.journal"].search( + [("code", "=", "KRDGR")], limit=1 + ) + + move_vals = { + "name": (f"{move_date.strftime('%d.%m.%Y')} {_('Currency Valuation')}"), + "journal_id": diff_journal.id, + "date": move_date, + "state": "draft", + "currency_id": self.env.company.currency_id.id, + } + + difference_aml_list = [] + for res in result: + old_try_balance = res["total_try_debit"] - res["total_try_credit"] + current_try_balance = ( + res["total_currency_amount"] / rate_dict[res["currency_id"]] + ) + difference = round(current_try_balance - old_try_balance, 2) + if float_is_zero(difference, precision_rounding=2): + continue + difference_aml_list.append( + { + "partner_id": res["partner_id"], + "account_id": res["account_id"], + "name": _("Currency Valuation"), + "debit": difference if difference > 0 else 0, + "credit": (abs(difference) if difference < 0 else 0), + "currency_id": res["currency_id"], + "amount_currency": 0.00001, } ) - # Clear amount_currency on receivable line - self.env.cr.execute( - """ - UPDATE account_move_line - SET amount_currency = 0.0, currency_id = %s - WHERE move_id = %s and account_id = %s - """, - ( - dif_inv.company_id.currency_id.id, - dif_inv.id, - dif_inv.partner_id.property_account_receivable_id.id, - ), + if not difference_aml_list: + raise UserError( + _("No records found to calculate exchange rate difference!") + ) + + total_debit = sum(x["debit"] for x in difference_aml_list) + total_credit = sum(x["credit"] for x in difference_aml_list) + + # 426: 646 Foreign Exchange Gains + # 429: 656 Foreign Exchange Losses + if total_debit > 0: + difference_aml_list.append( + { + "name": _("Currency Diff. Counterpart"), + "account_id": 426, + "debit": 0, + "credit": total_debit, + "currency_id": (self.env.company.currency_id.id), + } + ) + + if total_credit > 0: + difference_aml_list.append( + { + "name": _("Currency Diff. Counterpart"), + "account_id": 429, + "debit": total_credit, + "credit": 0, + "currency_id": (self.env.company.currency_id.id), + } ) - dif_inv.line_ids.invalidate_recordset(["amount_currency", "currency_id"]) - return dif_inv - return False + move_vals["line_ids"] = [(0, 0, x) for x in difference_aml_list] + move = self.env["account.move"].create(move_vals) + move.post() + return move