From 42bc368be5f837c9dbd0084032180bf6f028c29d Mon Sep 17 00:00:00 2001 From: Valentin Romero Date: Tue, 30 Jun 2026 22:08:52 -0300 Subject: [PATCH] add odoo erp --- config/odoo.conf | 16 + extra-addons/account_api/__init__.py | 2 + extra-addons/account_api/__manifest__.py | 16 + .../account_api/controllers/__init__.py | 1 + extra-addons/account_api/controllers/main.py | 548 ++++++++++++++++++ .../account_api/data/ir_config_parameter.xml | 9 + extra-addons/account_api/models/__init__.py | 1 + .../account_api/models/account_move.py | 75 +++ 8 files changed, 668 insertions(+) create mode 100644 config/odoo.conf create mode 100644 extra-addons/account_api/__init__.py create mode 100644 extra-addons/account_api/__manifest__.py create mode 100644 extra-addons/account_api/controllers/__init__.py create mode 100644 extra-addons/account_api/controllers/main.py create mode 100644 extra-addons/account_api/data/ir_config_parameter.xml create mode 100644 extra-addons/account_api/models/__init__.py create mode 100644 extra-addons/account_api/models/account_move.py diff --git a/config/odoo.conf b/config/odoo.conf new file mode 100644 index 0000000..f071d84 --- /dev/null +++ b/config/odoo.conf @@ -0,0 +1,16 @@ +[options] +; Rutas de addons: ruta predeterminada de Odoo y la carpeta extra-addons +addons_path = /usr/lib/python3/dist-packages/odoo/addons, + /mnt/extra-addons + +; Parámetros de conexión a la base de datos +db_host = db +db_port = 5432 +db_user = odoo +db_password = odoo + +; Archivo de log +# logfile = /var/log/odoo/odoo-server.log + +; Parámetros adicionales +limit_time_real = 3600 diff --git a/extra-addons/account_api/__init__.py b/extra-addons/account_api/__init__.py new file mode 100644 index 0000000..91c5580 --- /dev/null +++ b/extra-addons/account_api/__init__.py @@ -0,0 +1,2 @@ +from . import controllers +from . import models diff --git a/extra-addons/account_api/__manifest__.py b/extra-addons/account_api/__manifest__.py new file mode 100644 index 0000000..3ceacb5 --- /dev/null +++ b/extra-addons/account_api/__manifest__.py @@ -0,0 +1,16 @@ +{ + "name": "Account API (ARG)", + "version": "18.0.1.0.3", + "author": "Valentin Romero", + "website": "", + "license": "AGPL-3", + "depends": [ + "base", + "l10n_ar", + ], + "category": "Tools", + "data": [ + "data/ir_config_parameter.xml" + ], + "installable": True, +} diff --git a/extra-addons/account_api/controllers/__init__.py b/extra-addons/account_api/controllers/__init__.py new file mode 100644 index 0000000..12a7e52 --- /dev/null +++ b/extra-addons/account_api/controllers/__init__.py @@ -0,0 +1 @@ +from . import main diff --git a/extra-addons/account_api/controllers/main.py b/extra-addons/account_api/controllers/main.py new file mode 100644 index 0000000..6e8422d --- /dev/null +++ b/extra-addons/account_api/controllers/main.py @@ -0,0 +1,548 @@ +import json +import base64 +import logging +import requests +import datetime +import traceback +from odoo import http +from odoo.http import request +from dateutil.relativedelta import relativedelta + +_logger = logging.getLogger(__name__) + + +class AccountMoveController(http.Controller): + + @http.route( + "/account/move/create", type="json", auth="public", methods=["POST"], csrf=False + ) + def create_account_move(self, **kwargs): + try: + data = json.loads(request.httprequest.get_data()) + if not data: + return { + "success": False, + "error": "No se recibieron datos en la petición", + } + + company = ( + request.env["res.company"] + .sudo() + .search([("active", "=", True)], limit=1) + ) + if not company: + return {"success": False, "error": "No hay ninguna compañía activa."} + + journal = ( + request.env["account.journal"] + .sudo() + .search( + [("type", "=", "sale"), ("company_id", "=", company.id)], limit=1 + ) + ) + if not journal: + return { + "success": False, + "error": "No se encontró un diario de ventas en la compañía activa.", + } + + data_user = data.get("user") + data_product = data.get("product") + if not data_user or not data_product: + return { + "success": False, + "error": "Faltan datos de usuario o producto en la petición", + } + + user_external_id = data_user.get("id") + user_dni = data_user.get("dni") + data_product_description = data_product.get("description") + data_product_amount = data_product.get("amount", 0) + data_product_session_id = data_product.get("sessionId") + data_external_invoice_id = data_product.get("invoiceId") + if ( + not user_external_id + or not data_product_description + or not data_external_invoice_id + ): + return { + "success": False, + "error": "Faltan datos necesarios en la petición", + } + + partner_id = ( + request.env["res.partner"] + .sudo() + .search( + [ + ("vat", "=", user_dni), + ], + limit=1, + ) + ) + + if not partner_id: + partner_vals = { + "name": f"Client {user_external_id}", + "vat": user_dni, + "ref": user_external_id, + "company_id": company.id, + "l10n_ar_afip_responsibility_type_id": request.env.ref("l10n_ar.res_CF").id, + "l10n_latam_identification_type_id": request.env.ref("l10n_ar.it_dni").id + } + partner_id = request.env["res.partner"].sudo().create(partner_vals) + + product_id = ( + request.env["product.product"] + .sudo() + .search( + [ + ("default_code", "=", data_product_session_id) + ], + limit=1, + ) + ) + + if not product_id: + default_tax_id = company.account_sale_tax_id + product_vals = { + "name": data_product_description, + "default_code": data_product_session_id, + "list_price": data_product_amount, + "company_id": company.id, + "type": "service", + "taxes_id": [(6, 0, [default_tax_id.id])] if default_tax_id else [], + } + product_tmpl_id = request.env["product.template"].sudo().create(product_vals) + product_id = product_tmpl_id.product_variant_id + + today = datetime.date.today() + first_day = today.replace(day=1) + next_month_first = first_day + relativedelta(months=1) + date_value = datetime.datetime.now().strftime("%Y-%m-%d") + vals = { + "partner_id": partner_id.id, + "company_id": company.id, + "journal_id": journal.id, + "move_type": "out_invoice", + "date": date_value, + "invoice_date": date_value, + "l10n_ar_afip_service_start": first_day.strftime("%Y-%m-%d"), + "l10n_ar_afip_service_end": next_month_first.strftime("%Y-%m-%d"), + "ref": data_external_invoice_id, + "invoice_line_ids": [ + ( + 0, + 0, + { + "product_id": product_id.id, + "quantity": 1.0, + "price_unit": float(data_product_amount), + "name": data_product_description, + }, + ) + ], + } + + account_move = request.env["account.move"].sudo().create(vals) + + account_move.action_post() + account_move.sudo()._portal_ensure_token() + base_url = request.httprequest.host_url.rstrip("/") + share_url = "{}/my/invoices/{}?access_token={}".format( + base_url, + account_move.id, + account_move.access_token, + ) + + request.env.cr.commit() + + pdf_response = requests.get(f"{share_url}&report_type=pdf&download=true", timeout=30) + pdf_base64 = base64.b64encode(pdf_response.content) + + return { + "invoiceId": data_external_invoice_id, + "base64Invoice": pdf_base64, + "linkPayment": "url_de_mercadopago_o_payu", + } + + except Exception as e: + # Log del error + _logger.error("Error al crear account.move: %s", str(e)) + + # Rollback en caso de error + request.env.cr.rollback() + + return {"success": False, "error": str(e)} + + @http.route( + "/account/move/status", type="json", auth="public", methods=["GET"], csrf=False + ) + def get_account_move_status(self, **kwargs): + try: + data = json.loads(request.httprequest.get_data()) + if not data: + return { + "success": False, + "error": "No se recibieron datos en la petición", + } + + data_external_invoice_id = data.get("invoiceId") + if not data_external_invoice_id: + return { + "success": False, + "error": "Falta el ID de la factura en la petición", + } + + account_move = ( + request.env["account.move"] + .sudo() + .search([("ref", "=", data_external_invoice_id)], limit=1) + ) + + if not account_move: + return { + "success": False, + "error": "No se encontró ninguna factura con el ID proporcionado.", + } + + states = { + "not_paid": "PENDING", + "paid": "PAID", + "cancel": "CANCELED", + } + status = states.get(account_move.payment_state, "UNKNOWN") + return { + "invoiceId": data_external_invoice_id, + "status": status, + } + + except Exception as e: + # Log del error + _logger.error("Error al obtener el estado de account.move: %s", str(e)) + + return {"success": False, "error": str(e)} + + @http.route( + "/account/move/cancel", type="json", auth="public", methods=["POST"], csrf=False + ) + def cancel_account_move(self, **kwargs): + try: + data = json.loads(request.httprequest.get_data()) + if not data: + return {"success": False, "error": "No se recibieron datos en la petición"} + + data_external_invoice_id = data.get("invoiceId") + if not data_external_invoice_id: + return {"success": False, "error": "Falta el ID de la factura en la petición"} + + account_move = ( + request.env["account.move"] + .sudo() + .search([("ref", "=", data_external_invoice_id)], limit=1) + ) + + if not account_move: + return {"success": False, "error": "No se encontró ninguna factura con el ID proporcionado."} + + # Si la factura está en estado 'posted', crear nota de crédito automáticamente + # (esto prevalece sobre la comprobación de AFIP) + force_refund = account_move.state == "posted" + + # Determinar si la factura fue publicada en AFIP. + # En este entorno hay módulos que usan los campos `afip_auth_code` / `afip_auth_mode` + # (por ejemplo integraciones con pyafipws). Si existe un código de autorización, + # consideramos que ya fue publicada. + published_in_afip = False + try: + published_in_afip = bool(getattr(account_move, "afip_auth_code", False)) + except Exception: + published_in_afip = False + + # Si no está publicada y no está 'posted', cancelamos directamente + if not published_in_afip and not force_refund: + # Use el método estándar para cancelar (button_cancel está presente en account.move) + account_move.sudo().button_cancel() + request.env.cr.commit() + return {"invoiceId": data_external_invoice_id, "status": "CANCELED", "success": True} + + # Si está publicada en AFIP, crear nota de crédito (out_refund) con motivo 'Devolucion' + # Reutilizamos las líneas de la factura original + # Si ya existe una nota de crédito asociada a esta factura, devolverla en vez de crear otra + try: + existing_refunds = account_move.reversal_move_ids.filtered( + lambda m: m.move_type in ("out_refund", "in_refund") and m.state != "cancel" + ) + except Exception: + existing_refunds = account_move.reversal_move_ids.filtered( + lambda m: m.move_type in ("out_refund", "in_refund") + ) + + if existing_refunds: + # Use the most recent refund (by id) + credit_move = existing_refunds.sorted(lambda r: r.id, reverse=True)[0] + try: + credit_move.sudo()._portal_ensure_token() + except Exception: + pass + request.env.cr.commit() + base_url = request.httprequest.host_url.rstrip("/") + share_url = "{}/my/invoices/{}?access_token={}".format( + base_url, credit_move.id, credit_move.access_token + ) + try: + pdf_response = requests.get(f"{share_url}&report_type=pdf&download=true", timeout=30) + pdf_base64 = base64.b64encode(pdf_response.content) + except Exception: + pdf_base64 = False + + return { + "invoiceId": data_external_invoice_id, + "status": "REFUNDED", + "creditInvoiceRef": credit_move.ref or (credit_move.name or f"{data_external_invoice_id}-NC"), + "creditInvoiceId": credit_move.id, + "base64Invoice": pdf_base64, + "success": True, + } + + invoice_lines = [] + for line in account_move.invoice_line_ids: + tax_ids = line.tax_ids.ids if hasattr(line, "tax_ids") else [] + invoice_lines.append( + ( + 0, + 0, + { + "product_id": line.product_id.id if line.product_id else False, + "name": line.name, + "quantity": float(line.quantity), + "price_unit": float(line.price_unit), + "tax_ids": [(6, 0, tax_ids)], + "account_id": line.account_id.id if line.account_id else False, + }, + ) + ) + + today = datetime.datetime.now().strftime("%Y-%m-%d") + refund_ref = f"{data_external_invoice_id}-NC" + + # Prepare default values for reversal so Odoo sets reversed_entry_id and handles reconciliation + default_vals = { + "ref": refund_ref, + "invoice_origin": account_move.name or account_move.l10n_latam_document_number or account_move.ref, + "date": today, + "invoice_date": today, + # AFIP related fields: repetir periodo de servicio si existían + "l10n_ar_afip_service_start": getattr(account_move, "l10n_ar_afip_service_start", False), + "l10n_ar_afip_service_end": getattr(account_move, "l10n_ar_afip_service_end", False), + } + + # Determine reverse move_type using a safe local map (mirrors core TYPE_REVERSE_MAP) + reverse_map = { + "entry": "entry", + "out_invoice": "out_refund", + "out_refund": "out_invoice", + "in_invoice": "in_refund", + "in_refund": "in_invoice", + "out_receipt": "out_refund", + "in_receipt": "in_refund", + } + reverse_move_type = reverse_map.get(account_move.move_type, "out_refund") + default_vals["move_type"] = reverse_move_type + + # Prefer a l10n_latam document type from the invoice's available types filtered for credit_note + try: + # account_move.l10n_latam_available_document_type_ids is computed and should contain valid candidates + candidates = account_move.l10n_latam_available_document_type_ids.filtered( + lambda d: d.internal_type == "credit_note" + ) + if candidates: + default_vals["l10n_latam_document_type_id"] = candidates[0].id + else: + # Fallback: search globally by country of the company + country = account_move.company_id.account_fiscal_country_id and account_move.company_id.account_fiscal_country_id.id or False + if country: + doc_type = request.env["l10n_latam.document.type"].sudo().search([ + ("internal_type", "=", "credit_note"), + ("country_id", "=", country), + ], limit=1) + if doc_type: + default_vals["l10n_latam_document_type_id"] = doc_type.id + except Exception: + # If localization not available or any error, continue without setting the document type + pass + + # Prefer using the same journal as the original invoice for the reversal + try: + if account_move.journal_id: + default_vals["journal_id"] = account_move.journal_id.id + except Exception: + pass + + # Use the core _reverse_moves to create a proper reversal (sets reversed_entry_id and posts/reconciles if cancel=True) + # Run reversal using the invoice's company context to avoid multi-company singleton issues + try: + credit_moves = account_move.sudo().with_company(account_move.company_id)._reverse_moves(default_values_list=[default_vals], cancel=True) + except Exception: + # Fallback to calling without explicit company if something goes wrong + credit_moves = account_move.sudo()._reverse_moves(default_values_list=[default_vals], cancel=True) + # _reverse_moves returns the created reverse moves; we're dealing with a single invoice + credit_move = credit_moves and credit_moves[0] or None + if credit_move: + try: + credit_move.sudo().with_company(credit_move.company_id)._portal_ensure_token() + except Exception: + credit_move.sudo()._portal_ensure_token() + # Commit antes de intentar obtener el PDF + request.env.cr.commit() + + base_url = request.httprequest.host_url.rstrip("/") + share_url = "{}/my/invoices/{}?access_token={}".format( + base_url, credit_move.id, credit_move.access_token + ) + + # Obtener PDF de la nota de crédito + try: + pdf_response = requests.get(f"{share_url}&report_type=pdf&download=true", timeout=30) + pdf_base64 = base64.b64encode(pdf_response.content) + except Exception: + pdf_base64 = False + + return { + "invoiceId": data_external_invoice_id, + "status": "REFUNDED", + "creditInvoiceRef": refund_ref, + "creditInvoiceId": credit_move.id, + "base64Invoice": pdf_base64, + "success": True, + } + + except Exception as e: + # Log full traceback to help identify the source of 'Expected singleton' errors + _logger.error("Error al cancelar account.move: %s", str(e)) + _logger.error(traceback.format_exc()) + request.env.cr.rollback() + return {"success": False, "error": str(e)} + + @http.route( + "/account/move/paid", type="json", auth="public", methods=["POST"], csrf=False + ) + def pay_account_move(self, **kwargs): + try: + data = json.loads(request.httprequest.get_data()) + if not data: + return {"success": False, "error": "No se recibieron datos en la petición"} + + data_external_invoice_id = data.get("invoiceId") + amount = data.get("amount") + + if not data_external_invoice_id: + return {"success": False, "error": "Falta el ID de la factura en la petición"} + + account_move = ( + request.env["account.move"].sudo().search([("ref", "=", data_external_invoice_id)], limit=1) + ) + + if not account_move: + return {"success": False, "error": "No se encontró ninguna factura con el ID proporcionado."} + + # Only allow payments on posted invoices + if account_move.state != "posted": + return {"success": False, "error": "Solo se pueden pagar facturas en estado 'posted'."} + + # If the invoice was reversed/cancelled, don't allow payment + if account_move.reversal_move_ids: + return {"success": False, "error": "La factura ya tiene una nota de crédito asociada (revertida). No se puede pagar."} + + # If already fully paid, return status + if account_move.payment_state == "paid": + return { + "invoiceId": data_external_invoice_id, + "status": "PAID", + "paid": True, + "paymentIds": [p.id for p in account_move.payment_id | account_move.reconciled_payment_ids | account_move.matched_payment_ids], + "success": True, + } + + # Determine amount to pay: use provided amount or remaining residual + try: + if amount is None: + # amount_residual is in company currency + amount_to_pay = float(account_move.amount_residual) + else: + amount_to_pay = float(amount) + except Exception: + return {"success": False, "error": "El campo amount debe ser numérico."} + + # Use the register payment wizard to create/post/reconcile the payment properly + company = account_move.company_id + try: + PaymentRegisterModel = ( + request.env["account.payment.register"].sudo().with_company(company).with_context( + active_model="account.move", + active_ids=[account_move.id], + skip_invoice_sync=True, + company_id=company.id, + ) + ) + + # Create wizard record (default_get will prepare line_ids etc.) in the company's context + wizard = PaymentRegisterModel.create({}) + + # Set custom amount (wizard.amount is a compute editable field) + try: + wizard.amount = amount_to_pay + except Exception: + # If write fails for any reason, try setting custom_user_amount/currency + try: + wizard.custom_user_amount = amount_to_pay + except Exception: + pass + + # Create and post payments under company context + payments = wizard.with_company(company)._create_payments() + + except Exception as e: + # If company-scoped approach fails, fallback to plain context (more permissive) + _logger.warning("Falling back to non-company-scoped payment register due to: %s", str(e)) + _logger.warning(traceback.format_exc()) + try: + PaymentRegisterModel = request.env["account.payment.register"].sudo().with_context( + active_model="account.move", + active_ids=[account_move.id], + skip_invoice_sync=True, + ) + wizard = PaymentRegisterModel.create({}) + try: + wizard.amount = amount_to_pay + except Exception: + try: + wizard.custom_user_amount = amount_to_pay + except Exception: + pass + payments = wizard._create_payments() + except Exception as e2: + _logger.error("Error creando el pago para la factura %s: %s", data_external_invoice_id, str(e2)) + _logger.error(traceback.format_exc()) + request.env.cr.rollback() + return {"success": False, "error": str(e2)} + + # Commit DB before returning + request.env.cr.commit() + + # Refresh invoice payment state + account_move_sudo = request.env["account.move"].sudo().browse(account_move.id) + + return { + "invoiceId": data_external_invoice_id, + "status": account_move_sudo.payment_state.upper() if account_move_sudo.payment_state else "UNKNOWN", + "paid": account_move_sudo.payment_state == "paid", + "paymentIds": payments.ids if payments else [], + "success": True, + } + + except Exception as e: + _logger.error("Error al crear el pago account.move: %s", str(e)) + _logger.error(traceback.format_exc()) + request.env.cr.rollback() + return {"success": False, "error": str(e)} diff --git a/extra-addons/account_api/data/ir_config_parameter.xml b/extra-addons/account_api/data/ir_config_parameter.xml new file mode 100644 index 0000000..001e94e --- /dev/null +++ b/extra-addons/account_api/data/ir_config_parameter.xml @@ -0,0 +1,9 @@ + + + + + account.api.invoice.state.webhook + https://libraries-exclusive-vernon-device.trycloudflare.com/odoo/webhook + + + diff --git a/extra-addons/account_api/models/__init__.py b/extra-addons/account_api/models/__init__.py new file mode 100644 index 0000000..9c0a421 --- /dev/null +++ b/extra-addons/account_api/models/__init__.py @@ -0,0 +1 @@ +from . import account_move diff --git a/extra-addons/account_api/models/account_move.py b/extra-addons/account_api/models/account_move.py new file mode 100644 index 0000000..e4426bc --- /dev/null +++ b/extra-addons/account_api/models/account_move.py @@ -0,0 +1,75 @@ +from odoo import models, _ +import requests +import json +import logging + +_logger = logging.getLogger(__name__) + + +class AccountMove(models.Model): + _inherit = "account.move" + + def _send_payment_status_webhook(self): + """Send webhook notification when payment status changes""" + for record in self: + states = { + "not_paid": "PENDING", + "paid": "PAID", + "cancel": "CANCELED", + } + if not record.ref or len(record.ref) == 0: + _logger.warning( + f"Skipping webhook for invoice with empty reference (ID: {record.id})" + ) + continue + if not record.payment_state or len(record.payment_state) == 0: + _logger.warning( + f"Skipping webhook for invoice {record.ref} with empty payment state" + ) + continue + if not record.payment_state in states.keys(): + _logger.warning( + f"Skipping webhook for invoice {record.ref} with unsupported payment state {record.payment_state}" + ) + continue + + data = { + "invoiceId": record.ref, + "status": states.get(record.payment_state, "UNKNOWN"), + } + + webhook_url = ( + self.env["ir.config_parameter"] + .sudo() + .get_param("account.api.invoice.state.webhook") + ) + + try: + headers = {"Content-Type": "application/json"} + + response = requests.post( + webhook_url, data=json.dumps(data), headers=headers, timeout=30 + ) + + if response.status_code == 200: + _logger.info( + f"Notificación de pago enviada exitosamente para factura {record.ref}" + ) + else: + _logger.warning( + f"Error al enviar notificación de pago para factura {record.ref}. Status code: {response.status_code}" + ) + + except requests.exceptions.RequestException as e: + _logger.error( + f"Error al enviar notificación de pago para factura {record.ref}: {str(e)}" + ) + except Exception as e: + _logger.error( + f"Error inesperado al procesar notificación de pago para factura {record.ref}: {str(e)}" + ) + + def _compute_payment_state(self): + super()._compute_payment_state() + for record in self: + record._send_payment_status_webhook()