Skip to main content

Design Document

Overview

Este design define a arquitetura e componentes necessários para implementar o endpoint DELETE api/payment/v2/{id}/items/{cartItemId} no coezzion-service-checkout. O endpoint permite que um cliente no zzlink remova um item recomendado previamente adicionado ao seu pedido Store antes de finalizar o pagamento.

O fluxo principal segue o padrão CQRS existente no projeto: Controller → Command → Handler → Services/Repositories, usando ZZMediator para orquestração. O handler coordena validações locais, lock distribuído, chamada à API interna do coezzion-service-cart (DELETE) e atualização síncrona do pagamento, retornando um DTO consolidado com os dados atualizados do pedido.

Escopo de tipo de pagamento

  • Atende: pagamentos Store (PaymentsModel de Link.OrgDB.Entities).
  • Não atende: pagamentos Ecommerce (PaymentsEcommModel) — rejeitados na validação de SaleEcommerce.

Decisões de Design

#DecisãoRationale
1Command (não Query): a operação modifica estado (remove item do carrinho via API interna e atualiza Payment.Total), portanto é modelada como ICommand<BaseResult>.Segue padrão CQRS do projeto.
2Handler dedicado: RemoveRecommendedItemCommandHandler separado do ADD. Cada handler tem responsabilidade única e o handler do ADD já é dedicado.Consistência com AddRecommendedItemCommandHandler.
3Route param para CartItemId: DELETE api/payment/v2/{id:guid}/items/{cartItemId:int} sem body.RESTful: DELETE de sub-recurso identificado pela rota. Semanticamente correto para DELETE.
4Sem FluentValidation: cartItemId já é int na rota (ASP.NET model binding rejeita não-numéricos). Handler retorna "Item não encontrado no carrinho" se cartItemId <= 0 não bater.Evita validator que só checa > 0; elimina redundância.
5ResponseDTO separado: RemoveRecommendedItemResponseDTO com mesmo shape do AddRecommendedItemResponseDTO.Permite divergência futura entre ADD e REMOVE; segue CQRS.
6HttpRequestMessage manual para Cart API: o método RemoveRecommendedItemAsync usa HttpRequestMessage com Method.Delete ao invés de _httpClient.DeleteAsync.Controle explícito sobre headers e configuração da request.
7Mesmo padrão de error handling do ADD: try/catch no CartIntegrationService converte exceções em BaseResult.Errors; erros de negócio da Cart API propagados diretamente.Consistência com AddRecommendedItemAsync.
8Erro de persistência retorna 400 (não 500): ao falhar SaveAsync, retorna AddError("Erro interno ao atualizar o pagamento.") que resulta em 400 via CustomResponse.O PaymentController.CustomResponse não suporta HTTP 500 via pattern matching — { StatusCode = 500 } é ignorado.
9Validação do item em cart.Items: busca GetCartInfoAsync e filtra FirstOrDefault(i => i.Id == command.CartItemId). Valida existência e FromRecommendation == true.CartItemsInfoDTO já contém FromRecommendation; evita query adicional.
10DI auto-scan: handler registrado automaticamente pelo ZZMediator via AddZZMediatR(typeof(RemoveRecommendedItemCommand).Assembly). CartIntegrationService precisa de registro explícito via AddHttpClient.Segue padrão do ADD.
11Lock distribuído com mesma chave: "rec-item:{PaymentGuid}" com TTL 10s.Compartilha lock com ADD para evitar race condition entre adicionar e remover no mesmo payment.

Architecture

Diagrama de Fluxo

Camadas e Responsabilidades

CamadaComponenteResponsabilidade
APIPaymentControllerRecebe DELETE request, seta TransactionId, despacha command via mediator
APIRemoveRecommendedItemCommandDTO do command com PaymentGuid e CartItemId (da rota)
APIRemoveRecommendedItemCommandHandlerOrquestração: validações, lock, chamada Cart API, atualização, resposta
DomainICoreSqlRepository (existente)GetCartInfoAsync(cartId) para SaleEcommerce, items, DateCreated; GetRecommendedItemsAsync(cartId) para recomendações
DomainICartIntegrationService (estendido)Contrato para chamada HTTP ao coezzion-service-cart — novo método RemoveRecommendedItemAsync
DomainRemoveRecommendedItemResponseDTO (novo)DTO de resposta (mesmo shape do Add)
InfrastructureCartIntegrationService (estendido)HttpClient + HttpRequestMessage para DELETE no coezzion-service-cart

