diff --git a/frontend/src/api/payments.api.ts b/frontend/src/api/payments.api.ts index 6e72e00e..92f27a29 100644 --- a/frontend/src/api/payments.api.ts +++ b/frontend/src/api/payments.api.ts @@ -6,4 +6,8 @@ export const paymentsApi = { getById: (id: number) => apiClient.get(`/payments/${id}`).then((r) => r.data), + + // Fase 6 — ADMIN, one-shot: refunding an already-REFUNDED payment returns 409. + refund: (id: number) => + apiClient.post(`/payments/${id}/refund`).then((r) => r.data), }; diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 683ffde0..f09a3236 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -250,13 +250,17 @@ export interface OrderLineResponse { } // ── Payment ── +export type PaymentStatus = 'AUTHORIZED' | 'REFUNDED'; + +/** Backend JSON key is `paymentId` (PaymentResponse record, no @JsonProperty alias) — not `id`. */ export interface PaymentResponse { - id: number; + paymentId: number; amount: number; paymentMethod: string; orderId: number; orderReference: string; createdDate: string; + status?: PaymentStatus; } // ── Customer ── diff --git a/frontend/src/pages/admin/PaymentsPage.tsx b/frontend/src/pages/admin/PaymentsPage.tsx index 162b64fb..4917a4fe 100644 --- a/frontend/src/pages/admin/PaymentsPage.tsx +++ b/frontend/src/pages/admin/PaymentsPage.tsx @@ -1,26 +1,75 @@ -import { Container, Typography } from '@mui/material'; -import { useQuery } from '@tanstack/react-query'; +import { useState } from 'react'; +import { Button, Chip, Container, Typography } from '@mui/material'; +import { Undo } from '@mui/icons-material'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { motion } from 'framer-motion'; import { paymentsApi } from '@api/payments.api'; +import { normalizeError } from '@api/client'; import { QUERY_KEYS } from '@utils/constants'; import DataTable from '@components/data-display/DataTable'; import { TableSkeleton } from '@components/feedback/LoadingSkeleton'; +import ConfirmDialog from '@components/feedback/ConfirmDialog'; +import { useUIStore } from '@stores/ui.store'; import { formatCurrency, formatDateTime } from '@utils/format'; import type { PaymentResponse } from '@api/types'; export default function PaymentsPage() { + const qc = useQueryClient(); + const addToast = useUIStore((s) => s.addToast); + const [refundTarget, setRefundTarget] = useState(null); + const { data: payments, isLoading } = useQuery({ queryKey: [QUERY_KEYS.PAYMENTS], queryFn: paymentsApi.getAll, staleTime: 5 * 60 * 1000, }); + const refundMut = useMutation({ + mutationFn: (id: number) => paymentsApi.refund(id), + onSuccess: () => { + qc.invalidateQueries({ queryKey: [QUERY_KEYS.PAYMENTS] }); + addToast({ message: 'Payment refunded', variant: 'success' }); + setRefundTarget(null); + }, + onError: (err: unknown) => { + addToast({ message: normalizeError(err).message, variant: 'error' }); + setRefundTarget(null); + }, + }); + + // DataTable requires an `id` field on each row; the API's real key is `paymentId`. + const rows = (payments ?? []).map((p) => ({ ...p, id: p.paymentId })); + const COLUMNS = [ - { key: 'id', label: 'ID', render: (r: PaymentResponse) => #{r.id} }, + { key: 'id', label: 'ID', render: (r: PaymentResponse) => #{r.paymentId} }, { key: 'orderReference', label: 'Order', render: (r: PaymentResponse) => {r.orderReference} }, { key: 'amount', label: 'Amount', align: 'right' as const, render: (r: PaymentResponse) => {formatCurrency(r.amount)} }, { key: 'paymentMethod', label: 'Method' }, { key: 'createdDate', label: 'Date', render: (r: PaymentResponse) => {formatDateTime(r.createdDate)} }, + { + key: 'status', + label: 'Status', + render: (r: PaymentResponse) => ( + + ), + }, + { + key: 'actions', + label: 'Refund', + render: (r: PaymentResponse) => + r.status === 'REFUNDED' ? ( + + ) : ( + + ), + }, ]; return ( @@ -28,10 +77,25 @@ export default function PaymentsPage() { Payments {isLoading ? ( - + ) : ( - + )} + + refundTarget && refundMut.mutate(refundTarget.paymentId)} + onCancel={() => setRefundTarget(null)} + /> ); diff --git a/frontend/src/test/mocks/handlers.ts b/frontend/src/test/mocks/handlers.ts index 4f3f327f..21ad545d 100644 --- a/frontend/src/test/mocks/handlers.ts +++ b/frontend/src/test/mocks/handlers.ts @@ -107,7 +107,7 @@ const CUSTOMER: CustomerResponse = { }; const PAYMENT: PaymentResponse = { - id: 1, + paymentId: 1, amount: 59.98, paymentMethod: 'CREDIT_CARD', orderId: 1, diff --git a/order-service/src/main/java/code/with/vanilson/orderservice/OrderService.java b/order-service/src/main/java/code/with/vanilson/orderservice/OrderService.java index 00f26ed3..b3ad1df8 100644 --- a/order-service/src/main/java/code/with/vanilson/orderservice/OrderService.java +++ b/order-service/src/main/java/code/with/vanilson/orderservice/OrderService.java @@ -252,6 +252,78 @@ public void updateStatus(String correlationId, OrderStatus newStatus) { }); } + // ------------------------------------------------------- + // REFUND (Fase 6) — driven by payment.refunded, consumed by PaymentRefundConsumer + // ------------------------------------------------------- + + /** + * Applies a payment refund to the owning order: transitions it to {@code REFUNDED} and + * writes an {@code order.refunded} outbox row in the SAME transaction (outbox pattern — + * product-service's restock consumer picks it up from there). + *

+ * {@code PaymentRefundedEvent} carries neither {@code correlationId} nor {@code tenantId} + * (payment-service stores neither), so the order is looked up by its own PK + * ({@code orderId}) and both values are read off the found row. + *

+ * Idempotent: a redelivered event on an already-REFUNDED order is a no-op (no second + * outbox row). Failure semantics (documented, Fase 6 basic scope): if the order can't + * transition (e.g. already CANCELLED), this throws and the caller + * ({@code PaymentRefundConsumer}) does NOT acknowledge — Kafka retries, then DLQs. The + * payment stays REFUNDED regardless; reconciling that split is out of basic scope. + * + * @param orderId the order's internal PK (Payment.orderId — NOT the correlationId) + * @param eventId the source PaymentRefundedEvent's eventId (becomes the outbox eventId) + * @param occurredAt when payment-service processed the refund + */ + @Transactional + public void applyRefund(Integer orderId, String eventId, Instant occurredAt) { + Order order = orderRepository.findById(orderId) + .orElseThrow(() -> new OrderNotFoundException( + msg("order.not.found", orderId), "order.not.found")); + + if (order.getStatus() == OrderStatus.REFUNDED) { + log.info("[OrderService] Order already REFUNDED — skipping duplicate refund: orderId=[{}]", orderId); + return; + } + if (!order.getStatus().canTransitionTo(OrderStatus.REFUNDED)) { + throw new OrderIllegalStateTransitionException( + msg("order.status.transition.invalid", order.getStatus(), OrderStatus.REFUNDED, order.getCorrelationId()), + "order.status.transition.invalid"); + } + + order.setStatus(OrderStatus.REFUNDED); + Order saved = orderRepository.save(order); + + persistOrderRefundedOutboxEvent(saved, eventId, occurredAt); + + log.info("[OrderService] Order REFUNDED: orderId=[{}] correlationId=[{}]", + orderId, saved.getCorrelationId()); + eventPublisher.publishEvent(new OrderStatusChangedEvent( + saved.getCorrelationId(), OrderStatus.REFUNDED.name(), saved.getReference(), Instant.now())); + } + + private void persistOrderRefundedOutboxEvent(Order order, String sourceEventId, Instant occurredAt) { + code.with.vanilson.orderservice.kafka.OrderRefundedEvent event = + new code.with.vanilson.orderservice.kafka.OrderRefundedEvent( + sourceEventId, order.getCorrelationId(), order.getReference(), occurredAt, 1); + try { + String payload = objectMapper.writeValueAsString(event); + OutboxEvent outbox = OutboxEvent.builder() + .eventId(sourceEventId) + .correlationId(order.getCorrelationId()) + .tenantId(order.getTenantId()) + .topic("order.refunded") + .payload(payload) + .partitionKey(order.getCorrelationId()) + .status(OutboxEvent.OutboxStatus.PENDING) + .build(); + outboxRepository.save(outbox); + } catch (JsonProcessingException ex) { + log.error("[OrderService] Failed to serialise OrderRefundedEvent: {}", ex.getMessage(), ex); + throw new RuntimeException("Failed to create outbox event for refund: " + order.getCorrelationId(), ex); + } + } + // ------------------------------------------------------- // MANUAL FULFILLMENT STATUS UPDATE (Fase 5) — seller/admin driven // ------------------------------------------------------- diff --git a/order-service/src/main/java/code/with/vanilson/orderservice/config/KafkaOrderTopicConfig.java b/order-service/src/main/java/code/with/vanilson/orderservice/config/KafkaOrderTopicConfig.java index 6369bbb6..8f8fc1a0 100644 --- a/order-service/src/main/java/code/with/vanilson/orderservice/config/KafkaOrderTopicConfig.java +++ b/order-service/src/main/java/code/with/vanilson/orderservice/config/KafkaOrderTopicConfig.java @@ -85,6 +85,28 @@ public class KafkaOrderTopicConfig { return TopicBuilder.name("inventory.released").partitions(10).replicas(1).build(); } + // ------------------------------------------------------- + // Fase 6 — Refund topics + // ------------------------------------------------------- + + /** Published by payment-service after a successful refund → consumed by order-service */ + @Bean public NewTopic paymentRefundedTopic() { + return TopicBuilder.name("payment.refunded").partitions(10).replicas(1).build(); + } + + @Bean public NewTopic paymentRefundedDlq() { + return TopicBuilder.name("payment.refunded.DLQ").partitions(3).replicas(1).build(); + } + + /** Published by order-service's outbox after a payment refund is applied → consumed by product-service (restock) */ + @Bean public NewTopic orderRefundedTopic() { + return TopicBuilder.name("order.refunded").partitions(10).replicas(1).build(); + } + + @Bean public NewTopic orderRefundedDlq() { + return TopicBuilder.name("order.refunded.DLQ").partitions(3).replicas(1).build(); + } + // ------------------------------------------------------- // Phase 2 — Customer snapshot topics // ------------------------------------------------------- diff --git a/order-service/src/main/java/code/with/vanilson/orderservice/kafka/OrderRefundedEvent.java b/order-service/src/main/java/code/with/vanilson/orderservice/kafka/OrderRefundedEvent.java new file mode 100644 index 00000000..266adca1 --- /dev/null +++ b/order-service/src/main/java/code/with/vanilson/orderservice/kafka/OrderRefundedEvent.java @@ -0,0 +1,22 @@ +package code.with.vanilson.orderservice.kafka; + +import java.time.Instant; + +/** + * OrderRefundedEvent — Kafka event published via the order-service outbox after a + * payment refund is applied. + * Topic: order.refunded + * Consumed by: product-service (restocks reserved quantities — mirrors the compensation + * path used for cancelled orders). + * + * @author vamuhong + * @version 1.0 + */ +public record OrderRefundedEvent( + String eventId, + String correlationId, + String orderReference, + Instant occurredAt, + int version +) { +} diff --git a/order-service/src/main/java/code/with/vanilson/orderservice/kafka/PaymentRefundConsumer.java b/order-service/src/main/java/code/with/vanilson/orderservice/kafka/PaymentRefundConsumer.java new file mode 100644 index 00000000..db35d52e --- /dev/null +++ b/order-service/src/main/java/code/with/vanilson/orderservice/kafka/PaymentRefundConsumer.java @@ -0,0 +1,67 @@ +package code.with.vanilson.orderservice.kafka; + +import code.with.vanilson.orderservice.OrderService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.slf4j.MDC; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.support.Acknowledgment; +import org.springframework.kafka.support.KafkaHeaders; +import org.springframework.messaging.handler.annotation.Header; +import org.springframework.messaging.handler.annotation.Payload; +import org.springframework.stereotype.Service; + +/** + * PaymentRefundConsumer — Infrastructure Layer (Kafka Consumer, Fase 6). + *

+ * Consumes {@code payment.refunded} (published by payment-service after + * {@code PaymentService.refundPayment}) and applies the refund to the owning order via + * {@link OrderService#applyRefund}. Kept as its OWN class (SRP) rather than folded into + * {@link OrderSagaConsumer} — refunds are a separate lifecycle from the choreography saga + * that consumer drives (REQUESTED → CONFIRMED/CANCELLED); this handles the post-confirmation + * REFUNDED transition. + *

+ * Manual acknowledgement, same discipline as the saga consumer: offset is committed only + * after {@code applyRefund}'s transaction (status update + outbox row) succeeds. If the + * order can't transition (e.g. already CANCELLED), {@code applyRefund} throws, {@code ack} + * is never called, and Kafka's retry/DLQ error handler on {@code sagaKafkaListenerContainerFactory} + * takes over — the event lands on {@code payment.refunded.DLQ} after 3 retries (documented + * Fase 6 failure semantics: the payment stays REFUNDED regardless). + * + * @author vamuhong + * @version 1.0 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class PaymentRefundConsumer { + + private final OrderService orderService; + + @KafkaListener( + topics = "payment.refunded", + groupId = "order-saga-group", + containerFactory = "sagaKafkaListenerContainerFactory") + public void onPaymentRefunded( + @Payload PaymentRefundedEvent event, + @Header(KafkaHeaders.RECEIVED_PARTITION) int partition, + @Header(KafkaHeaders.OFFSET) long offset, + Acknowledgment ack) { + + try { + MDC.put("sagaStep", "order-refund"); + MDC.put("orderId", String.valueOf(event.orderId())); + + log.info("[PaymentRefundConsumer] payment.refunded received: orderId=[{}] paymentId=[{}] " + + "partition=[{}] offset=[{}]", + event.orderId(), event.paymentId(), partition, offset); + + orderService.applyRefund(event.orderId(), event.eventId(), event.occurredAt()); + + log.info("[PaymentRefundConsumer] Refund applied: orderId=[{}]", event.orderId()); + ack.acknowledge(); + } finally { + MDC.clear(); + } + } +} diff --git a/order-service/src/main/java/code/with/vanilson/orderservice/kafka/PaymentRefundedEvent.java b/order-service/src/main/java/code/with/vanilson/orderservice/kafka/PaymentRefundedEvent.java new file mode 100644 index 00000000..6dd04f41 --- /dev/null +++ b/order-service/src/main/java/code/with/vanilson/orderservice/kafka/PaymentRefundedEvent.java @@ -0,0 +1,27 @@ +package code.with.vanilson.orderservice.kafka; + +import java.math.BigDecimal; +import java.time.Instant; + +/** + * PaymentRefundedEvent — Kafka Event (Fase 6, consumer-side copy). + *

+ * Consumed by order-service from topic: payment.refunded (published by payment-service + * after {@code PaymentService.refundPayment}). Field names/order mirror the producer-side + * record exactly — {@code StringJsonMessageConverter} binds by JSON field name, and this + * POJO carries no {@code tenantId} or {@code correlationId} (payment-service doesn't store + * either); order-service resolves both from the {@code Order} row found by {@code orderId}. + * + * @author vamuhong + * @version 1.0 + */ +public record PaymentRefundedEvent( + String eventId, + Integer paymentId, + Integer orderId, + String orderReference, + BigDecimal amount, + Instant occurredAt, + int version +) { +} diff --git a/order-service/src/test/java/code/with/vanilson/orderservice/OrderServiceApplyRefundTest.java b/order-service/src/test/java/code/with/vanilson/orderservice/OrderServiceApplyRefundTest.java new file mode 100644 index 00000000..6711f064 --- /dev/null +++ b/order-service/src/test/java/code/with/vanilson/orderservice/OrderServiceApplyRefundTest.java @@ -0,0 +1,172 @@ +package code.with.vanilson.orderservice; + +import code.with.vanilson.orderservice.customer.CustomerClient; +import code.with.vanilson.orderservice.customer.CustomerSnapshotRepository; +import code.with.vanilson.orderservice.event.OrderStatusChangedEvent; +import code.with.vanilson.orderservice.exception.OrderIllegalStateTransitionException; +import code.with.vanilson.orderservice.exception.OrderNotFoundException; +import code.with.vanilson.orderservice.orderLine.OrderLineService; +import code.with.vanilson.orderservice.outbox.OutboxEvent; +import code.with.vanilson.orderservice.outbox.OutboxRepository; +import code.with.vanilson.orderservice.payment.PaymentMethod; +import code.with.vanilson.tenantcontext.TenantHibernateFilterActivator; +import io.micrometer.core.instrument.MeterRegistry; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.MessageSource; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.Locale; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.mockito.ArgumentCaptor.forClass; + +/** + * OrderServiceApplyRefundTest — Unit tests for {@link OrderService#applyRefund} (Fase 6). + *

+ * Covers the transition + outbox write, idempotent no-op on a redelivered event, and the + * illegal-transition failure path (no save, no outbox, no event — the caller does not + * acknowledge, so Kafka retries/DLQs per the documented Fase 6 failure semantics). + */ +@DisplayName("OrderService.applyRefund — Fase 6 (unit)") +class OrderServiceApplyRefundTest { + + private static final Integer ORDER_ID = 100; + + private OrderRepository orderRepository; + private OutboxRepository outboxRepository; + private ApplicationEventPublisher eventPublisher; + private OrderService orderService; + + @BeforeEach + void setUp() { + orderRepository = mock(OrderRepository.class); + OrderMapper orderMapper = mock(OrderMapper.class); + CustomerClient customerClient = mock(CustomerClient.class); + CustomerSnapshotRepository snapshotRepository = mock(CustomerSnapshotRepository.class); + OrderLineService orderLineService = mock(OrderLineService.class); + outboxRepository = mock(OutboxRepository.class); + MessageSource messageSource = mock(MessageSource.class); + TenantHibernateFilterActivator filterActivator = mock(TenantHibernateFilterActivator.class); + MeterRegistry meterRegistry = mock(MeterRegistry.class); + eventPublisher = mock(ApplicationEventPublisher.class); + + lenient().when(messageSource.getMessage(anyString(), any(), any(Locale.class))) + .thenAnswer(inv -> inv.getArgument(0)); + + orderService = new OrderService(orderRepository, orderMapper, customerClient, snapshotRepository, + orderLineService, outboxRepository, messageSource, filterActivator, meterRegistry, + eventPublisher); + } + + private static Order orderWithStatus(OrderStatus status) { + return Order.builder() + .orderId(ORDER_ID) + .tenantId("tenant-refund") + .correlationId("corr-refund-1") + .reference("ORD-REFUND-1") + .totalAmount(BigDecimal.valueOf(150.00)) + .paymentMethod(PaymentMethod.CREDIT_CARD) + .customerId("42") + .status(status) + .build(); + } + + @Nested + @DisplayName("Successful refund") + class SuccessfulRefund { + + @Test + @DisplayName("CONFIRMED → REFUNDED: saves, writes an order.refunded outbox row, publishes SSE event") + void refundsConfirmedOrder() { + Order order = orderWithStatus(OrderStatus.CONFIRMED); + when(orderRepository.findById(ORDER_ID)).thenReturn(Optional.of(order)); + when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0)); + + orderService.applyRefund(ORDER_ID, "evt-refund-1", Instant.now()); + + assertThat(order.getStatus()).isEqualTo(OrderStatus.REFUNDED); + verify(orderRepository).save(order); + + var captor = forClass(OutboxEvent.class); + verify(outboxRepository).save(captor.capture()); + OutboxEvent outbox = captor.getValue(); + assertThat(outbox.getEventId()).isEqualTo("evt-refund-1"); + assertThat(outbox.getCorrelationId()).isEqualTo("corr-refund-1"); + assertThat(outbox.getTenantId()).isEqualTo("tenant-refund"); + assertThat(outbox.getTopic()).isEqualTo("order.refunded"); + assertThat(outbox.getPartitionKey()).isEqualTo("corr-refund-1"); + assertThat(outbox.getStatus()).isEqualTo(OutboxEvent.OutboxStatus.PENDING); + + verify(eventPublisher).publishEvent(any(OrderStatusChangedEvent.class)); + } + + @Test + @DisplayName("SHIPPED → REFUNDED is also a legal transition") + void refundsShippedOrder() { + Order order = orderWithStatus(OrderStatus.SHIPPED); + when(orderRepository.findById(ORDER_ID)).thenReturn(Optional.of(order)); + when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0)); + + orderService.applyRefund(ORDER_ID, "evt-refund-2", Instant.now()); + + assertThat(order.getStatus()).isEqualTo(OrderStatus.REFUNDED); + } + } + + @Nested + @DisplayName("Idempotency + failure semantics") + class IdempotencyAndFailures { + + @Test + @DisplayName("redelivered event on an already-REFUNDED order is a no-op") + void alreadyRefundedIsNoOp() { + Order order = orderWithStatus(OrderStatus.REFUNDED); + when(orderRepository.findById(ORDER_ID)).thenReturn(Optional.of(order)); + + orderService.applyRefund(ORDER_ID, "evt-refund-3", Instant.now()); + + verify(orderRepository, never()).save(any()); + verify(outboxRepository, never()).save(any()); + verify(eventPublisher, never()).publishEvent(any()); + } + + @Test + @DisplayName("order not found throws OrderNotFoundException") + void orderNotFoundThrows() { + when(orderRepository.findById(ORDER_ID)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> orderService.applyRefund(ORDER_ID, "evt-refund-4", Instant.now())) + .isInstanceOf(OrderNotFoundException.class); + + verify(outboxRepository, never()).save(any()); + } + + @Test + @DisplayName("illegal transition (REQUESTED → REFUNDED) throws, no save, no outbox, no event") + void illegalTransitionThrows() { + Order order = orderWithStatus(OrderStatus.REQUESTED); + when(orderRepository.findById(ORDER_ID)).thenReturn(Optional.of(order)); + + assertThatThrownBy(() -> orderService.applyRefund(ORDER_ID, "evt-refund-5", Instant.now())) + .isInstanceOf(OrderIllegalStateTransitionException.class); + + verify(orderRepository, never()).save(any()); + verify(outboxRepository, never()).save(any()); + verify(eventPublisher, never()).publishEvent(any()); + } + } +} diff --git a/order-service/src/test/java/code/with/vanilson/orderservice/bdd/OrderRefundStepDefinitions.java b/order-service/src/test/java/code/with/vanilson/orderservice/bdd/OrderRefundStepDefinitions.java new file mode 100644 index 00000000..dbbf70f8 --- /dev/null +++ b/order-service/src/test/java/code/with/vanilson/orderservice/bdd/OrderRefundStepDefinitions.java @@ -0,0 +1,141 @@ +package code.with.vanilson.orderservice.bdd; + +import code.with.vanilson.orderservice.Order; +import code.with.vanilson.orderservice.OrderMapper; +import code.with.vanilson.orderservice.OrderRepository; +import code.with.vanilson.orderservice.OrderService; +import code.with.vanilson.orderservice.OrderStatus; +import code.with.vanilson.orderservice.customer.CustomerClient; +import code.with.vanilson.orderservice.customer.CustomerSnapshotRepository; +import code.with.vanilson.orderservice.exception.OrderIllegalStateTransitionException; +import code.with.vanilson.orderservice.orderLine.OrderLineService; +import code.with.vanilson.orderservice.outbox.OutboxEvent; +import code.with.vanilson.orderservice.outbox.OutboxRepository; +import code.with.vanilson.orderservice.payment.PaymentMethod; +import code.with.vanilson.tenantcontext.TenantContext; +import code.with.vanilson.tenantcontext.TenantHibernateFilterActivator; +import io.cucumber.java.After; +import io.cucumber.java.en.Given; +import io.cucumber.java.en.Then; +import io.cucumber.java.en.When; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.MessageSource; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.Locale; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * BDD step definitions for the Fase 6 order-refund flow. POJO + Mockito glue (no Spring + * context) — drives the real {@link OrderService#applyRefund} over mocked collaborators. + */ +public class OrderRefundStepDefinitions { + + private static final Integer ORDER_ID = 700; + + private OrderRepository orderRepository; + private OutboxRepository outboxRepository; + private OrderService orderService; + private Order order; + + private RuntimeException caught; + + @After + public void cleanup() { + TenantContext.clear(); + } + + private void bootstrap(OrderStatus initialStatus) { + orderRepository = mock(OrderRepository.class); + OrderMapper orderMapper = mock(OrderMapper.class); + CustomerClient customerClient = mock(CustomerClient.class); + CustomerSnapshotRepository snapshotRepository = mock(CustomerSnapshotRepository.class); + OrderLineService orderLineService = mock(OrderLineService.class); + outboxRepository = mock(OutboxRepository.class); + MessageSource messageSource = mock(MessageSource.class); + TenantHibernateFilterActivator filterActivator = mock(TenantHibernateFilterActivator.class); + io.micrometer.core.instrument.MeterRegistry meterRegistry = + mock(io.micrometer.core.instrument.MeterRegistry.class); + ApplicationEventPublisher eventPublisher = mock(ApplicationEventPublisher.class); + + lenient().when(messageSource.getMessage(anyString(), any(), any(Locale.class))) + .thenAnswer(inv -> inv.getArgument(0)); + + order = Order.builder() + .orderId(ORDER_ID) + .tenantId("default") + .correlationId("corr-refund-bdd") + .reference("ORD-REFUND-BDD") + .totalAmount(BigDecimal.valueOf(150.00)) + .paymentMethod(PaymentMethod.CREDIT_CARD) + .customerId("42") + .status(initialStatus) + .build(); + + when(orderRepository.findById(ORDER_ID)).thenReturn(Optional.of(order)); + lenient().when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0)); + + orderService = new OrderService(orderRepository, orderMapper, customerClient, snapshotRepository, + orderLineService, outboxRepository, messageSource, filterActivator, meterRegistry, + eventPublisher); + + caught = null; + } + + @Given("a confirmed order exists for the refund") + public void a_confirmed_order_exists() { + bootstrap(OrderStatus.CONFIRMED); + } + + @Given("an already-refunded order exists for the refund") + public void an_already_refunded_order_exists() { + bootstrap(OrderStatus.REFUNDED); + } + + @Given("a requested order exists for the refund") + public void a_requested_order_exists() { + bootstrap(OrderStatus.REQUESTED); + } + + @When("a payment.refunded event arrives for that order") + public void a_payment_refunded_event_arrives() { + try { + orderService.applyRefund(ORDER_ID, "evt-refund-bdd", Instant.now()); + } catch (RuntimeException e) { + caught = e; + } + } + + @Then("the refunded order's status becomes {string}") + public void the_refunded_order_status_becomes(String status) { + assertThat(caught).isNull(); + assertThat(order.getStatus()).isEqualTo(OrderStatus.valueOf(status)); + } + + @Then("an order.refunded outbox event is written") + public void an_order_refunded_outbox_event_is_written() { + verify(outboxRepository).save(org.mockito.ArgumentMatchers.argThat( + (OutboxEvent o) -> o.getTopic().equals("order.refunded"))); + } + + @Then("no new outbox event is written") + public void no_new_outbox_event_is_written() { + assertThat(caught).isNull(); + verify(outboxRepository, org.mockito.Mockito.never()).save(any()); + } + + @Then("the refund is rejected as an illegal transition") + public void the_refund_is_rejected_as_an_illegal_transition() { + assertThat(caught).isInstanceOf(OrderIllegalStateTransitionException.class); + verify(outboxRepository, org.mockito.Mockito.never()).save(any()); + } +} diff --git a/order-service/src/test/java/code/with/vanilson/orderservice/integration/OrderRefundIntegrationTest.java b/order-service/src/test/java/code/with/vanilson/orderservice/integration/OrderRefundIntegrationTest.java new file mode 100644 index 00000000..16a5a157 --- /dev/null +++ b/order-service/src/test/java/code/with/vanilson/orderservice/integration/OrderRefundIntegrationTest.java @@ -0,0 +1,186 @@ +package code.with.vanilson.orderservice.integration; + +import code.with.vanilson.orderservice.Order; +import code.with.vanilson.orderservice.OrderRepository; +import code.with.vanilson.orderservice.OrderService; +import code.with.vanilson.orderservice.OrderStatus; +import code.with.vanilson.orderservice.customer.CustomerClient; +import code.with.vanilson.orderservice.kafka.PaymentRefundedEvent; +import code.with.vanilson.orderservice.outbox.OutboxEvent; +import code.with.vanilson.orderservice.outbox.OutboxRepository; +import code.with.vanilson.orderservice.payment.PaymentMethod; +import code.with.vanilson.tenantcontext.TenantContext; +import org.apache.kafka.clients.producer.ProducerConfig; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.kafka.KafkaProperties; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Bean; +import org.springframework.kafka.core.DefaultKafkaProducerFactory; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.kafka.support.serializer.JsonSerializer; +import org.springframework.kafka.test.context.EmbeddedKafka; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * OrderRefundIntegrationTest — Fase 6 refunds, real PostgreSQL + a real Kafka message + * through the actual {@code payment.refunded} listener (not a direct service call), so a + * field-name mismatch between payment-service's producer event and this consumer's POJO — + * exactly the class of bug {@code StringJsonMessageConverter} binding can hide — would + * surface here. + */ +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = { + "spring.kafka.bootstrap-servers=${spring.embedded.kafka.brokers}", + "spring.kafka.consumer.group-id=order-refund-integration-test", + "spring.kafka.producer.acks=all" + }) +@Testcontainers +@EmbeddedKafka( + partitions = 1, + topics = {"payment.refunded", "order.refunded"}, + brokerProperties = { + "auto.create.topics.enable=true", + "offsets.topic.num.partitions=1", + "transaction.state.log.num.partitions=1", + "transaction.state.log.replication.factor=1", + "transaction.state.log.min.isr=1", + "log.index.size.max.bytes=1048576" + }) +@ActiveProfiles("test") +@DisplayName("Order Refund Integration — Fase 6 (Testcontainers PostgreSQL + EmbeddedKafka)") +class OrderRefundIntegrationTest { + + @Container + static PostgreSQLContainer postgres = + new PostgreSQLContainer<>("postgres:16-alpine") + .withDatabaseName("order_refund_test") + .withUsername("test") + .withPassword("test"); + + @DynamicPropertySource + static void configureDataSource(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", postgres::getJdbcUrl); + registry.add("spring.datasource.username", postgres::getUsername); + registry.add("spring.datasource.password", postgres::getPassword); + } + + /** + * Same test-only JSON producer template as {@code OrderSagaIntegrationTest} — the + * consumer factory reads values as plain strings and lets + * {@code StringJsonMessageConverter} bind by the listener's declared payload type, not + * a serialised class-name header. + */ + @TestConfiguration + static class RefundTestProducerConfig { + @Bean + KafkaTemplate objectKafkaTemplate(KafkaProperties kafkaProperties) { + Map props = new HashMap<>(kafkaProperties.buildProducerProperties(null)); + props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, JsonSerializer.class); + props.put(JsonSerializer.ADD_TYPE_INFO_HEADERS, false); + return new KafkaTemplate<>(new DefaultKafkaProducerFactory<>(props)); + } + } + + @Autowired OrderService orderService; + @Autowired OrderRepository orderRepository; + @Autowired OutboxRepository outboxRepository; + @Autowired KafkaTemplate kafkaTemplate; + + @MockBean CustomerClient customerClient; + + @BeforeEach + void setUp() { + TenantContext.setCurrentTenantId("test-tenant"); + } + + @AfterEach + void tearDown() { + TenantContext.clear(); + outboxRepository.deleteAll(); + orderRepository.deleteAll(); + } + + private Integer persistOrder(OrderStatus status) { + Order order = Order.builder() + .tenantId("test-tenant") + .correlationId(UUID.randomUUID().toString()) + .reference("ORD-" + UUID.randomUUID().toString().substring(0, 8)) + .totalAmount(BigDecimal.valueOf(150.00)) + .paymentMethod(PaymentMethod.CREDIT_CARD) + .customerId("cust-refund") + .status(status) + .build(); + return orderRepository.save(order).getOrderId(); + } + + @Test + @DisplayName("a real payment.refunded message flips the order to REFUNDED and writes the outbox row") + void paymentRefundedMessageAppliesRefund() throws Exception { + Integer orderId = persistOrder(OrderStatus.CONFIRMED); + + PaymentRefundedEvent event = new PaymentRefundedEvent( + "evt-int-refund-1", 1001, orderId, "ORD-INT", BigDecimal.valueOf(150.00), Instant.now(), 1); + kafkaTemplate.send("payment.refunded", String.valueOf(orderId), event); + + Thread.sleep(3000); // allow PaymentRefundConsumer to process + + Order refunded = orderRepository.findById(orderId).orElseThrow(); + assertThat(refunded.getStatus()).isEqualTo(OrderStatus.REFUNDED); + + // findAll() — not findPendingEvents() — because OutboxEventPublisher (fixedDelay=5s) + // may have already marked the row PUBLISHED within the 3-second sleep window. + List outboxRows = outboxRepository.findAll(); + assertThat(outboxRows) + .as("order.refunded outbox row must exist (PENDING or PUBLISHED)") + .anyMatch(o -> o.getTopic().equals("order.refunded") + && o.getCorrelationId().equals(refunded.getCorrelationId())); + } + + @Test + @DisplayName("redelivering the same message is idempotent — status stays REFUNDED, no second outbox row") + void redeliveredMessageIsIdempotent() throws Exception { + Integer orderId = persistOrder(OrderStatus.CONFIRMED); + String correlationId = orderRepository.findById(orderId).orElseThrow().getCorrelationId(); + + PaymentRefundedEvent event = new PaymentRefundedEvent( + "evt-int-refund-2", 1002, orderId, "ORD-INT-2", BigDecimal.valueOf(150.00), Instant.now(), 1); + kafkaTemplate.send("payment.refunded", String.valueOf(orderId), event); + Thread.sleep(3000); + + // Redeliver the identical event (at-least-once semantics) + kafkaTemplate.send("payment.refunded", String.valueOf(orderId), event); + Thread.sleep(3000); + + assertThat(orderRepository.findById(orderId).orElseThrow().getStatus()).isEqualTo(OrderStatus.REFUNDED); + + // findAll() — not findPendingEvents() — because OutboxEventPublisher (fixedDelay=5s) + // may have already marked the row PUBLISHED within the total 6-second sleep window. + long refundRowsForOrder = outboxRepository.findAll().stream() + .filter(o -> o.getTopic().equals("order.refunded") && o.getCorrelationId().equals(correlationId)) + .count(); + assertThat(refundRowsForOrder) + .as("second delivery must be a no-op — exactly one outbox row for this order") + .isEqualTo(1); + } +} diff --git a/order-service/src/test/java/code/with/vanilson/orderservice/kafka/PaymentRefundConsumerTest.java b/order-service/src/test/java/code/with/vanilson/orderservice/kafka/PaymentRefundConsumerTest.java new file mode 100644 index 00000000..4a982aaa --- /dev/null +++ b/order-service/src/test/java/code/with/vanilson/orderservice/kafka/PaymentRefundConsumerTest.java @@ -0,0 +1,69 @@ +package code.with.vanilson.orderservice.kafka; + +import code.with.vanilson.orderservice.OrderService; +import code.with.vanilson.orderservice.exception.OrderIllegalStateTransitionException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.kafka.support.Acknowledgment; + +import java.math.BigDecimal; +import java.time.Instant; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +/** + * PaymentRefundConsumerTest — Fase 6. + *

+ * Pins the manual-ack discipline: {@code ack.acknowledge()} fires only when + * {@code OrderService.applyRefund} succeeds; on failure the exception propagates + * un-caught (no ack) so Kafka's retry/DLQ handler takes over — the same contract + * {@code OrderSagaConsumer} uses for the choreography saga. + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("PaymentRefundConsumer — Fase 6 (unit)") +class PaymentRefundConsumerTest { + + @Mock private OrderService orderService; + @Mock private Acknowledgment acknowledgment; + + @InjectMocks + private PaymentRefundConsumer consumer; + + private PaymentRefundedEvent buildEvent() { + return new PaymentRefundedEvent( + "evt-refund-001", 42, 100, "ORD-REFUND-1", + BigDecimal.valueOf(150.00), Instant.now(), 1); + } + + @Test + @DisplayName("applies the refund via OrderService and acknowledges on success") + void appliesRefundAndAcknowledges() { + PaymentRefundedEvent event = buildEvent(); + + consumer.onPaymentRefunded(event, 0, 0L, acknowledgment); + + verify(orderService).applyRefund(eq(100), eq("evt-refund-001"), eq(event.occurredAt())); + verify(acknowledgment).acknowledge(); + } + + @Test + @DisplayName("does NOT acknowledge when applyRefund throws (illegal transition) — Kafka retries/DLQs") + void doesNotAcknowledgeOnFailure() { + PaymentRefundedEvent event = buildEvent(); + doThrow(new OrderIllegalStateTransitionException("illegal", "order.status.transition.invalid")) + .when(orderService).applyRefund(eq(100), eq("evt-refund-001"), eq(event.occurredAt())); + + assertThatThrownBy(() -> consumer.onPaymentRefunded(event, 0, 0L, acknowledgment)) + .isInstanceOf(OrderIllegalStateTransitionException.class); + + verify(acknowledgment, never()).acknowledge(); + } +} diff --git a/order-service/src/test/resources/features/order_refund.feature b/order-service/src/test/resources/features/order_refund.feature new file mode 100644 index 00000000..f9467915 --- /dev/null +++ b/order-service/src/test/resources/features/order_refund.feature @@ -0,0 +1,20 @@ +Feature: Order refund (Fase 6) + As the platform + I want a payment refund to transition the owning order to REFUNDED + So that stock can be restocked and the customer sees the correct status + + Scenario: A payment.refunded event refunds a confirmed order + Given a confirmed order exists for the refund + When a payment.refunded event arrives for that order + Then the refunded order's status becomes "REFUNDED" + And an order.refunded outbox event is written + + Scenario: A redelivered payment.refunded event is idempotent + Given an already-refunded order exists for the refund + When a payment.refunded event arrives for that order + Then no new outbox event is written + + Scenario: A payment.refunded event for a non-refundable order is rejected + Given a requested order exists for the refund + When a payment.refunded event arrives for that order + Then the refund is rejected as an illegal transition diff --git a/payment-service/pom.xml b/payment-service/pom.xml index ce9be307..9171951d 100644 --- a/payment-service/pom.xml +++ b/payment-service/pom.xml @@ -156,19 +156,19 @@ org.testcontainers testcontainers - 1.21.3 + 1.21.4 test org.testcontainers postgresql - 1.21.3 + 1.21.4 test org.testcontainers junit-jupiter - 1.21.3 + 1.21.4 test diff --git a/payment-service/src/main/java/code/with/vanilson/paymentservice/application/PaymentMapper.java b/payment-service/src/main/java/code/with/vanilson/paymentservice/application/PaymentMapper.java index 3373b901..53d38adf 100644 --- a/payment-service/src/main/java/code/with/vanilson/paymentservice/application/PaymentMapper.java +++ b/payment-service/src/main/java/code/with/vanilson/paymentservice/application/PaymentMapper.java @@ -60,7 +60,8 @@ public PaymentResponse toResponse(Payment payment) { payment.getPaymentMethod() != null ? payment.getPaymentMethod().name() : null, payment.getOrderId(), payment.getOrderReference(), - payment.getCreatedDate() + payment.getCreatedDate(), + payment.getStatus() != null ? payment.getStatus().name() : null ); } } diff --git a/payment-service/src/main/java/code/with/vanilson/paymentservice/application/PaymentResponse.java b/payment-service/src/main/java/code/with/vanilson/paymentservice/application/PaymentResponse.java index 767f5e3e..2d553664 100644 --- a/payment-service/src/main/java/code/with/vanilson/paymentservice/application/PaymentResponse.java +++ b/payment-service/src/main/java/code/with/vanilson/paymentservice/application/PaymentResponse.java @@ -23,6 +23,7 @@ public record PaymentResponse( String paymentMethod, Integer orderId, String orderReference, - LocalDateTime createdDate + LocalDateTime createdDate, + String status ) { } diff --git a/payment-service/src/main/java/code/with/vanilson/paymentservice/application/PaymentService.java b/payment-service/src/main/java/code/with/vanilson/paymentservice/application/PaymentService.java index ba556ee4..7184d8d0 100644 --- a/payment-service/src/main/java/code/with/vanilson/paymentservice/application/PaymentService.java +++ b/payment-service/src/main/java/code/with/vanilson/paymentservice/application/PaymentService.java @@ -1,18 +1,25 @@ package code.with.vanilson.paymentservice.application; import code.with.vanilson.paymentservice.domain.Payment; +import code.with.vanilson.paymentservice.domain.PaymentStatus; +import code.with.vanilson.paymentservice.exception.PaymentConflictException; import code.with.vanilson.paymentservice.exception.PaymentNotFoundException; +import code.with.vanilson.paymentservice.infrastructure.kafka.PaymentRefundedEvent; import code.with.vanilson.paymentservice.infrastructure.messaging.NotificationProducer; import code.with.vanilson.paymentservice.infrastructure.messaging.PaymentNotificationRequest; import code.with.vanilson.paymentservice.infrastructure.repository.PaymentRepository; import code.with.vanilson.tenantcontext.TenantContext; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.context.MessageSource; import org.springframework.context.i18n.LocaleContextHolder; +import org.springframework.kafka.core.KafkaTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.Instant; import java.util.List; +import java.util.UUID; import java.util.stream.Collectors; /** @@ -52,19 +59,24 @@ @Service public class PaymentService { + private static final String TOPIC_REFUNDED = "payment.refunded"; + private final PaymentRepository paymentRepository; private final PaymentMapper paymentMapper; private final NotificationProducer notificationProducer; private final MessageSource messageSource; + private final KafkaTemplate kafkaTemplate; public PaymentService(PaymentRepository paymentRepository, PaymentMapper paymentMapper, NotificationProducer notificationProducer, - MessageSource messageSource) { + MessageSource messageSource, + @Qualifier("paymentSagaKafkaTemplate") KafkaTemplate kafkaTemplate) { this.paymentRepository = paymentRepository; this.paymentMapper = paymentMapper; this.notificationProducer = notificationProducer; this.messageSource = messageSource; + this.kafkaTemplate = kafkaTemplate; } /** @@ -154,6 +166,51 @@ public PaymentResponse findById(Integer paymentId) { }); } + /** + * Refunds a payment (Fase 6 — basic refunds). ADMIN-triggered via + * {@code POST /api/v1/payments/{payment-id}/refund}. + *

+ * Guards: + *

    + *
  • 404 {@code payment.refund.not.found} — no such payment.
  • + *
  • 409 {@code payment.refund.invalid.status} — already REFUNDED (refund is one-shot; + * a second attempt is rejected before any write, so no CHECK constraint is needed).
  • + *
+ * On success: sets {@code REFUNDED}, saves, then publishes {@code payment.refunded} + * (Kafka publish outside the persisted state — same "commit first, publish after" + * discipline as {@link #processNewPayment}). order-service is the single authority on + * order status; this service only reports that the payment side is done. + * + * @param paymentId the payment to refund + * @return the updated PaymentResponse (status REFUNDED) + */ + @Transactional + public PaymentResponse refundPayment(Integer paymentId) { + Payment payment = paymentRepository.findById(paymentId) + .orElseThrow(() -> new PaymentNotFoundException( + messageSource.getMessage("payment.refund.not.found", + new Object[]{paymentId}, LocaleContextHolder.getLocale()), + "payment.refund.not.found")); + + if (payment.getStatus() == PaymentStatus.REFUNDED) { + throw new PaymentConflictException( + messageSource.getMessage("payment.refund.invalid.status", + new Object[]{paymentId}, LocaleContextHolder.getLocale()), + "payment.refund.invalid.status"); + } + + payment.setStatus(PaymentStatus.REFUNDED); + Payment saved = paymentRepository.save(payment); + + log.info(messageSource.getMessage("payment.log.refunded", + new Object[]{saved.getPaymentId(), saved.getOrderReference()}, + LocaleContextHolder.getLocale())); + + publishPaymentRefunded(saved); + + return paymentMapper.toResponse(saved); + } + // ------------------------------------------------------- // Private helpers // ------------------------------------------------------- @@ -214,4 +271,22 @@ private void publishPaymentNotification(PaymentRequest request, Payment savedPay ); notificationProducer.sendNotification(notification); } + + /** + * Publishes {@code payment.refunded} keyed by orderReference (same partition-key + * convention as {@code PaymentSagaConsumer}'s saga events, so consumers processing the + * same order stay ordered). + */ + private void publishPaymentRefunded(Payment payment) { + PaymentRefundedEvent event = new PaymentRefundedEvent( + UUID.randomUUID().toString(), + payment.getPaymentId(), + payment.getOrderId(), + payment.getOrderReference(), + payment.getAmount(), + Instant.now(), + 1 + ); + kafkaTemplate.send(TOPIC_REFUNDED, payment.getOrderReference(), event); + } } diff --git a/payment-service/src/main/java/code/with/vanilson/paymentservice/config/KafkaPaymentTopicConfig.java b/payment-service/src/main/java/code/with/vanilson/paymentservice/config/KafkaPaymentTopicConfig.java index b614d1c2..87da8dd9 100644 --- a/payment-service/src/main/java/code/with/vanilson/paymentservice/config/KafkaPaymentTopicConfig.java +++ b/payment-service/src/main/java/code/with/vanilson/paymentservice/config/KafkaPaymentTopicConfig.java @@ -38,4 +38,11 @@ public NewTopic paymentTopicDlq() { .replicas(1) .build(); } + + // Fase 6 note: "payment.refunded" is a SAGA topic (feeds order-service's REFUNDED + // transition) — per this codebase's established convention, saga topics are declared + // centrally in order-service's KafkaOrderTopicConfig (which already declares + // payment.authorized/payment.failed despite payment-service being their producer), not + // here. This file only owns payment-service's own non-saga topic (payment-topic, for + // email notifications). See KafkaOrderTopicConfig.paymentRefundedTopic(). } diff --git a/payment-service/src/main/java/code/with/vanilson/paymentservice/domain/Payment.java b/payment-service/src/main/java/code/with/vanilson/paymentservice/domain/Payment.java index 27dce270..621e3a6d 100644 --- a/payment-service/src/main/java/code/with/vanilson/paymentservice/domain/Payment.java +++ b/payment-service/src/main/java/code/with/vanilson/paymentservice/domain/Payment.java @@ -103,4 +103,14 @@ public class Payment { */ @Column(name = "tenant_id", nullable = false, updatable = false) private String tenantId; + + /** + * Fase 6 (basic refunds). {@code AUTHORIZED} by default (migration {@code V1.4} + * grandfathers every existing row); {@code REFUNDED} is terminal, set once by + * {@code PaymentService.refundPayment}. + */ + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + @Builder.Default + private PaymentStatus status = PaymentStatus.AUTHORIZED; } diff --git a/payment-service/src/main/java/code/with/vanilson/paymentservice/domain/PaymentStatus.java b/payment-service/src/main/java/code/with/vanilson/paymentservice/domain/PaymentStatus.java new file mode 100644 index 00000000..2246b44a --- /dev/null +++ b/payment-service/src/main/java/code/with/vanilson/paymentservice/domain/PaymentStatus.java @@ -0,0 +1,17 @@ +package code.with.vanilson.paymentservice.domain; + +/** + * PaymentStatus — Fase 6 (basic refunds). + *

+ * {@code AUTHORIZED} is the only status a payment is created with (existing rows are + * grandfathered to it — migration {@code V1.4}). {@code REFUNDED} is terminal: a payment + * can be refunded exactly once ({@code PaymentService.refundPayment} rejects a second + * attempt with 409). + * + * @author vamuhong + * @version 1.0 + */ +public enum PaymentStatus { + AUTHORIZED, + REFUNDED +} diff --git a/payment-service/src/main/java/code/with/vanilson/paymentservice/exception/PaymentConflictException.java b/payment-service/src/main/java/code/with/vanilson/paymentservice/exception/PaymentConflictException.java new file mode 100644 index 00000000..fbcb818e --- /dev/null +++ b/payment-service/src/main/java/code/with/vanilson/paymentservice/exception/PaymentConflictException.java @@ -0,0 +1,20 @@ +package code.with.vanilson.paymentservice.exception; + +import org.springframework.http.HttpStatus; + +/** + * PaymentConflictException + *

+ * General-purpose 409 for payment state conflicts (Fase 6: refunding an already-REFUNDED + * payment). Distinct from {@link DuplicatePaymentException}, which is specifically the + * idempotency-duplicate case on payment creation. + * + * @author vamuhong + * @version 1.0 + */ +public class PaymentConflictException extends PaymentBaseException { + + public PaymentConflictException(String resolvedMessage, String messageKey) { + super(resolvedMessage, HttpStatus.CONFLICT, messageKey); + } +} diff --git a/payment-service/src/main/java/code/with/vanilson/paymentservice/exception/PaymentGlobalExceptionHandler.java b/payment-service/src/main/java/code/with/vanilson/paymentservice/exception/PaymentGlobalExceptionHandler.java index 897f709b..3452e932 100644 --- a/payment-service/src/main/java/code/with/vanilson/paymentservice/exception/PaymentGlobalExceptionHandler.java +++ b/payment-service/src/main/java/code/with/vanilson/paymentservice/exception/PaymentGlobalExceptionHandler.java @@ -83,6 +83,14 @@ public ResponseEntity> handleDuplicatePayment( return buildResponse(ex.getHttpStatus(), ex.getMessage(), ex.getMessageKey(), request); } + @ExceptionHandler(PaymentConflictException.class) + public ResponseEntity> handlePaymentConflict( + PaymentConflictException ex, WebRequest request) { + log.warn("[PaymentExceptionHandler] Conflict: key=[{}] message=[{}]", + ex.getMessageKey(), ex.getMessage()); + return buildResponse(ex.getHttpStatus(), ex.getMessage(), ex.getMessageKey(), request); + } + /** * Handles Bean Validation errors (@Valid on request bodies). */ diff --git a/payment-service/src/main/java/code/with/vanilson/paymentservice/infrastructure/kafka/PaymentRefundedEvent.java b/payment-service/src/main/java/code/with/vanilson/paymentservice/infrastructure/kafka/PaymentRefundedEvent.java new file mode 100644 index 00000000..039497af --- /dev/null +++ b/payment-service/src/main/java/code/with/vanilson/paymentservice/infrastructure/kafka/PaymentRefundedEvent.java @@ -0,0 +1,23 @@ +package code.with.vanilson.paymentservice.infrastructure.kafka; + +import java.math.BigDecimal; +import java.time.Instant; + +/** + * PaymentRefundedEvent — Kafka event published by payment-service on a successful refund. + * Topic: payment.refunded + * Consumed by: order-service (transitions the order to REFUNDED via the payment saga extension) + * + * @author vamuhong + * @version 1.0 + */ +public record PaymentRefundedEvent( + String eventId, + Integer paymentId, + Integer orderId, + String orderReference, + BigDecimal amount, + Instant occurredAt, + int version +) { +} diff --git a/payment-service/src/main/java/code/with/vanilson/paymentservice/presentation/PaymentController.java b/payment-service/src/main/java/code/with/vanilson/paymentservice/presentation/PaymentController.java index cb3e4cfe..5bbae450 100644 --- a/payment-service/src/main/java/code/with/vanilson/paymentservice/presentation/PaymentController.java +++ b/payment-service/src/main/java/code/with/vanilson/paymentservice/presentation/PaymentController.java @@ -75,4 +75,15 @@ public ResponseEntity> findAll() { public ResponseEntity findById(@PathVariable("payment-id") Integer paymentId) { return ResponseEntity.ok(paymentService.findById(paymentId)); } + + /** + * Refunds a payment (Fase 6 — basic refunds). 404 if the payment does not exist; + * 409 if it was already refunded (one-shot). Publishes {@code payment.refunded} for + * order-service to pick up. + */ + @PreAuthorize("hasRole('ADMIN')") + @PostMapping("/{payment-id}/refund") + public ResponseEntity refundPayment(@PathVariable("payment-id") Integer paymentId) { + return ResponseEntity.ok(paymentService.refundPayment(paymentId)); + } } diff --git a/payment-service/src/main/resources/db/migration/V1.4__add_payment_status.sql b/payment-service/src/main/resources/db/migration/V1.4__add_payment_status.sql new file mode 100644 index 00000000..a8694b94 --- /dev/null +++ b/payment-service/src/main/resources/db/migration/V1.4__add_payment_status.sql @@ -0,0 +1,7 @@ +-- V1.4__add_payment_status.sql +-- Fase 6 (basic refunds). Every payment now carries a status: AUTHORIZED (default, +-- grandfathers every existing row) or REFUNDED (terminal, set once by +-- PaymentService.refundPayment). A second refund attempt is rejected with 409 before +-- any write, so this column never needs a CHECK constraint on its own. + +ALTER TABLE payment ADD COLUMN IF NOT EXISTS status VARCHAR(20) NOT NULL DEFAULT 'AUTHORIZED'; diff --git a/payment-service/src/main/resources/messages.properties b/payment-service/src/main/resources/messages.properties index 57a93030..c067b53a 100644 --- a/payment-service/src/main/resources/messages.properties +++ b/payment-service/src/main/resources/messages.properties @@ -44,9 +44,14 @@ payment.validation.order.required=Order ID is required. payment.validation.reference.required=Order reference is required. payment.validation.customer.required=Customer data is required. +# --- Refunds (Fase 6) --- +payment.refund.not.found=Payment with ID [{0}] not found. +payment.refund.invalid.status=Payment [{0}] has already been refunded. + # --- Logs --- payment.log.processing=Processing payment: orderId=[{0}] orderReference=[{1}] amount=[{2}] method=[{3}] payment.log.saved=Payment saved: paymentId=[{0}] orderId=[{1}] amount=[{2}] payment.log.notification.sending=Sending Kafka notification for: orderReference=[{0}] customerEmail=[{1}] payment.log.all.found=Payment listing: total=[{0}] payment.log.found.by.id=Payment found: paymentId=[{0}] +payment.log.refunded=Payment refunded: paymentId=[{0}] orderReference=[{1}] diff --git a/payment-service/src/test/java/code/with/vanilson/paymentservice/PaymentServiceTest.java b/payment-service/src/test/java/code/with/vanilson/paymentservice/PaymentServiceTest.java index 6e15f858..4beea1ed 100644 --- a/payment-service/src/test/java/code/with/vanilson/paymentservice/PaymentServiceTest.java +++ b/payment-service/src/test/java/code/with/vanilson/paymentservice/PaymentServiceTest.java @@ -62,6 +62,7 @@ class PaymentServiceTest { @Mock private PaymentMapper paymentMapper; @Mock private NotificationProducer notificationProducer; @Mock private MessageSource messageSource; + @Mock private org.springframework.kafka.core.KafkaTemplate kafkaTemplate; @InjectMocks private PaymentService paymentService; @@ -102,7 +103,7 @@ void setUp() { savedResponse = new PaymentResponse( 1, BigDecimal.valueOf(199.99), "CREDIT_CARD", - 42, "ORD-2024-001", LocalDateTime.now()); + 42, "ORD-2024-001", LocalDateTime.now(), "AUTHORIZED"); // MessageSource: return key itself so we can check calls without exact message strings when(messageSource.getMessage(anyString(), any(), any(Locale.class))) @@ -302,4 +303,66 @@ void shouldReturnEmptyListWhenNoPayments() { .isEmpty(); } } + + // ------------------------------------------------------- + // refundPayment (Fase 6) + // ------------------------------------------------------- + + @Nested + @DisplayName("refundPayment") + class RefundPayment { + + @Test + @DisplayName("should refund an AUTHORIZED payment, save REFUNDED, and publish payment.refunded") + void shouldRefundAuthorizedPaymentAndPublish() { + savedPayment.setStatus(code.with.vanilson.paymentservice.domain.PaymentStatus.AUTHORIZED); + when(paymentRepository.findById(1)).thenReturn(Optional.of(savedPayment)); + when(paymentRepository.save(any(Payment.class))).thenAnswer(inv -> inv.getArgument(0)); + when(paymentMapper.toResponse(any(Payment.class))).thenReturn(savedResponse); + + PaymentResponse result = paymentService.refundPayment(1); + + assertThat(result).isNotNull(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Payment.class); + verify(paymentRepository).save(captor.capture()); + assertThat(captor.getValue().getStatus()) + .as("Payment must be set to REFUNDED before saving") + .isEqualTo(code.with.vanilson.paymentservice.domain.PaymentStatus.REFUNDED); + + ArgumentCaptor eventCaptor = + ArgumentCaptor.forClass(code.with.vanilson.paymentservice.infrastructure.kafka.PaymentRefundedEvent.class); + verify(kafkaTemplate).send(org.mockito.ArgumentMatchers.eq("payment.refunded"), + org.mockito.ArgumentMatchers.eq("ORD-2024-001"), eventCaptor.capture()); + assertThat(eventCaptor.getValue().paymentId()).isEqualTo(1); + assertThat(eventCaptor.getValue().orderReference()).isEqualTo("ORD-2024-001"); + } + + @Test + @DisplayName("should throw PaymentNotFoundException (404) when payment does not exist") + void shouldThrowNotFoundWhenPaymentMissing() { + when(paymentRepository.findById(99)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> paymentService.refundPayment(99)) + .isInstanceOf(PaymentNotFoundException.class) + .hasMessageContaining("payment.refund.not.found"); + + verify(paymentRepository, never()).save(any()); + verify(kafkaTemplate, never()).send(anyString(), any(), any()); + } + + @Test + @DisplayName("should throw PaymentConflictException (409) when payment is already REFUNDED") + void shouldThrowConflictWhenAlreadyRefunded() { + savedPayment.setStatus(code.with.vanilson.paymentservice.domain.PaymentStatus.REFUNDED); + when(paymentRepository.findById(1)).thenReturn(Optional.of(savedPayment)); + + assertThatThrownBy(() -> paymentService.refundPayment(1)) + .isInstanceOf(code.with.vanilson.paymentservice.exception.PaymentConflictException.class) + .hasMessageContaining("payment.refund.invalid.status"); + + verify(paymentRepository, never()).save(any()); + verify(kafkaTemplate, never()).send(anyString(), any(), any()); + } + } } diff --git a/payment-service/src/test/java/code/with/vanilson/paymentservice/bdd/PaymentStepDefinitions.java b/payment-service/src/test/java/code/with/vanilson/paymentservice/bdd/PaymentStepDefinitions.java index b64fd87c..3978cfc2 100644 --- a/payment-service/src/test/java/code/with/vanilson/paymentservice/bdd/PaymentStepDefinitions.java +++ b/payment-service/src/test/java/code/with/vanilson/paymentservice/bdd/PaymentStepDefinitions.java @@ -7,6 +7,8 @@ import code.with.vanilson.paymentservice.domain.CustomerData; import code.with.vanilson.paymentservice.domain.Payment; import code.with.vanilson.paymentservice.domain.PaymentMethod; +import code.with.vanilson.paymentservice.domain.PaymentStatus; +import code.with.vanilson.paymentservice.exception.PaymentConflictException; import code.with.vanilson.paymentservice.infrastructure.messaging.NotificationProducer; import code.with.vanilson.paymentservice.infrastructure.repository.PaymentRepository; import code.with.vanilson.tenantcontext.TenantContext; @@ -36,6 +38,7 @@ public class PaymentStepDefinitions { private NotificationProducer notificationProducer; private PaymentMapper paymentMapper; private MessageSource messageSource; + private org.springframework.kafka.core.KafkaTemplate kafkaTemplate; private PaymentRequest paymentRequest; private Integer savedPaymentId; @@ -43,14 +46,20 @@ public class PaymentStepDefinitions { private boolean duplicateScenario; private String currentOrderRef; + // Refund scenario state (Fase 6) + private static final Integer REFUND_PAYMENT_ID = 42; + private Payment refundTargetPayment; + private code.with.vanilson.paymentservice.application.PaymentResponse refundResult; + @Before public void setUp() { paymentRepository = Mockito.mock(PaymentRepository.class); notificationProducer = Mockito.mock(NotificationProducer.class); paymentMapper = Mockito.mock(PaymentMapper.class); messageSource = Mockito.mock(MessageSource.class); + kafkaTemplate = Mockito.mock(org.springframework.kafka.core.KafkaTemplate.class); - paymentService = new PaymentService(paymentRepository, paymentMapper, notificationProducer, messageSource); + paymentService = new PaymentService(paymentRepository, paymentMapper, notificationProducer, messageSource, kafkaTemplate); lenient().when(messageSource.getMessage(anyString(), any(), any(Locale.class))) .thenAnswer(inv -> inv.getArgument(0)); @@ -84,7 +93,7 @@ public void a_valid_payment_request(String orderRef, double amount) { .build(); PaymentResponse response = new PaymentResponse( - 1, BigDecimal.valueOf(amount), "CREDIT_CARD", 1, orderRef, LocalDateTime.now()); + 1, BigDecimal.valueOf(amount), "CREDIT_CARD", 1, orderRef, LocalDateTime.now(), "AUTHORIZED"); lenient().when(paymentMapper.toPayment(any())).thenReturn(payment); lenient().when(paymentMapper.toResponse(any())).thenReturn(response); @@ -149,4 +158,68 @@ public void the_system_should_reject_duplicate() { assertThat(savedPaymentId).isEqualTo(1); verify(paymentRepository, never()).save(any(Payment.class)); } + + // ------------------------------------------------------- + // Refund (Fase 6) + // ------------------------------------------------------- + + @Given("an authorized payment exists for order {string}") + public void an_authorized_payment_exists(String orderRef) { + refundTargetPayment = Payment.builder() + .paymentId(REFUND_PAYMENT_ID) + .orderId(7) + .orderReference(orderRef) + .amount(BigDecimal.valueOf(75.00)) + .paymentMethod(PaymentMethod.CREDIT_CARD) + .status(PaymentStatus.AUTHORIZED) + .build(); + when(paymentRepository.findById(REFUND_PAYMENT_ID)).thenReturn(Optional.of(refundTargetPayment)); + when(paymentRepository.save(any(Payment.class))).thenAnswer(inv -> inv.getArgument(0)); + when(paymentMapper.toResponse(any(Payment.class))).thenAnswer(inv -> { + Payment p = inv.getArgument(0); + return new code.with.vanilson.paymentservice.application.PaymentResponse( + p.getPaymentId(), p.getAmount(), p.getPaymentMethod().name(), + p.getOrderId(), p.getOrderReference(), LocalDateTime.now(), p.getStatus().name()); + }); + } + + @Given("an already-refunded payment exists for order {string}") + public void an_already_refunded_payment_exists(String orderRef) { + refundTargetPayment = Payment.builder() + .paymentId(REFUND_PAYMENT_ID) + .orderId(7) + .orderReference(orderRef) + .amount(BigDecimal.valueOf(75.00)) + .paymentMethod(PaymentMethod.CREDIT_CARD) + .status(PaymentStatus.REFUNDED) + .build(); + when(paymentRepository.findById(REFUND_PAYMENT_ID)).thenReturn(Optional.of(refundTargetPayment)); + } + + @When("the payment is refunded") + public void the_payment_is_refunded() { + try { + refundResult = paymentService.refundPayment(REFUND_PAYMENT_ID); + } catch (Exception e) { + caughtException = e; + } + } + + @Then("the payment status becomes {string}") + public void the_payment_status_becomes(String status) { + assertThat(caughtException).isNull(); + assertThat(refundResult).isNotNull(); + assertThat(refundResult.status()).isEqualTo(status); + } + + @Then("a payment.refunded event is published") + public void a_payment_refunded_event_is_published() { + verify(kafkaTemplate).send(eq("payment.refunded"), anyString(), any()); + } + + @Then("the refund is rejected as already processed") + public void the_refund_is_rejected_as_already_processed() { + assertThat(caughtException).isInstanceOf(PaymentConflictException.class); + verify(paymentRepository, never()).save(any(Payment.class)); + } } diff --git a/payment-service/src/test/java/code/with/vanilson/paymentservice/integration/PaymentRefundIntegrationTest.java b/payment-service/src/test/java/code/with/vanilson/paymentservice/integration/PaymentRefundIntegrationTest.java new file mode 100644 index 00000000..b545fc04 --- /dev/null +++ b/payment-service/src/test/java/code/with/vanilson/paymentservice/integration/PaymentRefundIntegrationTest.java @@ -0,0 +1,137 @@ +package code.with.vanilson.paymentservice.integration; + +import code.with.vanilson.paymentservice.application.PaymentResponse; +import code.with.vanilson.paymentservice.application.PaymentService; +import code.with.vanilson.paymentservice.domain.Payment; +import code.with.vanilson.paymentservice.domain.PaymentMethod; +import code.with.vanilson.paymentservice.domain.PaymentStatus; +import code.with.vanilson.paymentservice.exception.PaymentConflictException; +import code.with.vanilson.paymentservice.exception.PaymentNotFoundException; +import code.with.vanilson.paymentservice.infrastructure.repository.PaymentRepository; +import code.with.vanilson.tenantcontext.TenantContext; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.kafka.test.context.EmbeddedKafka; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.math.BigDecimal; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * PaymentRefundIntegrationTest — Fase 6 (basic refunds) against real PostgreSQL. + *

