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)}