Components and Interfaces

Novos Arquivos

ArquivoCamadaTipo
src/Checkout.API/Application/Messages/Commands/RecommendedItemCommands/RemoveRecommendedItemCommand.csAPICommand
src/Checkout.API/Application/Messages/Commands/RecommendedItemCommands/RemoveRecommendedItemCommandHandler.csAPIHandler
src/Checkout.Domain/DTO/RecommendedItem/RemoveRecommendedItemResponseDTO.csDomainDTO

Arquivos Modificados

ArquivoModificação
src/Checkout.API/Controllers/PaymentController.csNovo endpoint DeleteRemoveRecommendedItem
src/Checkout.Domain/Interfaces/Services/Cart/ICartIntegrationService.csAdicionar método RemoveRecommendedItemAsync(int cartId, int cartItemId)
src/Checkout.Infraestructure/Integrations/Cart/CartIntegrationService.csImplementar RemoveRecommendedItemAsync

Nota sobre validator: Não será criado RemoveRecommendedItemValidator. A validação de cartItemId é feita no handler (busca em cart.Items).

Interfaces

// ICartIntegrationService — EXTENDIDA com novo método
public interface ICartIntegrationService
{
Task<BaseResult> AddRecommendedItemAsync(int cartId, int recommendationId);
Task<BaseResult> RemoveRecommendedItemAsync(int cartId, int cartItemId); // NOVO
}

Data Models

RemoveRecommendedItemCommand

public class RemoveRecommendedItemCommand : ICommand<BaseResult>
{
public Guid PaymentGuid { get; set; }
public int CartItemId { get; set; }
}

Nota: Ambos os campos são setados pelo Controller a partir da rota. Sem [FromBody].

RemoveRecommendedItemResponseDTO

public record RemoveRecommendedItemResponseDTO
{
public Values Values { get; init; }
public List<Product> Products { get; init; }
public PaymentMethods PaymentMethods { get; init; }
public RecommendationsInfoDTO? Recommendations { get; init; }
}

Nota: Mesmo shape do AddRecommendedItemResponseDTO. DTO separado para permitir divergência futura entre ADD e REMOVE.

CartInfoDTO — campos relevantes (JÁ EXISTE)

Campos usados pelo handler:

CampoTipoUso no handler
IdintPassado para RemoveRecommendedItemAsync(cartId, cartItemId)
SaleEcommerceboolValidação: true → 400
TotaldecimalComparado com payment.Total para atualização
DateCreatedDateTimeUsado em GetHoursToExpire nas recomendações
ItemsList<CartItemsInfoDTO>Busca do item por cartItemId + validação FromRecommendation

CartItemsInfoDTO — campos relevantes (JÁ EXISTE)

CampoTipoUso no handler
IdintMatch com command.CartItemId
FromRecommendationboolValidação: false → 400 "Item não removível"

PaymentsModel — campos relevantes (JÁ EXISTE)

CampoTipoUso no handler
IdintPassado para alllogs
PaymentGuidGuidLock key
CartIdintValidação > 0, passado para GetCartInfoAsync
StatusPaymentStatusValidação em permittedStatuses
TotaldecimalComparado com updatedCart.Total

Diagrama de Relacionamento

Handler Flow

RemoveRecommendedItemCommandHandler — Pseudocódigo

