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 GetInvoiceAsync(int id) { var data = await apiClient.GetResourceAsync($"invoices/{id}"); var dto = InvoiceMapper.MapToInvoiceDetailDto(data); if (dto.ClientId > 0) { try { var client = await apiClient.GetResourceAsync($"thirdparties/{dto.ClientId}"); dto.ClientName = client?.name; } catch { /* best-effort */ } } return dto; } public async Task> 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(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 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 AddInvoiceLineAsync(int invoiceId, CreateInvoiceLineDto lineDto) { var invoice = await apiClient.GetResourceAsync($"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($"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($"invoices/{id}"); await notifications.NotifyInvoiceStatusChangedAsync(id, invoice.@ref ?? $"#{id}", normalized); } public async Task ValidateInvoiceAsync(int id) { var invoice = await apiClient.GetResourceAsync($"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($"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($"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($"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 { ["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> 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($"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 GetInvoiceTemplateAsync(int id) { var data = await apiClient.GetResourceAsync($"invoices/templates/{id}"); if (data.id == 0) throw new NotFoundException($"Template invoice {id} not found"); return InvoiceMapper.MapTemplateToDetailDto(data); } private async Task> GetClientNamesAsync(List 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($"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> GetInvoicePaymentsAsync(int invoiceId) { var dataList = await apiClient.GetCollectionAsync($"invoices/{invoiceId}/payments"); var payments = dataList.Select(InvoiceMapper.MapToInvoicePaymentDto).ToList(); return payments; } private async Task NotifyIfPaid(int invoiceId) { var inv = await apiClient.GetResourceAsync($"invoices/{invoiceId}"); var status = inv.statut switch { "2" => "paid", "1" => "unpaid", _ => "draft" }; await notifications.NotifyInvoiceStatusChangedAsync(invoiceId, inv.@ref ?? $"#{invoiceId}", status); } public async Task 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 { ["arrayofamounts"] = new Dictionary { { 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 { ["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; } } }