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:
parent
9f1295e7d0
commit
d1f793b1e3
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
Loading…
Reference in New Issue