public sealed class RemoveRecommendedItemCommandHandler :
CommandHandler,
ICommandHandler<RemoveRecommendedItemCommand, BaseResult>
{
private const string LOG_METHOD = "Handle-RemoveRecommendedItem";
private static readonly ImmutableHashSet<PaymentStatus> permittedStatuses = ImmutableHashSet.Create(
PaymentStatus.Created,
PaymentStatus.CreateTransactionFail
);

private readonly IPaymentsRepository _paymentRepository;
private readonly ICoreSqlRepository _coreSqlRepository;
private readonly ICartIntegrationService _cartIntegrationService;
private readonly ILockService _lockService;
private readonly ILogQueueService _logQueueService;
private readonly IUserProvider _userProvider;

public async Task<BaseResult> Handle(RemoveRecommendedItemCommand command, CancellationToken cancellationToken)
{
var locKey = $"rec-item:{command.PaymentGuid}";
try
{
// RF-06: Lock distribuído
var lockResult = await _lockService.LockAsync(locKey, command.PaymentGuid.ToString(), TimeSpan.FromSeconds(10));
if (!lockResult) return BaseResult.WithTooManyRequest("Pagamento em processamento.");

// RF-01: Buscar payment
var payment = await _paymentRepository.GetByGuidPaymentAsync(command.PaymentGuid);
if (payment is null) return ReturnError("Payment não encontrado.");
if (payment.CartId <= 0) return ReturnError("Pagamento não possui carrinho associado.");

// RF-07: Validar status
if (!permittedStatuses.Contains(payment.Status))
{
_ = _logQueueService.EnqueueAsync(LogQueueMessageEvent.SaveLogOrderHistoryMessage(
_userProvider.GetSchemaName(), payment.Id,
$"[Invalido] Tentativa de remover item recomendado {command.CartItemId} do carrinho {payment.CartId} - {payment.Status}",
LOG_METHOD));
return ReturnError("Não foi possível remover o item recomendado do pedido.");
}

// RF-01: Buscar cart e validar SaleEcommerce
var cart = await _coreSqlRepository.GetCartInfoAsync(payment.CartId);
if (cart is null) return ReturnError("Carrinho não encontrado.");
if (cart.SaleEcommerce) return ReturnError("Operação não disponível para este tipo de pedido.");

// RF-02: Validar item
var cartItem = cart.Items.FirstOrDefault(i => i.Id == command.CartItemId);
if (cartItem is null) return ReturnError("Item não encontrado no carrinho.");
if (!cartItem.FromRecommendation) return ReturnError("Item não removível");

// RF-05: Log de tentativa
_ = _logQueueService.EnqueueAsync(LogQueueMessageEvent.SaveLogOrderHistoryMessage(
_userProvider.GetSchemaName(), payment.Id,
$"Tentativa de remover item recomendado {command.CartItemId} do carrinho {payment.CartId}",
LOG_METHOD));

// RF-03: Chamar Cart API
var cartResult = await _cartIntegrationService.RemoveRecommendedItemAsync(payment.CartId, command.CartItemId);
if (cartResult.Errors.Count != 0)
{
_ = _logQueueService.EnqueueAsync(LogQueueMessageEvent.SaveLogOrderHistoryMessage(
_userProvider.GetSchemaName(), payment.Id,
$"Erro ao chamar Cart API para remover item recomendado: {string.Join("; ", cartResult.Errors)}",
LOG_METHOD));
return ReturnErrors(cartResult.Errors);
}

// RF-04: Atualizar Payment.Total
var updatedCart = await _coreSqlRepository.GetCartInfoAsync(payment.CartId);
try
{
if (payment.Total != updatedCart.Total)
{
var last = payment.Total;
payment.Total = updatedCart.Total;
_paymentRepository.Update(payment);
await _paymentRepository.SaveAsync();
_ = _logQueueService.EnqueueAsync(LogQueueMessageEvent.SaveLogOrderHistoryMessage(
_userProvider.GetSchemaName(), payment.Id,
$"Valor do pedido mudou: {last} -> {payment.Total}",
LOG_METHOD));
}
}
catch (Exception ex)
{
var errorMessage = ex.Message.Length > 512 ? ex.Message[..512] : ex.Message;
_ = _logQueueService.EnqueueAsync(LogQueueMessageEvent.SaveLogOrderHistoryMessage(
_userProvider.GetSchemaName(), payment.Id,
$"Erro ao persistir pagamento após remover item recomendado: {errorMessage}",
LOG_METHOD));
return ReturnError("Erro interno ao atualizar o pagamento.");
}

// Montar resposta
var updatedRecommendedItems = await _coreSqlRepository.GetRecommendedItemsAsync(payment.CartId);
var values = new Values(updatedCart);
var products = updatedCart.Items.Select(i => new Product(i)).ToList();
var paymentMethods = new PaymentMethods(updatedCart.PaymentsData, updatedCart.Total,
values.Products.Value - (values.Discount?.Value ?? 0));
var recommendations = BuildAvailableRecommendations(updatedCart, updatedRecommendedItems);

var dto = new RemoveRecommendedItemResponseDTO
{
Values = values,
Products = products,
PaymentMethods = paymentMethods,
Recommendations = recommendations
};

return BaseResult.With(dto);
}
finally
{
await _lockService.ReleaseLockAsync(locKey, command.PaymentGuid.ToString());
}
}

private RecommendationsInfoDTO BuildAvailableRecommendations(CartInfoDTO cart,
List<RecommendedItemsInfoDTO> recommendedItems)
{
var expirationTime = cart.DateCreated.AddMinutes(60);
var available = recommendedItems.Where(r => r.CartItemId == null).ToList();
if (available.Count == 0) return null;
return new RecommendationsInfoDTO
{
HoursToExpire = GetInfoBase.GetHoursToExpire(expirationTime),
Items = available
};
}

private BaseResult ReturnError(string message) { BaseResult.Errors.Add(message); return BaseResult; }
private BaseResult ReturnErrors(IEnumerable<string> errors) { BaseResult.Errors.AddRange(errors); return BaseResult; }
}

PaymentController — Novo Endpoint

/// <summary>
/// Remove um item recomendado do carrinho do pagamento
/// </summary>
/// <response code="200">Item removido com sucesso</response>
/// <response code="400">Falha na requisição</response>
/// <response code="429">Pagamento em processamento</response>
[HttpDelete("v2/{id:guid}/items/{cartItemId:int}")]
[Authorize(AuthenticationSchemes = "PaymentsScheme")]
[AddSchema]
public async Task<IActionResult> RemoveRecommendedItem(Guid id, int cartItemId)
{
transactionContext.SetTransactionId(Guid.NewGuid().ToString());
var command = new RemoveRecommendedItemCommand
{
PaymentGuid = id,
CartItemId = cartItemId
};
var result = await mediator.SendCommand(command);
return CustomResponse(result);
}

CartIntegrationService — Novo Método

public async Task<BaseResult> RemoveRecommendedItemAsync(int cartId, int cartItemId)
{
var result = new BaseResult();
try
{
var schema = _userProvider.GetSchemaName();
var request = new HttpRequestMessage(HttpMethod.Delete, $"api/cart/{cartId}/items/{cartItemId}");
request.Headers.Add("x-api-key", _apiKeysSettings.Value.CartApiKey);
request.Headers.Add("api-company-target", schema);

var response = await _httpClient.SendAsync(request);
if (!response.IsSuccessStatusCode)
{
var content = await response.Content.ReadAsStringAsync();
var errors = JsonSerializer.Deserialize<List<string>>(content, _jsonOptions);
if (errors is not null && errors.Count > 0)
result.Errors.AddRange(errors);
else
result.Errors.Add("Não foi possível remover o item");
}
}
catch (TaskCanceledException)
{
result.Errors.Add("Não foi possível remover o item");
}
catch (HttpRequestException)
{
result.Errors.Add("Não foi possível remover o item");
}
catch (Exception)
{
result.Errors.Add("Não foi possível remover o item");
}
return result;
}

Nota: A Cart API é idempotente — retorna HTTP 200 silencioso para cart/item não encontrado. O checkout já validou existence antes da chamada (RF-01, RF-02). Erros de negócio da Cart API ("Operacao nao disponivel para este tipo de pedido", "Item nao originado de recomendacao") são propagados diretamente via response.Content.

Correctness Properties

Property 1: Lock key shared with ADD

