add odoo erp
This commit is contained in:
@@ -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
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
from . import controllers
|
||||||
|
from . import models
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
from . import main
|
||||||
@@ -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)}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<data noupdate="1">
|
||||||
|
<record id="account_invoice_webhook_url" model="ir.config_parameter">
|
||||||
|
<field name="key">account.api.invoice.state.webhook</field>
|
||||||
|
<field name="value">https://libraries-exclusive-vernon-device.trycloudflare.com/odoo/webhook</field>
|
||||||
|
</record>
|
||||||
|
</data>
|
||||||
|
</odoo>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
from . import account_move
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user