+ * Proves the {@code V1.4} migration end-to-end: a payment persisted before the refund + * carries {@code status=AUTHORIZED} (the column default), a refund flips it to + * {@code REFUNDED} in the real row, and a second refund attempt is rejected with 409 + * without mutating the already-refunded row. + */ +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.NONE, + properties = { + "spring.cloud.config.enabled=false", + "spring.config.import=", + "spring.kafka.bootstrap-servers=${spring.embedded.kafka.brokers}", + "spring.kafka.consumer.group-id=payment-refund-integration-test", + "application.security.jwt.secret-key=dGVzdFNlY3JldEtleUZvclRlc3RpbmdPbmx5Tm90Rm9yUHJvZHVjdGlvblVzYWdlMDAwMDAwMDAwMDAwMDAwMA==" + }) +@Testcontainers +@EmbeddedKafka( + partitions = 1, + topics = {"inventory.reserved", "payment.authorized", "payment.failed", "payment-topic", "payment.refunded"}, + brokerProperties = { + "auto.create.topics.enable=true", + "offsets.topic.num.partitions=1", + "transaction.state.log.num.partitions=1", + "transaction.state.log.replication.factor=1", + "transaction.state.log.min.isr=1", + "log.index.size.max.bytes=1048576" + }) +@DisplayName("Payment Refund Integration — Fase 6 (Testcontainers PostgreSQL + EmbeddedKafka)") +class PaymentRefundIntegrationTest { + + @Container + static PostgreSQLContainer postgres = + new PostgreSQLContainer<>("postgres:16-alpine") + .withDatabaseName("payment_refund_test") + .withUsername("test") + .withPassword("test"); + + @DynamicPropertySource + static void configureDataSource(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", postgres::getJdbcUrl); + registry.add("spring.datasource.username", postgres::getUsername); + registry.add("spring.datasource.password", postgres::getPassword); + } + + private static final String TENANT = "test-tenant"; + + @Autowired PaymentService paymentService; + @Autowired PaymentRepository paymentRepository; + + @BeforeEach + void setUp() { + TenantContext.setCurrentTenantId(TENANT); + } + + @AfterEach + void tearDown() { + paymentRepository.deleteAll(); + TenantContext.clear(); + } + + private Integer persistAuthorizedPayment(String orderReference) { + Payment payment = Payment.builder() + .amount(BigDecimal.valueOf(150.00)) + .paymentMethod(PaymentMethod.CREDIT_CARD) + .orderId(77) + .orderReference(orderReference) + .idempotencyKey("payment:" + orderReference) + .tenantId(TENANT) + .build(); // status defaults to AUTHORIZED via @Builder.Default + return paymentRepository.save(payment).getPaymentId(); + } + + @Test + @DisplayName("refunding an AUTHORIZED payment flips status to REFUNDED in the real row") + void refundFlipsStatusInDatabase() { + Integer paymentId = persistAuthorizedPayment("ORD-REFUND-001"); + assertThat(paymentRepository.findById(paymentId).orElseThrow().getStatus()) + .isEqualTo(PaymentStatus.AUTHORIZED); + + PaymentResponse response = paymentService.refundPayment(paymentId); + + assertThat(response.status()).isEqualTo("REFUNDED"); + assertThat(paymentRepository.findById(paymentId).orElseThrow().getStatus()) + .isEqualTo(PaymentStatus.REFUNDED); + } + + @Test + @DisplayName("a second refund attempt is rejected with 409 and does not re-mutate the row") + void secondRefundIsRejected() { + Integer paymentId = persistAuthorizedPayment("ORD-REFUND-002"); + paymentService.refundPayment(paymentId); + + assertThatThrownBy(() -> paymentService.refundPayment(paymentId)) + .isInstanceOf(PaymentConflictException.class); + + assertThat(paymentRepository.findById(paymentId).orElseThrow().getStatus()) + .isEqualTo(PaymentStatus.REFUNDED); + } + + @Test + @DisplayName("refunding a non-existent payment throws 404") + void refundingMissingPaymentThrows404() { + assertThatThrownBy(() -> paymentService.refundPayment(999_999)) + .isInstanceOf(PaymentNotFoundException.class); + } +} diff --git a/payment-service/src/test/java/code/with/vanilson/paymentservice/presentation/PaymentControllerSecurityTest.java b/payment-service/src/test/java/code/with/vanilson/paymentservice/presentation/PaymentControllerSecurityTest.java index 6905ecec..bc9ede70 100644 --- a/payment-service/src/test/java/code/with/vanilson/paymentservice/presentation/PaymentControllerSecurityTest.java +++ b/payment-service/src/test/java/code/with/vanilson/paymentservice/presentation/PaymentControllerSecurityTest.java @@ -85,7 +85,7 @@ private static UsernamePasswordAuthenticationToken authAs(long userId, String ro } private PaymentResponse samplePayment() { - return new PaymentResponse(1, BigDecimal.valueOf(100), "CREDIT_CARD", 1, "REF-001", LocalDateTime.now()); + return new PaymentResponse(1, BigDecimal.valueOf(100), "CREDIT_CARD", 1, "REF-001", LocalDateTime.now(), "AUTHORIZED"); } // ------------------------------------------------------- @@ -213,4 +213,49 @@ void admin_can_access_payment() throws Exception { .andExpect(status().isOk()); } } + + // ------------------------------------------------------- + // POST /payments/{id}/refund — ADMIN only + // ------------------------------------------------------- + + @Nested + @DisplayName("POST /payments/{id}/refund — ADMIN only") + class RefundPayment { + + @Test + @DisplayName("401 when unauthenticated") + void unauthenticated_gets_401() throws Exception { + mockMvc.perform(post(BASE + "/1/refund").header(TENANT_HDR, TENANT_VAL)) + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("403 when USER tries to refund") + void user_cannot_refund() throws Exception { + mockMvc.perform(post(BASE + "/1/refund") + .header(TENANT_HDR, TENANT_VAL) + .with(authentication(authAs(42L, "USER")))) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("403 when SELLER tries to refund") + void seller_cannot_refund() throws Exception { + mockMvc.perform(post(BASE + "/1/refund") + .header(TENANT_HDR, TENANT_VAL) + .with(authentication(authAs(5L, "SELLER")))) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("200 when ADMIN refunds a payment") + void admin_can_refund_payment() throws Exception { + when(paymentService.refundPayment(anyInt())).thenReturn(samplePayment()); + + mockMvc.perform(post(BASE + "/1/refund") + .header(TENANT_HDR, TENANT_VAL) + .with(authentication(authAs(1L, "ADMIN")))) + .andExpect(status().isOk()); + } + } } diff --git a/payment-service/src/test/java/code/with/vanilson/paymentservice/presentation/PaymentControllerTest.java b/payment-service/src/test/java/code/with/vanilson/paymentservice/presentation/PaymentControllerTest.java index c9ccfcf8..e69f3f16 100644 --- a/payment-service/src/test/java/code/with/vanilson/paymentservice/presentation/PaymentControllerTest.java +++ b/payment-service/src/test/java/code/with/vanilson/paymentservice/presentation/PaymentControllerTest.java @@ -5,6 +5,7 @@ import code.with.vanilson.paymentservice.application.PaymentService; import code.with.vanilson.paymentservice.domain.CustomerData; import code.with.vanilson.paymentservice.domain.PaymentMethod; +import code.with.vanilson.paymentservice.exception.PaymentConflictException; import code.with.vanilson.paymentservice.exception.PaymentNotFoundException; import code.with.vanilson.tenantcontext.TenantHibernateFilterActivator; import com.fasterxml.jackson.databind.ObjectMapper; @@ -97,7 +98,8 @@ void setUp() { "CREDIT_CARD", 42, "ORD-2024-001", - LocalDateTime.of(2024, 1, 15, 10, 30) + LocalDateTime.of(2024, 1, 15, 10, 30), + "AUTHORIZED" ); // Return the message key itself — keeps assertions decoupled from actual message text. @@ -323,4 +325,52 @@ void shouldIncludeTimestampIn404ErrorBody() throws Exception { .andExpect(jsonPath("$.status").value(404)); } } + + // ------------------------------------------------------- + // POST /api/v1/payments/{payment-id}/refund + // ------------------------------------------------------- + + @Nested + @DisplayName("POST /api/v1/payments/{payment-id}/refund") + class RefundPayment { + + @Test + @DisplayName("should return 200 with REFUNDED payment when refund succeeds") + void shouldReturn200OnSuccessfulRefund() throws Exception { + PaymentResponse refunded = new PaymentResponse( + 1, BigDecimal.valueOf(199.99), "CREDIT_CARD", 42, "ORD-2024-001", + LocalDateTime.now(), "REFUNDED"); + when(paymentService.refundPayment(1)).thenReturn(refunded); + + mockMvc.perform(post("/api/v1/payments/1/refund") + .header("X-Tenant-ID", "test-tenant-123")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.paymentId").value(1)) + .andExpect(jsonPath("$.status").value("REFUNDED")); + } + + @Test + @DisplayName("should return 404 when payment does not exist") + void shouldReturn404WhenPaymentMissing() throws Exception { + when(paymentService.refundPayment(999)) + .thenThrow(new PaymentNotFoundException("Not found", "payment.refund.not.found")); + + mockMvc.perform(post("/api/v1/payments/999/refund") + .header("X-Tenant-ID", "test-tenant-123")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.errorCode").value("payment.refund.not.found")); + } + + @Test + @DisplayName("should return 409 when payment is already refunded") + void shouldReturn409WhenAlreadyRefunded() throws Exception { + when(paymentService.refundPayment(1)) + .thenThrow(new PaymentConflictException("Already refunded", "payment.refund.invalid.status")); + + mockMvc.perform(post("/api/v1/payments/1/refund") + .header("X-Tenant-ID", "test-tenant-123")) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.errorCode").value("payment.refund.invalid.status")); + } + } } diff --git a/payment-service/src/test/resources/features/payment.feature b/payment-service/src/test/resources/features/payment.feature index 5fe4904f..ada0275f 100644 --- a/payment-service/src/test/resources/features/payment.feature +++ b/payment-service/src/test/resources/features/payment.feature @@ -15,3 +15,14 @@ Feature: Process Payments And the payment has already been processed successfully When the payment is submitted Then the system should reject the duplicate payment request + + Scenario: Refund an authorized payment + Given an authorized payment exists for order "ORD-PAY-REFUND" + When the payment is refunded + Then the payment status becomes "REFUNDED" + And a payment.refunded event is published + + Scenario: Reject refunding an already-refunded payment + Given an already-refunded payment exists for order "ORD-PAY-REREFUND" + When the payment is refunded + Then the refund is rejected as already processed diff --git a/product-service/src/main/java/code/with/vanilson/productservice/kafka/OrderRefundedEvent.java b/product-service/src/main/java/code/with/vanilson/productservice/kafka/OrderRefundedEvent.java new file mode 100644 index 00000000..68a3830d --- /dev/null +++ b/product-service/src/main/java/code/with/vanilson/productservice/kafka/OrderRefundedEvent.java @@ -0,0 +1,21 @@ +package code.with.vanilson.productservice.kafka; + +import java.time.Instant; + +/** + * OrderRefundedEvent — local DTO consumed from topic: order.refunded + * Published via order-service's outbox after a payment refund is applied. Used by + * {@link RefundRestockConsumer} to restore reserved stock for the refunded order. + * Field names mirror order-service's producer-side record exactly — bound by + * {@code StringJsonMessageConverter} via the listener's declared payload type. + * + * @author vamuhong + * @version 1.0 + */ +public record OrderRefundedEvent( + String eventId, + String correlationId, + String orderReference, + Instant occurredAt, + int version +) {} diff --git a/product-service/src/main/java/code/with/vanilson/productservice/kafka/RefundRestockConsumer.java b/product-service/src/main/java/code/with/vanilson/productservice/kafka/RefundRestockConsumer.java new file mode 100644 index 00000000..2334b6a2 --- /dev/null +++ b/product-service/src/main/java/code/with/vanilson/productservice/kafka/RefundRestockConsumer.java @@ -0,0 +1,95 @@ +package code.with.vanilson.productservice.kafka; + +import code.with.vanilson.productservice.domain.InventoryReservation; +import code.with.vanilson.productservice.domain.InventoryReservationRepository; +import code.with.vanilson.productservice.ProductRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.support.Acknowledgment; +import org.springframework.kafka.support.KafkaHeaders; +import org.springframework.messaging.handler.annotation.Header; +import org.springframework.messaging.handler.annotation.Payload; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.slf4j.MDC; +import io.micrometer.core.instrument.MeterRegistry; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * RefundRestockConsumer — Fase 6 (basic refunds), clone of + * {@link InventoryCompensationConsumer}'s semantics. + *

+ * Consumes {@code order.refunded} (order-service's outbox, after a payment refund is + * applied) and restores the quantities that were reserved for that order — + * RESERVED→RELEASED, idempotent via the same reservation-status check the compensation + * consumer uses (redelivery of the same event is a no-op once every row is RELEASED). + * Terminal step — unlike the compensation path, restock does not publish a follow-up event. + * + * @author vamuhong + * @version 1.0 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class RefundRestockConsumer { + + private final InventoryReservationRepository reservationRepository; + private final ProductRepository productRepository; + private final MeterRegistry meterRegistry; + + @KafkaListener( + topics = "order.refunded", + groupId = "refund-restock-group", + containerFactory = "inventoryKafkaListenerContainerFactory") + @Transactional + public void onOrderRefunded( + @Payload OrderRefundedEvent event, + @Header(KafkaHeaders.RECEIVED_PARTITION) int partition, + @Header(KafkaHeaders.OFFSET) long offset, + Acknowledgment ack) { + + try { + MDC.put("correlationId", event.correlationId()); + MDC.put("sagaStep", "refund-restock"); + + log.info("[RefundRestock] order.refunded received: correlationId=[{}] partition=[{}] offset=[{}]", + event.correlationId(), partition, offset); + + List reservations = reservationRepository + .findByCorrelationIdAndStatus(event.correlationId(), InventoryReservation.ReservationStatus.RESERVED); + + if (reservations.isEmpty()) { + log.info("[RefundRestock] No RESERVED records for correlationId=[{}] — already released or never reserved", + event.correlationId()); + ack.acknowledge(); + return; + } + + for (InventoryReservation reservation : reservations) { + productRepository.findById(reservation.getProductId()).ifPresent(product -> { + double restored = product.getAvailableQuantity() + reservation.getReservedQuantity(); + product.setAvailableQuantity(restored); + productRepository.save(product); + log.info("[RefundRestock] Stock restored: productId=[{}] qty=[+{}] newTotal=[{}] correlationId=[{}]", + product.getId(), reservation.getReservedQuantity(), restored, event.correlationId()); + }); + + reservation.setStatus(InventoryReservation.ReservationStatus.RELEASED); + reservation.setReleasedAt(LocalDateTime.now()); + reservationRepository.save(reservation); + } + + log.info("[RefundRestock] Restocked {} reservations for correlationId=[{}]", + reservations.size(), event.correlationId()); + + meterRegistry.counter("saga.step.completed", "step", "refund-restock", "outcome", "success").increment(); + + ack.acknowledge(); + } finally { + MDC.clear(); + } + } +} diff --git a/product-service/src/test/java/code/with/vanilson/productservice/bdd/CucumberTestRunner.java b/product-service/src/test/java/code/with/vanilson/productservice/bdd/CucumberTestRunner.java index 0d528b0d..f3676276 100644 --- a/product-service/src/test/java/code/with/vanilson/productservice/bdd/CucumberTestRunner.java +++ b/product-service/src/test/java/code/with/vanilson/productservice/bdd/CucumberTestRunner.java @@ -31,6 +31,7 @@ @SelectClasspathResource("features/seller_approval_guard.feature") @SelectClasspathResource("features/product_status.feature") @SelectClasspathResource("features/category_management.feature") +@SelectClasspathResource("features/refund_restock.feature") @ConfigurationParameter(key = Constants.GLUE_PROPERTY_NAME, value = "code.with.vanilson.productservice.bdd") @ConfigurationParameter(key = Constants.PLUGIN_PROPERTY_NAME, diff --git a/product-service/src/test/java/code/with/vanilson/productservice/bdd/RefundRestockSteps.java b/product-service/src/test/java/code/with/vanilson/productservice/bdd/RefundRestockSteps.java new file mode 100644 index 00000000..dbf59ef9 --- /dev/null +++ b/product-service/src/test/java/code/with/vanilson/productservice/bdd/RefundRestockSteps.java @@ -0,0 +1,107 @@ +package code.with.vanilson.productservice.bdd; + +import code.with.vanilson.productservice.Product; +import code.with.vanilson.productservice.ProductRepository; +import code.with.vanilson.productservice.domain.InventoryReservation; +import code.with.vanilson.productservice.domain.InventoryReservationRepository; +import code.with.vanilson.productservice.kafka.OrderRefundedEvent; +import code.with.vanilson.productservice.kafka.RefundRestockConsumer; +import io.cucumber.java.Before; +import io.cucumber.java.en.Given; +import io.cucumber.java.en.Then; +import io.cucumber.java.en.When; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.mockito.Mockito; +import org.springframework.kafka.support.Acknowledgment; + +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * RefundRestockSteps — BDD Step Definitions (Fase 6). + *

+ * POJO + Mockito, like the sibling step classes in this glue package. The REAL + * {@link RefundRestockConsumer} runs against mocked repositories, exercising the same + * restock logic as production without a broker or database. + */ +public class RefundRestockSteps { + + private static final String CORRELATION_ID = "corr-bdd-refund-001"; + private static final Integer PRODUCT_ID = 1; + + private ProductRepository productRepository; + private InventoryReservationRepository reservationRepository; + private RefundRestockConsumer consumer; + private Acknowledgment acknowledgment; + + private Product product; + private InventoryReservation reservation; + + @Before + public void setUp() { + productRepository = Mockito.mock(ProductRepository.class); + reservationRepository = Mockito.mock(InventoryReservationRepository.class); + acknowledgment = Mockito.mock(Acknowledgment.class); + + consumer = new RefundRestockConsumer(reservationRepository, productRepository, new SimpleMeterRegistry()); + + product = new Product(PRODUCT_ID, "Laptop", "Gaming Laptop", 10.0, BigDecimal.valueOf(1200)); + lenient().when(productRepository.findById(PRODUCT_ID)).thenReturn(Optional.of(product)); + lenient().when(productRepository.save(any(Product.class))).thenAnswer(inv -> inv.getArgument(0)); + } + + @Given("a reserved product with quantity {int} for the refund correlation") + public void a_reserved_product_with_quantity(int quantity) { + reservation = InventoryReservation.builder() + .id(1L).correlationId(CORRELATION_ID).productId(PRODUCT_ID) + .reservedQuantity(quantity).status(InventoryReservation.ReservationStatus.RESERVED) + .createdAt(LocalDateTime.now()).build(); + when(reservationRepository.findByCorrelationIdAndStatus( + CORRELATION_ID, InventoryReservation.ReservationStatus.RESERVED)) + .thenReturn(List.of(reservation)); + lenient().when(reservationRepository.save(any(InventoryReservation.class))) + .thenAnswer(inv -> inv.getArgument(0)); + } + + @Given("no RESERVED records exist for the refund correlation") + public void no_reserved_records_exist() { + when(reservationRepository.findByCorrelationIdAndStatus( + CORRELATION_ID, InventoryReservation.ReservationStatus.RESERVED)) + .thenReturn(List.of()); + } + + @When("the order.refunded event is consumed") + public void the_order_refunded_event_is_consumed() { + OrderRefundedEvent event = new OrderRefundedEvent( + "evt-bdd-refund-001", CORRELATION_ID, "ORD-BDD-REFUND-001", Instant.now(), 1); + consumer.onOrderRefunded(event, 0, 0L, acknowledgment); + } + + @Then("the product stock is restored by {int}") + public void the_product_stock_is_restored_by(int quantity) { + assertThat(product.getAvailableQuantity()).isEqualTo(10.0 + quantity); + verify(acknowledgment).acknowledge(); + } + + @Then("the reservation is marked RELEASED") + public void the_reservation_is_marked_released() { + assertThat(reservation.getStatus()).isEqualTo(InventoryReservation.ReservationStatus.RELEASED); + assertThat(reservation.getReleasedAt()).isNotNull(); + } + + @Then("the product stock is not touched") + public void the_product_stock_is_not_touched() { + verify(productRepository, never()).save(any()); + verify(acknowledgment).acknowledge(); + } +} diff --git a/product-service/src/test/java/code/with/vanilson/productservice/integration/RefundRestockIntegrationTest.java b/product-service/src/test/java/code/with/vanilson/productservice/integration/RefundRestockIntegrationTest.java new file mode 100644 index 00000000..2b30bf54 --- /dev/null +++ b/product-service/src/test/java/code/with/vanilson/productservice/integration/RefundRestockIntegrationTest.java @@ -0,0 +1,191 @@ +package code.with.vanilson.productservice.integration; + +import code.with.vanilson.productservice.Product; +import code.with.vanilson.productservice.ProductRepository; +import code.with.vanilson.productservice.kafka.OrderRefundedEvent; +import code.with.vanilson.productservice.kafka.PaymentFailedEvent; +import code.with.vanilson.productservice.domain.InventoryReservation; +import code.with.vanilson.productservice.domain.InventoryReservationRepository; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.kafka.test.context.EmbeddedKafka; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * RefundRestockIntegrationTest — Fase 6 (basic refunds), clone of + * {@link InventoryCompensationIntegrationTest}'s structure for the {@code order.refunded} + * trigger. Also proves the pre-existing compensation path ({@code payment.failed} → + * {@code InventoryCompensationConsumer}) is untouched — both consumers share + * {@code inventoryKafkaListenerContainerFactory}, so a wiring mistake in one could silently + * break the other. + */ +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = { + "spring.cloud.config.enabled=false", + "spring.cloud.config.import-check.enabled=false", + "spring.config.import=optional:configserver:", + "spring.kafka.bootstrap-servers=${spring.embedded.kafka.brokers}", + "spring.kafka.consumer.group-id=test-refund-restock", + "application.security.jwt.secret-key=404E635266556A586E3272357538782F413F4428472B4B6250645367566B5970", + "management.health.redis.enabled=false", + "spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.data.redis.RedisReactiveAutoConfiguration" + }) +@Testcontainers +@EmbeddedKafka( + partitions = 1, + brokerProperties = { + "auto.create.topics.enable=true", + "log.retention.hours=1", + "offsets.topic.num.partitions=1", + "transaction.state.log.num.partitions=1", + "transaction.state.log.replication.factor=1", + "transaction.state.log.min.isr=1", + "log.index.size.max.bytes=1048576" + }) +@ActiveProfiles("test") +@DisplayName("Refund Restock Integration Tests (Fase 6)") +class RefundRestockIntegrationTest { + + @Container + static PostgreSQLContainer postgres = + new PostgreSQLContainer<>("postgres:16-alpine") + .withDatabaseName("product_refund_restock_test") + .withUsername("test") + .withPassword("test"); + + @DynamicPropertySource + static void configureContainers(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", postgres::getJdbcUrl); + registry.add("spring.datasource.username", postgres::getUsername); + registry.add("spring.datasource.password", postgres::getPassword); + } + + @MockBean StringRedisTemplate stringRedisTemplate; + @MockBean RedisConnectionFactory redisConnectionFactory; + + @Autowired ProductRepository productRepository; + @Autowired InventoryReservationRepository reservationRepository; + @Autowired KafkaTemplate kafkaTemplate; + + private Product laptop; + + @BeforeEach + void setUp() { + productRepository.deleteAll(); + reservationRepository.deleteAll(); + + laptop = productRepository.save(Product.builder() + .name("Laptop") + .description("Gaming Laptop") + .availableQuantity(10.0) + .price(BigDecimal.valueOf(1200)) + .tenantId("default") + .createdBy("system") + .build()); + } + + @AfterEach + void tearDown() { + productRepository.deleteAll(); + reservationRepository.deleteAll(); + } + + @Test + @DisplayName("order.refunded restores stock and marks the reservation RELEASED") + void shouldRestockOnOrderRefunded() throws Exception { + String correlationId = "corr-refund-int-001"; + reservationRepository.save(InventoryReservation.builder() + .correlationId(correlationId) + .productId(laptop.getId()) + .reservedQuantity(3) + .status(InventoryReservation.ReservationStatus.RESERVED) + .createdAt(LocalDateTime.now()) + .build()); + + OrderRefundedEvent event = new OrderRefundedEvent( + "evt-refund-int-001", correlationId, "ORD-REFUND-INT-001", Instant.now(), 1); + kafkaTemplate.send("order.refunded", correlationId, event); + + Thread.sleep(2000); + + Product restored = productRepository.findById(laptop.getId()).orElseThrow(); + assertThat(restored.getAvailableQuantity()).isEqualTo(13.0); + + List released = reservationRepository + .findByCorrelationIdAndStatus(correlationId, InventoryReservation.ReservationStatus.RELEASED); + assertThat(released).hasSize(1); + assertThat(released.get(0).getReleasedAt()).isNotNull(); + } + + @Test + @DisplayName("redelivering order.refunded is idempotent — stock restored once") + void shouldNotDoubleRestockOnDuplicateEvent() throws Exception { + String correlationId = "corr-refund-int-002"; + reservationRepository.save(InventoryReservation.builder() + .correlationId(correlationId) + .productId(laptop.getId()) + .reservedQuantity(2) + .status(InventoryReservation.ReservationStatus.RESERVED) + .createdAt(LocalDateTime.now()) + .build()); + + OrderRefundedEvent event = new OrderRefundedEvent( + "evt-refund-int-002", correlationId, "ORD-REFUND-INT-002", Instant.now(), 1); + kafkaTemplate.send("order.refunded", correlationId, event); + Thread.sleep(1500); + kafkaTemplate.send("order.refunded", correlationId, event); + Thread.sleep(1500); + + Product restored = productRepository.findById(laptop.getId()).orElseThrow(); + assertThat(restored.getAvailableQuantity()).isEqualTo(12.0); + + List released = reservationRepository + .findByCorrelationIdAndStatus(correlationId, InventoryReservation.ReservationStatus.RELEASED); + assertThat(released).hasSize(1); + } + + @Test + @DisplayName("regression: the payment.failed compensation path is unaffected by the new listener") + void compensationPathStillWorks() throws Exception { + String correlationId = "corr-comp-regression-001"; + reservationRepository.save(InventoryReservation.builder() + .correlationId(correlationId) + .productId(laptop.getId()) + .reservedQuantity(4) + .status(InventoryReservation.ReservationStatus.RESERVED) + .createdAt(LocalDateTime.now()) + .build()); + + PaymentFailedEvent failedEvent = new PaymentFailedEvent( + "evt-fail-regression-001", correlationId, "ORD-COMP-REGRESSION-001", + "Insufficient funds", Instant.now(), 1); + kafkaTemplate.send("payment.failed", correlationId, failedEvent); + + Thread.sleep(2000); + + Product restored = productRepository.findById(laptop.getId()).orElseThrow(); + assertThat(restored.getAvailableQuantity()).isEqualTo(14.0); + + List released = reservationRepository + .findByCorrelationIdAndStatus(correlationId, InventoryReservation.ReservationStatus.RELEASED); + assertThat(released).hasSize(1); + } +} diff --git a/product-service/src/test/java/code/with/vanilson/productservice/kafka/RefundRestockConsumerTest.java b/product-service/src/test/java/code/with/vanilson/productservice/kafka/RefundRestockConsumerTest.java new file mode 100644 index 00000000..0f472504 --- /dev/null +++ b/product-service/src/test/java/code/with/vanilson/productservice/kafka/RefundRestockConsumerTest.java @@ -0,0 +1,128 @@ +package code.with.vanilson.productservice.kafka; + +import code.with.vanilson.productservice.domain.InventoryReservation; +import code.with.vanilson.productservice.domain.InventoryReservationRepository; +import code.with.vanilson.productservice.Product; +import code.with.vanilson.productservice.ProductRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.kafka.support.Acknowledgment; + +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * RefundRestockConsumerTest — Fase 6 (basic refunds). + *

+ * Mirrors {@code InventoryCompensationConsumerTest} exactly (same restock mechanics, + * different trigger topic) — restock once, idempotent when already RELEASED, no follow-up + * event published (restock is terminal, unlike the compensation path). + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("RefundRestockConsumer — Fase 6 Refund Restock Unit Tests") +class RefundRestockConsumerTest { + + @Mock private InventoryReservationRepository reservationRepository; + @Mock private ProductRepository productRepository; + @Mock private Acknowledgment acknowledgment; + @Mock private io.micrometer.core.instrument.MeterRegistry meterRegistry; + + @InjectMocks + private RefundRestockConsumer consumer; + + @org.junit.jupiter.api.BeforeEach + void setUp() { + lenient().when(meterRegistry.counter(anyString(), any(String[].class))) + .thenReturn(mock(io.micrometer.core.instrument.Counter.class)); + } + + private static final String CORRELATION_ID = "corr-refund-001"; + + private OrderRefundedEvent buildEvent() { + return new OrderRefundedEvent("evt-refund-001", CORRELATION_ID, "ORD-REFUND-001", Instant.now(), 1); + } + + @Nested + @DisplayName("onOrderRefunded — restock") + class Restock { + + @Test + @DisplayName("should restore stock and mark reservation as RELEASED") + void shouldRestoreStockAndMarkReleased() { + Product laptop = new Product(1, "Laptop", "Gaming Laptop", 7.0, BigDecimal.valueOf(1200)); + InventoryReservation reservation = InventoryReservation.builder() + .id(1L).correlationId(CORRELATION_ID).productId(1) + .reservedQuantity(3).status(InventoryReservation.ReservationStatus.RESERVED) + .createdAt(LocalDateTime.now()).build(); + + when(reservationRepository.findByCorrelationIdAndStatus( + CORRELATION_ID, InventoryReservation.ReservationStatus.RESERVED)) + .thenReturn(List.of(reservation)); + when(productRepository.findById(1)).thenReturn(Optional.of(laptop)); + + consumer.onOrderRefunded(buildEvent(), 0, 0L, acknowledgment); + + assertThat(laptop.getAvailableQuantity()).isEqualTo(10.0); + assertThat(reservation.getStatus()).isEqualTo(InventoryReservation.ReservationStatus.RELEASED); + assertThat(reservation.getReleasedAt()).isNotNull(); + verify(acknowledgment).acknowledge(); + } + + @Test + @DisplayName("should restock every reserved line for the order, not just the first") + void shouldRestockEveryLine() { + Product laptop = new Product(1, "Laptop", "Gaming Laptop", 7.0, BigDecimal.valueOf(1200)); + Product mouse = new Product(2, "Mouse", "Wireless Mouse", 20.0, BigDecimal.valueOf(30)); + InventoryReservation r1 = InventoryReservation.builder() + .id(1L).correlationId(CORRELATION_ID).productId(1) + .reservedQuantity(2).status(InventoryReservation.ReservationStatus.RESERVED).build(); + InventoryReservation r2 = InventoryReservation.builder() + .id(2L).correlationId(CORRELATION_ID).productId(2) + .reservedQuantity(5).status(InventoryReservation.ReservationStatus.RESERVED).build(); + + when(reservationRepository.findByCorrelationIdAndStatus( + CORRELATION_ID, InventoryReservation.ReservationStatus.RESERVED)) + .thenReturn(List.of(r1, r2)); + when(productRepository.findById(1)).thenReturn(Optional.of(laptop)); + when(productRepository.findById(2)).thenReturn(Optional.of(mouse)); + + consumer.onOrderRefunded(buildEvent(), 0, 0L, acknowledgment); + + assertThat(laptop.getAvailableQuantity()).isEqualTo(9.0); + assertThat(mouse.getAvailableQuantity()).isEqualTo(25.0); + assertThat(r1.getStatus()).isEqualTo(InventoryReservation.ReservationStatus.RELEASED); + assertThat(r2.getStatus()).isEqualTo(InventoryReservation.ReservationStatus.RELEASED); + } + } + + @Nested + @DisplayName("onOrderRefunded — idempotency") + class Idempotency { + + @Test + @DisplayName("should be idempotent when no RESERVED records exist (already released)") + void shouldBeIdempotentWhenAlreadyReleased() { + when(reservationRepository.findByCorrelationIdAndStatus( + CORRELATION_ID, InventoryReservation.ReservationStatus.RESERVED)) + .thenReturn(List.of()); + + consumer.onOrderRefunded(buildEvent(), 0, 0L, acknowledgment); + + verify(productRepository, never()).findById(any()); + verify(productRepository, never()).save(any()); + verify(acknowledgment).acknowledge(); + } + } +} diff --git a/product-service/src/test/resources/features/refund_restock.feature b/product-service/src/test/resources/features/refund_restock.feature new file mode 100644 index 00000000..5b185811 --- /dev/null +++ b/product-service/src/test/resources/features/refund_restock.feature @@ -0,0 +1,15 @@ +Feature: Refund restock (Fase 6) + As the platform + I want a refunded order's reserved stock restored + So that other customers can purchase it again + + Scenario: Restocking a reserved product on order.refunded + Given a reserved product with quantity 3 for the refund correlation + When the order.refunded event is consumed + Then the product stock is restored by 3 + And the reservation is marked RELEASED + + Scenario: Redelivering order.refunded is idempotent + Given no RESERVED records exist for the refund correlation + When the order.refunded event is consumed + Then the product stock is not touched