feat: rol de terceros, webhook mejorado y fix pagos

Terceros:
- ClientResponse: campo fournisseur añadido
- CreateClientDto: campo Role (client/supplier/both)
- ClientDto / ClientDetailDto: campo Role devuelto en respuesta
- ClientMapper: ResolveRole() calcula rol desde client+fournisseur de Dolibarr
- ClientService: CreateClientAsync mapea Role a client/fournisseur en Dolibarr

Webhook:
- WebhookSettings: persistencia en archivo data/webhook_url.txt (volumen Docker)
- WebhookNotificationService: fire-and-forget, Adaptive Card 1.2, timeout 8s
- SettingsController: usa UpdateUrl(), endpoint test añadido
- Program.cs: HttpClient Webhook con timeout de 8s
- InvoiceService: notificación también al registrar pago (AddInvoicePaymentAsync)
  + datepaye enviado como Unix timestamp (fix 400 de Dolibarr)
- SupplierInvoiceService: notificación webhook en cambios de estado,
  pago vía payments endpoint con Unix timestamp

compose.yaml: volumen bff_data para persistir webhook_url.txt entre rebuilds

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Алекс 2026-05-29 16:35:17 +02:00
parent 9f1295e7d0
commit d1f793b1e3
13 changed files with 139 additions and 37 deletions

View File

@ -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<IActionResult> 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

View File

@ -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; }
}

View File

@ -39,4 +39,7 @@ public class CreateClientDto
[StringLength(500)]
public string? NotePrivate { get; set; }
/// <summary>"client" | "supplier" | "both" — defaults to client</summary>
public string? Role { get; set; }
}

View File

@ -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; }

View File

@ -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; }

View File

@ -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<ContactDto> 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,

View File

@ -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)

View File

@ -47,10 +47,14 @@ public class ClientService(IDolibarrApiClient apiClient)
public async Task<int> CreateClientAsync(CreateClientDto dto)
{
var isSupplier = dto.Role == "supplier" || dto.Role == "both";
var isClient = dto.Role != "supplier";
var requestBody = new Dictionary<string, object?>
{
["name"] = dto.Name,
["client"] = "1",
["client"] = isClient ? "1" : "0",
["fournisseur"] = isSupplier ? "1" : "0",
["address"] = dto.Address,
["zip"] = dto.Zip,
["town"] = dto.Town,

View File

@ -251,45 +251,58 @@ public class InvoiceService(IDolibarrApiClient apiClient, INotificationService n
return payments;
}
private async Task NotifyIfPaid(int invoiceId)
{
var inv = await apiClient.GetResourceAsync<InvoiceDetailResponse>($"invoices/{invoiceId}");
var status = inv.statut switch { "2" => "paid", "1" => "unpaid", _ => "draft" };
await notifications.NotifyInvoiceStatusChangedAsync(invoiceId, inv.@ref ?? $"#{invoiceId}", status);
}
public async Task<int> 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<string, object>
{
arrayofamounts = new Dictionary<string, object>
["arrayofamounts"] = new Dictionary<string, object>
{
{
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<string, object>
{
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;
}
}
}

View File

@ -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

View File

@ -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();
}

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 SupplierInvoiceService(IDolibarrApiClient apiClient)
public class SupplierInvoiceService(IDolibarrApiClient apiClient, INotificationService notifications)
{
public async Task<List<SupplierInvoiceDto>> 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<SupplierInvoiceDetailResponse>($"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<string, object>
{
["datepaye"] = unixNow,
["payment_mode_id"] = 6,
["closepaidinvoices"] = "yes",
["accountid"] = 1,
["amount"] = amount
};
await apiClient.PostAsync($"supplierinvoices/{id}/payments", payBody);
var paidInv = await apiClient.GetResourceAsync<SupplierInvoiceDetailResponse>($"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<SupplierInvoiceDetailResponse>($"supplierinvoices/{id}");
await notifications.NotifyInvoiceStatusChangedAsync(id, updatedInv.@ref ?? $"#{id}", normalized);
}
public async Task<string> AddLineAsync(int invoiceId, CreateInvoiceLineDto dto)

View File

@ -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:
dolibarr_custom:
bff_data: