diff --git a/DoliMiddlewareApi/Controllers/SettingsController.cs b/DoliMiddlewareApi/Controllers/SettingsController.cs index 7bb1ace..65e1719 100644 --- a/DoliMiddlewareApi/Controllers/SettingsController.cs +++ b/DoliMiddlewareApi/Controllers/SettingsController.cs @@ -7,7 +7,9 @@ namespace DoliMiddlewareApi.Controllers; [ApiController] [Route("api/[controller]")] [Authorize] -public class SettingsController(WebhookSettings webhookSettings) : ControllerBase +public class SettingsController( + WebhookSettings webhookSettings, + INotificationService notifications) : ControllerBase { [HttpGet("webhook")] [ProducesResponseType(typeof(object), StatusCodes.Status200OK)] @@ -18,12 +20,23 @@ public class SettingsController(WebhookSettings webhookSettings) : ControllerBas [HttpPut("webhook")] [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] public IActionResult SetWebhook([FromBody] SetWebhookDto dto) { - webhookSettings.WebhookUrl = string.IsNullOrWhiteSpace(dto.Url) ? null : dto.Url.Trim(); + webhookSettings.UpdateUrl(dto.Url); return NoContent(); } + + [HttpPost("webhook/test")] + [ProducesResponseType(typeof(object), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + public async Task TestWebhook() + { + if (string.IsNullOrWhiteSpace(webhookSettings.WebhookUrl)) + return BadRequest(new { error = "No hay webhook configurado." }); + + await notifications.NotifyInvoiceStatusChangedAsync(0, "TEST-0001", "paid"); + return Ok(new { message = "Notificación de prueba enviada.", url = webhookSettings.WebhookUrl }); + } } public class SetWebhookDto diff --git a/DoliMiddlewareApi/Dtos/Dolibarr/ClientResponse.cs b/DoliMiddlewareApi/Dtos/Dolibarr/ClientResponse.cs index b2511b7..3040feb 100644 --- a/DoliMiddlewareApi/Dtos/Dolibarr/ClientResponse.cs +++ b/DoliMiddlewareApi/Dtos/Dolibarr/ClientResponse.cs @@ -18,4 +18,5 @@ public class ClientResponse public string? note_public { get; set; } public string? note_private { get; set; } public string? client { get; set; } + public string? fournisseur { get; set; } } \ No newline at end of file diff --git a/DoliMiddlewareApi/Dtos/command/CreateClientDto.cs b/DoliMiddlewareApi/Dtos/command/CreateClientDto.cs index 904b5bb..abe8fe8 100644 --- a/DoliMiddlewareApi/Dtos/command/CreateClientDto.cs +++ b/DoliMiddlewareApi/Dtos/command/CreateClientDto.cs @@ -39,4 +39,7 @@ public class CreateClientDto [StringLength(500)] public string? NotePrivate { get; set; } + + /// "client" | "supplier" | "both" — defaults to client + public string? Role { get; set; } } \ No newline at end of file diff --git a/DoliMiddlewareApi/Dtos/query/ClientDetailDto.cs b/DoliMiddlewareApi/Dtos/query/ClientDetailDto.cs index d442b48..2fcfa98 100644 --- a/DoliMiddlewareApi/Dtos/query/ClientDetailDto.cs +++ b/DoliMiddlewareApi/Dtos/query/ClientDetailDto.cs @@ -6,6 +6,7 @@ public class ClientDetailDto public required string Name { get; set; } public string? CodeClient { get; set; } public string? TypentCode { get; set; } + public string? Role { get; set; } public string? Status { get; set; } public string? Email { get; set; } public string? Phone { get; set; } diff --git a/DoliMiddlewareApi/Dtos/query/ClientDto.cs b/DoliMiddlewareApi/Dtos/query/ClientDto.cs index d55d1b0..78835d4 100644 --- a/DoliMiddlewareApi/Dtos/query/ClientDto.cs +++ b/DoliMiddlewareApi/Dtos/query/ClientDto.cs @@ -6,6 +6,7 @@ public class ClientDto public required string? Name { get; set; } public required string? CodeClient { get; set; } public string? TypentCode { get; set; } + public string? Role { get; set; } public string? Status { get; set; } public string? Email { get; set; } public string? Phone { get; set; } diff --git a/DoliMiddlewareApi/Mappers/ClientMapper.cs b/DoliMiddlewareApi/Mappers/ClientMapper.cs index febb44e..5086fa9 100644 --- a/DoliMiddlewareApi/Mappers/ClientMapper.cs +++ b/DoliMiddlewareApi/Mappers/ClientMapper.cs @@ -5,6 +5,18 @@ namespace DoliMiddlewareApi.Mappers; public static class ClientMapper { + private static string ResolveRole(string? client, string? fournisseur) + { + var isClient = client is "1" or "2" or "3"; + var isSupplier = fournisseur is "1"; + return (isClient, isSupplier) switch + { + (true, true) => "both", + (false, true) => "supplier", + _ => "client" + }; + } + public static ClientDto MapToClientDto(ClientResponse clientResponse, List contacts) { var clientId = int.TryParse(clientResponse.id, out int id) ? id : 0; @@ -15,6 +27,7 @@ public static class ClientMapper Name = clientResponse.name, CodeClient = clientResponse.code_client, TypentCode = clientResponse.typent_code, + Role = ResolveRole(clientResponse.client, clientResponse.fournisseur), Status = clientResponse.status, Email = clientResponse.email, Phone = clientResponse.phone, @@ -30,6 +43,7 @@ public static class ClientMapper Name = clientResponse.name, CodeClient = clientResponse.code_client, TypentCode = clientResponse.typent_code, + Role = ResolveRole(clientResponse.client, clientResponse.fournisseur), Status = clientResponse.status, Email = clientResponse.email, Phone = clientResponse.phone @@ -46,6 +60,7 @@ public static class ClientMapper Name = r.name ?? "", CodeClient = r.code_client, TypentCode = r.typent_code, + Role = ResolveRole(r.client, r.fournisseur), Status = r.status, Email = r.email, Phone = r.phone, diff --git a/DoliMiddlewareApi/Program.cs b/DoliMiddlewareApi/Program.cs index b82b5ea..7baa529 100644 --- a/DoliMiddlewareApi/Program.cs +++ b/DoliMiddlewareApi/Program.cs @@ -204,7 +204,7 @@ builder.Services.AddMemoryCache(); // HttpContext accessor para acceder a User.Claims en servicios builder.Services.AddHttpContextAccessor(); -builder.Services.AddHttpClient("Webhook"); +builder.Services.AddHttpClient("Webhook", c => c.Timeout = TimeSpan.FromSeconds(8)); // ========================================= // NOTIFICACIONES — Webhook (Teams / Slack) diff --git a/DoliMiddlewareApi/Services/ClientService.cs b/DoliMiddlewareApi/Services/ClientService.cs index 398924c..e0369f4 100644 --- a/DoliMiddlewareApi/Services/ClientService.cs +++ b/DoliMiddlewareApi/Services/ClientService.cs @@ -47,10 +47,14 @@ public class ClientService(IDolibarrApiClient apiClient) public async Task CreateClientAsync(CreateClientDto dto) { + var isSupplier = dto.Role == "supplier" || dto.Role == "both"; + var isClient = dto.Role != "supplier"; + var requestBody = new Dictionary { ["name"] = dto.Name, - ["client"] = "1", + ["client"] = isClient ? "1" : "0", + ["fournisseur"] = isSupplier ? "1" : "0", ["address"] = dto.Address, ["zip"] = dto.Zip, ["town"] = dto.Town, diff --git a/DoliMiddlewareApi/Services/InvoiceService.cs b/DoliMiddlewareApi/Services/InvoiceService.cs index 522db99..ed7f2f5 100644 --- a/DoliMiddlewareApi/Services/InvoiceService.cs +++ b/DoliMiddlewareApi/Services/InvoiceService.cs @@ -251,45 +251,58 @@ public class InvoiceService(IDolibarrApiClient apiClient, INotificationService n return payments; } + private async Task NotifyIfPaid(int invoiceId) + { + var inv = await apiClient.GetResourceAsync($"invoices/{invoiceId}"); + var status = inv.statut switch { "2" => "paid", "1" => "unpaid", _ => "draft" }; + await notifications.NotifyInvoiceStatusChangedAsync(invoiceId, inv.@ref ?? $"#{invoiceId}", status); + } + public async Task AddInvoicePaymentAsync(int invoiceId, CreateInvoicePaymentDto dto) { // Usar paymentMethodId si viene, si no usar paymentModeId var paymentModeId = dto.PaymentMethodId ?? dto.PaymentModeId; + var datepaye = new DateTimeOffset(dto.PaymentDate, TimeSpan.Zero).ToUnixTimeSeconds(); + if (dto.Amount.HasValue) { // Pago parcial: usar /invoices/paymentsdistributed - var requestBody = new + var requestBody = new Dictionary { - arrayofamounts = new Dictionary + ["arrayofamounts"] = new Dictionary { { invoiceId.ToString(), new { amount = dto.Amount.Value.ToString(CultureInfo.InvariantCulture) } } }, - datepaye = dto.PaymentDate.ToString("yyyy-MM-dd"), - paymentid = paymentModeId, - closepaidinvoices = dto.ClosePaidInvoices, - accountid = dto.AccountId, - num_payment = dto.PaymentNumber, + ["datepaye"] = datepaye, + ["paymentid"] = paymentModeId, + ["closepaidinvoices"] = dto.ClosePaidInvoices, + ["accountid"] = dto.AccountId, + ["num_payment"] = dto.PaymentNumber ?? "", }; var responseBody = await apiClient.PostAsync("invoices/paymentsdistributed", requestBody); - return int.Parse(responseBody); + var paymentId = int.Parse(responseBody); + await NotifyIfPaid(invoiceId); + return paymentId; } else { // Pagar completa pendiente: usar /invoices/{id}/payments - var requestBody = new + var requestBody = new Dictionary { - datepaye = dto.PaymentDate.ToString("yyyy-MM-dd"), - paymentid = paymentModeId, - closepaidinvoices = dto.ClosePaidInvoices, - accountid = dto.AccountId, - num_payment = dto.PaymentNumber, + ["datepaye"] = datepaye, + ["paymentid"] = paymentModeId, + ["closepaidinvoices"] = dto.ClosePaidInvoices, + ["accountid"] = dto.AccountId, + ["num_payment"] = dto.PaymentNumber ?? "", }; var responseBody = await apiClient.PostAsync($"invoices/{invoiceId}/payments", requestBody); - return int.Parse(responseBody); + var paymentId = int.Parse(responseBody); + await NotifyIfPaid(invoiceId); + return paymentId; } } } diff --git a/DoliMiddlewareApi/Services/Notifications/WebhookNotificationService.cs b/DoliMiddlewareApi/Services/Notifications/WebhookNotificationService.cs index c2a17ce..b6e60e8 100644 --- a/DoliMiddlewareApi/Services/Notifications/WebhookNotificationService.cs +++ b/DoliMiddlewareApi/Services/Notifications/WebhookNotificationService.cs @@ -30,26 +30,24 @@ public class WebhookNotificationService( var payload = BuildPayload(invoiceId, invoiceRef, label, color); + _ = SendAsync(webhookUrl, payload, invoiceId); + } + + private async Task SendAsync(string url, object payload, int invoiceId) + { try { var client = httpClientFactory.CreateClient("Webhook"); var content = new StringContent( - JsonSerializer.Serialize(payload), - Encoding.UTF8, - "application/json"); - - var response = await client.PostAsync(webhookUrl, content); - + JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"); + var response = await client.PostAsync(url, content); if (!response.IsSuccessStatusCode) - logger.LogWarning( - "Webhook respondió {Status} para factura {InvoiceId}", + logger.LogWarning("Webhook respondió {Status} para factura {Id}", 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); + logger.LogError(ex, "Error enviando notificación para factura {Id}", invoiceId); } } @@ -96,7 +94,7 @@ public class WebhookNotificationService( content = new { type = "AdaptiveCard", - version = "1.4", + version = "1.2", body = new object[] { new diff --git a/DoliMiddlewareApi/Services/Notifications/WebhookSettings.cs b/DoliMiddlewareApi/Services/Notifications/WebhookSettings.cs index 8cfe5c5..72a4ff0 100644 --- a/DoliMiddlewareApi/Services/Notifications/WebhookSettings.cs +++ b/DoliMiddlewareApi/Services/Notifications/WebhookSettings.cs @@ -2,10 +2,33 @@ namespace DoliMiddlewareApi.Services.Notifications; public class WebhookSettings { - public string? WebhookUrl { get; set; } + private static readonly string FilePath = + Path.Combine(AppContext.BaseDirectory, "data", "webhook_url.txt"); + + public string? WebhookUrl { get; private set; } public WebhookSettings(IConfiguration configuration) { - WebhookUrl = configuration["Notifications:WebhookUrl"]; + Directory.CreateDirectory(Path.GetDirectoryName(FilePath)!); + // Archivo tiene prioridad sobre config (persiste actualizaciones en caliente) + if (File.Exists(FilePath)) + WebhookUrl = File.ReadAllText(FilePath).Trim().NullIfEmpty(); + else + WebhookUrl = configuration["Notifications:WebhookUrl"].NullIfEmpty(); + } + + public void UpdateUrl(string? url) + { + WebhookUrl = url.NullIfEmpty(); + if (WebhookUrl is not null) + File.WriteAllText(FilePath, WebhookUrl); + else if (File.Exists(FilePath)) + File.Delete(FilePath); } } + +file static class StringExtensions +{ + public static string? NullIfEmpty(this string? s) => + string.IsNullOrWhiteSpace(s) ? null : s.Trim(); +} diff --git a/DoliMiddlewareApi/Services/SupplierInvoiceService.cs b/DoliMiddlewareApi/Services/SupplierInvoiceService.cs index 56dcd44..c525c28 100644 --- a/DoliMiddlewareApi/Services/SupplierInvoiceService.cs +++ b/DoliMiddlewareApi/Services/SupplierInvoiceService.cs @@ -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 SupplierInvoiceService(IDolibarrApiClient apiClient) +public class SupplierInvoiceService(IDolibarrApiClient apiClient, INotificationService notifications) { public async Task> GetInvoicesAsync(int limit, int page, string? status) { @@ -98,15 +99,41 @@ public class SupplierInvoiceService(IDolibarrApiClient apiClient) public async Task ChangeStatusAsync(int id, string status) { var normalized = status.Trim().ToLowerInvariant(); + + if (normalized == "paid") + { + var inv = await apiClient.GetResourceAsync($"supplierinvoices/{id}"); + var remain = double.TryParse(inv.remaintopay, System.Globalization.NumberStyles.Any, + System.Globalization.CultureInfo.InvariantCulture, out var r) ? r : 0; + var amount = remain > 0 ? remain + : double.TryParse(inv.total_ttc, System.Globalization.NumberStyles.Any, + System.Globalization.CultureInfo.InvariantCulture, out var t) ? t : 0; + + var unixNow = new DateTimeOffset(DateTime.UtcNow).ToUnixTimeSeconds(); + var payBody = new Dictionary + { + ["datepaye"] = unixNow, + ["payment_mode_id"] = 6, + ["closepaidinvoices"] = "yes", + ["accountid"] = 1, + ["amount"] = amount + }; + await apiClient.PostAsync($"supplierinvoices/{id}/payments", payBody); + var paidInv = await apiClient.GetResourceAsync($"supplierinvoices/{id}"); + await notifications.NotifyInvoiceStatusChangedAsync(id, paidInv.@ref ?? $"#{id}", "paid"); + return; + } + var endpoint = normalized switch { "draft" => $"supplierinvoices/{id}/settodraft", "unpaid" => $"supplierinvoices/{id}/validate", - "paid" => $"supplierinvoices/{id}/setpaid", _ => throw new ValidationException("Estado invalido. Usa: draft, unpaid, paid.") }; await apiClient.PostAsync(endpoint, new { }); + var updatedInv = await apiClient.GetResourceAsync($"supplierinvoices/{id}"); + await notifications.NotifyInvoiceStatusChangedAsync(id, updatedInv.@ref ?? $"#{id}", normalized); } public async Task AddLineAsync(int invoiceId, CreateInvoiceLineDto dto) diff --git a/compose.yaml b/compose.yaml index 5acb1c8..356b586 100644 --- a/compose.yaml +++ b/compose.yaml @@ -63,9 +63,12 @@ - ASPNETCORE_ENVIRONMENT=Production - Jwt__Secret=DevDemoSecretKeyForDockerCompose2026Min32Chars!! - Dolibarr__ApiUrl=http://dolibarr/api/index.php + volumes: + - bff_data:/app/data restart: unless-stopped volumes: mysql_data: dolibarr_documents: - dolibarr_custom: \ No newline at end of file + dolibarr_custom: + bff_data: \ No newline at end of file