feat: fase 2 - services externos (dte, transbank, email)

- New solution ServicesExternos.slnx with standalone API project
- DteService + DteController (LibreDTE: emitir, generar PDF, consultar folio)
- TransbankService + TransbankController (crear transacción, confirmar, voucher SP)
- EmailService + EmailController (MailKit: send with HTML + attachments)
- Models for DTE, Transbank, Email requests/responses
- Dockerfile + appsettings.json with all service configs
- HttpClient factory for Transbank and LibreDTE
- Build: 0 errors
This commit is contained in:
2026-07-08 10:33:21 -04:00
parent 202a59326b
commit c43dbcce8b
17 changed files with 580 additions and 7 deletions
+5
View File
@@ -0,0 +1,5 @@
<Solution>
<Folder Name="/src/">
<Project Path="src/ServicesExternos.API/ServicesExternos.API.csproj" />
</Folder>
</Solution>
@@ -0,0 +1,34 @@
using Microsoft.AspNetCore.Mvc;
using ServicesExternos.API.Models;
using ServicesExternos.API.Services;
namespace ServicesExternos.API.Controllers;
[ApiController]
[Route("api/dte")]
public class DteController : ControllerBase
{
private readonly DteService _dteService;
public DteController(DteService dteService)
{
_dteService = dteService;
}
[HttpPost("emitir")]
public async Task<IActionResult> Emitir([FromBody] DteEmissionRequest request)
{
var result = await _dteService.EmitirYDescargarPdfAsync(request);
if (result.PdfBytes != null)
return File(result.PdfBytes, "application/pdf", $"dte_{result.Folio}.pdf");
return BadRequest(new { error = result.Estado });
}
[HttpGet("estado/{codigo}")]
public async Task<IActionResult> ConsultarEstado(string codigo, [FromQuery] int dte, [FromQuery] long emisor)
{
var result = await _dteService.ConsultarSiguienteFolioAsync(dte, emisor);
return Ok(result);
}
}
@@ -0,0 +1,27 @@
using Microsoft.AspNetCore.Mvc;
using ServicesExternos.API.Models;
using ServicesExternos.API.Services;
namespace ServicesExternos.API.Controllers;
[ApiController]
[Route("api/email")]
public class EmailController : ControllerBase
{
private readonly EmailService _emailService;
public EmailController(EmailService emailService)
{
_emailService = emailService;
}
[HttpPost("send")]
public async Task<IActionResult> Send([FromBody] EmailRequest request)
{
var result = await _emailService.SendAsync(request);
if (result == "ok")
return Ok(new { mensaje = result });
return BadRequest(new { error = result });
}
}
@@ -0,0 +1,49 @@
using Microsoft.AspNetCore.Mvc;
using ServicesExternos.API.Models;
using ServicesExternos.API.Services;
namespace ServicesExternos.API.Controllers;
[ApiController]
[Route("api/pagos/transbank")]
public class TransbankController : ControllerBase
{
private readonly TransbankService _transbankService;
public TransbankController(TransbankService transbankService)
{
_transbankService = transbankService;
}
[HttpPost("crear-transaccion")]
public async Task<IActionResult> CrearTransaccion([FromBody] TransbankTransactionRequest request)
{
var result = await _transbankService.CrearTransaccionAsync(request);
return Ok(result);
}
[HttpPost("confirmar")]
public async Task<IActionResult> Confirmar([FromBody] TransbankConfirmRequest request)
{
var result = await _transbankService.ConfirmarTransaccionAsync(request.TokenWs!);
return Ok(result);
}
[HttpPost("voucher")]
public async Task<IActionResult> GrabarVoucher([FromBody] Models.VoucherRequest request)
{
var result = await _transbankService.GrabarVoucherAsync(
request.Token, request.AccountingDate, request.BuyOrder,
request.CardNumber, request.AuthorizationCode, request.PaymentType,
request.SharesNumber, request.Amount);
return Ok(new { mensaje = result });
}
[HttpGet("buscar/{token}")]
public async Task<IActionResult> Buscar(string token)
{
var result = await _transbankService.BuscarTransaccionAsync(token);
return Ok(result);
}
}
@@ -0,0 +1,67 @@
namespace ServicesExternos.API.Models;
public class DteEmissionRequest
{
public int Dte { get; set; } = 33;
public int Emisor { get; set; }
public string? Fecha { get; set; }
public string? Sucursal { get; set; }
public int? SucursalSii { get; set; }
public string? Terminal { get; set; }
public string? IndicadorServicio { get; set; }
public string? TipoDespacho { get; set; }
public string? TipoTraslado { get; set; }
public string? TpoTranCompra { get; set; }
public string? TpoTranVenta { get; set; }
public string? FechaVencimiento { get; set; }
public Receptor? Receptor { get; set; }
public List<DetalleItem>? Detalles { get; set; }
public Referencia? Referencia { get; set; }
}
public class Receptor
{
public string? RUT { get; set; }
public string? RazonSocial { get; set; }
public string? Direccion { get; set; }
public string? Comuna { get; set; }
public string? Ciudad { get; set; }
}
public class DetalleItem
{
public string? Nombre { get; set; }
public int Cantidad { get; set; }
public int Precio { get; set; }
public string? Descuento { get; set; }
}
public class Referencia
{
public int TipoDocReferencia { get; set; }
public int FolioReferencia { get; set; }
public string? FechaReferencia { get; set; }
public string? RazonReferencia { get; set; }
}
public class DteGenerarPorCodigo
{
public string? Codigo { get; set; }
public int TipoDte { get; set; }
public int Emisor { get; set; }
public int Receptor { get; set; }
}
public class DteFolioInfo
{
public int FoliosDisponibles { get; set; }
public int FolioActual { get; set; }
public int? Siguiente { get; set; }
}
public class DteResultado
{
public byte[]? PdfBytes { get; set; }
public int Folio { get; set; }
public string Estado { get; set; } = string.Empty;
}
@@ -0,0 +1,10 @@
namespace ServicesExternos.API.Models;
public class EmailRequest
{
public string? To { get; set; }
public string? Subject { get; set; }
public string? Body { get; set; }
public bool IsHtml { get; set; } = true;
public List<string>? AttachmentPaths { get; set; }
}
@@ -0,0 +1,26 @@
namespace ServicesExternos.API.Models;
public class TransbankTransactionRequest
{
public string? BuyOrder { get; set; }
public string? SessionId { get; set; }
public int Amount { get; set; }
public string? ReturnUrl { get; set; }
}
public class TransbankConfirmRequest
{
public string? TokenWs { get; set; }
}
public class VoucherRequest
{
public string Token { get; set; } = string.Empty;
public string AccountingDate { get; set; } = string.Empty;
public string BuyOrder { get; set; } = string.Empty;
public string CardNumber { get; set; } = string.Empty;
public string AuthorizationCode { get; set; } = string.Empty;
public string PaymentType { get; set; } = string.Empty;
public string SharesNumber { get; set; } = string.Empty;
public int Amount { get; set; }
}
@@ -0,0 +1,27 @@
using ServicesExternos.API.Services;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddOpenApi();
builder.Services.AddHttpClient<DteService>();
builder.Services.AddHttpClient<TransbankService>();
builder.Services.AddScoped<EmailService>();
builder.Services.AddCors(options =>
{
options.AddDefaultPolicy(policy =>
{
policy.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod();
});
});
var app = builder.Build();
if (app.Environment.IsDevelopment())
app.MapOpenApi();
app.UseCors();
app.MapControllers();
app.Run();
@@ -0,0 +1,14 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5194",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
@@ -0,0 +1,90 @@
using System.Net.Http.Headers;
using System.Text;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using ServicesExternos.API.Models;
namespace ServicesExternos.API.Services;
public class DteService
{
private readonly HttpClient _client;
private readonly string _userHash;
private readonly string _ambiente;
public DteService(HttpClient client, IConfiguration configuration)
{
_client = client;
_client.BaseAddress = new Uri("https://libredte.cl");
_client.Timeout = TimeSpan.FromSeconds(60);
_userHash = configuration["LibreDTE:UserHash"] ?? throw new Exception("LibreDTE:UserHash required");
_ambiente = configuration["LibreDTE:Ambiente"] ?? "1";
}
private void SetAuth()
{
var authString = Convert.ToBase64String(Encoding.ASCII.GetBytes("X:" + _userHash));
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", authString);
}
public async Task<DteResultado> EmitirYDescargarPdfAsync(DteEmissionRequest datos)
{
SetAuth();
string jsonFinal = JsonConvert.SerializeObject(datos, Formatting.Indented);
var jsonContent = new StringContent(jsonFinal, Encoding.UTF8, "application/json");
var response = await _client.PostAsync("/api/dte/documentos/emitir", jsonContent);
string respuestaJson = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
return new DteResultado { Estado = respuestaJson };
JObject jsonResp = JObject.Parse(respuestaJson);
string cod = (string)jsonResp["codigo"]!;
int emi = (int)jsonResp["emisor"]!;
int rec = (int)jsonResp["receptor"]!;
int dte = (int)jsonResp["dte"]!;
var payload = new DteGenerarPorCodigo
{
Codigo = cod,
TipoDte = dte,
Emisor = emi,
Receptor = rec
};
jsonFinal = JsonConvert.SerializeObject(payload);
jsonContent = new StringContent(jsonFinal, Encoding.UTF8, "application/json");
response = await _client.PostAsync("/api/dte/documentos/generar?getXML=0&links=1&email=1&retry=1&gzip=0", jsonContent);
respuestaJson = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
return new DteResultado { Estado = respuestaJson };
jsonResp = JObject.Parse(respuestaJson);
int folio = (int)jsonResp["folio"]!;
var responsePdf = await _client.GetAsync($"/api/dte/dte_emitidos/pdf/{dte}/{folio}/{emi}?formato=general&papelContinuo=0&copias_tributarias=1&copias_cedibles=1&cedible=0&compress=0&base64=0");
if (!responsePdf.IsSuccessStatusCode)
throw new Exception("Se generó el DTE pero falló la descarga del PDF.");
await _client.GetAsync($"/api/dte/dte_emitidos/actualizar_estado/{dte}/{folio}/{emi}?usarWebservice=1");
byte[] pdfBytes = await responsePdf.Content.ReadAsByteArrayAsync();
return new DteResultado { PdfBytes = pdfBytes, Folio = folio, Estado = "ok" };
}
public async Task<DteFolioInfo> ConsultarSiguienteFolioAsync(int dte, long emisor)
{
SetAuth();
var response = await _client.GetAsync($"/api/dte/admin/dte_folios/info/{dte}/{emisor}");
string respuestaJson = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
throw new Exception($"Error al consultar folio ({response.StatusCode}): {respuestaJson}");
return JsonConvert.DeserializeObject<DteFolioInfo>(respuestaJson)!;
}
}
@@ -0,0 +1,70 @@
using MailKit.Net.Smtp;
using MailKit.Security;
using MimeKit;
using ServicesExternos.API.Models;
namespace ServicesExternos.API.Services;
public class EmailService
{
private readonly string _smtpHost;
private readonly int _smtpPort;
private readonly string _smtpUser;
private readonly string _smtpPassword;
public EmailService(IConfiguration configuration)
{
_smtpHost = configuration["Email:Host"] ?? "smtp.gmail.com";
_smtpPort = int.Parse(configuration["Email:Port"] ?? "587");
_smtpUser = configuration["Email:User"] ?? "noresponder@norteamericano.cl";
_smtpPassword = configuration["Email:Password"] ?? "";
}
public async Task<string> SendAsync(EmailRequest request)
{
try
{
var message = new MimeMessage();
message.From.Add(new MailboxAddress("Norteamericano", _smtpUser));
message.To.Add(new MailboxAddress("", request.To));
message.Subject = request.Subject;
var body = new TextPart(request.IsHtml ? "html" : "plain")
{
Text = request.Body
};
if (request.AttachmentPaths != null && request.AttachmentPaths.Count > 0)
{
var multipart = new Multipart("mixed") { body };
foreach (var path in request.AttachmentPaths)
{
if (File.Exists(path))
multipart.Add(new MimePart("application", "pdf")
{
Content = new MimeContent(File.OpenRead(path), ContentEncoding.Default),
ContentDisposition = new ContentDisposition(ContentDisposition.Attachment),
FileName = Path.GetFileName(path)
});
}
message.Body = multipart;
}
else
{
message.Body = body;
}
using var client = new SmtpClient();
await client.ConnectAsync(_smtpHost, _smtpPort, SecureSocketOptions.StartTls);
await client.AuthenticateAsync(_smtpUser, _smtpPassword);
await client.SendAsync(message);
await client.DisconnectAsync(true);
return "ok";
}
catch (Exception ex)
{
return $"Error: {ex.Message}";
}
}
}
@@ -0,0 +1,86 @@
using System.Text;
using Newtonsoft.Json;
using ServicesExternos.API.Models;
using Dapper;
using Npgsql;
namespace ServicesExternos.API.Services;
public class TransbankService
{
private readonly HttpClient _client;
private readonly string _apiKey;
private readonly string _commerceCode;
private readonly string _connectionString;
private readonly string _environment;
public TransbankService(HttpClient client, IConfiguration configuration)
{
_client = client;
_apiKey = configuration["Transbank:ApiKey"] ?? throw new Exception("Transbank:ApiKey required");
_commerceCode = configuration["Transbank:CommerceCode"] ?? throw new Exception("Transbank:CommerceCode required");
_connectionString = configuration.GetConnectionString("Default")!;
_environment = configuration["Transbank:Environment"] ?? "integration";
var baseUrl = _environment == "production"
? "https://webpay3g.transbank.cl"
: "https://webpay3gint.transbank.cl";
_client.BaseAddress = new Uri(baseUrl);
_client.DefaultRequestHeaders.Add("Tbk-Api-Key-Id", _commerceCode);
_client.DefaultRequestHeaders.Add("Tbk-Api-Key-Secret", _apiKey);
}
public async Task<string> CrearTransaccionAsync(TransbankTransactionRequest request)
{
var payload = new
{
buy_order = request.BuyOrder,
session_id = request.SessionId,
amount = request.Amount,
return_url = request.ReturnUrl
};
var json = JsonConvert.SerializeObject(payload);
var content = new StringContent(json, Encoding.UTF8, "application/json");
var response = await _client.PostAsync("/rswebpaytransaction/api/webpay/v1.2/transactions", content);
return await response.Content.ReadAsStringAsync();
}
public async Task<string> ConfirmarTransaccionAsync(string tokenWs)
{
var response = await _client.PutAsync($"/rswebpaytransaction/api/webpay/v1.2/transactions/{tokenWs}", null);
return await response.Content.ReadAsStringAsync();
}
public async Task<string> GrabarVoucherAsync(string token, string accountingDate, string buyOrder,
string cardNumber, string authCode, string paymentType, string sharesNumber, int amount)
{
using var connection = new NpgsqlConnection(_connectionString);
await connection.ExecuteAsync(
"caja_tbk.GrabaVoucher",
new
{
token_id = token,
xaccountingDate = accountingDate,
xbuyOrder = buyOrder,
xcardDetailcardNumber = cardNumber,
xdetailOutputAuthorizationCode = authCode,
xdetailOutputPaymentTypeCode = paymentType,
xdetailOutputSharesNumber = sharesNumber,
xdetailOutputAmount = amount
},
commandType: System.Data.CommandType.StoredProcedure);
return "ok";
}
public async Task<string> BuscarTransaccionAsync(string token)
{
using var connection = new NpgsqlConnection(_connectionString);
var rows = await connection.QueryAsync(
"caja_tbk.BuscarInfoToken",
new { tokenid = token },
commandType: System.Data.CommandType.StoredProcedure);
return JsonConvert.SerializeObject(rows);
}
}
@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Dapper" Version="2.1.79" />
<PackageReference Include="MailKit" Version="4.17.0" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.2" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
<PackageReference Include="Npgsql" Version="10.0.3" />
</ItemGroup>
</Project>
@@ -0,0 +1,6 @@
@ServicesExternos.API_HostAddress = http://localhost:5194
GET {{ServicesExternos.API_HostAddress}}/weatherforecast/
Accept: application/json
###
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
@@ -0,0 +1,27 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"Default": "Host=192.168.0.254;Port=5432;Database=ichn;Username=postgres;Password=apoca11"
},
"LibreDTE": {
"UserHash": "",
"Ambiente": "1"
},
"Transbank": {
"ApiKey": "",
"CommerceCode": "",
"Environment": "integration"
},
"Email": {
"Host": "smtp.gmail.com",
"Port": 587,
"User": "noresponder@norteamericano.cl",
"Password": ""
}
}