defmodule Sat.Cfdi.Descarga.Masiva.Internal.SoapEnvelope do
@moduledoc false
# Construye los sobres SOAP firmados para los 4 servicios del WS de
# Descarga Masiva del SAT. Los sobres se construyen ya en forma
# canonica (atributos ordenados, sin whitespace) para que el digest y
# la firma coincidan sin necesidad de canonicalizar despues.
alias Sat.Certificados.Credential
alias Sat.Cfdi.Descarga.Masiva.Internal.{X509Info, XmlDsig}
alias Sat.Cfdi.Descarga.Masiva.Types.SolicitudParams
@ns_soap "http://schemas.xmlsoap.org/soap/envelope/"
@ns_wsse "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd"
@ns_wsu "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd"
@ns_des "http://DescargaMasivaTerceros.sat.gob.mx"
@ns_terceros "http://DescargaMasivaTerceros.gob.mx"
@doc """
Sobre SOAP para `Autentica`. Firma un `wsu:Timestamp` con FIEL.
Opciones:
* `:now` — DateTime para el `Created` (default `DateTime.utc_now/0`)
* `:lifetime_seconds` — duracion de la ventana (default 300s)
"""
@spec build_autenticacion(Credential.t(), keyword()) :: String.t()
def build_autenticacion(%Credential{} = cred, opts \\ []) do
now = Keyword.get(opts, :now, DateTime.utc_now())
lifetime = Keyword.get(opts, :lifetime_seconds, 300)
created = format_datetime(now)
expires = format_datetime(DateTime.add(now, lifetime, :second))
timestamp_id = "_0"
token_id = "BinarySecurityToken-#{:crypto.strong_rand_bytes(8) |> Base.encode16(case: :lower)}-4"
timestamp =
~s|<u:Timestamp xmlns:u="#{@ns_wsu}" u:Id="#{timestamp_id}">| <>
~s|<u:Created>#{created}</u:Created>| <>
~s|<u:Expires>#{expires}</u:Expires>| <>
~s|</u:Timestamp>|
digest = XmlDsig.sha1_base64(timestamp)
signed_info = XmlDsig.build_signed_info(timestamp_id, digest)
signature_value = XmlDsig.sign_signed_info(signed_info, cred)
key_info = XmlDsig.build_key_info_str(token_id)
signature = XmlDsig.build_signature(signed_info, signature_value, key_info)
bst = XmlDsig.build_binary_security_token(cred, token_id)
~s|<?xml version="1.0" encoding="UTF-8"?>| <>
~s|<s:Envelope xmlns:s="#{@ns_soap}" xmlns:u="#{@ns_wsu}">| <>
~s|<s:Header>| <>
~s|<o:Security xmlns:o="#{@ns_wsse}" s:mustUnderstand="1">| <>
timestamp <>
bst <>
signature <>
~s|</o:Security>| <>
~s|</s:Header>| <>
~s|<s:Body>| <>
~s|<Autentica xmlns="#{@ns_terceros}"/>| <>
~s|</s:Body>| <>
~s|</s:Envelope>|
end
@doc """
Sobre SOAP para `SolicitaDescargaEmitidos`, `SolicitaDescargaRecibidos` o
`SolicitaDescargaFolio`. Firma el nodo `solicitud` con FIEL.
`operation` debe ser el nombre de la operacion sin prefijo de namespace,
p. ej. `"SolicitaDescargaEmitidos"`.
"""
@spec build_solicitud(Credential.t(), SolicitudParams.t(), String.t(), String.t()) :: String.t()
def build_solicitud(
%Credential{} = cred,
%SolicitudParams{} = params,
token,
operation \\ "SolicitaDescargaEmitidos"
)
when is_binary(token) and is_binary(operation) do
solicitud_attrs = solicitud_attributes(cred, params)
# Nodo a firmar en forma canonica para que el digest sea reproducible.
solicitud_open = "<des:solicitud" <> solicitud_attrs <> ">"
rfc_receptores = rfc_receptores_xml(params)
solicitud_node = solicitud_open <> rfc_receptores <> "</des:solicitud>"
digest = XmlDsig.sha1_base64(solicitud_node)
signed_info = XmlDsig.build_signed_info("", digest)
signature_value = XmlDsig.sign_signed_info(signed_info, cred)
key_info = XmlDsig.build_key_info_x509(cred)
signature = XmlDsig.build_signature(signed_info, signature_value, key_info)
body =
~s|<des:#{operation} xmlns:des="#{@ns_des}" xmlns:xd="http://www.w3.org/2000/09/xmldsig#">| <>
solicitud_open <>
rfc_receptores <>
signature <>
"</des:solicitud>" <>
"</des:#{operation}>"
wrap_envelope_with_token(body, token)
end
@doc "Sobre SOAP para `VerificaSolicitudDescarga`."
@spec build_verificacion(Credential.t(), String.t(), String.t(), String.t()) :: String.t()
def build_verificacion(
%Credential{} = cred,
rfc_solicitante,
id_solicitud,
token
)
when is_binary(rfc_solicitante) and is_binary(id_solicitud) and is_binary(token) do
solicitud_attrs =
~s| IdSolicitud="#{id_solicitud}" RfcSolicitante="#{escape(rfc_solicitante)}"|
solicitud_open = "<des:solicitud" <> solicitud_attrs <> ">"
solicitud_node = solicitud_open <> "</des:solicitud>"
digest = XmlDsig.sha1_base64(solicitud_node)
signed_info = XmlDsig.build_signed_info("", digest)
signature_value = XmlDsig.sign_signed_info(signed_info, cred)
key_info = XmlDsig.build_key_info_x509(cred)
signature = XmlDsig.build_signature(signed_info, signature_value, key_info)
body =
~s|<des:VerificaSolicitudDescarga xmlns:des="#{@ns_des}" xmlns:xd="http://www.w3.org/2000/09/xmldsig#">| <>
solicitud_open <>
signature <>
"</des:solicitud>" <>
"</des:VerificaSolicitudDescarga>"
wrap_envelope_with_token(body, token)
end
@doc "Sobre SOAP para `DescargaMasivaSolicitudes`. Firma el nodo `peticionDescarga`."
@spec build_descarga(Credential.t(), String.t(), String.t(), String.t()) :: String.t()
def build_descarga(
%Credential{} = cred,
rfc_solicitante,
id_paquete,
token
)
when is_binary(rfc_solicitante) and is_binary(id_paquete) and is_binary(token) do
peticion_attrs =
~s| IdPaquete="#{id_paquete}" RfcSolicitante="#{escape(rfc_solicitante)}"|
peticion_open = "<des:peticionDescarga" <> peticion_attrs <> ">"
peticion_node = peticion_open <> "</des:peticionDescarga>"
digest = XmlDsig.sha1_base64(peticion_node)
signed_info = XmlDsig.build_signed_info("", digest)
signature_value = XmlDsig.sign_signed_info(signed_info, cred)
key_info = XmlDsig.build_key_info_x509(cred)
signature = XmlDsig.build_signature(signed_info, signature_value, key_info)
body =
~s|<des:PeticionDescargaMasivaTercerosEntrada xmlns:des="#{@ns_des}" xmlns:xd="http://www.w3.org/2000/09/xmldsig#">| <>
peticion_open <>
signature <>
"</des:peticionDescarga>" <>
"</des:PeticionDescargaMasivaTercerosEntrada>"
wrap_envelope_with_token(body, token)
end
defp wrap_envelope_with_token(body, token) do
~s|<?xml version="1.0" encoding="UTF-8"?>| <>
~s|<s:Envelope xmlns:s="#{@ns_soap}">| <>
~s|<s:Header>| <>
~s|<o:Security xmlns:o="#{@ns_wsse}" s:mustUnderstand="1">| <>
~s|<o:BinarySecurityToken>#{token}</o:BinarySecurityToken>| <>
~s|</o:Security>| <>
~s|</s:Header>| <>
~s|<s:Body>| <>
body <>
~s|</s:Body>| <>
~s|</s:Envelope>|
end
defp solicitud_attributes(%Credential{} = cred, %SolicitudParams{} = p) do
rfc_emisor =
if p.tipo_solicitud == :recibidos,
do: p.rfc_emisor,
else: p.rfc_emisor || Credential.rfc(cred)
rfc_solicitante = p.rfc_solicitante || Credential.rfc(cred)
# Orden exacto requerido por el SAT para validacion de firma (C14N):
# Complemento, EstadoComprobante, FechaInicial, FechaFinal, Folio,
# RfcACuentaTerceros, RfcEmisor, RfcSolicitante, TipoComprobante, TipoSolicitud
[
{"Complemento", complemento_string(p.complemento)},
{"EstadoComprobante", estado_comprobante_string(p.estado_comprobante)},
{"FechaInicial", to_datetime_string(p.fecha_inicial)},
{"FechaFinal", to_datetime_string(p.fecha_final)},
{"Folio", p.uuid},
{"RfcACuentaTerceros", p.rfc_a_cuenta_terceros},
{"RfcEmisor", rfc_emisor},
{"RfcSolicitante", rfc_solicitante},
{"TipoComprobante", tipo_comprobante_string(p.tipo_comprobante)},
{"TipoSolicitud", tipo_solicitud_string(p.tipo_solicitud)}
]
|> Enum.reject(fn {_, v} -> is_nil(v) or v == "" end)
|> Enum.map(fn {k, v} -> ~s| #{k}="#{escape(v)}"| end)
|> Enum.join("")
end
defp rfc_receptores_xml(%SolicitudParams{rfc_receptor: nil}), do: ""
defp rfc_receptores_xml(%SolicitudParams{rfc_receptor: rfcs}) when is_list(rfcs) do
inner =
rfcs
|> Enum.map(fn rfc -> ~s|<des:RfcReceptor>#{escape(rfc)}</des:RfcReceptor>| end)
|> Enum.join("")
"<des:RfcReceptores>" <> inner <> "</des:RfcReceptores>"
end
defp rfc_receptores_xml(%SolicitudParams{rfc_receptor: rfc}) when is_binary(rfc) do
"<des:RfcReceptores><des:RfcReceptor>#{escape(rfc)}</des:RfcReceptor></des:RfcReceptores>"
end
defp to_datetime_string(%DateTime{} = dt) do
dt
|> DateTime.truncate(:second)
|> DateTime.to_iso8601()
|> String.replace("Z", "")
end
defp to_datetime_string(s) when is_binary(s), do: s
defp format_datetime(%DateTime{} = dt) do
dt
|> DateTime.truncate(:millisecond)
|> DateTime.to_iso8601()
end
defp complemento_string(nil), do: nil
defp complemento_string(s) when is_binary(s), do: s
defp tipo_solicitud_string(:emitidos), do: "CFDI"
defp tipo_solicitud_string(:recibidos), do: "CFDI"
defp tipo_solicitud_string(:folio), do: "CFDI"
defp tipo_solicitud_string(:metadata), do: "Metadata"
defp tipo_solicitud_string(:cfdi), do: "CFDI"
defp tipo_solicitud_string(other) when is_binary(other), do: other
defp tipo_comprobante_string(nil), do: nil
defp tipo_comprobante_string(:null), do: "Null"
defp tipo_comprobante_string(atom) when atom in [:i, :e, :t, :n, :p],
do: atom |> Atom.to_string() |> String.upcase()
defp tipo_comprobante_string(other) when is_binary(other), do: other
defp estado_comprobante_string(nil), do: nil
defp estado_comprobante_string(:todos), do: nil
defp estado_comprobante_string(:cancelado), do: "0"
defp estado_comprobante_string(:vigente), do: "1"
defp estado_comprobante_string(other) when is_binary(other), do: other
defp escape(value) when is_binary(value) do
value
|> String.replace("&", "&")
|> String.replace("<", "<")
|> String.replace(">", ">")
|> String.replace("\"", """)
|> String.replace("'", "'")
end
defp escape(other), do: to_string(other)
_ = X509Info
end