309 lines
13 KiB
C#
309 lines
13 KiB
C#
|
|
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;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|