For any PaymentGuid, the lock key SHALL be "rec-item:{PaymentGuid}", shared between ADD and REMOVE handlers. This ensures mutual exclusion between add and remove operations on the same payment.

Validates: Requirements RF-06

Property 2: FromRecommendation validation is required

For any CartItem where FromRecommendation == false, the handler SHALL reject the removal with "Item não removível". This prevents removal of regular (non-recommended) items through this endpoint.

Validates: Requirements RF-02

Property 3: Cart API idempotency is safe

For any successful call to RemoveRecommendedItemAsync, the handler SHALL treat HTTP 200 from Cart API as success regardless of whether the cart or item existed. Pre-validation (RF-01, RF-02) guarantees existence before the call.

Validates: Requirements RF-03

Property 4: Payment.Total synchronization

For any successful removal where payment.Total != updatedCart.Total, the handler SHALL update payment.Total to updatedCart.Total and persist via Update + SaveAsync. If payment.Total == updatedCart.Total, no update is performed.

Validates: Requirements RF-04

Property 5: Recommendations revert to available

After a successful removal, the CartItemRecommendationModel.CartItemId for the removed item is set to null by the Cart API (RevertRecommendedItem). The response recommendations.Items SHALL contain only items where CartItemId == null, making previously-accepted recommendations available again.

Validates: Requirements RF-04

Property 6: Error propagation from Cart API

For any Cart API error response, the handler SHALL:

  • Log the full error details via alllogs: "Erro ao chamar Cart API para remover item recomendado: {errors_concatenados}"
  • Return the errors via ReturnErrors(cartResult.Errors)

Business errors from Cart API (e.g., "Operacao nao disponivel para este tipo de pedido") are propagated directly. Infrastructure errors (timeout, connection) return "Não foi possível remover o item".

Validates: Requirements RF-03

Property 7: Persistence failure returns 400

For any exception during _paymentRepository.SaveAsync(), the handler SHALL:

  • Log the error (truncated to 512 chars): "Erro ao persistir pagamento após remover item recomendado: {message}"
  • Return AddError("Erro interno ao atualizar o pagamento.") which results in HTTP 400 (not 500)

Validates: Requirements RF-04

Property 8: Log method consistency

For any alllogs entry, the method SHALL be "Handle-RemoveRecommendedItem".

Validates: Requirements RF-05

Error Handling

Tabela de Erros

