"""
Facturama Multiemisor PAC adapter.
Facturama seals and stamps from a JSON payload (CSD must already be uploaded).
Pre-signed XML stamp is not supported by their public Multiemisor API.
Supported TipoDeComprobante: I, E, T, P (Pagos 2.0), N (Nómina 1.2).
Docs: https://apisandbox.facturama.mx/guias
"""
from __future__ import annotations
import base64
from datetime import date, datetime
from decimal import Decimal
from enum import Enum
from typing import Any
import requests
from requests.auth import HTTPBasicAuth
from . import (
PAC,
Accept,
CancelReason,
CancelationAcknowledgment,
Document,
Environment,
)
from .. import __version__
from ..cfdi import CFDI
from ..exceptions import DocumentNotFoundError, ResponseError
from ..models import Signer
from ..utils import iterate
_TAX_NAME = {
"001": "ISR",
"002": "IVA",
"003": "IEPS",
}
_ITEM_TIPOS = {"I", "E", "T"}
_SUPPORTED_TIPOS = _ITEM_TIPOS | {"P", "N"}
def _enum_value(val):
if isinstance(val, Enum):
return val.value
return val
def _num(val) -> float | None:
if val is None:
return None
if isinstance(val, Decimal):
return float(val)
if isinstance(val, (int, float)):
return float(val)
return float(Decimal(str(val)))
def _format_date(fecha, *, date_only: bool = False) -> str | None:
if fecha is None:
return None
if isinstance(fecha, datetime):
if date_only:
return fecha.strftime("%Y-%m-%d")
return fecha.strftime("%Y-%m-%d %H:%M:%S")
if isinstance(fecha, date):
return fecha.strftime("%Y-%m-%d")
text = str(fecha)
if date_only:
return text[:10]
if "T" in text:
return text.replace("T", " ")[:19]
return text
def _as_bool(val) -> bool | None:
if val is None:
return None
if isinstance(val, bool):
return val
text = str(_enum_value(val)).strip().lower()
if text in {"si", "sí", "yes", "true", "1"}:
return True
if text in {"no", "false", "0"}:
return False
return bool(val)
def _tax_entry(
tax: dict,
*,
is_retention: bool,
base_key="Base",
impuesto_key="Impuesto",
tipo_factor_key="TipoFactor",
tasa_key="TasaOCuota",
importe_key="Importe",
) -> dict:
impuesto = str(_enum_value(tax.get(impuesto_key)))
name = _TAX_NAME.get(impuesto)
if not name:
raise NotImplementedError(f"Unsupported tax code: {impuesto}")
tipo_factor = str(_enum_value(tax.get(tipo_factor_key) or ""))
entry = {
"Name": name,
"IsRetention": is_retention,
"Base": _num(tax.get(base_key)),
"Total": _num(tax.get(importe_key)) or 0.0,
}
if tipo_factor.lower() == "exento":
entry["Rate"] = 0.0
entry["IsQuota"] = False
else:
rate = tax.get(tasa_key)
entry["Rate"] = _num(rate) if rate is not None else 0.0
entry["IsQuota"] = tipo_factor.lower() == "cuota"
return entry
def _complement_node(cfdi: CFDI, name: str):
"""
Return a complement node whether Complemento is a dict of complements
(after XML parse) or the complement CFDI itself (after create()).
"""
complemento = cfdi.get("Complemento")
if not complemento:
return None
if name in complemento:
node = complemento[name]
# Avoid treating Pagos body keys as a nested "Pago" complement name clash
if node is not None and not (
name == "Pago" and isinstance(node, list) and "Version" in complemento
):
return node
tag = getattr(complemento, "tag", "") or ""
if name == "Pagos" and (
"Pagos" in tag
or ("Pago" in complemento and "Version" in complemento)
):
return complemento
if name == "Nomina" and (
"nomina" in tag.lower()
or "TipoNomina" in complemento
):
return complemento
return None
def _uuid_from_relacionado(uuid_node) -> str:
if isinstance(uuid_node, str):
return uuid_node
return str(uuid_node["UUID"])
def _map_relations(cfdi: CFDI) -> dict | None:
relacionados = cfdi.get("CfdiRelacionados")
if not relacionados:
return None
groups = list(iterate(relacionados))
if not groups:
return None
tipo = str(_enum_value(groups[0]["TipoRelacion"]))
uuids = []
for group in groups:
group_tipo = str(_enum_value(group["TipoRelacion"]))
if group_tipo != tipo:
raise NotImplementedError(
"Facturama Relations supports a single TipoRelacion; "
f"got both {tipo!r} and {group_tipo!r}"
)
for uuid_node in iterate(group.get("CfdiRelacionado")):
uuids.append({"Uuid": _uuid_from_relacionado(uuid_node)})
return {"Type": tipo, "Cfdis": uuids}
def _map_items(cfdi: CFDI) -> list[dict]:
items = []
for concepto in iterate(cfdi["Conceptos"]):
item: dict[str, Any] = {
"ProductCode": str(concepto["ClaveProdServ"]),
"Description": str(concepto["Descripcion"]),
"UnitCode": str(concepto["ClaveUnidad"]),
"Quantity": _num(concepto["Cantidad"]),
"UnitPrice": _num(concepto["ValorUnitario"]),
"Subtotal": _num(concepto["Importe"]),
"TaxObject": str(_enum_value(concepto.get("ObjetoImp") or "01")),
}
if no_id := concepto.get("NoIdentificacion"):
item["IdentificationNumber"] = str(no_id)
if unidad := concepto.get("Unidad"):
item["Unit"] = str(unidad)
if desc := concepto.get("Descuento"):
item["Discount"] = _num(desc)
if terceros := concepto.get("ACuentaTerceros"):
item["ThirdPartyAccount"] = {
"Rfc": terceros["RfcACuentaTerceros"],
"Name": terceros["NombreACuentaTerceros"],
"FiscalRegime": str(_enum_value(terceros["RegimenFiscalACuentaTerceros"])),
"TaxZipCode": str(terceros["DomicilioFiscalACuentaTerceros"]),
}
taxes = []
impuestos = concepto.get("Impuestos") or {}
for traslado in iterate(impuestos.get("Traslados")):
taxes.append(_tax_entry(traslado, is_retention=False))
for retencion in iterate(impuestos.get("Retenciones")):
taxes.append(_tax_entry(retencion, is_retention=True))
if taxes:
item["Taxes"] = taxes
line_total = _num(concepto["Importe"]) or 0.0
if desc := item.get("Discount"):
line_total -= desc
for tax in taxes:
if tax["IsRetention"]:
line_total -= tax["Total"] or 0.0
else:
line_total += tax["Total"] or 0.0
item["Total"] = line_total
items.append(item)
return items
def _map_payments(pagos) -> list[dict]:
payments = []
for pago in iterate(pagos.get("Pago")):
payment: dict[str, Any] = {
"Date": _format_date(pago["FechaPago"]),
"PaymentForm": str(_enum_value(pago["FormaDePagoP"])),
"Amount": _num(pago["Monto"]),
}
if moneda := pago.get("MonedaP"):
payment["Currency"] = str(_enum_value(moneda))
if tipo_cambio := pago.get("TipoCambioP"):
payment["ExchangeRate"] = _num(tipo_cambio)
if num_op := pago.get("NumOperacion"):
payment["OperationNumber"] = str(num_op)
if rfc_ord := pago.get("RfcEmisorCtaOrd"):
payment["AccountOrderRfc"] = str(rfc_ord)
if banco := pago.get("NomBancoOrdExt"):
payment["AccountOrderBankName"] = str(banco)
if cta_ord := pago.get("CtaOrdenante"):
payment["AccountOrderNumber"] = str(cta_ord)
if rfc_ben := pago.get("RfcEmisorCtaBen"):
payment["AccountBeneficiaryRfc"] = str(rfc_ben)
if cta_ben := pago.get("CtaBeneficiario"):
payment["AccountBeneficiaryNumber"] = str(cta_ben)
related = []
for docto in iterate(pago.get("DoctoRelacionado")):
doc: dict[str, Any] = {
"Uuid": str(docto["IdDocumento"]),
"PaymentMethod": str(
_enum_value(
docto.get("MetodoDePagoDR")
or docto.get("MetodoPagoDR")
or "PPD"
)
),
"PartialityNumber": str(docto["NumParcialidad"]),
"PreviousBalanceAmount": _num(docto["ImpSaldoAnt"]),
"AmountPaid": _num(docto["ImpPagado"]),
"ImpSaldoInsoluto": _num(
docto.get("ImpSaldoInsoluto")
if docto.get("ImpSaldoInsoluto") is not None
else (docto["ImpSaldoAnt"] - docto["ImpPagado"])
),
"TaxObject": str(_enum_value(docto.get("ObjetoImpDR") or "01")),
}
if serie := docto.get("Serie"):
doc["Serie"] = str(serie)
if folio := docto.get("Folio"):
doc["Folio"] = str(folio)
if moneda := docto.get("MonedaDR"):
doc["Currency"] = str(_enum_value(moneda))
if equiv := docto.get("EquivalenciaDR"):
doc["EquivalenceDocRel"] = _num(equiv)
taxes = []
impuestos = docto.get("ImpuestosDR") or {}
for traslado in iterate(impuestos.get("TrasladosDR")):
taxes.append(
_tax_entry(
traslado,
is_retention=False,
base_key="BaseDR",
impuesto_key="ImpuestoDR",
tipo_factor_key="TipoFactorDR",
tasa_key="TasaOCuotaDR",
importe_key="ImporteDR",
)
)
for retencion in iterate(impuestos.get("RetencionesDR")):
taxes.append(
_tax_entry(
retencion,
is_retention=True,
base_key="BaseDR",
impuesto_key="ImpuestoDR",
tipo_factor_key="TipoFactorDR",
tasa_key="TasaOCuotaDR",
importe_key="ImporteDR",
)
)
if taxes:
doc["Taxes"] = taxes
related.append(doc)
payment["RelatedDocuments"] = related
payments.append(payment)
return payments
def _map_payroll(nomina) -> dict:
payroll: dict[str, Any] = {
"Type": str(_enum_value(nomina["TipoNomina"])),
"PaymentDate": _format_date(nomina["FechaPago"], date_only=True),
"InitialPaymentDate": _format_date(nomina["FechaInicialPago"], date_only=True),
"FinalPaymentDate": _format_date(nomina["FechaFinalPago"], date_only=True),
"DaysPaid": _num(nomina["NumDiasPagados"]),
}
if emisor := nomina.get("Emisor"):
issuer: dict[str, Any] = {}
if registro := emisor.get("RegistroPatronal"):
issuer["EmployerRegistration"] = str(registro)
if rfc_origen := emisor.get("RfcPatronOrigen"):
issuer["FromEmployerRfc"] = str(rfc_origen)
if issuer:
payroll["Issuer"] = issuer
receptor = nomina["Receptor"]
employee: dict[str, Any] = {
"Curp": str(receptor["Curp"]),
"ContractType": str(_enum_value(receptor["TipoContrato"])),
"RegimeType": str(_enum_value(receptor["TipoRegimen"])),
"EmployeeNumber": str(receptor["NumEmpleado"]),
"FrequencyPayment": str(_enum_value(receptor["PeriodicidadPago"])),
"FederalEntityKey": str(_enum_value(receptor["ClaveEntFed"])),
}
if nss := receptor.get("NumSeguridadSocial"):
employee["SocialSecurityNumber"] = str(nss)
if inicio := receptor.get("FechaInicioRelLaboral"):
employee["StartDateLaborRelations"] = _format_date(inicio, date_only=True)
if receptor.get("Sindicalizado") is not None:
employee["Unionized"] = bool(_as_bool(receptor.get("Sindicalizado")))
if jornada := receptor.get("TipoJornada"):
employee["TypeOfJourney"] = str(_enum_value(jornada))
if depto := receptor.get("Departamento"):
employee["Department"] = str(depto)
if puesto := receptor.get("Puesto"):
employee["Position"] = str(puesto)
if riesgo := receptor.get("RiesgoPuesto"):
employee["PositionRisk"] = str(_enum_value(riesgo))
if banco := receptor.get("Banco"):
employee["Bank"] = str(_enum_value(banco))
if cuenta := receptor.get("CuentaBancaria"):
employee["BankAccount"] = str(cuenta)
if sbc := receptor.get("SalarioBaseCotApor"):
employee["BaseSalary"] = _num(sbc)
if sdi := receptor.get("SalarioDiarioIntegrado"):
employee["DailySalary"] = _num(sdi)
payroll["Employee"] = employee
if percepciones := nomina.get("Percepciones"):
details = []
for p in iterate(percepciones.get("Percepcion")):
details.append(
{
"PerceptionType": str(_enum_value(p["TipoPercepcion"])),
"Code": str(p["Clave"]),
"Description": str(p["Concepto"]),
"TaxedAmount": _num(p["ImporteGravado"]),
"ExemptAmount": _num(p["ImporteExento"]),
}
)
payroll["Perceptions"] = {"Details": details}
if deducciones := nomina.get("Deducciones"):
details = []
for d in iterate(deducciones.get("Deduccion")):
details.append(
{
"DeduccionType": str(_enum_value(d["TipoDeduccion"])),
"Code": str(d["Clave"]),
"Description": str(d["Concepto"]),
"Amount": _num(d["Importe"]),
}
)
payroll["Deductions"] = {"Details": details}
if otros := nomina.get("OtrosPagos"):
other_payments = []
for o in iterate(otros):
entry: dict[str, Any] = {
"OtherPaymentType": str(_enum_value(o["TipoOtroPago"])),
"Code": str(o["Clave"]),
"Description": str(o["Concepto"]),
"Amount": _num(o["Importe"]),
}
subsidio = o.get("SubsidioAlEmpleo")
if subsidio is not None:
if isinstance(subsidio, dict):
amount = subsidio.get("SubsidioCausado") or subsidio.get("Amount")
else:
amount = subsidio
entry["EmploymentSubsidy"] = {"Amount": _num(amount)}
other_payments.append(entry)
payroll["OtherPayments"] = other_payments
if incapacidades := nomina.get("Incapacidades"):
incapacity = []
for i in iterate(incapacidades):
entry = {
"Days": i["DiasIncapacidad"],
"IncapacityType": str(_enum_value(i["TipoIncapacidad"])),
}
if importe := i.get("ImporteMonetario"):
entry["Amount"] = _num(importe)
incapacity.append(entry)
payroll["Incapacities"] = incapacity
return payroll
def _base_payload(cfdi: CFDI, tipo: str) -> dict:
emisor = cfdi["Emisor"]
receptor = cfdi["Receptor"]
payload: dict[str, Any] = {
"CfdiType": tipo,
"ExpeditionPlace": str(cfdi["LugarExpedicion"]),
"Issuer": {
"Rfc": emisor["Rfc"],
"Name": emisor["Nombre"],
"FiscalRegime": str(_enum_value(emisor["RegimenFiscal"])),
},
"Receiver": {
"Rfc": receptor["Rfc"],
"Name": receptor["Nombre"],
"CfdiUse": str(_enum_value(receptor["UsoCFDI"])),
"FiscalRegime": str(_enum_value(receptor["RegimenFiscalReceptor"])),
"TaxZipCode": str(receptor["DomicilioFiscalReceptor"]),
},
}
if fecha := _format_date(cfdi.get("Fecha")):
payload["Date"] = fecha
if serie := cfdi.get("Serie"):
payload["Serie"] = str(serie)
if folio := cfdi.get("Folio"):
payload["Folio"] = str(folio)
if confirmacion := cfdi.get("Confirmacion"):
payload["Confirmation"] = str(confirmacion)
if exportacion := cfdi.get("Exportacion"):
payload["Exportation"] = str(_enum_value(exportacion))
if relations := _map_relations(cfdi):
payload["Relations"] = relations
if info_global := cfdi.get("InformacionGlobal"):
payload["GlobalInformation"] = {
"Periodicity": str(_enum_value(info_global["Periodicidad"])),
"Months": str(_enum_value(info_global["Meses"])),
"Year": str(info_global["Año"]),
}
return payload
[docs]
def cfdi_to_facturama_payload(cfdi: CFDI) -> dict:
"""Map a satcfdi CFDI (typically unsigned) to Facturama Multiemisor JSON."""
if not isinstance(cfdi, CFDI):
raise TypeError("cfdi must be a CFDI object")
tipo = str(_enum_value(cfdi.get("TipoDeComprobante")))
if tipo not in _SUPPORTED_TIPOS:
raise NotImplementedError(
f"Facturama Multiemisor adapter supports TipoDeComprobante "
f"{sorted(_SUPPORTED_TIPOS)}, got {tipo!r}"
)
payload = _base_payload(cfdi, tipo)
if tipo in _ITEM_TIPOS:
if forma := cfdi.get("FormaPago"):
payload["PaymentForm"] = str(_enum_value(forma))
if metodo := cfdi.get("MetodoPago"):
payload["PaymentMethod"] = str(_enum_value(metodo))
if moneda := cfdi.get("Moneda"):
payload["Currency"] = str(_enum_value(moneda))
if tipo_cambio := cfdi.get("TipoCambio"):
payload["ExchangeRate"] = _num(tipo_cambio)
if descuento := cfdi.get("Descuento"):
payload["Discount"] = _num(descuento)
if condiciones := cfdi.get("CondicionesDePago"):
payload["PaymentConditions"] = str(condiciones)
payload["Items"] = _map_items(cfdi)
unsupported = set()
complemento = cfdi.get("Complemento") or {}
if isinstance(complemento, dict):
unsupported = {
k for k in complemento
if k not in {"TimbreFiscalDigital", "Pagos", "Nomina", "Pago"}
}
if unsupported:
raise NotImplementedError(
f"Unsupported Complemento nodes for item CFDI mapper: {sorted(unsupported)}"
)
return payload
if tipo == "P":
pagos = _complement_node(cfdi, "Pagos")
if not pagos:
raise ValueError("CFDI tipo P requires Complemento Pagos 2.0")
payload["Complemento"] = {"Payments": _map_payments(pagos)}
# Facturama forbids root PaymentForm/Method/Currency/Items on payment CFDI
return payload
# tipo == "N"
if metodo := cfdi.get("MetodoPago"):
payload["PaymentMethod"] = str(_enum_value(metodo))
if forma := cfdi.get("FormaPago"):
payload["PaymentForm"] = str(_enum_value(forma))
if moneda := cfdi.get("Moneda"):
currency = str(_enum_value(moneda))
if currency != "MXN":
payload["Currency"] = currency
if tipo_cambio := cfdi.get("TipoCambio"):
payload["ExchangeRate"] = _num(tipo_cambio)
nomina = _complement_node(cfdi, "Nomina")
if not nomina:
raise ValueError("CFDI tipo N requires Complemento Nomina 1.2")
payload["NameId"] = "16"
payload["Complemento"] = {"Payroll": _map_payroll(nomina)}
return payload
[docs]
class Facturama(PAC):
"""
Facturama Multiemisor API adapter.
Facturama is a billing platform that seals+stamps via Multiemisor; CSD files
must be uploaded first (see :meth:`upload_csd`).
Documentation: https://apisandbox.facturama.mx/guias/api-multi/proceso-facturacion
"""
# Facturama aggregates multiple PACs; no single PAC RFC applies.
RFC = None
def __init__(self, username: str, password: str, environment=Environment.PRODUCTION):
super().__init__(environment=environment)
self.auth = HTTPBasicAuth(username, password)
@property
def host(self) -> str:
match self.environment:
case Environment.PRODUCTION:
return "https://api.facturama.mx"
case Environment.TEST:
return "https://apisandbox.facturama.mx"
case _:
raise NotImplementedError("Environment not supported")
def _request(
self,
method: str,
path: str,
*,
json: dict | None = None,
params: dict | None = None,
):
r = requests.request(
method=method,
url=f"{self.host}/{path.lstrip('/')}",
headers={
"User-Agent": __version__.__user_agent__,
"Content-Type": "application/json",
"Accept": "application/json",
},
auth=self.auth,
json=json,
params=params,
)
if r.ok:
if not r.content:
return None
content_type = (r.headers.get("Content-Type") or "").lower()
if "application/json" in content_type:
return r.json()
return r.content
raise ResponseError(r)
def _download_file(self, document_id: str, fmt: str) -> bytes:
res = self._request(
"get",
f"api/Cfdi/{fmt}/issuedLite/{document_id}",
)
if isinstance(res, dict) and "Content" in res:
return base64.urlsafe_b64decode(res["Content"].encode("utf-8"))
if isinstance(res, (bytes, bytearray)):
return bytes(res)
raise ResponseError(res)
[docs]
def upload_csd(
self,
rfc: str,
certificate: bytes,
key: bytes,
password: str | bytes,
) -> dict:
"""Upload issuer CSD required before Multiemisor issue()."""
if isinstance(password, bytes):
password = password.decode()
return self._request(
"post",
"api-lite/csds",
json={
"Rfc": rfc.upper(),
"Certificate": base64.b64encode(certificate).decode("ascii"),
"PrivateKey": base64.b64encode(key).decode("ascii"),
"PrivateKeyPassword": password,
},
)
[docs]
def delete_csd(self, rfc: str) -> None:
self._request("delete", f"api-lite/csds/{rfc.upper()}")
[docs]
def get_csd(self, rfc: str) -> dict:
return self._request("get", f"api-lite/csds/{rfc.upper()}")
[docs]
def list_cfdis(self, *, keyword: str | None = None, status: str = "all") -> list[dict]:
"""List Multiemisor CFDIs (optionally filtered by keyword / UUID)."""
params = {"status": status}
if keyword:
params["keyword"] = keyword
results = self._request("get", "api-lite/cfdis", params=params)
if results is None:
return []
if isinstance(results, list):
return results
for key in ("data", "Data", "Cfdis", "items"):
if isinstance(results.get(key), list):
return results[key]
return []
[docs]
def find_id_by_uuid(self, uuid: str) -> str:
"""Resolve Facturama document Id from a stamped CFDI UUID."""
uuid_u = str(uuid).upper()
for item in self.list_cfdis(keyword=uuid):
item_uuid = str(item.get("Uuid") or item.get("UUID") or "").upper()
item_id = str(item.get("Id") or "")
if item_uuid == uuid_u or item_id.upper() == uuid_u:
return item["Id"]
raise DocumentNotFoundError(f"Facturama document not found for UUID {uuid}")
[docs]
def issue(self, cfdi: CFDI, accept: Accept = Accept.XML) -> Document:
"""
Seal and stamp via Facturama Multiemisor.
The local CFDI does not need to be signed; Facturama seals with the
uploaded CSD for the issuer RFC.
"""
payload = cfdi_to_facturama_payload(cfdi)
created = self._request("post", "api-lite/3/cfdis", json=payload)
document_id = created["Id"]
xml = None
pdf = None
if accept & Accept.XML:
xml = self._download_file(document_id, "xml")
if accept & Accept.PDF:
pdf = self._download_file(document_id, "pdf")
return Document(document_id=document_id, xml=xml, pdf=pdf)
[docs]
def stamp(self, cfdi: CFDI, accept: Accept = Accept.XML) -> Document:
raise NotImplementedError(
"Facturama Multiemisor does not stamp pre-signed XML; use issue() "
"(Facturama seals and stamps from the mapped JSON payload)."
)
[docs]
def recover(self, document_id: str, accept: Accept = Accept.XML) -> Document:
xml = self._download_file(document_id, "xml") if accept & Accept.XML else None
pdf = self._download_file(document_id, "pdf") if accept & Accept.PDF else None
return Document(document_id=document_id, xml=xml, pdf=pdf)
[docs]
def recover_by_uuid(self, uuid: str, accept: Accept = Accept.XML) -> Document:
return self.recover(self.find_id_by_uuid(uuid), accept=accept)
[docs]
def cancel(
self,
cfdi: CFDI,
reason: CancelReason,
substitution_id: str = None,
signer: Signer = None,
document_id: str = None,
) -> CancelationAcknowledgment:
"""
Cancel a Multiemisor CFDI.
Resolves Facturama Id from ``document_id`` or from the CFDI UUID
(``Complemento.TimbreFiscalDigital.UUID``) via :meth:`find_id_by_uuid`.
"""
del signer # Facturama cancels with account credentials, not local FIEL
if document_id:
facturama_id = document_id
else:
uuid = cfdi["Complemento"]["TimbreFiscalDigital"]["UUID"]
facturama_id = self.find_id_by_uuid(uuid)
params = {"motive": reason.value}
if substitution_id:
params["uuidReplacement"] = substitution_id
res = self._request(
"delete",
f"api-lite/cfdis/{facturama_id}",
params=params,
)
status = None
acuse = None
if isinstance(res, dict):
status = res.get("Status") or res.get("Message") or res
if content := res.get("AcuseXmlBase64") or res.get("Acuse"):
if isinstance(content, str):
try:
acuse = base64.b64decode(content)
except Exception:
acuse = content.encode() if content else None
return CancelationAcknowledgment(code=status or "cancelled", acuse=acuse)