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]
|
[ApiController]
|
||||||
[Route("api/[controller]")]
|
[Route("api/[controller]")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public class SettingsController(WebhookSettings webhookSettings) : ControllerBase
|
public class SettingsController(
|
||||||
|
WebhookSettings webhookSettings,
|
||||||
|
INotificationService notifications) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet("webhook")]
|
[HttpGet("webhook")]
|
||||||
[ProducesResponseType(typeof(object), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(object), StatusCodes.Status200OK)]
|
||||||
|
|
@ -18,12 +20,23 @@ public class SettingsController(WebhookSettings webhookSettings) : ControllerBas
|
||||||
|
|
||||||
[HttpPut("webhook")]
|
[HttpPut("webhook")]
|
||||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
|
|
||||||
public IActionResult SetWebhook([FromBody] SetWebhookDto dto)
|
public IActionResult SetWebhook([FromBody] SetWebhookDto dto)
|
||||||
{
|
{
|
||||||
webhookSettings.WebhookUrl = string.IsNullOrWhiteSpace(dto.Url) ? null : dto.Url.Trim();
|
webhookSettings.UpdateUrl(dto.Url);
|
||||||
return NoContent();
|
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
|
public class SetWebhookDto
|
||||||
|
|
|
||||||
|
|
@ -18,4 +18,5 @@ public class ClientResponse
|
||||||
public string? note_public { get; set; }
|
public string? note_public { get; set; }
|
||||||
public string? note_private { get; set; }
|
public string? note_private { get; set; }
|
||||||
public string? client { get; set; }
|
public string? client { get; set; }
|
||||||
|
public string? fournisseur { get; set; }
|
||||||
}
|
}
|
||||||
|
|
@ -39,4 +39,7 @@ public class CreateClientDto
|
||||||
|
|
||||||
[StringLength(500)]
|
[StringLength(500)]
|
||||||
public string? NotePrivate { get; set; }
|
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 required string Name { get; set; }
|
||||||
public string? CodeClient { get; set; }
|
public string? CodeClient { get; set; }
|
||||||
public string? TypentCode { get; set; }
|
public string? TypentCode { get; set; }
|
||||||
|
public string? Role { get; set; }
|
||||||
public string? Status { get; set; }
|
public string? Status { get; set; }
|
||||||
public string? Email { get; set; }
|
public string? Email { get; set; }
|
||||||
public string? Phone { get; set; }
|
public string? Phone { get; set; }
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ public class ClientDto
|
||||||
public required string? Name { get; set; }
|
public required string? Name { get; set; }
|
||||||
public required string? CodeClient { get; set; }
|
public required string? CodeClient { get; set; }
|
||||||
public string? TypentCode { get; set; }
|
public string? TypentCode { get; set; }
|
||||||
|
public string? Role { get; set; }
|
||||||
public string? Status { get; set; }
|
public string? Status { get; set; }
|
||||||
public string? Email { get; set; }
|
public string? Email { get; set; }
|
||||||
public string? Phone { get; set; }
|
public string? Phone { get; set; }
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,18 @@ namespace DoliMiddlewareApi.Mappers;
|
||||||
|
|
||||||
public static class ClientMapper
|
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)
|
public static ClientDto MapToClientDto(ClientResponse clientResponse, List<ContactDto> contacts)
|
||||||
{
|
{
|
||||||
var clientId = int.TryParse(clientResponse.id, out int id) ? id : 0;
|
var clientId = int.TryParse(clientResponse.id, out int id) ? id : 0;
|
||||||
|
|
@ -15,6 +27,7 @@ public static class ClientMapper
|
||||||
Name = clientResponse.name,
|
Name = clientResponse.name,
|
||||||
CodeClient = clientResponse.code_client,
|
CodeClient = clientResponse.code_client,
|
||||||
TypentCode = clientResponse.typent_code,
|
TypentCode = clientResponse.typent_code,
|
||||||
|
Role = ResolveRole(clientResponse.client, clientResponse.fournisseur),
|
||||||
Status = clientResponse.status,
|
Status = clientResponse.status,
|
||||||
Email = clientResponse.email,
|
Email = clientResponse.email,
|
||||||
Phone = clientResponse.phone,
|
Phone = clientResponse.phone,
|
||||||
|
|
@ -30,6 +43,7 @@ public static class ClientMapper
|
||||||
Name = clientResponse.name,
|
Name = clientResponse.name,
|
||||||
CodeClient = clientResponse.code_client,
|
CodeClient = clientResponse.code_client,
|
||||||
TypentCode = clientResponse.typent_code,
|
TypentCode = clientResponse.typent_code,
|
||||||
|
Role = ResolveRole(clientResponse.client, clientResponse.fournisseur),
|
||||||
Status = clientResponse.status,
|
Status = clientResponse.status,
|
||||||
Email = clientResponse.email,
|
Email = clientResponse.email,
|
||||||
Phone = clientResponse.phone
|
Phone = clientResponse.phone
|
||||||
|
|
@ -46,6 +60,7 @@ public static class ClientMapper
|
||||||
Name = r.name ?? "",
|
Name = r.name ?? "",
|
||||||
CodeClient = r.code_client,
|
CodeClient = r.code_client,
|
||||||
TypentCode = r.typent_code,
|
TypentCode = r.typent_code,
|
||||||
|
Role = ResolveRole(r.client, r.fournisseur),
|
||||||
Status = r.status,
|
Status = r.status,
|
||||||
Email = r.email,
|
Email = r.email,
|
||||||
Phone = r.phone,
|
Phone = r.phone,
|
||||||
|
|
|
||||||
|
|
@ -204,7 +204,7 @@ builder.Services.AddMemoryCache();
|
||||||
// HttpContext accessor para acceder a User.Claims en servicios
|
// HttpContext accessor para acceder a User.Claims en servicios
|
||||||
builder.Services.AddHttpContextAccessor();
|
builder.Services.AddHttpContextAccessor();
|
||||||
|
|
||||||
builder.Services.AddHttpClient("Webhook");
|
builder.Services.AddHttpClient("Webhook", c => c.Timeout = TimeSpan.FromSeconds(8));
|
||||||
|
|
||||||
// =========================================
|
// =========================================
|
||||||
// NOTIFICACIONES — Webhook (Teams / Slack)
|
// NOTIFICACIONES — Webhook (Teams / Slack)
|
||||||
|
|
|
||||||
|
|
@ -47,10 +47,14 @@ public class ClientService(IDolibarrApiClient apiClient)
|
||||||
|
|
||||||
public async Task<int> CreateClientAsync(CreateClientDto dto)
|
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?>
|
var requestBody = new Dictionary<string, object?>
|
||||||
{
|
{
|
||||||
["name"] = dto.Name,
|
["name"] = dto.Name,
|
||||||
["client"] = "1",
|
["client"] = isClient ? "1" : "0",
|
||||||
|
["fournisseur"] = isSupplier ? "1" : "0",
|
||||||
["address"] = dto.Address,
|
["address"] = dto.Address,
|
||||||
["zip"] = dto.Zip,
|
["zip"] = dto.Zip,
|
||||||
["town"] = dto.Town,
|
["town"] = dto.Town,
|
||||||
|
|
|
||||||
|
|
@ -251,45 +251,58 @@ public class InvoiceService(IDolibarrApiClient apiClient, INotificationService n
|
||||||
|
|
||||||
return payments;
|
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)
|
public async Task<int> AddInvoicePaymentAsync(int invoiceId, CreateInvoicePaymentDto dto)
|
||||||
{
|
{
|
||||||
// Usar paymentMethodId si viene, si no usar paymentModeId
|
// Usar paymentMethodId si viene, si no usar paymentModeId
|
||||||
var paymentModeId = dto.PaymentMethodId ?? dto.PaymentModeId;
|
var paymentModeId = dto.PaymentMethodId ?? dto.PaymentModeId;
|
||||||
|
|
||||||
|
var datepaye = new DateTimeOffset(dto.PaymentDate, TimeSpan.Zero).ToUnixTimeSeconds();
|
||||||
|
|
||||||
if (dto.Amount.HasValue)
|
if (dto.Amount.HasValue)
|
||||||
{
|
{
|
||||||
// Pago parcial: usar /invoices/paymentsdistributed
|
// 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(),
|
invoiceId.ToString(),
|
||||||
new { amount = dto.Amount.Value.ToString(CultureInfo.InvariantCulture) }
|
new { amount = dto.Amount.Value.ToString(CultureInfo.InvariantCulture) }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
datepaye = dto.PaymentDate.ToString("yyyy-MM-dd"),
|
["datepaye"] = datepaye,
|
||||||
paymentid = paymentModeId,
|
["paymentid"] = paymentModeId,
|
||||||
closepaidinvoices = dto.ClosePaidInvoices,
|
["closepaidinvoices"] = dto.ClosePaidInvoices,
|
||||||
accountid = dto.AccountId,
|
["accountid"] = dto.AccountId,
|
||||||
num_payment = dto.PaymentNumber,
|
["num_payment"] = dto.PaymentNumber ?? "",
|
||||||
};
|
};
|
||||||
var responseBody = await apiClient.PostAsync("invoices/paymentsdistributed", requestBody);
|
var responseBody = await apiClient.PostAsync("invoices/paymentsdistributed", requestBody);
|
||||||
return int.Parse(responseBody);
|
var paymentId = int.Parse(responseBody);
|
||||||
|
await NotifyIfPaid(invoiceId);
|
||||||
|
return paymentId;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// Pagar completa pendiente: usar /invoices/{id}/payments
|
// Pagar completa pendiente: usar /invoices/{id}/payments
|
||||||
var requestBody = new
|
var requestBody = new Dictionary<string, object>
|
||||||
{
|
{
|
||||||
datepaye = dto.PaymentDate.ToString("yyyy-MM-dd"),
|
["datepaye"] = datepaye,
|
||||||
paymentid = paymentModeId,
|
["paymentid"] = paymentModeId,
|
||||||
closepaidinvoices = dto.ClosePaidInvoices,
|
["closepaidinvoices"] = dto.ClosePaidInvoices,
|
||||||
accountid = dto.AccountId,
|
["accountid"] = dto.AccountId,
|
||||||
num_payment = dto.PaymentNumber,
|
["num_payment"] = dto.PaymentNumber ?? "",
|
||||||
};
|
};
|
||||||
var responseBody = await apiClient.PostAsync($"invoices/{invoiceId}/payments", requestBody);
|
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);
|
var payload = BuildPayload(invoiceId, invoiceRef, label, color);
|
||||||
|
|
||||||
|
_ = SendAsync(webhookUrl, payload, invoiceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SendAsync(string url, object payload, int invoiceId)
|
||||||
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var client = httpClientFactory.CreateClient("Webhook");
|
var client = httpClientFactory.CreateClient("Webhook");
|
||||||
var content = new StringContent(
|
var content = new StringContent(
|
||||||
JsonSerializer.Serialize(payload),
|
JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json");
|
||||||
Encoding.UTF8,
|
var response = await client.PostAsync(url, content);
|
||||||
"application/json");
|
|
||||||
|
|
||||||
var response = await client.PostAsync(webhookUrl, content);
|
|
||||||
|
|
||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
logger.LogWarning(
|
logger.LogWarning("Webhook respondió {Status} para factura {Id}",
|
||||||
"Webhook respondió {Status} para factura {InvoiceId}",
|
|
||||||
response.StatusCode, invoiceId);
|
response.StatusCode, invoiceId);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
// Nunca rompemos el flujo principal por un fallo en notificaciones
|
logger.LogError(ex, "Error enviando notificación para factura {Id}", invoiceId);
|
||||||
logger.LogError(ex,
|
|
||||||
"Error enviando notificación para factura {InvoiceId}", invoiceId);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -96,7 +94,7 @@ public class WebhookNotificationService(
|
||||||
content = new
|
content = new
|
||||||
{
|
{
|
||||||
type = "AdaptiveCard",
|
type = "AdaptiveCard",
|
||||||
version = "1.4",
|
version = "1.2",
|
||||||
body = new object[]
|
body = new object[]
|
||||||
{
|
{
|
||||||
new
|
new
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,33 @@ namespace DoliMiddlewareApi.Services.Notifications;
|
||||||
|
|
||||||
public class WebhookSettings
|
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)
|
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.Exceptions;
|
||||||
using DoliMiddlewareApi.Mappers;
|
using DoliMiddlewareApi.Mappers;
|
||||||
using DoliMiddlewareApi.Services.Clients;
|
using DoliMiddlewareApi.Services.Clients;
|
||||||
|
using DoliMiddlewareApi.Services.Notifications;
|
||||||
|
|
||||||
namespace DoliMiddlewareApi.Services;
|
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)
|
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)
|
public async Task ChangeStatusAsync(int id, string status)
|
||||||
{
|
{
|
||||||
var normalized = status.Trim().ToLowerInvariant();
|
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
|
var endpoint = normalized switch
|
||||||
{
|
{
|
||||||
"draft" => $"supplierinvoices/{id}/settodraft",
|
"draft" => $"supplierinvoices/{id}/settodraft",
|
||||||
"unpaid" => $"supplierinvoices/{id}/validate",
|
"unpaid" => $"supplierinvoices/{id}/validate",
|
||||||
"paid" => $"supplierinvoices/{id}/setpaid",
|
|
||||||
_ => throw new ValidationException("Estado invalido. Usa: draft, unpaid, paid.")
|
_ => throw new ValidationException("Estado invalido. Usa: draft, unpaid, paid.")
|
||||||
};
|
};
|
||||||
|
|
||||||
await apiClient.PostAsync(endpoint, new { });
|
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)
|
public async Task<string> AddLineAsync(int invoiceId, CreateInvoiceLineDto dto)
|
||||||
|
|
|
||||||
|
|
@ -63,9 +63,12 @@
|
||||||
- ASPNETCORE_ENVIRONMENT=Production
|
- ASPNETCORE_ENVIRONMENT=Production
|
||||||
- Jwt__Secret=DevDemoSecretKeyForDockerCompose2026Min32Chars!!
|
- Jwt__Secret=DevDemoSecretKeyForDockerCompose2026Min32Chars!!
|
||||||
- Dolibarr__ApiUrl=http://dolibarr/api/index.php
|
- Dolibarr__ApiUrl=http://dolibarr/api/index.php
|
||||||
|
volumes:
|
||||||
|
- bff_data:/app/data
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
mysql_data:
|
mysql_data:
|
||||||
dolibarr_documents:
|
dolibarr_documents:
|
||||||
dolibarr_custom:
|
dolibarr_custom:
|
||||||
|
bff_data:
|
||||||
Loading…
Reference in New Issue