Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 21 additions & 2 deletions JobFlow.API/Controllers/InventoryController.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using JobFlow.Business.Extensions;
using JobFlow.API.Extensions;
using JobFlow.Business.Extensions;
using JobFlow.Business.Services.ServiceInterfaces;
using JobFlow.Domain.Models;
using Microsoft.AspNetCore.Mvc;
Expand All @@ -19,34 +20,52 @@ public InventoryController(IInventoryService service)
[HttpGet("{id}")]
public async Task<IResult> Get(Guid id)
{
var organizationId = HttpContext.GetOrganizationId();
var result = await _service.GetByIdAsync(id);
return result.IsSuccess ? Results.Ok(result.Value) : result.ToProblemDetails();
if (!result.IsSuccess)
return result.ToProblemDetails();
if (result.Value.OrganizationId != organizationId)
return Results.NotFound();
return Results.Ok(result.Value);
}

[HttpGet("org/{orgId}")]
public async Task<IResult> GetAll(Guid orgId)
{
var organizationId = HttpContext.GetOrganizationId();
if (orgId != organizationId)
return Results.Forbid();
var result = await _service.GetAllAsync(orgId);
return result.IsSuccess ? Results.Ok(result.Value) : result.ToProblemDetails();
}

[HttpPost]
public async Task<IResult> Create([FromBody] InventoryItem item)
{
var organizationId = HttpContext.GetOrganizationId();
item.OrganizationId = organizationId;
var result = await _service.CreateAsync(item);
return result.IsSuccess ? Results.Ok(result.Value) : result.ToProblemDetails();
}

[HttpPut]
public async Task<IResult> Update([FromBody] InventoryItem item)
{
var organizationId = HttpContext.GetOrganizationId();
item.OrganizationId = organizationId;
var result = await _service.UpdateAsync(item);
return result.IsSuccess ? Results.Ok(result.Value) : result.ToProblemDetails();
}

[HttpDelete("{id}")]
public async Task<IResult> Delete(Guid id)
{
var organizationId = HttpContext.GetOrganizationId();
var existing = await _service.GetByIdAsync(id);
if (!existing.IsSuccess)
return existing.ToProblemDetails();
if (existing.Value.OrganizationId != organizationId)
return Results.NotFound();
var result = await _service.DeleteAsync(id);
return result.IsSuccess ? Results.Ok() : result.ToProblemDetails();
}
Expand Down
50 changes: 43 additions & 7 deletions JobFlow.API/Controllers/InvoiceComtroller.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,15 +46,38 @@ IMapper mapper
public async Task<IActionResult> Get(Guid id)
{
var result = await invoiceService.GetInvoiceByIdAsync(id);
if (!result.IsSuccess)
return NotFound(result.Error);

var organizationId = HttpContext.GetOrganizationId();
if (result.Value.OrganizationId != organizationId)
return NotFound();

var value = _mapper.Map<InvoiceDto>(result.Value);
return result.IsSuccess ? Ok(value) : NotFound(result.Error);
return Ok(value);
}

[HttpGet("organization/summary")]
public async Task<IActionResult> GetSummary()
{
var organizationId = HttpContext.GetOrganizationId();
if (organizationId == Guid.Empty)
return Unauthorized("Organization context missing.");

var result = await invoiceService.GetInvoiceAggregatesByOrganizationAsync(organizationId);
return result.IsSuccess ? Ok(result.Value) : BadRequest(result.Error);
}

[HttpGet("client/{clientId}")]
public async Task<IActionResult> GetByClient(Guid clientId)
{
var organizationId = HttpContext.GetOrganizationId();
var result = await invoiceService.GetInvoicesByClientAsync(clientId);
return Ok(result.Value.ToDto());
if (!result.IsSuccess)
return NotFound(result.Error);

var filtered = result.Value.Where(i => i.OrganizationId == organizationId).ToList();
return Ok(filtered.ToDto());
}

