feat: add webhook notifications for invoice status changes

Implements INotificationService with Teams/Slack-compatible WebhookNotificationService.
Fires on ValidateInvoice and ChangeInvoiceStatus. Silently skipped if WebhookUrl not configured.
This commit is contained in:
javiermengual 2026-05-14 22:59:16 +02:00
parent 256a07f68c
commit ef42797b31
5 changed files with 157 additions and 6 deletions

View File

@ -3,6 +3,7 @@ using DoliMiddlewareApi.Exceptions;
using DoliMiddlewareApi.Services;
using DoliMiddlewareApi.Services.Auth;
using DoliMiddlewareApi.Services.Clients;
using DoliMiddlewareApi.Services.Notifications;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.Extensions.Caching.Memory;
@ -46,7 +47,7 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
});
builder.Services.AddAuthorization();
// CORS Configuration
builder.Services.AddCors(options =>
{
@ -123,6 +124,15 @@ builder.Services.AddMemoryCache();
// HttpContext accessor para acceder a User.Claims en servicios
builder.Services.AddHttpContextAccessor();
builder.Services.AddHttpClient("Webhook");
// =========================================
// NOTIFICACIONES — Webhook (Teams / Slack)
// Configura Notifications:WebhookUrl en appsettings o variable de entorno
// Si no está configurado, las notificaciones se ignoran silenciosamente
// =========================================
builder.Services.AddScoped<INotificationService, WebhookNotificationService>();
var app = builder.Build();
// =========================================

View File

@ -7,10 +7,11 @@ using DoliMiddlewareApi.Dtos.query;
using DoliMiddlewareApi.Exceptions;
using DoliMiddlewareApi.Mappers;
using DoliMiddlewareApi.Services.Clients;
using DoliMiddlewareApi.Services.Notifications;
namespace DoliMiddlewareApi.Services;
public class InvoiceService(IDolibarrApiClient apiClient)
public class InvoiceService(IDolibarrApiClient apiClient, INotificationService notifications)
{
public async Task<InvoiceDetailDto> GetInvoiceAsync(int id)
{
@ -121,13 +122,17 @@ public class InvoiceService(IDolibarrApiClient apiClient)
var normalized = status.Trim().ToLowerInvariant();
var endpoint = normalized switch
{
"draft" => $"invoices/{id}/settodraft",
"draft" => $"invoices/{id}/settodraft",
"unpaid" => $"invoices/{id}/settounpaid",
"paid" => $"invoices/{id}/settopaid",
"paid" => $"invoices/{id}/settopaid",
_ => throw new ValidationException("Estado invalido. Usa: draft, unpaid, paid.")
};
await apiClient.PostAsync(endpoint, new { });
// Notificación a Teams/Slack — fire and forget desde el punto de vista del caller
var invoice = await apiClient.GetResourceAsync<InvoiceDetailResponse>($"invoices/{id}");
await notifications.NotifyInvoiceStatusChangedAsync(id, invoice.@ref ?? $"#{id}", normalized);
}
public async Task ValidateInvoiceAsync(int id)
@ -136,6 +141,8 @@ public class InvoiceService(IDolibarrApiClient apiClient)
if (invoice.statut != "0") throw new ForbiddenException("Solo se pueden validar facturas en borrador (draft)");
await apiClient.PostAsync($"invoices/{id}/validate", new { });
await notifications.NotifyInvoiceStatusChangedAsync(id, invoice.@ref ?? $"#{id}", "unpaid");
}
public async Task DeleteInvoiceAsync(int id)

View File

@ -0,0 +1,6 @@
namespace DoliMiddlewareApi.Services.Notifications;
public interface INotificationService
{
Task NotifyInvoiceStatusChangedAsync(int invoiceId, string invoiceRef, string newStatus);
}

View File

@ -0,0 +1,126 @@
using System.Text;
using System.Text.Json;
namespace DoliMiddlewareApi.Services.Notifications;
/// <summary>
/// Envía notificaciones a un webhook de Teams o Slack cuando cambia el estado de una factura.
/// Compatible con Teams (Adaptive Cards) y Slack (Block Kit).
/// Configurar la URL en appsettings: Notifications:WebhookUrl
/// </summary>
public class WebhookNotificationService(
IHttpClientFactory httpClientFactory,
IConfiguration configuration,
ILogger<WebhookNotificationService> logger) : INotificationService
{
private static readonly Dictionary<string, (string label, string color)> StatusMeta = new()
{
["draft"] = ("Borrador", "#8a7dff"),
["unpaid"] = ("Pendiente de pago", "#ffb454"),
["paid"] = ("Pagada ✓", "#5dd39e"),
};
public async Task NotifyInvoiceStatusChangedAsync(int invoiceId, string invoiceRef, string newStatus)
{
var webhookUrl = configuration["Notifications:WebhookUrl"];
if (string.IsNullOrWhiteSpace(webhookUrl))
return; // Notificaciones desactivadas — no hay webhook configurado
var (label, color) = StatusMeta.GetValueOrDefault(newStatus, (newStatus, "#888"));
var payload = BuildPayload(invoiceId, invoiceRef, label, color);
try
{
var client = httpClientFactory.CreateClient("Webhook");
var content = new StringContent(
JsonSerializer.Serialize(payload),
Encoding.UTF8,
"application/json");
var response = await client.PostAsync(webhookUrl, content);
if (!response.IsSuccessStatusCode)
logger.LogWarning(
"Webhook respondió {Status} para factura {InvoiceId}",
response.StatusCode, invoiceId);
}
catch (Exception ex)
{
// Nunca rompemos el flujo principal por un fallo en notificaciones
logger.LogError(ex,
"Error enviando notificación para factura {InvoiceId}", invoiceId);
}
}
/// <summary>
/// Construye un payload compatible con Teams (Adaptive Card via Incoming Webhook)
/// y con Slack (message payload básico con attachments).
/// Teams espera { "type": "message", "attachments": [...] } con Adaptive Card.
/// Slack espera { "text": "...", "attachments": [...] }.
/// Detectamos por URL cuál usar.
/// </summary>
private object BuildPayload(int invoiceId, string invoiceRef, string label, string color)
{
var webhookUrl = configuration["Notifications:WebhookUrl"] ?? "";
if (webhookUrl.Contains("slack.com"))
{
return new
{
text = $"🧾 Factura *{invoiceRef}* ha cambiado de estado",
attachments = new[]
{
new
{
color,
fields = new[]
{
new { title = "Factura", value = invoiceRef, @short = true },
new { title = "Estado", value = label, @short = true },
}
}
}
};
}
// Teams — Adaptive Card
return new
{
type = "message",
attachments = new[]
{
new
{
contentType = "application/vnd.microsoft.card.adaptive",
content = new
{
type = "AdaptiveCard",
version = "1.4",
body = new object[]
{
new
{
type = "TextBlock",
text = "🧾 Estado de factura actualizado",
weight = "Bolder",
size = "Medium"
},
new
{
type = "FactSet",
facts = new[]
{
new { title = "Factura", value = invoiceRef },
new { title = "ID", value = invoiceId.ToString() },
new { title = "Estado", value = label },
}
}
},
msteams = new { width = "Full" }
}
}
}
};
}
}

View File

@ -15,5 +15,7 @@
"Secret": "mi-super-secreto-jwt-aqui-cambiar-en-produccion",
"Issuer": "DoliMiddleware",
"Audience": "DoliClients"
}
}
},
"Notifications":{
"WebhookUrl": ""
}}