ProyectoGrupal/dolibarr-bff/DoliMiddlewareApi/Services/InvoiceService.cs

309 lines
13 KiB
C#
Raw Permalink Normal View History

using System.ComponentModel.DataAnnotations;
using System.Globalization;
using DoliMiddlewareApi.Dtos;
using DoliMiddlewareApi.Dtos.command;
using DoliMiddlewareApi.Dtos.Dolibarr;
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, INotificationService notifications)
{
public async Task<InvoiceDetailDto> GetInvoiceAsync(int id)
{
var data = await apiClient.GetResourceAsync<InvoiceDetailResponse>($"invoices/{id}");
var dto = InvoiceMapper.MapToInvoiceDetailDto(data);
if (dto.ClientId > 0)
{
try
{
var client = await apiClient.GetResourceAsync<ClientResponse>($"thirdparties/{dto.ClientId}");
dto.ClientName = client?.name;
}
catch { /* best-effort */ }
}
return dto;
}
public async Task<List<InvoiceDto>> GetInvoicesAsync(
int limit = 50,
int page = 1,
string? status = null,
string? search = null)
{
// empieza por 1 para el frontend
var endpoint = $"invoices?limit={limit}&page={page - 1}";
if (!string.IsNullOrEmpty(status))
{
endpoint += $"&status={status}";
}
if (!string.IsNullOrWhiteSpace(search))
{
search = search.Trim().ToUpperInvariant();
var filter = $"(t.ref:like:'%{search}%')";
endpoint += $"&sqlfilters={Uri.EscapeDataString(filter)}";
}
var dataList = await apiClient.GetCollectionAsync<InvoiceResponse>(endpoint);
var invoices = dataList.Select(InvoiceMapper.MapToInvoiceDto).ToList();
if (invoices.Count == 0)
return invoices;
var clientIds = invoices.Select(i => i.ClientId).Distinct().ToList();
var clientNames = await GetClientNamesAsync(clientIds);
foreach (var invoice in invoices)
{
invoice.ClientName = clientNames.GetValueOrDefault(invoice.ClientId);
}
return invoices;
}
public async Task<int> CreateInvoiceAsync(CreateInvoiceDto dto)
{
var requestBody = new
{
socid = dto.ClientId.ToString(),
type = "0",
statut = InvoiceMapper.ConvertStatusToDolibarr(dto.Status),
date = ((DateTimeOffset)dto.Date).ToUnixTimeSeconds().ToString(),
date_lim_reglement = dto.ExpireDate.HasValue
? ((DateTimeOffset)dto.ExpireDate.Value).ToUnixTimeSeconds().ToString()
: null,
@ref = dto.Reference,
note_public = dto.NotePublic,
note_private = dto.NotePrivate,
lines = dto.Lines.Select(line => new
{
desc = line.Description,
qty = line.Quantity.ToString(CultureInfo.InvariantCulture),
subprice = line.UnitPrice.ToString(CultureInfo.InvariantCulture),
tva_tx = line.TaxRate.ToString(CultureInfo.InvariantCulture)
}).ToArray()
};
var responseBody = await apiClient.PostAsync("invoices", requestBody);
return int.Parse(responseBody);
}
public async Task<string> AddInvoiceLineAsync(int invoiceId, CreateInvoiceLineDto lineDto)
{
var invoice = await apiClient.GetResourceAsync<InvoiceDetailResponse>($"invoices/{invoiceId}");
if (invoice.statut != "0") throw new ForbiddenException("Solo se pueden añadir líneas a facturas en borrador");
var requestBody = new
{
desc = lineDto.Description,
qty = lineDto.Quantity.ToString(CultureInfo.InvariantCulture),
subprice = lineDto.UnitPrice.ToString(CultureInfo.InvariantCulture),
tva_tx = lineDto.TaxRate.ToString(CultureInfo.InvariantCulture)
};
return await apiClient.PostAsync($"invoices/{invoiceId}/lines", requestBody);
}
public async Task UpdateInvoiceAsync(int id, UpdateInvoiceDto dto)
{
// GET el JSON completo
var current = await apiClient.GetResourceAsync<InvoiceDetailResponse>($"invoices/{id}");
// Modifica solo campos que Dolibarr permite en PUT
if (dto.Number != null) current.@ref = dto.Number;
if (dto.NotePublic != null) current.note_public = dto.NotePublic;
if (dto.NotePrivate != null) current.note_private = dto.NotePrivate;
if (dto.ExpireDate.HasValue) current.date_lim_reglement = ((DateTimeOffset)dto.ExpireDate.Value).ToUnixTimeSeconds();
// No tocar: date, socid, lines (Dolibarr no los cambia en PUT)
await apiClient.PutAsync($"invoices/{id}", current);
}
public async Task ChangeInvoiceStatusAsync(int id, string status)
{
var normalized = status.Trim().ToLowerInvariant();
var endpoint = normalized switch
{
"draft" => $"invoices/{id}/settodraft",
"unpaid" => $"invoices/{id}/settounpaid",
"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)
{
var invoice = await apiClient.GetResourceAsync<InvoiceDetailResponse>($"invoices/{id}");
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)
{
var invoice = await apiClient.GetResourceAsync<InvoiceDetailResponse>($"invoices/{id}");
if (invoice.statut != "0") throw new ForbiddenException("Solo se pueden eliminar facturas en borrador (draft)");
await apiClient.DeleteAsync($"invoices/{id}");
}
public async Task DeleteInvoiceLineAsync(int invoiceId, int lineId)
{
var invoice = await apiClient.GetResourceAsync<InvoiceDetailResponse>($"invoices/{invoiceId}");
if (invoice.statut != "0") throw new ForbiddenException("Solo se pueden eliminar líneas de facturas en borrador (draft)");
await apiClient.DeleteAsync($"invoices/{invoiceId}/lines/{lineId}");
}
public async Task UpdateInvoiceLineAsync(int invoiceId, int lineId, UpdateInvoiceLineDto dto)
{
var invoice = await apiClient.GetResourceAsync<InvoiceDetailResponse>($"invoices/{invoiceId}");
if (invoice.statut != "0") throw new ForbiddenException("Solo se pueden modificar líneas de facturas en borrador (draft)");
var existingLine = invoice.Lines?.FirstOrDefault(l => l.id == lineId.ToString());
if (existingLine == null) throw new NotFoundException($"Línea {lineId} no encontrada en factura {invoiceId}");
var requestBody = new Dictionary<string, object?>
{
["desc"] = dto.Description ?? existingLine.description ?? existingLine.desc,
["qty"] = dto.Quantity?.ToString(CultureInfo.InvariantCulture) ?? existingLine.qty,
["subprice"] = dto.UnitPrice?.ToString(CultureInfo.InvariantCulture) ?? existingLine.subprice,
["tva_tx"] = dto.TaxRate?.ToString(CultureInfo.InvariantCulture) ?? existingLine.tva_tx
};
await apiClient.PutAsync($"invoices/{invoiceId}/lines/{lineId}", requestBody);
}
public async Task<List<InvoiceDto>> GetInvoiceTemplatesAsync()
{
// Dolibarr has no list endpoint for templates — only GET invoices/templates/{id}.
// FactureRec serializes numeric fields as JSON numbers (not strings), so we use
// TemplateInvoiceResponse with proper int/decimal types.
// Non-existent IDs return HTTP 200 with id=0 (Dolibarr quirk), so we filter by id > 0.
const int maxId = 50;
var tasks = Enumerable.Range(1, maxId).Select(async id =>
{
try { return await apiClient.GetResourceAsync<TemplateInvoiceResponse>($"invoices/templates/{id}"); }
catch { return null; }
});
var results = await Task.WhenAll(tasks);
var dtos = results
.Where(r => r is { id: > 0 })
.Select(r => InvoiceMapper.MapTemplateToInvoiceDto(r!))
.ToList();
if (dtos.Count == 0) return dtos;
var clientIds = dtos.Select(d => d.ClientId).Distinct().ToList();
var names = await GetClientNamesAsync(clientIds);
foreach (var dto in dtos)
dto.ClientName = names.GetValueOrDefault(dto.ClientId);
return dtos;
}
public async Task<InvoiceDetailDto> GetInvoiceTemplateAsync(int id)
{
var data = await apiClient.GetResourceAsync<TemplateInvoiceResponse>($"invoices/templates/{id}");
if (data.id == 0) throw new NotFoundException($"Template invoice {id} not found");
return InvoiceMapper.MapTemplateToDetailDto(data);
}
private async Task<Dictionary<int, string>> GetClientNamesAsync(List<int> clientIds)
{
var validIds = clientIds.Distinct().Where(id => id > 0).ToList();
if (validIds.Count == 0) return [];
var tasks = validIds.Select(async id =>
{
try { return await apiClient.GetResourceAsync<ClientResponse>($"thirdparties/{id}"); }
catch { return null; }
});
var clients = await Task.WhenAll(tasks);
return clients.Where(c => c is { id: not null })
.ToDictionary(c => int.Parse(c!.id!), c => c!.name ?? "");
}
public async Task<List<InvoicePaymentDto>> GetInvoicePaymentsAsync(int invoiceId)
{
var dataList = await apiClient.GetCollectionAsync<InvoicePaymentResponse>($"invoices/{invoiceId}/payments");
var payments = dataList.Select(InvoiceMapper.MapToInvoicePaymentDto).ToList();
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 Dictionary<string, object>
{
["arrayofamounts"] = new Dictionary<string, object>
{
{
invoiceId.ToString(),
new { amount = dto.Amount.Value.ToString(CultureInfo.InvariantCulture) }
}
},
["datepaye"] = datepaye,
["paymentid"] = paymentModeId,
["closepaidinvoices"] = dto.ClosePaidInvoices,
["accountid"] = dto.AccountId,
["num_payment"] = dto.PaymentNumber ?? "",
};
var responseBody = await apiClient.PostAsync("invoices/paymentsdistributed", requestBody);
var paymentId = int.Parse(responseBody);
await NotifyIfPaid(invoiceId);
return paymentId;
}
else
{
// Pagar completa pendiente: usar /invoices/{id}/payments
var requestBody = new Dictionary<string, object>
{
["datepaye"] = datepaye,
["paymentid"] = paymentModeId,
["closepaidinvoices"] = dto.ClosePaidInvoices,
["accountid"] = dto.AccountId,
["num_payment"] = dto.PaymentNumber ?? "",
};
var responseBody = await apiClient.PostAsync($"invoices/{invoiceId}/payments", requestBody);
var paymentId = int.Parse(responseBody);
await NotifyIfPaid(invoiceId);
return paymentId;
}
}
}