Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
cdea039
refactor(repositories): rename GetByIdAndUserIdAsync to GetTrackedByI…
evans-costa Apr 1, 2026
73c5742
refactor(repositories): update UpdateOnly explicit implementations wi…
evans-costa Apr 1, 2026
5238221
refactor(handlers): update client handlers to call GetTrackedByIdAndU…
evans-costa Apr 1, 2026
b5e60b9
refactor(handlers): update material handlers to call GetTrackedByIdAn…
evans-costa Apr 1, 2026
696afbe
test(clients): update mocks to use GetTrackedByIdAndUserIdAsync
evans-costa Apr 1, 2026
603e540
test(materials): update mocks to use GetTrackedByIdAndUserIdAsync
evans-costa Apr 1, 2026
6dfba74
refactor(pipeline): fix behavior registration order (Authorization be…
evans-costa Apr 1, 2026
6455cbd
test(budgets): add failing tests for Budget commands and queries
evans-costa Apr 1, 2026
ce42150
feat(budgets): add ORCAMENTO_ITEMS_OBRIGATORIOS resource message
evans-costa Apr 1, 2026
0608e7f
feat(budgets): add Client nav property to Budget and update BudgetCon…
evans-costa Apr 1, 2026
a07403b
feat(budgets): add GetByUserIdWithClientAsync and GetByIdWithItemsAnd…
evans-costa Apr 1, 2026
c91ac95
feat(budgets): implement GetByUserIdWithClientAsync and GetByIdWithIt…
evans-costa Apr 1, 2026
363afbc
feat(budgets): add ORCAMENTO_ITEMS_OBRIGATORIOS to ResourceErrorMessa…
evans-costa Apr 2, 2026
d08cf03
feat(budgets): add budget response DTOs
evans-costa Apr 2, 2026
cbd3592
feat(budgets): add BudgetMappingExtensions
evans-costa Apr 2, 2026
3f95be5
feat(budgets): implement RegisterBudgetCommand
evans-costa Apr 2, 2026
dd1e433
feat(budgets): implement DeleteBudgetCommand
evans-costa Apr 2, 2026
1b17500
feat(budgets): implement GetBudgetsQuery
evans-costa Apr 2, 2026
4b73500
feat(budgets): implement GetBudgetByIdQuery
evans-costa Apr 2, 2026
ff38c97
feat(budgets): add BudgetsController
evans-costa Apr 2, 2026
621a9b0
feat(budgets): enhance security requirements and update README with s…
evans-costa Apr 10, 2026
f9dfb12
style(budgets): fix formatting in UpdateClient and UpdateMaterial com…
evans-costa Apr 10, 2026
387c0b9
feat(budgets): add development seed script for initial data population
evans-costa Apr 10, 2026
dfec73f
chore(git): update .gitignore to ignore agent directories
evans-costa May 26, 2026
5bb09ba
feat(domain): add BudgetItemType enum and validations
evans-costa May 26, 2026
314a235
feat(application): update budget commands to support item type
evans-costa May 26, 2026
b583aed
feat(infrastructure): map BudgetItemType and generate migration
evans-costa May 26, 2026
06e1486
chore(scripts): update seed-dev.cs for BudgetItemType changes
evans-costa May 26, 2026
3d64cdc
test(domain): add tests for budget state transitions and updates
evans-costa Jun 4, 2026
fd787f1
feat(domain): add state transitions, status enum, and events for budgets
evans-costa Jun 4, 2026
b57d5ed
test(application): add tests for budget commands (update, approve, re…
evans-costa Jun 4, 2026
8b93a15
feat(application): implement commands to update, approve, reject and …
evans-costa Jun 4, 2026
56c7eb5
feat(api): expose endpoints for update, approve, reject and finalize …
evans-costa Jun 4, 2026
d6727bd
docs: document budget lifecycle endpoints in README.md
evans-costa Jun 4, 2026
63eb984
chore(git): update .gitignore to ignore antigravity cli state
evans-costa Jun 4, 2026
a3d39a3
Merge branch 'main' into feature/budget-management
evans-costa Jun 4, 2026
3c8223a
fix: correct lint and whitespaces errors
evans-costa Jun 4, 2026
dcf655b
Merge branch 'feature/budget-management' of github.com:evans-costa/Vo…
evans-costa Jun 4, 2026
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
17 changes: 3 additions & 14 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,6 @@ nuget.config
.vs/
*.rsuser

# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates

# OS
.DS_Store
Thumbs.db
Expand All @@ -69,12 +62,8 @@ appsettings.Production.json
# Logs
logs/


# Copilot instructions
.github/copilot-instructions.md
# Squad: ignore runtime state (logs, inbox, sessions)
.squad/orchestration-log/
.squad/log/
.squad/decisions/inbox/
.squad/sessions/
# Squad: SubSquad activation file (local to this machine)
.squad-workstream
.agents
.antigravitycli
61 changes: 61 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -460,8 +460,69 @@ Cria um novo usuário na plataforma.
- Documento deve ser único no sistema.
- Senha é armazenada como hash **Argon2id** — nunca em texto puro.

### Orçamentos

#### `PUT /api/v1/budgets/{id}/finalize` — Finalizar orçamento

Muda o status do orçamento de `Draft` para `Finalized`. O orçamento deve ter pelo menos um item e pertencer ao usuário autenticado. Uma vez finalizado, o orçamento torna-se somente leitura e não pode mais ser editado.

**Headers obrigatórios:**
```
Authorization: Bearer <token>
```

**Respostas:**

| Status | Descrição |
|---|---|
| `204 NoContent` | Orçamento finalizado com sucesso |
| `400 Bad Request` | Orçamento não está em rascunho ou não contém itens (lança DomainException) |
| `401 Unauthorized` | Token ausente ou inválido |
| `404 Not Found` | Orçamento não encontrado ou não pertence ao usuário |

---

#### `PUT /api/v1/budgets/{id}/approve` — Aprovar orçamento

Aprova um orçamento que foi finalizado ou tem PDF gerado.

**Headers obrigatórios:**
```
Authorization: Bearer <token>
```

**Respostas:**

| Status | Descrição |
|---|---|
| `204 NoContent` | Orçamento aprovado com sucesso |
| `400 Bad Request` | Orçamento não está nos estados `Finalized` ou `PdfGenerated` (lança DomainException) |
| `401 Unauthorized` | Token ausente ou inválido |
| `404 Not Found` | Orçamento não encontrado ou não pertence ao usuário |

---

#### `PUT /api/v1/budgets/{id}/reject` — Rejeitar orçamento

Rejeita um orçamento que foi finalizado ou tem PDF gerado.

**Headers obrigatórios:**
```
Authorization: Bearer <token>
```

**Respostas:**

| Status | Descrição |
|---|---|
| `204 NoContent` | Orçamento rejeitado com sucesso |
| `400 Bad Request` | Orçamento não está nos estados `Finalized` ou `PdfGenerated` (lança DomainException) |
| `401 Unauthorized` | Token ausente ou inválido |
| `404 Not Found` | Orçamento não encontrado ou não pertence ao usuário |

---




### CQRS com MediatR
Expand Down
14 changes: 7 additions & 7 deletions scripts/seed-dev.cs
Original file line number Diff line number Diff line change
Expand Up @@ -118,19 +118,19 @@

// Orçamento 1: Construtora ABC — mix de material vinculado + item customizado
var budget1 = Budget.Register(user.Id, clients[0].Id);
budget1.AddItem(BudgetItem.Create(budget1.Id, materials[0].Id, "Cabo Flexível 2,5mm",
MaterialUnit.Metro, 50, 4.80m));
budget1.AddItem(BudgetItem.Create(budget1.Id, materials[2].Id, "Tomada 2P+T", MaterialUnit.Unidade,
8, 18.50m));
budget1.AddItem(BudgetItem.Create(budget1.Id, null, "Mão de obra elétrica", null, 1, 350.00m));
budget1.AddItem(BudgetItem.Create(budget1.Id, materials[0].Id, BudgetItemType.Material,
MaterialUnit.Metro, 50, 4.80m, "Cabo Flexível 2,5mm"));
budget1.AddItem(BudgetItem.Create(budget1.Id, materials[2].Id, BudgetItemType.Material, MaterialUnit.Unidade,
8, 18.50m, "Tomada 2P+T"));
budget1.AddItem(BudgetItem.Create(budget1.Id, null, BudgetItemType.MaoDeObra, null, 1, 350.00m, "Mão de obra elétrica"));

await db.Budgets.AddAsync(budget1);
await db.SaveChangesAsync();

// Orçamento 2: João da Silva — item customizado
var budget2 = Budget.Register(user.Id, clients[1].Id);
budget2.AddItem(BudgetItem.Create(budget2.Id, materials[3].Id, "Disjuntor 20A",
MaterialUnit.Unidade, 2, 32.00m));
budget2.AddItem(BudgetItem.Create(budget2.Id, materials[3].Id, BudgetItemType.Material,
MaterialUnit.Unidade, 2, 32.00m, "Disjuntor 20A"));

await db.Budgets.AddAsync(budget2);
await db.SaveChangesAsync();
Expand Down
65 changes: 65 additions & 0 deletions src/Voltiq.API/Controllers/Budgets/BudgetsController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
using Voltiq.Application.Features.Budgets;
using Voltiq.Application.Features.Budgets.Commands.DeleteBudget;
using Voltiq.Application.Features.Budgets.Commands.RegisterBudget;
using Voltiq.Application.Features.Budgets.Commands.UpdateBudget;
using Voltiq.Application.Features.Budgets.Commands.FinalizeBudget;
using Voltiq.Application.Features.Budgets.Commands.ApproveBudget;
using Voltiq.Application.Features.Budgets.Commands.RejectBudget;
using Voltiq.Application.Features.Budgets.Queries.GetBudgetById;
using Voltiq.Application.Features.Budgets.Queries.GetBudgets;
using Voltiq.Application.Mappings.Budgets;
Expand Down Expand Up @@ -67,4 +71,65 @@ public async Task<IActionResult> Delete(

return result.Match(_ => NoContent(), ToErrorResult);
}

/// <summary>Updates a budget (must belong to the authenticated user).</summary>
[HttpPut("{id:guid}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Update(
Guid id,
[FromBody] UpdateBudgetRequest request,
CancellationToken cancellationToken)
{
var result = await Sender.Send(request.ToCommand(id), cancellationToken);

return result.Match(_ => NoContent(), ToErrorResult);
}

/// <summary>Finalizes a budget, making it read-only.</summary>
[HttpPut("{id:guid}/finalize")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Finalize(
Guid id,
CancellationToken cancellationToken)
{
var result = await Sender.Send(new FinalizeBudgetCommand(id), cancellationToken);

return result.Match(_ => NoContent(), ToErrorResult);
}

/// <summary>Approves a finalized budget.</summary>
[HttpPut("{id:guid}/approve")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Approve(
Guid id,
CancellationToken cancellationToken)
{
var result = await Sender.Send(new ApproveBudgetCommand(id), cancellationToken);

return result.Match(_ => NoContent(), ToErrorResult);
}

/// <summary>Rejects a finalized budget.</summary>
[HttpPut("{id:guid}/reject")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Reject(
Guid id,
CancellationToken cancellationToken)
{
var result = await Sender.Send(new RejectBudgetCommand(id), cancellationToken);

return result.Match(_ => NoContent(), ToErrorResult);
}
}
1 change: 1 addition & 0 deletions src/Voltiq.Application/Features/Budgets/BudgetResponse.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ public sealed record BudgetItemResponse(
Guid Id,
Guid? MaterialId,
string MaterialName,
BudgetItemType Type,
MaterialUnit? Unit,
int Quantity,
decimal UnitPrice,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
using ErrorOr;
using MediatR;
using Voltiq.Application.Common.Interfaces;

namespace Voltiq.Application.Features.Budgets.Commands.ApproveBudget;

public record ApproveBudgetCommand(Guid Id) : IAuthenticatedRequest<ErrorOr<Updated>>
{
public Guid UserId { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
using ErrorOr;
using MediatR;
using Voltiq.Domain.Interfaces;
using Voltiq.Domain.Interfaces.Repositories.Budget;
using Voltiq.Exceptions.Resources;

namespace Voltiq.Application.Features.Budgets.Commands.ApproveBudget;

public sealed class ApproveBudgetCommandHandler(
IBudgetUpdateOnlyRepository budgetUpdateOnly,
IUnitOfWork unitOfWork)
: IRequestHandler<ApproveBudgetCommand, ErrorOr<Updated>>
{
public async Task<ErrorOr<Updated>> Handle(
ApproveBudgetCommand command, CancellationToken cancellationToken)
{
var budget = await budgetUpdateOnly.GetTrackedByIdAndUserIdAsync(
command.Id, command.UserId, cancellationToken);

if (budget is null)
return Error.NotFound(description: ResourceErrorMessages.ORCAMENTO_NAO_ENCONTRADO);

budget.Approve();

await unitOfWork.SaveChangesAsync(cancellationToken);

return Result.Updated;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
using ErrorOr;
using MediatR;
using Voltiq.Application.Common.Interfaces;

namespace Voltiq.Application.Features.Budgets.Commands.FinalizeBudget;

public record FinalizeBudgetCommand(Guid Id) : IAuthenticatedRequest<ErrorOr<Updated>>
{
public Guid UserId { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
using ErrorOr;
using MediatR;
using Voltiq.Domain.Interfaces;
using Voltiq.Domain.Interfaces.Repositories.Budget;
using Voltiq.Exceptions.Resources;

namespace Voltiq.Application.Features.Budgets.Commands.FinalizeBudget;

public sealed class FinalizeBudgetCommandHandler(
IBudgetUpdateOnlyRepository budgetUpdateOnly,
IUnitOfWork unitOfWork)
: IRequestHandler<FinalizeBudgetCommand, ErrorOr<Updated>>
{
public async Task<ErrorOr<Updated>> Handle(
FinalizeBudgetCommand command, CancellationToken cancellationToken)
{
var budget = await budgetUpdateOnly.GetTrackedByIdWithItemsAndUserIdAsync(
command.Id, command.UserId, cancellationToken);

if (budget is null)
return Error.NotFound(description: ResourceErrorMessages.ORCAMENTO_NAO_ENCONTRADO);

budget.FinalizeBudget();

await unitOfWork.SaveChangesAsync(cancellationToken);

return Result.Updated;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ public sealed record RegisterBudgetCommand(
public sealed record RegisterBudgetItemCommand(
Guid? MaterialId,
string MaterialName,
BudgetItemType Type,
MaterialUnit? Unit,
int Quantity,
decimal UnitPrice);
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
return Error.NotFound(description: ResourceErrorMessages.CLIENTE_NAO_ENCONTRADO);

var materialLookup = new Dictionary<Guid, Material>();
foreach (var item in command.Items.Where(i => i.MaterialId.HasValue))

Check warning on line 30 in src/Voltiq.Application/Features/Budgets/Commands/RegisterBudget/RegisterBudgetCommandHandler.cs

View workflow job for this annotation

GitHub Actions / Build and Unit Tests

Loop should be simplified by calling Select(item => item.MaterialId))
{
var material = await materialReadOnly.GetByIdAndUserIdAsync(
item.MaterialId!.Value, command.UserId, cancellationToken);
Expand All @@ -43,8 +43,7 @@
foreach (var item in command.Items)
{
var budgetItem = BudgetItem.Create(
budget.Id, item.MaterialId, item.MaterialName,
item.Unit, item.Quantity, item.UnitPrice);
budget.Id, item.MaterialId, item.Type, item.Unit, item.Quantity, item.UnitPrice, item.MaterialName);

budget.AddItem(budgetItem);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using FluentValidation;
using Voltiq.Domain.Enums;
using Voltiq.Exceptions.Resources;

namespace Voltiq.Application.Features.Budgets.Commands.RegisterBudget;
Expand All @@ -21,6 +22,30 @@ public RegisterBudgetCommandValidator()
.NotEmpty()
.WithMessage(ResourceErrorMessages.ORCAMENTO_ITEM_NOME_OBRIGATORIO);

item.RuleFor(i => i.Type)
.IsInEnum()
.WithMessage(ResourceErrorMessages.ORCAMENTO_ITEM_TIPO_INVALIDO);

item.RuleFor(i => i.MaterialId)
.NotNull()
.When(i => i.Type == BudgetItemType.Material)
.WithMessage(ResourceErrorMessages.ORCAMENTO_ITEM_MATERIAL_ID_OBRIGATORIO_PARA_MATERIAL);

item.RuleFor(i => i.MaterialId)
.Null()
.When(i => i.Type != BudgetItemType.Material && Enum.IsDefined(i.Type))
.WithMessage(ResourceErrorMessages.ORCAMENTO_ITEM_MATERIAL_ID_DEVE_SER_NULO);

item.RuleFor(i => i.Unit)
.NotNull()
.When(i => i.Type == BudgetItemType.Material)
.WithMessage(ResourceErrorMessages.ORCAMENTO_ITEM_UNIDADE_OBRIGATORIA_PARA_MATERIAL);

item.RuleFor(i => i.Unit)
.Null()
.When(i => i.Type != BudgetItemType.Material && Enum.IsDefined(i.Type))
.WithMessage(ResourceErrorMessages.ORCAMENTO_ITEM_UNIDADE_DEVE_SER_NULA);

item.RuleFor(i => i.Quantity)
.GreaterThan(0)
.WithMessage(ResourceErrorMessages.ORCAMENTO_ITEM_QUANTIDADE_INVALIDA);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ public sealed record RegisterBudgetRequest(
public sealed record RegisterBudgetItemRequest(
Guid? MaterialId,
string MaterialName,
BudgetItemType Type,
MaterialUnit? Unit,
int Quantity,
decimal UnitPrice);
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
using ErrorOr;
using MediatR;
using Voltiq.Application.Common.Interfaces;

namespace Voltiq.Application.Features.Budgets.Commands.RejectBudget;

public record RejectBudgetCommand(Guid Id) : IAuthenticatedRequest<ErrorOr<Updated>>
{
public Guid UserId { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
using ErrorOr;
using MediatR;
using Voltiq.Domain.Interfaces;
using Voltiq.Domain.Interfaces.Repositories.Budget;
using Voltiq.Exceptions.Resources;

namespace Voltiq.Application.Features.Budgets.Commands.RejectBudget;

public sealed class RejectBudgetCommandHandler(
IBudgetUpdateOnlyRepository budgetUpdateOnly,
IUnitOfWork unitOfWork)
: IRequestHandler<RejectBudgetCommand, ErrorOr<Updated>>
{
public async Task<ErrorOr<Updated>> Handle(
RejectBudgetCommand command, CancellationToken cancellationToken)
{
var budget = await budgetUpdateOnly.GetTrackedByIdAndUserIdAsync(
command.Id, command.UserId, cancellationToken);

if (budget is null)
return Error.NotFound(description: ResourceErrorMessages.ORCAMENTO_NAO_ENCONTRADO);

budget.Reject();

await unitOfWork.SaveChangesAsync(cancellationToken);

return Result.Updated;
}
}
Loading
Loading