[HttpGet("organization")]
Expand Down Expand Up @@ -108,6 +131,8 @@ public async Task<IActionResult> Upsert(
var invoiceNumber = await numberGenerator.GenerateAsync(organizationId);

var jobInfo = await this._jobService.GetJobByIdAsync(request.JobId, organizationId);
if (!jobInfo.IsSuccess)
return BadRequest(jobInfo.Error);

request.OrganizationClientId = jobInfo.Value.OrganizationClientId;
var invoice = request.ToInvoice(invoiceNumber);
Expand Down Expand Up @@ -140,9 +165,12 @@ public Task<IActionResult> UpsertForOrganization([FromBody] CreateInvoiceRequest
[HttpPost("{id:guid}/send")]
public async Task<IActionResult> SendInvoice(Guid id)
{
var organizationId = HttpContext.GetOrganizationId();
var result = await invoiceService.GetInvoiceByIdAsync(id);
if (!result.IsSuccess)
return NotFound(result.Error);
if (result.Value.OrganizationId != organizationId)
return NotFound();

await invoiceService.SendInvoiceToClientAsync(result.Value.Id);

Expand All @@ -152,14 +180,14 @@ public async Task<IActionResult> SendInvoice(Guid id)
[HttpPost("{id:guid}/remind")]
public async Task<IActionResult> SendInvoiceReminder(Guid id)
{
var organizationId = HttpContext.GetOrganizationId();
var result = await invoiceService.GetInvoiceByIdAsync(id);
if (!result.IsSuccess)
return NotFound(result.Error);

var invoice = result.Value;

if (invoice.OrganizationClient is null)
return BadRequest("Invoice client is missing.");
if (invoice.OrganizationId != organizationId)
return NotFound();

if (invoice.OrganizationClient is null)
return BadRequest("Invoice client is missing.");
Expand Down Expand Up @@ -196,7 +224,13 @@ await notificationService.SendClientInvoiceReminderNotificationAsync(
[HttpDelete("{id:guid}")]
public async Task<IActionResult> Delete(Guid id)
{
// Optional: delete line items too
var organizationId = HttpContext.GetOrganizationId();
var existing = await invoiceService.GetInvoiceByIdAsync(id);
if (!existing.IsSuccess)
return NotFound(existing.Error);
if (existing.Value.OrganizationId != organizationId)
return NotFound();

await lineItemService.DeleteByInvoiceIdAsync(id);
var result = await invoiceService.DeleteInvoiceAsync(id);
return result.IsSuccess ? Ok() : NotFound(result.Error);
Expand All @@ -205,13 +239,15 @@ public async Task<IActionResult> Delete(Guid id)
[HttpGet("{id:guid}/pdf")]
public async Task<IActionResult> GeneratePdf(Guid id)
{
var organizationId = HttpContext.GetOrganizationId();
var result = await invoiceService.GetInvoiceByIdAsync(id);
if (!result.IsSuccess)
return NotFound(result.Error);
if (result.Value.OrganizationId != organizationId)
return NotFound();
//46455c4d-58c0-49ef-b18a-84704dbd50aa
var pdf = await pdfGenerator.GenerateInvoicePdfAsync(result.Value);
var invoice = result.Value;
await invoiceService.SendInvoiceToClientAsync(invoice.Id);
var pdfName = $"{invoice.OrganizationClient.Organization.OrganizationName}-Invoice-{invoice.InvoiceNumber}.pdf";
return File(pdf, "application/pdf", $"{pdfName}");
}
Expand Down
3 changes: 2 additions & 1 deletion JobFlow.API/Controllers/JobController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,8 @@ public async Task<IActionResult> UpsertJob([FromBody] JobDto model)
[HttpDelete("{id:guid}")]
public async Task<IActionResult> DeleteJob(Guid id)
{
var result = await _jobService.DeleteJobAsync(id);
var organizationId = HttpContext.GetOrganizationId();
var result = await _jobService.DeleteJobAsync(id, organizationId);
if (result.IsFailure)
return BadRequest(result.Error);

Expand Down
5 changes: 5 additions & 0 deletions JobFlow.API/Controllers/PaymentController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -458,6 +458,11 @@ public async Task<IActionResult> RefundPayment([FromBody] PaymentRefundRequestDt
: null
});

if (result.Success)
{
await _invoiceService.RecordRefundAsync(request.InvoiceId.Value, request.Amount);
}

return result.Success ? Ok(result) : BadRequest(result);
}

Expand Down
15 changes: 14 additions & 1 deletion JobFlow.API/Controllers/PriceBookItemController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,13 @@ public PriceBookController(IPriceBookItemService service)
[HttpGet("{id}")]
public async Task<IResult> Get(Guid id)
{
var organizationId = HttpContext.GetOrganizationId();
var result = await _service.GetByIdAsync(id);
return result.IsSuccess ? Results.Ok(result.Value) : result.ToProblemDetails();
if (!result.IsSuccess)
return result.ToProblemDetails();
if (result.Value.OrganizationId != organizationId)
return Results.NotFound();
return Results.Ok(result.Value);
}

[HttpGet("category/{categoryId}")]
Expand Down Expand Up @@ -75,6 +80,8 @@ public async Task<IResult> Update([FromBody] UpdatePriceBookItemRequest dto)
Description = dto.Description,
Unit = dto.Unit,
PricePerUnit = dto.Cost,
Price = dto.Price,
Cost = dto.Cost,
ItemType = dto.Type,
InventoryUnitsPerSale = dto.InventoryUnitsPerSale,
CategoryId = dto.CategoryId
Expand All @@ -86,6 +93,12 @@ public async Task<IResult> Update([FromBody] UpdatePriceBookItemRequest dto)
[HttpDelete("{id}")]
public async Task<IResult> Delete(Guid id)
{
var organizationId = HttpContext.GetOrganizationId();
var existing = await _service.GetByIdAsync(id);
if (!existing.IsSuccess)
return existing.ToProblemDetails();
if (existing.Value.OrganizationId != organizationId)
return Results.NotFound();
var result = await _service.DeleteAsync(id);
return result.IsSuccess ? Results.Ok() : result.ToProblemDetails();
}
Expand Down
2 changes: 1 addition & 1 deletion JobFlow.API/Models/CreateInvoiceRequest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ public class InvoiceLineItemDto
{
public Guid? PriceBookItemId { get; set; }
public string Description { get; set; } = null!;
public int Quantity { get; set; }
public decimal Quantity { get; set; }
public decimal UnitPrice { get; set; }
public decimal LineTotal { get; set; }
}
3 changes: 3 additions & 0 deletions JobFlow.Business/ModelErrors/EstimateErrors.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,7 @@ public static class EstimateErrors

public static readonly Error CannotRespondInCurrentStatus =
Error.Conflict("Estimate.CannotRespondInCurrentStatus", "Only sent estimates can be accepted or declined.");

public static readonly Error CannotEditInCurrentStatus =
Error.Conflict("Estimate.CannotEditInCurrentStatus", "Estimates that have been sent, accepted, or declined cannot be edited.");
}
2 changes: 2 additions & 0 deletions JobFlow.Business/ModelErrors/InvoiceErrors.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,6 @@
public static class InvoiceErrors
{
public static readonly Error NotFound = Error.NotFound("Invoice.NotFound", "The invoice was not found.");
public static readonly Error InvalidAmount = Error.Validation("Invoice.InvalidAmount", "The refund amount must be greater than zero.");
public static readonly Error RefundExceedsTotal = Error.Validation("Invoice.RefundExceedsTotal", "The total refunded amount cannot exceed the invoice total.");
}
9 changes: 8 additions & 1 deletion JobFlow.Business/Models/DTOs/InvoiceAggregateDto.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@ namespace JobFlow.Business.Models.DTOs;

public class InvoiceAggregateDto
{
public decimal Outstanding { get; set; }
public int InvoiceCount { get; set; }
public int DraftCount { get; set; }
public int SentCount { get; set; }
public int PaidCount { get; set; }
public int OverdueCount { get; set; }
public int RefundedCount { get; set; }
public decimal TotalBilled { get; set; }
public decimal BalanceDue { get; set; }
public decimal Outstanding { get; set; }
}
77 changes: 77 additions & 0 deletions JobFlow.Business/Services/EstimateNumberGenerator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
using System.Data;
using JobFlow.Business.DI;
using JobFlow.Business.Services.ServiceInterfaces;
using JobFlow.Domain;
using JobFlow.Domain.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;

namespace JobFlow.Business.Services;

[ScopedService]
public class EstimateNumberGenerator : IEstimateNumberGenerator
{
private readonly ILogger<EstimateNumberGenerator> _logger;
private readonly IRepository<EstimateSequence> _sequenceRepo;
private readonly IUnitOfWork _unitOfWork;

public EstimateNumberGenerator(
IUnitOfWork unitOfWork,
ILogger<EstimateNumberGenerator> logger)
{
_unitOfWork = unitOfWork;
_sequenceRepo = _unitOfWork.RepositoryOf<EstimateSequence>();
_logger = logger;
}

public async Task<string> GenerateAsync(Guid organizationId)
{
var now = DateTime.UtcNow;
var year = now.Year;
var month = now.Month;
var day = now.Day;
var estimateNumber = string.Empty;

var dbContext = _unitOfWork.Context;
var strategy = dbContext.Database.CreateExecutionStrategy();

await strategy.ExecuteAsync(async () =>
{
using var transaction = await dbContext.Database.BeginTransactionAsync(IsolationLevel.Serializable);

var sequence = await _sequenceRepo.Query()
.SingleOrDefaultAsync(s =>
s.OrganizationId == organizationId &&
s.Year == year &&
s.Month == month &&
s.Day == day);

if (sequence == null)
{
sequence = new EstimateSequence
{
OrganizationId = organizationId,
Year = year,
Month = month,
Day = day,
LastSequence = 0
};
await _sequenceRepo.AddAsync(sequence);
}

sequence.LastSequence++;
var nextSeq = sequence.LastSequence;

await _unitOfWork.SaveChangesAsync();
await transaction.CommitAsync();

estimateNumber = $"EST-{year}{month:D2}{day:D2}-{nextSeq:D4}";
});

_logger.LogInformation(
"Generated estimate number {EstimateNumber} for organization {OrgId}",
estimateNumber, organizationId);

return estimateNumber;
}
}
Loading
Loading