CenárioStatusMensagemAlllogs
Token ausente/inválido401(middleware)
api-company-target ausente401"Schema invalid"
Lock não adquirido (race)429"Pagamento em processamento."
Payment não encontrado400"Payment não encontrado."
CartId <= 0400"Pagamento não possui carrinho associado."
Status inválido400"Não foi possível remover o item recomendado do pedido."[Invalido]
SaleEcommerce400"Operação não disponível para este tipo de pedido."
Cart não encontrado400"Carrinho não encontrado."
Item não encontrado no cart400"Item não encontrado no carrinho."
Item não é recomendado400`"Item não removível"
Erro Cart API (negócio)400Mensagem propagada da Cart API"Erro ao chamar Cart API para remover item recomendado: {errors}"
Erro Cart API (timeout/conexão)400"Não foi possível remover o item""Erro ao chamar Cart API para remover item recomendado: {errors}"
Falha persistência Payment400"Erro interno ao atualizar o pagamento.""Erro ao persistir pagamento após remover item recomendado: {msg_truncada}"

Handler Error Pattern

private BaseResult ReturnError(string message)
{
BaseResult.Errors.Add(message);
return BaseResult;
}

private BaseResult ReturnErrors(IEnumerable<string> errors)
{
BaseResult.Errors.AddRange(errors);
return BaseResult;
}

Nota: Não usar BaseResult.Response = new { StatusCode = 500 } — o PaymentController.CustomResponse não mapeia isso para HTTP 500. Erros sempre resultam em 400.

Testing Strategy

Testes Unitários — RemoveRecommendedItemCommandHandlerTests

Arquivo: src/Checkout.UnitTests/API.Application/Messages/Commands/RecommendedItemCommands/RemoveRecommendedItemCommandHandlerTests.cs

#Nome do TesteCategoriaDescrição
1LockNotAcquired_Executed_Returns429ValidationLock não adquirido → BaseResult.WithTooManyRequest
2PaymentNotFound_Executed_Returns400ValidationPayment null → erro
3InvalidCartId_Executed_Returns400ValidationCartId <= 0 → erro
4InvalidStatus_Executed_Returns400AndLogsValidationStatus fora de [Created, CreateTransactionFail] → erro + alllogs [Invalido]
5SaleEcommerce_Executed_Returns400Validationcart.SaleEcommerce == true → erro
6ItemNotFound_Executed_Returns400ValidationcartItemId não encontrado em cart.Items → erro
7ItemNotRemovable_Executed_Returns400ValidationcartItem.FromRecommendation == false → erro
8CartApiError_Executed_ReturnsErrorsCart APICart API com erros → propagados via ReturnErrors
9CartApiSuccess_TotalUpdated_Executed_Returns200Cart APISucesso + total mudou → update payment + DTO
10CartApiSuccess_TotalUnchanged_Executed_Returns200Cart APISucesso + total igual → sem update + DTO
11PersistenceFailure_Executed_Returns400PersistenceExceção ao salvar → 400 + log truncado
12LockReleasedInFinally_OnExceptionInfrastructureLock liberado no finally mesmo com exceção em SaveAsync
13RecommendationsAvailable_AfterRemoveCart APIRecomendações com CartItemId == null aparecem no DTO

Convenções de Teste

  • Classe: RemoveRecommendedItemCommandHandlerTests
  • Namespace: Checkout.UnitTests.API.Application.Messages.Commands.RecommendedItemCommands
  • Framework: xUnit 2.9.2 + Moq 4.20.72 + AutoFixture 4.18.0
  • DisplayName: português (ex: "Lock não adquirido retorna 429")
  • Trait: [Trait("Layer", "Application - Commands")]
  • Padrão AAA: // Arrange, // Act, // Assert
  • Mocks como campos readonly + SUT instanciado no construtor
  • Fixture configurada com OmitOnRecursionBehavior

Estrutura da Classe de Teste

public class RemoveRecommendedItemCommandHandlerTests
{
private readonly Mock<IPaymentsRepository> _paymentRepositoryMock;
private readonly Mock<ICoreSqlRepository> _coreSqlRepositoryMock;
private readonly Mock<ICartIntegrationService> _cartIntegrationServiceMock;
private readonly Mock<ILockService> _lockServiceMock;
private readonly Mock<ILogQueueService> _logQueueServiceMock;
private readonly Mock<IUserProvider> _userProviderMock;
private readonly Fixture _fixture;
private readonly RemoveRecommendedItemCommandHandler _handler;

// Helper methods: CreateHandler(), CreateCommand(), CreatePayment(), CreateCart(), etc.
// SetupLockAcquired() mock helper
}

Dependências

DependênciaReferênciaStatus
Cart API — DELETE api/cart/{cartId}/items/{cartItemId}Task 194883✅ Implementado
CartItemsModel.FromRecommendationCartItemsModel.cs:57✅ Disponível
Product.CartItemId no DTOADR-008 · GetInfoBase.cs✅ Implementado
ICoreSqlRepository.GetCartInfoAsyncCommit #194879✅ Disponível
ICoreSqlRepository.GetRecommendedItemsAsyncCommit #194879✅ Disponível
ILockServiceCoezzion.Common.LockService✅ Disponível
ILogQueueService + LogQueueMessageEventJá utilizado no ADD✅ Disponível
IUserProviderCoezzion.Common.Providers✅ Disponível
ICartIntegrationService.AddRecommendedItemAsyncJá implementado no ADD✅ Disponível
RemoveRecommendedItemCommandA ser implementado🔴 Pendente
RemoveRecommendedItemCommandHandlerA ser implementado🔴 Pendente
RemoveRecommendedItemResponseDTOA ser implementado🔴 Pendente
CartIntegrationService.RemoveRecommendedItemAsyncA ser implementado🔴 Pendente
PaymentController endpoint DELETEA ser implementado🔴 Pendente