diff --git a/purchase_rappel/README.md b/purchase_rappel/README.md
new file mode 100644
index 0000000..dda3969
--- /dev/null
+++ b/purchase_rappel/README.md
@@ -0,0 +1,40 @@
+# Purchase Rappel Commission
+
+This module allows managing **rappel commission invoices** for vendors.
+
+## Features
+
+- Configure a rappel commission rate per vendor.
+- Automatically mark vendor bills as "pending commission".
+- Generate customer (sale) invoices from selected vendor bills via a wizard.
+- Group invoices by vendor and compute commission amounts based on the taxable base.
+
+## Configuration
+
+Go to **Purchase > Configuration > Settings > Invoicing** and set:
+
+- **Rappel Journal**: Sales journal where rappel commission invoices will be posted.
+- **Commission Product**: Product used as invoice line in rappel commission invoices (should have an income account and taxes configured).
+
+Open a vendor contact and go to the **Sales & Purchases** tab:
+
+- Enable **Rappel Commission**.
+- Set the **Commission Rate (%)** (e.g., 5.00).
+
+## Usage
+
+When a vendor bill is created for a partner with rappel enabled, the bill is automatically marked as **Pending Commission**. This flag is visible in the vendor bill list and form views.
+
+To generate rappel invoices:
+
+1. Go to **Invoicing > Vendors > Bills**.
+2. Select one or more posted vendor bills with pending commission.
+3. Click **Action > Create Rappel Commission Invoice**.
+4. In the wizard, confirm the **Invoice Date**, **Journal**, and **Product**.
+5. Click **Create Rappel Invoice(s)**.
+
+The system will create one sale invoice per vendor, with one line per original bill. The commission amount is computed as:
+
+ taxable_base × commission_rate / 100
+
+The original vendor bills are automatically unmarked as pending.
diff --git a/purchase_rappel/__init__.py b/purchase_rappel/__init__.py
new file mode 100644
index 0000000..08ff432
--- /dev/null
+++ b/purchase_rappel/__init__.py
@@ -0,0 +1,4 @@
+# Copyright 2024 Criptomart
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
+from . import models
+from . import wizard
diff --git a/purchase_rappel/__manifest__.py b/purchase_rappel/__manifest__.py
new file mode 100644
index 0000000..5a5850e
--- /dev/null
+++ b/purchase_rappel/__manifest__.py
@@ -0,0 +1,22 @@
+# Copyright 2024 Criptomart
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
+
+{
+ "name": "Purchase Rappel Commission",
+ "version": "18.0.1.0.0",
+ "summary": "Manage rappel commission invoices for vendors",
+ "category": "Purchase",
+ "author": "Criptomart",
+ "website": "https://criptomart.net",
+ "license": "AGPL-3",
+ "depends": ["purchase", "account"],
+ "data": [
+ "security/ir.model.access.csv",
+ "wizard/account_move_rappel_wizard_views.xml",
+ "views/res_partner_views.xml",
+ "views/res_config_settings_views.xml",
+ "views/account_move_views.xml",
+ ],
+ "installable": True,
+ "application": False,
+}
diff --git a/purchase_rappel/i18n/ca.po b/purchase_rappel/i18n/ca.po
new file mode 100644
index 0000000..b396106
--- /dev/null
+++ b/purchase_rappel/i18n/ca.po
@@ -0,0 +1,167 @@
+# Translation of Odoo Server.
+# This file contains the translation of the following modules:
+# * purchase_rappel
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: Odoo Server 17.0\n"
+"Report-Msgid-Bugs-To: \n"
+"POT-Creation-Date: 2024-06-12 10:00+0000\n"
+"PO-Revision-Date: 2024-06-12 10:00+0000\n"
+"Last-Translator: \n"
+"Language-Team: \n"
+"Language: ca\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: \n"
+"Plural-Forms: nplurals=2; plural=(n != 1);\n"
+
+#. module: purchase_rappel
+#: model:ir.model.fields,field_description:purchase_rappel.field_res_company__rappel_journal_id
+#: model:ir.model.fields,field_description:purchase_rappel.field_res_config_settings__rappel_journal_id
+msgid "Rappel Journal"
+msgstr "Diari de Rappel"
+
+#. module: purchase_rappel
+#: model:ir.model.fields,help:purchase_rappel.field_res_company__rappel_journal_id
+#: model:ir.model.fields,help:purchase_rappel.field_res_config_settings__rappel_journal_id
+msgid "Sales journal used to post rappel commission invoices."
+msgstr "Diari de vendes utilitzat per registrar les factures de comissió per rappel."
+
+#. module: purchase_rappel
+#: model:ir.model.fields,field_description:purchase_rappel.field_res_company__rappel_product_id
+#: model:ir.model.fields,field_description:purchase_rappel.field_res_config_settings__rappel_product_id
+#: model:ir.model.fields,field_description:purchase_rappel.field_account_move_rappel_wizard__product_id
+msgid "Commission Product"
+msgstr "Producte de Comissió"
+
+#. module: purchase_rappel
+#: model:ir.model.fields,help:purchase_rappel.field_res_company__rappel_product_id
+#: model:ir.model.fields,help:purchase_rappel.field_res_config_settings__rappel_product_id
+msgid "Product used as invoice line in rappel commission invoices."
+msgstr "Producte utilitzat com a línia de factura en les factures de comissió per rappel."
+
+#. module: purchase_rappel
+#: model:ir.model.fields,field_description:purchase_rappel.field_res_partner__rappel_commission
+#: model_terms:ir.ui.view,arch_db:purchase_rappel.view_partner_form_rappel
+#: model_terms:ir.ui.view,arch_db:purchase_rappel.res_config_settings_view_form_rappel
+msgid "Rappel Commission"
+msgstr "Comissió per Rappel"
+
+#. module: purchase_rappel
+#: model:ir.model.fields,help:purchase_rappel.field_res_partner__rappel_commission
+msgid "If enabled, vendor bills from this partner will be eligible for rappel commission invoicing."
+msgstr "Si està activat, les factures de proveïdor d'aquest contacte seran elegibles per a la facturació de comissió per rappel."
+
+#. module: purchase_rappel
+#: model:ir.model.fields,field_description:purchase_rappel.field_res_partner__rappel_commission_rate
+msgid "Commission Rate (%)"
+msgstr "Taxa de Comissió (%)"
+
+#. module: purchase_rappel
+#: model:ir.model.fields,help:purchase_rappel.field_res_partner__rappel_commission_rate
+msgid "Percentage applied on the taxable base of vendor bills to calculate the rappel commission amount."
+msgstr "Percentatge aplicat sobre la base imposable de les factures de proveïdor per calcular l'import de la comissió per rappel."
+
+#. module: purchase_rappel
+#: model:ir.model.fields,field_description:purchase_rappel.field_account_move__rappel_pending
+#: model_terms:ir.ui.view,arch_db:purchase_rappel.view_account_invoice_filter_rappel
+msgid "Pending Commission"
+msgstr "Pendent de Comissió"
+
+#. module: purchase_rappel
+#: model:ir.model.fields,help:purchase_rappel.field_account_move__rappel_pending
+msgid "Indicates that this vendor bill has not yet been included in a rappel commission invoice. Automatically set based on the vendor's configuration."
+msgstr "Indica que aquesta factura de proveïdor encara no ha estat inclosa en una factura de comissió per rappel. S'estableix automàticament segons la configuració del proveïdor."
+
+#. module: purchase_rappel
+#: model:ir.model,name:purchase_rappel.model_account_move_rappel_wizard
+#: model_terms:ir.ui.view,arch_db:purchase_rappel.view_account_move_rappel_wizard_form
+msgid "Create Rappel Commission Invoice"
+msgstr "Crear Factura de Comissió per Rappel"
+
+#. module: purchase_rappel
+#: model:ir.model.fields,field_description:purchase_rappel.field_account_move_rappel_wizard__move_ids
+#: model_terms:ir.ui.view,arch_db:purchase_rappel.view_account_move_rappel_wizard_form
+msgid "Vendor Bills"
+msgstr "Factures de Proveïdor"
+
+#. module: purchase_rappel
+#: model:ir.model.fields,field_description:purchase_rappel.field_account_move_rappel_wizard__invoice_date
+msgid "Invoice Date"
+msgstr "Data de Factura"
+
+#. module: purchase_rappel
+#: model:ir.model.fields,field_description:purchase_rappel.field_account_move_rappel_wizard__journal_id
+msgid "Journal"
+msgstr "Diari"
+
+#. module: purchase_rappel
+#: code:addons/purchase_rappel/wizard/account_move_rappel_wizard.py:0
+msgid "No posted vendor bills found in the selection. Only posted (validated) vendor bills can be used to create rappel commission invoices."
+msgstr "No s'han trobat factures de proveïdor comptabilitzades en la selecció. Només les factures de proveïdor comptabilitzades (validades) es poden fer servir per crear factures de comissió per rappel."
+
+#. module: purchase_rappel
+#: code:addons/purchase_rappel/wizard/account_move_rappel_wizard.py:0
+msgid "None of the selected vendor bills are pending commission. They may have already been commissioned."
+msgstr "Cap de les factures de proveïdor seleccionades està pendent de comissió. Pot ser que ja hagin estat comissionades."
+
+#. module: purchase_rappel
+#: code:addons/purchase_rappel/wizard/account_move_rappel_wizard.py:0
+msgid "None of the selected vendors have the rappel commission enabled. Please configure the commission rate on the vendor contact first."
+msgstr "Cap dels proveïdors seleccionats té la comissió per rappel activada. Configureu primer la taxa de comissió en el contacte del proveïdor."
+
+#. module: purchase_rappel
+#: code:addons/purchase_rappel/wizard/account_move_rappel_wizard.py:0
+msgid "Please select a journal for the rappel commission invoices, or configure a default one in Purchase Settings."
+msgstr "Seleccioneu un diari per a les factures de comissió per rappel, o configureu-ne un per defecte en els Ajustos de Compres."
+
+#. module: purchase_rappel
+#: code:addons/purchase_rappel/wizard/account_move_rappel_wizard.py:0
+msgid "Please select a product for the rappel commission invoices, or configure a default one in Purchase Settings."
+msgstr "Seleccioneu un producte per a les factures de comissió per rappel, o configureu-ne un per defecte en els Ajustos de Compres."
+
+#. module: purchase_rappel
+#: code:addons/purchase_rappel/wizard/account_move_rappel_wizard.py:0
+msgid "None of the selected vendor bills belong to vendors with a rappel commission rate configured."
+msgstr "Cap de les factures de proveïdor seleccionades pertany a proveïdors amb una taxa de comissió per rappel configurada."
+
+#. module: purchase_rappel
+#: code:addons/purchase_rappel/wizard/account_move_rappel_wizard.py:0
+msgid "Rappel commission %(rate)s%% — Bill %(bill)s (taxable base: %(base)s %(currency)s)"
+msgstr "Comissió per rappel %(rate)s%% — Factura %(bill)s (base imposable: %(base)s %(currency)s)"
+
+#. module: purchase_rappel
+#: code:addons/purchase_rappel/wizard/account_move_rappel_wizard.py:0
+msgid "Rappel commission invoice generated from %d vendor bill(s)."
+msgstr "Factura de comissió per rappel generada a partir de %d factura(es) de proveïdor."
+
+#. module: purchase_rappel
+#: model:ir.actions.act_window,name:purchase_rappel.action_open_rappel_wizard
+msgid "Rappel Commission Invoices"
+msgstr "Factures de Comissió per Rappel"
+
+#. module: purchase_rappel
+#: model_terms:ir.ui.view,arch_db:purchase_rappel.res_config_settings_view_form_rappel
+msgid "Configure the journal and product for rappel commission invoices"
+msgstr "Configureu el diari i el producte per a les factures de comissió per rappel"
+
+#. module: purchase_rappel
+#: model_terms:ir.ui.view,arch_db:purchase_rappel.view_account_invoice_filter_rappel
+msgid "Vendor bills not yet included in a rappel commission invoice"
+msgstr "Factures de proveïdor que encara no estan incloses en una factura de comissió per rappel"
+
+#. module: purchase_rappel
+#: model_terms:ir.ui.view,arch_db:purchase_rappel.view_account_move_rappel_wizard_form
+msgid "Taxable Base"
+msgstr "Base Imposable"
+
+#. module: purchase_rappel
+#: model_terms:ir.ui.view,arch_db:purchase_rappel.view_account_move_rappel_wizard_form
+msgid "Create Rappel Invoice(s)"
+msgstr "Crear Factura(es) de Rappel"
+
+#. module: purchase_rappel
+#: model_terms:ir.ui.view,arch_db:purchase_rappel.view_account_move_rappel_wizard_form
+msgid "Cancel"
+msgstr "Cancel·lar"
diff --git a/purchase_rappel/i18n/es.po b/purchase_rappel/i18n/es.po
new file mode 100644
index 0000000..77ab323
--- /dev/null
+++ b/purchase_rappel/i18n/es.po
@@ -0,0 +1,167 @@
+# Translation of Odoo Server.
+# This file contains the translation of the following modules:
+# * purchase_rappel
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: Odoo Server 17.0\n"
+"Report-Msgid-Bugs-To: \n"
+"POT-Creation-Date: 2024-06-12 10:00+0000\n"
+"PO-Revision-Date: 2024-06-12 10:00+0000\n"
+"Last-Translator: \n"
+"Language-Team: \n"
+"Language: es\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: \n"
+"Plural-Forms: nplurals=2; plural=(n != 1);\n"
+
+#. module: purchase_rappel
+#: model:ir.model.fields,field_description:purchase_rappel.field_res_company__rappel_journal_id
+#: model:ir.model.fields,field_description:purchase_rappel.field_res_config_settings__rappel_journal_id
+msgid "Rappel Journal"
+msgstr "Diario de Rappel"
+
+#. module: purchase_rappel
+#: model:ir.model.fields,help:purchase_rappel.field_res_company__rappel_journal_id
+#: model:ir.model.fields,help:purchase_rappel.field_res_config_settings__rappel_journal_id
+msgid "Sales journal used to post rappel commission invoices."
+msgstr "Diario de ventas utilizado para registrar las facturas de comisión por rappel."
+
+#. module: purchase_rappel
+#: model:ir.model.fields,field_description:purchase_rappel.field_res_company__rappel_product_id
+#: model:ir.model.fields,field_description:purchase_rappel.field_res_config_settings__rappel_product_id
+#: model:ir.model.fields,field_description:purchase_rappel.field_account_move_rappel_wizard__product_id
+msgid "Commission Product"
+msgstr "Producto de Comisión"
+
+#. module: purchase_rappel
+#: model:ir.model.fields,help:purchase_rappel.field_res_company__rappel_product_id
+#: model:ir.model.fields,help:purchase_rappel.field_res_config_settings__rappel_product_id
+msgid "Product used as invoice line in rappel commission invoices."
+msgstr "Producto utilizado como línea de factura en las facturas de comisión por rappel."
+
+#. module: purchase_rappel
+#: model:ir.model.fields,field_description:purchase_rappel.field_res_partner__rappel_commission
+#: model_terms:ir.ui.view,arch_db:purchase_rappel.view_partner_form_rappel
+#: model_terms:ir.ui.view,arch_db:purchase_rappel.res_config_settings_view_form_rappel
+msgid "Rappel Commission"
+msgstr "Comisión por Rappel"
+
+#. module: purchase_rappel
+#: model:ir.model.fields,help:purchase_rappel.field_res_partner__rappel_commission
+msgid "If enabled, vendor bills from this partner will be eligible for rappel commission invoicing."
+msgstr "Si está activado, las facturas de proveedor de este contacto serán elegibles para la facturación de comisión por rappel."
+
+#. module: purchase_rappel
+#: model:ir.model.fields,field_description:purchase_rappel.field_res_partner__rappel_commission_rate
+msgid "Commission Rate (%)"
+msgstr "Tasa de Comisión (%)"
+
+#. module: purchase_rappel
+#: model:ir.model.fields,help:purchase_rappel.field_res_partner__rappel_commission_rate
+msgid "Percentage applied on the taxable base of vendor bills to calculate the rappel commission amount."
+msgstr "Porcentaje aplicado sobre la base imponible de las facturas de proveedor para calcular el importe de la comisión por rappel."
+
+#. module: purchase_rappel
+#: model:ir.model.fields,field_description:purchase_rappel.field_account_move__rappel_pending
+#: model_terms:ir.ui.view,arch_db:purchase_rappel.view_account_invoice_filter_rappel
+msgid "Pending Commission"
+msgstr "Pendiente de Comisión"
+
+#. module: purchase_rappel
+#: model:ir.model.fields,help:purchase_rappel.field_account_move__rappel_pending
+msgid "Indicates that this vendor bill has not yet been included in a rappel commission invoice. Automatically set based on the vendor's configuration."
+msgstr "Indica que esta factura de proveedor aún no ha sido incluida en una factura de comisión por rappel. Se establece automáticamente según la configuración del proveedor."
+
+#. module: purchase_rappel
+#: model:ir.model,name:purchase_rappel.model_account_move_rappel_wizard
+#: model_terms:ir.ui.view,arch_db:purchase_rappel.view_account_move_rappel_wizard_form
+msgid "Create Rappel Commission Invoice"
+msgstr "Crear Factura de Comisión por Rappel"
+
+#. module: purchase_rappel
+#: model:ir.model.fields,field_description:purchase_rappel.field_account_move_rappel_wizard__move_ids
+#: model_terms:ir.ui.view,arch_db:purchase_rappel.view_account_move_rappel_wizard_form
+msgid "Vendor Bills"
+msgstr "Facturas de Proveedor"
+
+#. module: purchase_rappel
+#: model:ir.model.fields,field_description:purchase_rappel.field_account_move_rappel_wizard__invoice_date
+msgid "Invoice Date"
+msgstr "Fecha de Factura"
+
+#. module: purchase_rappel
+#: model:ir.model.fields,field_description:purchase_rappel.field_account_move_rappel_wizard__journal_id
+msgid "Journal"
+msgstr "Diario"
+
+#. module: purchase_rappel
+#: code:addons/purchase_rappel/wizard/account_move_rappel_wizard.py:0
+msgid "No posted vendor bills found in the selection. Only posted (validated) vendor bills can be used to create rappel commission invoices."
+msgstr "No se encontraron facturas de proveedor contabilizadas en la selección. Solo las facturas de proveedor contabilizadas (validadas) pueden usarse para crear facturas de comisión por rappel."
+
+#. module: purchase_rappel
+#: code:addons/purchase_rappel/wizard/account_move_rappel_wizard.py:0
+msgid "None of the selected vendor bills are pending commission. They may have already been commissioned."
+msgstr "Ninguna de las facturas de proveedor seleccionadas está pendiente de comisión. Puede que ya hayan sido comisionadas."
+
+#. module: purchase_rappel
+#: code:addons/purchase_rappel/wizard/account_move_rappel_wizard.py:0
+msgid "None of the selected vendors have the rappel commission enabled. Please configure the commission rate on the vendor contact first."
+msgstr "Ninguno de los proveedores seleccionados tiene la comisión por rappel activada. Configure primero la tasa de comisión en el contacto del proveedor."
+
+#. module: purchase_rappel
+#: code:addons/purchase_rappel/wizard/account_move_rappel_wizard.py:0
+msgid "Please select a journal for the rappel commission invoices, or configure a default one in Purchase Settings."
+msgstr "Seleccione un diario para las facturas de comisión por rappel, o configure uno por defecto en los Ajustes de Compras."
+
+#. module: purchase_rappel
+#: code:addons/purchase_rappel/wizard/account_move_rappel_wizard.py:0
+msgid "Please select a product for the rappel commission invoices, or configure a default one in Purchase Settings."
+msgstr "Seleccione un producto para las facturas de comisión por rappel, o configure uno por defecto en los Ajustes de Compras."
+
+#. module: purchase_rappel
+#: code:addons/purchase_rappel/wizard/account_move_rappel_wizard.py:0
+msgid "None of the selected vendor bills belong to vendors with a rappel commission rate configured."
+msgstr "Ninguna de las facturas de proveedor seleccionadas pertenece a proveedores con una tasa de comisión por rappel configurada."
+
+#. module: purchase_rappel
+#: code:addons/purchase_rappel/wizard/account_move_rappel_wizard.py:0
+msgid "Rappel commission %(rate)s%% — Bill %(bill)s (taxable base: %(base)s %(currency)s)"
+msgstr "Comisión por rappel %(rate)s%% — Factura %(bill)s (base imponible: %(base)s %(currency)s)"
+
+#. module: purchase_rappel
+#: code:addons/purchase_rappel/wizard/account_move_rappel_wizard.py:0
+msgid "Rappel commission invoice generated from %d vendor bill(s)."
+msgstr "Factura de comisión por rappel generada a partir de %d factura(s) de proveedor."
+
+#. module: purchase_rappel
+#: model:ir.actions.act_window,name:purchase_rappel.action_open_rappel_wizard
+msgid "Rappel Commission Invoices"
+msgstr "Facturas de Comisión por Rappel"
+
+#. module: purchase_rappel
+#: model_terms:ir.ui.view,arch_db:purchase_rappel.res_config_settings_view_form_rappel
+msgid "Configure the journal and product for rappel commission invoices"
+msgstr "Configure el diario y el producto para las facturas de comisión por rappel"
+
+#. module: purchase_rappel
+#: model_terms:ir.ui.view,arch_db:purchase_rappel.view_account_invoice_filter_rappel
+msgid "Vendor bills not yet included in a rappel commission invoice"
+msgstr "Facturas de proveedor que aún no están incluidas en una factura de comisión por rappel"
+
+#. module: purchase_rappel
+#: model_terms:ir.ui.view,arch_db:purchase_rappel.view_account_move_rappel_wizard_form
+msgid "Taxable Base"
+msgstr "Base Imponible"
+
+#. module: purchase_rappel
+#: model_terms:ir.ui.view,arch_db:purchase_rappel.view_account_move_rappel_wizard_form
+msgid "Create Rappel Invoice(s)"
+msgstr "Crear Factura(s) de Rappel"
+
+#. module: purchase_rappel
+#: model_terms:ir.ui.view,arch_db:purchase_rappel.view_account_move_rappel_wizard_form
+msgid "Cancel"
+msgstr "Cancelar"
diff --git a/purchase_rappel/i18n/gl.po b/purchase_rappel/i18n/gl.po
new file mode 100644
index 0000000..6731153
--- /dev/null
+++ b/purchase_rappel/i18n/gl.po
@@ -0,0 +1,167 @@
+# Translation of Odoo Server.
+# This file contains the translation of the following modules:
+# * purchase_rappel
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: Odoo Server 17.0\n"
+"Report-Msgid-Bugs-To: \n"
+"POT-Creation-Date: 2024-06-12 10:00+0000\n"
+"PO-Revision-Date: 2024-06-12 10:00+0000\n"
+"Last-Translator: \n"
+"Language-Team: \n"
+"Language: gl\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: \n"
+"Plural-Forms: nplurals=2; plural=(n != 1);\n"
+
+#. module: purchase_rappel
+#: model:ir.model.fields,field_description:purchase_rappel.field_res_company__rappel_journal_id
+#: model:ir.model.fields,field_description:purchase_rappel.field_res_config_settings__rappel_journal_id
+msgid "Rappel Journal"
+msgstr "Diario de Rappel"
+
+#. module: purchase_rappel
+#: model:ir.model.fields,help:purchase_rappel.field_res_company__rappel_journal_id
+#: model:ir.model.fields,help:purchase_rappel.field_res_config_settings__rappel_journal_id
+msgid "Sales journal used to post rappel commission invoices."
+msgstr "Diario de vendas utilizado para rexistrar as facturas de comisión por rappel."
+
+#. module: purchase_rappel
+#: model:ir.model.fields,field_description:purchase_rappel.field_res_company__rappel_product_id
+#: model:ir.model.fields,field_description:purchase_rappel.field_res_config_settings__rappel_product_id
+#: model:ir.model.fields,field_description:purchase_rappel.field_account_move_rappel_wizard__product_id
+msgid "Commission Product"
+msgstr "Produto de Comisión"
+
+#. module: purchase_rappel
+#: model:ir.model.fields,help:purchase_rappel.field_res_company__rappel_product_id
+#: model:ir.model.fields,help:purchase_rappel.field_res_config_settings__rappel_product_id
+msgid "Product used as invoice line in rappel commission invoices."
+msgstr "Produto utilizado como liña de factura nas facturas de comisión por rappel."
+
+#. module: purchase_rappel
+#: model:ir.model.fields,field_description:purchase_rappel.field_res_partner__rappel_commission
+#: model_terms:ir.ui.view,arch_db:purchase_rappel.view_partner_form_rappel
+#: model_terms:ir.ui.view,arch_db:purchase_rappel.res_config_settings_view_form_rappel
+msgid "Rappel Commission"
+msgstr "Comisión por Rappel"
+
+#. module: purchase_rappel
+#: model:ir.model.fields,help:purchase_rappel.field_res_partner__rappel_commission
+msgid "If enabled, vendor bills from this partner will be eligible for rappel commission invoicing."
+msgstr "Se está activado, as facturas de provedor deste contacto serán elixibles para a facturación de comisión por rappel."
+
+#. module: purchase_rappel
+#: model:ir.model.fields,field_description:purchase_rappel.field_res_partner__rappel_commission_rate
+msgid "Commission Rate (%)"
+msgstr "Taxa de Comisión (%)"
+
+#. module: purchase_rappel
+#: model:ir.model.fields,help:purchase_rappel.field_res_partner__rappel_commission_rate
+msgid "Percentage applied on the taxable base of vendor bills to calculate the rappel commission amount."
+msgstr "Porcentaxe aplicado sobre a base imponible das facturas de provedor para calcular o importe da comisión por rappel."
+
+#. module: purchase_rappel
+#: model:ir.model.fields,field_description:purchase_rappel.field_account_move__rappel_pending
+#: model_terms:ir.ui.view,arch_db:purchase_rappel.view_account_invoice_filter_rappel
+msgid "Pending Commission"
+msgstr "Pendente de Comisión"
+
+#. module: purchase_rappel
+#: model:ir.model.fields,help:purchase_rappel.field_account_move__rappel_pending
+msgid "Indicates that this vendor bill has not yet been included in a rappel commission invoice. Automatically set based on the vendor's configuration."
+msgstr "Indica que esta factura de provedor aínda non foi incluída nunha factura de comisión por rappel. Establécese automaticamente segundo a configuración do provedor."
+
+#. module: purchase_rappel
+#: model:ir.model,name:purchase_rappel.model_account_move_rappel_wizard
+#: model_terms:ir.ui.view,arch_db:purchase_rappel.view_account_move_rappel_wizard_form
+msgid "Create Rappel Commission Invoice"
+msgstr "Crear Factura de Comisión por Rappel"
+
+#. module: purchase_rappel
+#: model:ir.model.fields,field_description:purchase_rappel.field_account_move_rappel_wizard__move_ids
+#: model_terms:ir.ui.view,arch_db:purchase_rappel.view_account_move_rappel_wizard_form
+msgid "Vendor Bills"
+msgstr "Facturas de Provedor"
+
+#. module: purchase_rappel
+#: model:ir.model.fields,field_description:purchase_rappel.field_account_move_rappel_wizard__invoice_date
+msgid "Invoice Date"
+msgstr "Data de Factura"
+
+#. module: purchase_rappel
+#: model:ir.model.fields,field_description:purchase_rappel.field_account_move_rappel_wizard__journal_id
+msgid "Journal"
+msgstr "Diario"
+
+#. module: purchase_rappel
+#: code:addons/purchase_rappel/wizard/account_move_rappel_wizard.py:0
+msgid "No posted vendor bills found in the selection. Only posted (validated) vendor bills can be used to create rappel commission invoices."
+msgstr "Non se atoparon facturas de provedor contabilizadas na selección. Só as facturas de provedor contabilizadas (validadas) poden usarse para crear facturas de comisión por rappel."
+
+#. module: purchase_rappel
+#: code:addons/purchase_rappel/wizard/account_move_rappel_wizard.py:0
+msgid "None of the selected vendor bills are pending commission. They may have already been commissioned."
+msgstr "Ningunha das facturas de provedor seleccionadas está pendente de comisión. Pode que xa fosen comisionadas."
+
+#. module: purchase_rappel
+#: code:addons/purchase_rappel/wizard/account_move_rappel_wizard.py:0
+msgid "None of the selected vendors have the rappel commission enabled. Please configure the commission rate on the vendor contact first."
+msgstr "Ningún dos provedores seleccionados ten a comisión por rappel activada. Configure primeiro a taxa de comisión no contacto do provedor."
+
+#. module: purchase_rappel
+#: code:addons/purchase_rappel/wizard/account_move_rappel_wizard.py:0
+msgid "Please select a journal for the rappel commission invoices, or configure a default one in Purchase Settings."
+msgstr "Seleccione un diario para as facturas de comisión por rappel, ou configure un por defecto nos Axustes de Compras."
+
+#. module: purchase_rappel
+#: code:addons/purchase_rappel/wizard/account_move_rappel_wizard.py:0
+msgid "Please select a product for the rappel commission invoices, or configure a default one in Purchase Settings."
+msgstr "Seleccione un produto para as facturas de comisión por rappel, ou configure un por defecto nos Axustes de Compras."
+
+#. module: purchase_rappel
+#: code:addons/purchase_rappel/wizard/account_move_rappel_wizard.py:0
+msgid "None of the selected vendor bills belong to vendors with a rappel commission rate configured."
+msgstr "Ningunha das facturas de provedor seleccionadas pertence a provedores cunha taxa de comisión por rappel configurada."
+
+#. module: purchase_rappel
+#: code:addons/purchase_rappel/wizard/account_move_rappel_wizard.py:0
+msgid "Rappel commission %(rate)s%% — Bill %(bill)s (taxable base: %(base)s %(currency)s)"
+msgstr "Comisión por rappel %(rate)s%% — Factura %(bill)s (base imponible: %(base)s %(currency)s)"
+
+#. module: purchase_rappel
+#: code:addons/purchase_rappel/wizard/account_move_rappel_wizard.py:0
+msgid "Rappel commission invoice generated from %d vendor bill(s)."
+msgstr "Factura de comisión por rappel xerada a partir de %d factura(s) de provedor."
+
+#. module: purchase_rappel
+#: model:ir.actions.act_window,name:purchase_rappel.action_open_rappel_wizard
+msgid "Rappel Commission Invoices"
+msgstr "Facturas de Comisión por Rappel"
+
+#. module: purchase_rappel
+#: model_terms:ir.ui.view,arch_db:purchase_rappel.res_config_settings_view_form_rappel
+msgid "Configure the journal and product for rappel commission invoices"
+msgstr "Configure o diario e o produto para as facturas de comisión por rappel"
+
+#. module: purchase_rappel
+#: model_terms:ir.ui.view,arch_db:purchase_rappel.view_account_invoice_filter_rappel
+msgid "Vendor bills not yet included in a rappel commission invoice"
+msgstr "Facturas de provedor que aínda non están incluídas nunha factura de comisión por rappel"
+
+#. module: purchase_rappel
+#: model_terms:ir.ui.view,arch_db:purchase_rappel.view_account_move_rappel_wizard_form
+msgid "Taxable Base"
+msgstr "Base Imponible"
+
+#. module: purchase_rappel
+#: model_terms:ir.ui.view,arch_db:purchase_rappel.view_account_move_rappel_wizard_form
+msgid "Create Rappel Invoice(s)"
+msgstr "Crear Factura(s) de Rappel"
+
+#. module: purchase_rappel
+#: model_terms:ir.ui.view,arch_db:purchase_rappel.view_account_move_rappel_wizard_form
+msgid "Cancel"
+msgstr "Cancelar"
diff --git a/purchase_rappel/i18n/purchase_rappel.pot b/purchase_rappel/i18n/purchase_rappel.pot
new file mode 100644
index 0000000..27b0e4a
--- /dev/null
+++ b/purchase_rappel/i18n/purchase_rappel.pot
@@ -0,0 +1,166 @@
+# Translation of Odoo Server.
+# This file contains the translation of the following modules:
+# * purchase_rappel
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: Odoo Server 17.0\n"
+"Report-Msgid-Bugs-To: \n"
+"POT-Creation-Date: 2024-06-12 10:00+0000\n"
+"PO-Revision-Date: 2024-06-12 10:00+0000\n"
+"Last-Translator: \n"
+"Language-Team: \n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: \n"
+"Plural-Forms: \n"
+
+#. module: purchase_rappel
+#: model:ir.model.fields,field_description:purchase_rappel.field_res_company__rappel_journal_id
+#: model:ir.model.fields,field_description:purchase_rappel.field_res_config_settings__rappel_journal_id
+msgid "Rappel Journal"
+msgstr ""
+
+#. module: purchase_rappel
+#: model:ir.model.fields,help:purchase_rappel.field_res_company__rappel_journal_id
+#: model:ir.model.fields,help:purchase_rappel.field_res_config_settings__rappel_journal_id
+msgid "Sales journal used to post rappel commission invoices."
+msgstr ""
+
+#. module: purchase_rappel
+#: model:ir.model.fields,field_description:purchase_rappel.field_res_company__rappel_product_id
+#: model:ir.model.fields,field_description:purchase_rappel.field_res_config_settings__rappel_product_id
+#: model:ir.model.fields,field_description:purchase_rappel.field_account_move_rappel_wizard__product_id
+msgid "Commission Product"
+msgstr ""
+
+#. module: purchase_rappel
+#: model:ir.model.fields,help:purchase_rappel.field_res_company__rappel_product_id
+#: model:ir.model.fields,help:purchase_rappel.field_res_config_settings__rappel_product_id
+msgid "Product used as invoice line in rappel commission invoices."
+msgstr ""
+
+#. module: purchase_rappel
+#: model:ir.model.fields,field_description:purchase_rappel.field_res_partner__rappel_commission
+#: model_terms:ir.ui.view,arch_db:purchase_rappel.view_partner_form_rappel
+#: model_terms:ir.ui.view,arch_db:purchase_rappel.res_config_settings_view_form_rappel
+msgid "Rappel Commission"
+msgstr ""
+
+#. module: purchase_rappel
+#: model:ir.model.fields,help:purchase_rappel.field_res_partner__rappel_commission
+msgid "If enabled, vendor bills from this partner will be eligible for rappel commission invoicing."
+msgstr ""
+
+#. module: purchase_rappel
+#: model:ir.model.fields,field_description:purchase_rappel.field_res_partner__rappel_commission_rate
+msgid "Commission Rate (%)"
+msgstr ""
+
+#. module: purchase_rappel
+#: model:ir.model.fields,help:purchase_rappel.field_res_partner__rappel_commission_rate
+msgid "Percentage applied on the taxable base of vendor bills to calculate the rappel commission amount."
+msgstr ""
+
+#. module: purchase_rappel
+#: model:ir.model.fields,field_description:purchase_rappel.field_account_move__rappel_pending
+#: model_terms:ir.ui.view,arch_db:purchase_rappel.view_account_invoice_filter_rappel
+msgid "Pending Commission"
+msgstr ""
+
+#. module: purchase_rappel
+#: model:ir.model.fields,help:purchase_rappel.field_account_move__rappel_pending
+msgid "Indicates that this vendor bill has not yet been included in a rappel commission invoice. Automatically set based on the vendor's configuration."
+msgstr ""
+
+#. module: purchase_rappel
+#: model:ir.model,name:purchase_rappel.model_account_move_rappel_wizard
+#: model_terms:ir.ui.view,arch_db:purchase_rappel.view_account_move_rappel_wizard_form
+msgid "Create Rappel Commission Invoice"
+msgstr ""
+
+#. module: purchase_rappel
+#: model:ir.model.fields,field_description:purchase_rappel.field_account_move_rappel_wizard__move_ids
+#: model_terms:ir.ui.view,arch_db:purchase_rappel.view_account_move_rappel_wizard_form
+msgid "Vendor Bills"
+msgstr ""
+
+#. module: purchase_rappel
+#: model:ir.model.fields,field_description:purchase_rappel.field_account_move_rappel_wizard__invoice_date
+msgid "Invoice Date"
+msgstr ""
+
+#. module: purchase_rappel
+#: model:ir.model.fields,field_description:purchase_rappel.field_account_move_rappel_wizard__journal_id
+msgid "Journal"
+msgstr ""
+
+#. module: purchase_rappel
+#: code:addons/purchase_rappel/wizard/account_move_rappel_wizard.py:0
+msgid "No posted vendor bills found in the selection. Only posted (validated) vendor bills can be used to create rappel commission invoices."
+msgstr ""
+
+#. module: purchase_rappel
+#: code:addons/purchase_rappel/wizard/account_move_rappel_wizard.py:0
+msgid "None of the selected vendor bills are pending commission. They may have already been commissioned."
+msgstr ""
+
+#. module: purchase_rappel
+#: code:addons/purchase_rappel/wizard/account_move_rappel_wizard.py:0
+msgid "None of the selected vendors have the rappel commission enabled. Please configure the commission rate on the vendor contact first."
+msgstr ""
+
+#. module: purchase_rappel
+#: code:addons/purchase_rappel/wizard/account_move_rappel_wizard.py:0
+msgid "Please select a journal for the rappel commission invoices, or configure a default one in Purchase Settings."
+msgstr ""
+
+#. module: purchase_rappel
+#: code:addons/purchase_rappel/wizard/account_move_rappel_wizard.py:0
+msgid "Please select a product for the rappel commission invoices, or configure a default one in Purchase Settings."
+msgstr ""
+
+#. module: purchase_rappel
+#: code:addons/purchase_rappel/wizard/account_move_rappel_wizard.py:0
+msgid "None of the selected vendor bills belong to vendors with a rappel commission rate configured."
+msgstr ""
+
+#. module: purchase_rappel
+#: code:addons/purchase_rappel/wizard/account_move_rappel_wizard.py:0
+msgid "Rappel commission %(rate)s%% — Bill %(bill)s (taxable base: %(base)s %(currency)s)"
+msgstr ""
+
+#. module: purchase_rappel
+#: code:addons/purchase_rappel/wizard/account_move_rappel_wizard.py:0
+msgid "Rappel commission invoice generated from %d vendor bill(s)."
+msgstr ""
+
+#. module: purchase_rappel
+#: model:ir.actions.act_window,name:purchase_rappel.action_open_rappel_wizard
+msgid "Rappel Commission Invoices"
+msgstr ""
+
+#. module: purchase_rappel
+#: model_terms:ir.ui.view,arch_db:purchase_rappel.res_config_settings_view_form_rappel
+msgid "Configure the journal and product for rappel commission invoices"
+msgstr ""
+
+#. module: purchase_rappel
+#: model_terms:ir.ui.view,arch_db:purchase_rappel.view_account_invoice_filter_rappel
+msgid "Vendor bills not yet included in a rappel commission invoice"
+msgstr ""
+
+#. module: purchase_rappel
+#: model_terms:ir.ui.view,arch_db:purchase_rappel.view_account_move_rappel_wizard_form
+msgid "Taxable Base"
+msgstr ""
+
+#. module: purchase_rappel
+#: model_terms:ir.ui.view,arch_db:purchase_rappel.view_account_move_rappel_wizard_form
+msgid "Create Rappel Invoice(s)"
+msgstr ""
+
+#. module: purchase_rappel
+#: model_terms:ir.ui.view,arch_db:purchase_rappel.view_account_move_rappel_wizard_form
+msgid "Cancel"
+msgstr ""
diff --git a/purchase_rappel/models/__init__.py b/purchase_rappel/models/__init__.py
new file mode 100644
index 0000000..1ced8dd
--- /dev/null
+++ b/purchase_rappel/models/__init__.py
@@ -0,0 +1,6 @@
+# Copyright 2024 Criptomart
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
+from . import res_company
+from . import res_partner
+from . import res_config_settings
+from . import account_move
diff --git a/purchase_rappel/models/account_move.py b/purchase_rappel/models/account_move.py
new file mode 100644
index 0000000..75549c2
--- /dev/null
+++ b/purchase_rappel/models/account_move.py
@@ -0,0 +1,32 @@
+# Copyright 2024 Criptomart
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
+from odoo import api, fields, models
+
+
+class AccountMove(models.Model):
+ _inherit = "account.move"
+
+ rappel_pending = fields.Boolean(
+ string="Pending Commission",
+ copy=False,
+ help="Indicates that this vendor bill has not yet been included in a rappel "
+ "commission invoice. Automatically set based on the vendor's configuration.",
+ )
+
+ @api.model_create_multi
+ def create(self, vals_list):
+ moves = super().create(vals_list)
+ for move in moves:
+ if (
+ move.move_type in ("in_invoice", "in_refund")
+ and move.partner_id.rappel_commission
+ ):
+ move.rappel_pending = True
+ return moves
+
+ @api.onchange("partner_id")
+ def _onchange_partner_id_rappel(self):
+ if self.move_type in ("in_invoice", "in_refund"):
+ self.rappel_pending = bool(
+ self.partner_id and self.partner_id.rappel_commission
+ )
diff --git a/purchase_rappel/models/res_company.py b/purchase_rappel/models/res_company.py
new file mode 100644
index 0000000..358faf0
--- /dev/null
+++ b/purchase_rappel/models/res_company.py
@@ -0,0 +1,19 @@
+# Copyright 2024 Criptomart
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
+from odoo import fields, models
+
+
+class ResCompany(models.Model):
+ _inherit = "res.company"
+
+ rappel_journal_id = fields.Many2one(
+ comodel_name="account.journal",
+ string="Rappel Journal",
+ domain="[('type', '=', 'sale'), ('company_id', '=', id)]",
+ help="Sales journal used to post rappel commission invoices.",
+ )
+ rappel_product_id = fields.Many2one(
+ comodel_name="product.product",
+ string="Commission Product",
+ help="Product used as invoice line in rappel commission invoices.",
+ )
diff --git a/purchase_rappel/models/res_config_settings.py b/purchase_rappel/models/res_config_settings.py
new file mode 100644
index 0000000..afe0f54
--- /dev/null
+++ b/purchase_rappel/models/res_config_settings.py
@@ -0,0 +1,23 @@
+# Copyright 2024 Criptomart
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
+from odoo import api, fields, models
+
+
+class ResConfigSettings(models.TransientModel):
+ _inherit = "res.config.settings"
+
+ rappel_journal_id = fields.Many2one(
+ comodel_name="account.journal",
+ string="Rappel Journal",
+ related="company_id.rappel_journal_id",
+ domain=[("type", "=", "sale")],
+ readonly=False,
+ help="Sales journal used to post rappel commission invoices.",
+ )
+ rappel_product_id = fields.Many2one(
+ comodel_name="product.product",
+ string="Commission Product",
+ related="company_id.rappel_product_id",
+ readonly=False,
+ help="Product used as invoice line in rappel commission invoices.",
+ )
diff --git a/purchase_rappel/models/res_partner.py b/purchase_rappel/models/res_partner.py
new file mode 100644
index 0000000..7698d9c
--- /dev/null
+++ b/purchase_rappel/models/res_partner.py
@@ -0,0 +1,19 @@
+# Copyright 2024 Criptomart
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
+from odoo import fields, models
+
+
+class ResPartner(models.Model):
+ _inherit = "res.partner"
+
+ rappel_commission = fields.Boolean(
+ string="Rappel Commission",
+ help="If enabled, vendor bills from this partner will be eligible for rappel "
+ "commission invoicing.",
+ )
+ rappel_commission_rate = fields.Float(
+ string="Commission Rate (%)",
+ digits=(5, 4),
+ help="Percentage applied on the taxable base of vendor bills to calculate the "
+ "rappel commission amount.",
+ )
diff --git a/purchase_rappel/readme/CONFIGURE.md b/purchase_rappel/readme/CONFIGURE.md
new file mode 100644
index 0000000..072c482
--- /dev/null
+++ b/purchase_rappel/readme/CONFIGURE.md
@@ -0,0 +1,13 @@
+## Company Settings
+
+Go to **Purchase > Configuration > Settings > Invoicing** and set:
+
+- **Rappel Journal**: Sales journal where rappel commission invoices will be posted.
+- **Commission Product**: Product used as invoice line in rappel commission invoices (should have an income account and taxes configured).
+
+## Vendor Configuration
+
+Open a vendor contact and go to the **Sales & Purchases** tab:
+
+- Enable **Rappel Commission**.
+- Set the **Commission Rate (%)** (e.g., 5.00).
diff --git a/purchase_rappel/readme/DESCRIPTION.md b/purchase_rappel/readme/DESCRIPTION.md
new file mode 100644
index 0000000..f960608
--- /dev/null
+++ b/purchase_rappel/readme/DESCRIPTION.md
@@ -0,0 +1,7 @@
+This module allows managing **rappel commission invoices** for vendors.
+
+Features:
+- Configure a rappel commission rate per vendor.
+- Automatically mark vendor bills as "pending commission".
+- Generate customer (sale) invoices from selected vendor bills via a wizard.
+- Group invoices by vendor and compute commission amounts based on the taxable base.
diff --git a/purchase_rappel/readme/USAGE.md b/purchase_rappel/readme/USAGE.md
new file mode 100644
index 0000000..16eaee7
--- /dev/null
+++ b/purchase_rappel/readme/USAGE.md
@@ -0,0 +1,15 @@
+When a vendor bill is created for a partner with rappel enabled, the bill is automatically marked as **Pending Commission**. This flag is visible in the vendor bill list and form views.
+
+To generate rappel invoices:
+
+1. Go to **Invoicing > Vendors > Bills**.
+2. Select one or more posted vendor bills with pending commission.
+3. Click **Action > Create Rappel Commission Invoice**.
+4. In the wizard, confirm the **Invoice Date**, **Journal**, and **Product**.
+5. Click **Create Rappel Invoice(s)**.
+
+The system will create one sale invoice per vendor, with one line per original bill. The commission amount is computed as:
+
+ taxable_base × commission_rate / 100
+
+The original vendor bills are automatically unmarked as pending.
diff --git a/purchase_rappel/security/ir.model.access.csv b/purchase_rappel/security/ir.model.access.csv
new file mode 100644
index 0000000..1b8dade
--- /dev/null
+++ b/purchase_rappel/security/ir.model.access.csv
@@ -0,0 +1,2 @@
+id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
+access_account_move_rappel_wizard_user,account.move.rappel.wizard user,model_account_move_rappel_wizard,account.group_account_invoice,1,1,1,1
diff --git a/purchase_rappel/tests/__init__.py b/purchase_rappel/tests/__init__.py
new file mode 100644
index 0000000..eabb36b
--- /dev/null
+++ b/purchase_rappel/tests/__init__.py
@@ -0,0 +1,4 @@
+# Copyright 2024 Criptomart
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
+
+from . import test_purchase_rappel
diff --git a/purchase_rappel/tests/test_purchase_rappel.py b/purchase_rappel/tests/test_purchase_rappel.py
new file mode 100644
index 0000000..2bea079
--- /dev/null
+++ b/purchase_rappel/tests/test_purchase_rappel.py
@@ -0,0 +1,333 @@
+# Copyright 2024 Criptomart
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
+
+from odoo import fields
+from odoo.exceptions import UserError
+from odoo.tests import tagged
+from odoo.addons.account.tests.common import AccountTestInvoicingCommon
+
+
+@tagged("post_install", "-at_install")
+class TestPurchaseRappel(AccountTestInvoicingCommon):
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.vendor_rappel = cls.env["res.partner"].create(
+ {
+ "name": "Vendor with Rappel",
+ "rappel_commission": True,
+ "rappel_commission_rate": 5.0,
+ "property_account_receivable_id": cls.company_data[
+ "default_account_receivable"
+ ].id,
+ "property_account_payable_id": cls.company_data[
+ "default_account_payable"
+ ].id,
+ }
+ )
+ cls.vendor_no_rappel = cls.env["res.partner"].create(
+ {
+ "name": "Vendor without Rappel",
+ "rappel_commission": False,
+ "property_account_receivable_id": cls.company_data[
+ "default_account_receivable"
+ ].id,
+ "property_account_payable_id": cls.company_data[
+ "default_account_payable"
+ ].id,
+ }
+ )
+ cls.vendor_zero_rate = cls.env["res.partner"].create(
+ {
+ "name": "Vendor Zero Rate",
+ "rappel_commission": True,
+ "rappel_commission_rate": 0.0,
+ "property_account_receivable_id": cls.company_data[
+ "default_account_receivable"
+ ].id,
+ "property_account_payable_id": cls.company_data[
+ "default_account_payable"
+ ].id,
+ }
+ )
+ cls.rappel_product = cls.env["product.product"].create(
+ {
+ "name": "Rappel Commission Product",
+ "type": "service",
+ "list_price": 100.0,
+ "property_account_income_id": cls.company_data[
+ "default_account_revenue"
+ ].id,
+ "taxes_id": [(6, 0, [cls.tax_sale_a.id])],
+ }
+ )
+ cls.rappel_journal = cls.company_data["default_journal_sale"]
+ cls.company_data["company"].write(
+ {
+ "rappel_journal_id": cls.rappel_journal.id,
+ "rappel_product_id": cls.rappel_product.id,
+ }
+ )
+
+ def _create_vendor_bill(self, partner, amount=1000.0, post=True, ref=None):
+ move = self.init_invoice(
+ "in_invoice",
+ partner=partner,
+ products=self.product_a,
+ post=False,
+ )
+ move.invoice_line_ids[0].price_unit = amount
+ if ref is not None:
+ move.ref = ref
+ if post:
+ move.action_post()
+ return move
+
+ def _create_vendor_refund(self, partner, amount=200.0, post=True, ref=None):
+ move = self.init_invoice(
+ "in_refund",
+ partner=partner,
+ products=self.product_a,
+ post=False,
+ )
+ move.invoice_line_ids[0].price_unit = amount
+ if ref is not None:
+ move.ref = ref
+ if post:
+ move.action_post()
+ return move
+
+ def test_rappel_pending_on_create(self):
+ bill_rappel = self._create_vendor_bill(self.vendor_rappel)
+ self.assertTrue(bill_rappel.rappel_pending)
+
+ bill_no_rappel = self._create_vendor_bill(self.vendor_no_rappel)
+ self.assertFalse(bill_no_rappel.rappel_pending)
+
+ def test_rappel_pending_onchange(self):
+ move = self.env["account.move"].new(
+ {
+ "move_type": "in_invoice",
+ "partner_id": self.vendor_no_rappel.id,
+ "invoice_date": fields.Date.from_string("2019-01-01"),
+ }
+ )
+ move._onchange_partner_id_rappel()
+ self.assertFalse(move.rappel_pending)
+
+ move.partner_id = self.vendor_rappel
+ move._onchange_partner_id_rappel()
+ self.assertTrue(move.rappel_pending)
+
+ def test_wizard_default_get_valid(self):
+ bill = self._create_vendor_bill(self.vendor_rappel)
+ wizard = (
+ self.env["account.move.rappel.wizard"]
+ .with_context(active_ids=bill.ids)
+ .create({})
+ )
+ self.assertIn(bill, wizard.move_ids)
+ self.assertEqual(wizard.journal_id, self.rappel_journal)
+ self.assertEqual(wizard.product_id, self.rappel_product)
+
+ def test_wizard_default_get_no_valid(self):
+ bill = self._create_vendor_bill(self.vendor_rappel, post=False)
+ with self.assertRaises(UserError):
+ self.env["account.move.rappel.wizard"].with_context(
+ active_ids=bill.ids
+ ).create({})
+
+ def test_wizard_default_get_no_rappel(self):
+ bill = self._create_vendor_bill(self.vendor_no_rappel)
+ with self.assertRaises(UserError):
+ self.env["account.move.rappel.wizard"].with_context(
+ active_ids=bill.ids
+ ).create({})
+
+ def test_wizard_default_get_not_pending(self):
+ bill = self._create_vendor_bill(self.vendor_rappel)
+ bill.rappel_pending = False
+ with self.assertRaises(UserError):
+ self.env["account.move.rappel.wizard"].with_context(
+ active_ids=bill.ids
+ ).create({})
+
+ def test_wizard_create_invoice(self):
+ bill = self._create_vendor_bill(self.vendor_rappel, amount=2000.0)
+ self.assertTrue(bill.rappel_pending)
+
+ wizard = (
+ self.env["account.move.rappel.wizard"]
+ .with_context(active_ids=bill.ids)
+ .create({})
+ )
+ action = wizard.action_create_rappel_invoices()
+
+ rappel_invoice = self.env["account.move"].browse(action["res_id"])
+ self.assertEqual(rappel_invoice.move_type, "out_invoice")
+ self.assertEqual(rappel_invoice.partner_id, self.vendor_rappel)
+ self.assertEqual(len(rappel_invoice.invoice_line_ids), 1)
+
+ expected_amount = 2000.0 * 5.0 / 100.0
+ self.assertEqual(rappel_invoice.invoice_line_ids[0].price_unit, expected_amount)
+
+ self.assertFalse(bill.rappel_pending)
+
+ def test_wizard_grouping(self):
+ bill1 = self._create_vendor_bill(self.vendor_rappel, amount=1000.0)
+ vendor_rappel_2 = self.env["res.partner"].create(
+ {
+ "name": "Vendor Rappel 2",
+ "rappel_commission": True,
+ "rappel_commission_rate": 10.0,
+ "property_account_receivable_id": self.company_data[
+ "default_account_receivable"
+ ].id,
+ "property_account_payable_id": self.company_data[
+ "default_account_payable"
+ ].id,
+ }
+ )
+ bill2 = self._create_vendor_bill(vendor_rappel_2, amount=2000.0)
+
+ wizard = (
+ self.env["account.move.rappel.wizard"]
+ .with_context(active_ids=(bill1 + bill2).ids)
+ .create({})
+ )
+ action = wizard.action_create_rappel_invoices()
+
+ invoices = self.env["account.move"].search(action.get("domain", []))
+ self.assertEqual(len(invoices), 2)
+
+ invoice_vendor_1 = invoices.filtered(
+ lambda m: m.partner_id == self.vendor_rappel
+ )
+ self.assertEqual(
+ invoice_vendor_1.invoice_line_ids[0].price_unit,
+ 1000.0 * 5.0 / 100.0,
+ )
+
+ invoice_vendor_2 = invoices.filtered(lambda m: m.partner_id == vendor_rappel_2)
+ self.assertEqual(
+ invoice_vendor_2.invoice_line_ids[0].price_unit,
+ 2000.0 * 10.0 / 100.0,
+ )
+
+ self.assertFalse(bill1.rappel_pending)
+ self.assertFalse(bill2.rappel_pending)
+
+ def test_wizard_no_journal(self):
+ bill = self._create_vendor_bill(self.vendor_rappel)
+ wizard = self.env["account.move.rappel.wizard"].new(
+ {
+ "move_ids": [(6, 0, bill.ids)],
+ "journal_id": False,
+ "product_id": self.rappel_product.id,
+ "company_id": self.env.company.id,
+ }
+ )
+ with self.assertRaises(UserError):
+ wizard.action_create_rappel_invoices()
+
+ def test_wizard_no_product(self):
+ bill = self._create_vendor_bill(self.vendor_rappel)
+ wizard = self.env["account.move.rappel.wizard"].new(
+ {
+ "move_ids": [(6, 0, bill.ids)],
+ "journal_id": self.rappel_journal.id,
+ "product_id": False,
+ "company_id": self.env.company.id,
+ }
+ )
+ with self.assertRaises(UserError):
+ wizard.action_create_rappel_invoices()
+
+ def test_wizard_zero_rate(self):
+ bill = self._create_vendor_bill(self.vendor_zero_rate, amount=1000.0)
+ wizard = (
+ self.env["account.move.rappel.wizard"]
+ .with_context(active_ids=bill.ids)
+ .create({})
+ )
+ with self.assertRaises(UserError):
+ wizard.action_create_rappel_invoices()
+
+ def test_wizard_vendor_ref_in_description(self):
+ bill_with_ref = self._create_vendor_bill(
+ self.vendor_rappel, amount=1000.0, ref="VENDOR-REF-001"
+ )
+ wizard = (
+ self.env["account.move.rappel.wizard"]
+ .with_context(active_ids=bill_with_ref.ids)
+ .create({})
+ )
+ action = wizard.action_create_rappel_invoices()
+ rappel_invoice = self.env["account.move"].browse(action["res_id"])
+ line_name = rappel_invoice.invoice_line_ids[0].name
+ self.assertIn("VENDOR-REF-001", line_name)
+ self.assertNotIn(bill_with_ref.name, line_name)
+
+ bill_without_ref = self._create_vendor_bill(
+ self.vendor_rappel, amount=1000.0, ref=None
+ )
+ wizard2 = (
+ self.env["account.move.rappel.wizard"]
+ .with_context(active_ids=bill_without_ref.ids)
+ .create({})
+ )
+ action2 = wizard2.action_create_rappel_invoices()
+ rappel_invoice2 = self.env["account.move"].browse(action2["res_id"])
+ line_name2 = rappel_invoice2.invoice_line_ids[0].name
+ self.assertIn(bill_without_ref.name, line_name2)
+
+ def test_wizard_includes_in_refund(self):
+ bill = self._create_vendor_bill(self.vendor_rappel, amount=1000.0)
+ refund = self._create_vendor_refund(self.vendor_rappel, amount=200.0)
+ self.assertTrue(refund.rappel_pending)
+
+ wizard = (
+ self.env["account.move.rappel.wizard"]
+ .with_context(active_ids=(bill + refund).ids)
+ .create({})
+ )
+ action = wizard.action_create_rappel_invoices()
+ rappel_invoice = self.env["account.move"].browse(action["res_id"])
+
+ self.assertEqual(len(rappel_invoice.invoice_line_ids), 2)
+ amounts = sorted(rappel_invoice.invoice_line_ids.mapped("price_unit"))
+ self.assertEqual(amounts, [-10.0, 50.0])
+ self.assertFalse(bill.rappel_pending)
+ self.assertFalse(refund.rappel_pending)
+
+ def test_wizard_refund_subtracts_commission(self):
+ bill = self._create_vendor_bill(self.vendor_rappel, amount=1000.0)
+ refund = self._create_vendor_refund(self.vendor_rappel, amount=200.0)
+
+ wizard = (
+ self.env["account.move.rappel.wizard"]
+ .with_context(active_ids=(bill + refund).ids)
+ .create({})
+ )
+ action = wizard.action_create_rappel_invoices()
+ rappel_invoice = self.env["account.move"].browse(action["res_id"])
+
+ self.assertEqual(len(rappel_invoice.invoice_line_ids), 2)
+ line_by_sign = {
+ line.price_unit > 0: line for line in rappel_invoice.invoice_line_ids
+ }
+ self.assertEqual(line_by_sign[True].price_unit, 50.0)
+ self.assertEqual(line_by_sign[False].price_unit, -10.0)
+ self.assertEqual(rappel_invoice.amount_untaxed, 40.0)
+ self.assertIn("Refund", line_by_sign[False].name)
+
+ def test_rappel_pending_set_on_refund_create(self):
+ refund = self._create_vendor_refund(
+ self.vendor_rappel, amount=200.0, post=False
+ )
+ self.assertTrue(refund.rappel_pending)
+
+ refund_no_rappel = self._create_vendor_refund(
+ self.vendor_no_rappel, amount=200.0, post=False
+ )
+ self.assertFalse(refund_no_rappel.rappel_pending)
diff --git a/purchase_rappel/views/account_move_views.xml b/purchase_rappel/views/account_move_views.xml
new file mode 100644
index 0000000..2ded4a4
--- /dev/null
+++ b/purchase_rappel/views/account_move_views.xml
@@ -0,0 +1,51 @@
+
+
+
+
+
+ account.move.tree.rappel
+ account.move
+
+
+
+
+
+
+
+
+
+
+ account.invoice.select.rappel
+ account.move
+
+
+
+
+
+
+
+
+
+
+
+ account.move.form.rappel
+ account.move
+
+
+
+
+
+
+
+
+
diff --git a/purchase_rappel/views/res_config_settings_views.xml b/purchase_rappel/views/res_config_settings_views.xml
new file mode 100644
index 0000000..23faa25
--- /dev/null
+++ b/purchase_rappel/views/res_config_settings_views.xml
@@ -0,0 +1,26 @@
+
+
+
+
+ res.config.settings.view.form.inherit.purchase.rappel
+ res.config.settings
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/purchase_rappel/views/res_partner_views.xml b/purchase_rappel/views/res_partner_views.xml
new file mode 100644
index 0000000..a9d3eca
--- /dev/null
+++ b/purchase_rappel/views/res_partner_views.xml
@@ -0,0 +1,20 @@
+
+
+
+
+ res.partner.form.rappel
+ res.partner
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/purchase_rappel/wizard/__init__.py b/purchase_rappel/wizard/__init__.py
new file mode 100644
index 0000000..7cb416e
--- /dev/null
+++ b/purchase_rappel/wizard/__init__.py
@@ -0,0 +1,3 @@
+# Copyright 2024 Criptomart
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
+from . import account_move_rappel_wizard
diff --git a/purchase_rappel/wizard/account_move_rappel_wizard.py b/purchase_rappel/wizard/account_move_rappel_wizard.py
new file mode 100644
index 0000000..fdf9f22
--- /dev/null
+++ b/purchase_rappel/wizard/account_move_rappel_wizard.py
@@ -0,0 +1,220 @@
+# Copyright 2024 Criptomart
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
+from collections import defaultdict
+
+from odoo import _, api, fields, models
+from odoo.exceptions import UserError
+
+
+class AccountMoveRappelWizard(models.TransientModel):
+ """Wizard to generate rappel commission (sale) invoices from selected vendor bills.
+
+ One sale invoice is created per vendor. Each original bill becomes one invoice
+ line whose amount is computed as: taxable_base × commission_rate / 100.
+ """
+
+ _name = "account.move.rappel.wizard"
+ _description = "Create Rappel Commission Invoice"
+
+ move_ids = fields.Many2many(
+ comodel_name="account.move",
+ string="Vendor Bills",
+ readonly=True,
+ )
+ invoice_date = fields.Date(
+ string="Invoice Date",
+ default=fields.Date.context_today,
+ required=True,
+ )
+ company_id = fields.Many2one(
+ comodel_name="res.company",
+ default=lambda self: self.env.company,
+ required=True,
+ )
+ journal_id = fields.Many2one(
+ comodel_name="account.journal",
+ string="Journal",
+ domain="[('type', '=', 'sale'), ('company_id', '=', company_id)]",
+ required=True,
+ )
+ product_id = fields.Many2one(
+ comodel_name="product.product",
+ string="Commission Product",
+ required=True,
+ )
+
+ @api.model
+ def default_get(self, fields_list):
+ res = super().default_get(fields_list)
+ company = self.env.company
+ active_ids = self.env.context.get("active_ids", [])
+ moves = self.env["account.move"].browse(active_ids)
+
+ # Filter only posted vendor bills and refunds
+ valid_moves = moves.filtered(
+ lambda m: m.move_type in ("in_invoice", "in_refund")
+ and m.state == "posted"
+ )
+ if not valid_moves:
+ raise UserError(
+ _(
+ "No posted vendor bills or refunds found in the selection. "
+ "Only posted (validated) vendor bills and refunds can be used to "
+ "create rappel commission invoices."
+ )
+ )
+
+ # Filter only pending bills
+ pending_moves = valid_moves.filtered(lambda m: m.rappel_pending)
+ if not pending_moves:
+ raise UserError(
+ _(
+ "None of the selected vendor bills are pending commission. "
+ "They may have already been commissioned."
+ )
+ )
+
+ # Warn about bills whose partner has no rappel configured
+ no_rappel = pending_moves.filtered(lambda m: not m.partner_id.rappel_commission)
+ if no_rappel and len(no_rappel) == len(pending_moves):
+ raise UserError(
+ _(
+ "None of the selected vendors have the rappel commission enabled. "
+ "Please configure the commission rate on the vendor contact first."
+ )
+ )
+
+ res.update(
+ {
+ "move_ids": [(6, 0, pending_moves.ids)],
+ "journal_id": company.rappel_journal_id.id or False,
+ "product_id": company.rappel_product_id.id or False,
+ }
+ )
+ return res
+
+ def action_create_rappel_invoices(self):
+ self.ensure_one()
+
+ if not self.journal_id:
+ raise UserError(
+ _(
+ "Please select a journal for the rappel commission invoices, or "
+ "configure a default one in Purchase Settings."
+ )
+ )
+ if not self.product_id:
+ raise UserError(
+ _(
+ "Please select a product for the rappel commission invoices, or "
+ "configure a default one in Purchase Settings."
+ )
+ )
+
+ # Group eligible bills by partner (only partners with rappel enabled)
+ bills_by_partner = defaultdict(lambda: self.env["account.move"])
+ for bill in self.move_ids:
+ if (
+ bill.partner_id.rappel_commission
+ and bill.partner_id.rappel_commission_rate
+ ):
+ bills_by_partner[bill.partner_id] |= bill
+
+ if not bills_by_partner:
+ raise UserError(
+ _(
+ "None of the selected vendor bills belong to vendors with a rappel "
+ "commission rate configured."
+ )
+ )
+
+ created_invoices = self.env["account.move"]
+
+ for partner, bills in bills_by_partner.items():
+ rate = partner.rappel_commission_rate
+ invoice_lines = []
+
+ for bill in bills:
+ taxable_base = bill.amount_untaxed
+ commission_amount = taxable_base * rate / 100.0
+ vendor_ref = bill.ref or bill.name
+ if bill.move_type == "in_refund":
+ # Vendor refunds reduce the commission: negate the amount so
+ # the resulting line subtracts from the rappel invoice total.
+ # amount_untaxed is always positive in Odoo, so we flip the
+ # sign here and display a negative base in the description.
+ commission_amount = -commission_amount
+ display_base = -taxable_base
+ name = _(
+ "Rappel commission %(rate)s%% — Refund %(bill)s "
+ "(taxable base: %(base)s %(currency)s)",
+ rate=rate,
+ bill=vendor_ref,
+ base=f"{display_base:,.2f}",
+ currency=bill.currency_id.name,
+ )
+ else:
+ name = _(
+ "Rappel commission %(rate)s%% — Bill %(bill)s "
+ "(taxable base: %(base)s %(currency)s)",
+ rate=rate,
+ bill=vendor_ref,
+ base=f"{taxable_base:,.2f}",
+ currency=bill.currency_id.name,
+ )
+ invoice_lines.append(
+ (
+ 0,
+ 0,
+ {
+ "product_id": self.product_id.id,
+ "name": name,
+ "quantity": 1,
+ "price_unit": commission_amount,
+ "tax_ids": [
+ (
+ 6,
+ 0,
+ self.product_id.taxes_id.filtered(
+ lambda t: t.company_id == self.company_id
+ ).ids,
+ )
+ ],
+ },
+ )
+ )
+
+ invoice_vals = {
+ "move_type": "out_invoice",
+ "partner_id": partner.id,
+ "invoice_date": self.invoice_date,
+ "journal_id": self.journal_id.id,
+ "company_id": self.company_id.id,
+ "invoice_line_ids": invoice_lines,
+ "narration": _(
+ "Rappel commission invoice generated from %d vendor bill(s).",
+ len(bills),
+ ),
+ }
+ created_invoice = self.env["account.move"].create(invoice_vals)
+ created_invoices |= created_invoice
+
+ # Mark source bills as commissioned
+ bills.write({"rappel_pending": False})
+
+ # Open the created invoices
+ if len(created_invoices) == 1:
+ return {
+ "type": "ir.actions.act_window",
+ "res_model": "account.move",
+ "res_id": created_invoices.id,
+ "view_mode": "form",
+ "views": [(False, "form")],
+ }
+ return {
+ "type": "ir.actions.act_window",
+ "res_model": "account.move",
+ "domain": [("id", "in", created_invoices.ids)],
+ "view_mode": "list,form",
+ "name": _("Rappel Commission Invoices"),
+ }
diff --git a/purchase_rappel/wizard/account_move_rappel_wizard_views.xml b/purchase_rappel/wizard/account_move_rappel_wizard_views.xml
new file mode 100644
index 0000000..f30326f
--- /dev/null
+++ b/purchase_rappel/wizard/account_move_rappel_wizard_views.xml
@@ -0,0 +1,62 @@
+
+
+
+
+
+ account.move.rappel.wizard.form
+ account.move.rappel.wizard
+
+
+
+
+
+
+
+ Create Rappel Commission Invoice
+ account.move.rappel.wizard
+ form
+ new
+
+ list
+
+
+