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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions frontend/src/api/payments.api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,8 @@ export const paymentsApi = {

getById: (id: number) =>
apiClient.get<PaymentResponse>(`/payments/${id}`).then((r) => r.data),

// Fase 6 — ADMIN, one-shot: refunding an already-REFUNDED payment returns 409.
refund: (id: number) =>
apiClient.post<PaymentResponse>(`/payments/${id}/refund`).then((r) => r.data),
};
6 changes: 5 additions & 1 deletion frontend/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──
Expand Down
74 changes: 69 additions & 5 deletions frontend/src/pages/admin/PaymentsPage.tsx
Original file line number Diff line number Diff line change
@@ -1,37 +1,101 @@
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<PaymentResponse | null>(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) => <Typography sx={{ fontFamily: 'var(--font-mono)', fontSize: '0.8rem', color: 'text.secondary' }}>#{r.id}</Typography> },
{ key: 'id', label: 'ID', render: (r: PaymentResponse) => <Typography sx={{ fontFamily: 'var(--font-mono)', fontSize: '0.8rem', color: 'text.secondary' }}>#{r.paymentId}</Typography> },
{ key: 'orderReference', label: 'Order', render: (r: PaymentResponse) => <Typography sx={{ fontFamily: 'var(--font-mono)', fontSize: '0.8rem', color: 'primary.main' }}>{r.orderReference}</Typography> },
{ key: 'amount', label: 'Amount', align: 'right' as const, render: (r: PaymentResponse) => <Typography sx={{ fontFamily: 'var(--font-mono)', fontWeight: 600 }}>{formatCurrency(r.amount)}</Typography> },
{ key: 'paymentMethod', label: 'Method' },
{ key: 'createdDate', label: 'Date', render: (r: PaymentResponse) => <Typography variant="caption" color="text.secondary">{formatDateTime(r.createdDate)}</Typography> },
{
key: 'status',
label: 'Status',
render: (r: PaymentResponse) => (
<Chip
size="small"
label={r.status === 'REFUNDED' ? 'Refunded' : 'Authorized'}
color={r.status === 'REFUNDED' ? 'warning' : 'success'}
variant="outlined"
/>
),
},
{
key: 'actions',
label: 'Refund',
render: (r: PaymentResponse) =>
r.status === 'REFUNDED' ? (
<Typography variant="caption" color="text.secondary">—</Typography>
) : (
<Button size="small" color="warning" startIcon={<Undo />} onClick={() => setRefundTarget(r)}>
Refund
</Button>
),
},
];

return (
<Container maxWidth="xl" sx={{ py: 2 }}>
<motion.div initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.3 }}>
<Typography variant="h3" sx={{ fontFamily: 'var(--font-serif)', mb: 4 }}>Payments</Typography>
{isLoading ? (
<TableSkeleton rows={8} cols={5} />
<TableSkeleton rows={8} cols={7} />
) : (
<DataTable columns={COLUMNS} rows={payments ?? []} />
<DataTable columns={COLUMNS} rows={rows} />
)}

<ConfirmDialog
open={!!refundTarget}
title="Refund this payment?"
description={
refundTarget
? `Payment #${refundTarget.paymentId} for order ${refundTarget.orderReference} (${formatCurrency(refundTarget.amount)}) will be refunded. This cannot be undone.`
: undefined
}
confirmLabel="Refund"
destructive
loading={refundMut.isPending}
onConfirm={() => refundTarget && refundMut.mutate(refundTarget.paymentId)}
onCancel={() => setRefundTarget(null)}
/>
</motion.div>
</Container>
);
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/test/mocks/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ const CUSTOMER: CustomerResponse = {
};

const PAYMENT: PaymentResponse = {
id: 1,
paymentId: 1,
amount: 59.98,
paymentMethod: 'CREDIT_CARD',
orderId: 1,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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).
* <p>
* {@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.
* <p>
* 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
// -------------------------------------------------------
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
// -------------------------------------------------------
Expand Down
Original file line number Diff line number Diff line change
@@ -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
) {
}
Original file line number Diff line number Diff line change
@@ -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).
* <p>
* 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.
* <p>
* 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();
}
}
}
Original file line number Diff line number Diff line change
@@ -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).
* <p>
* 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
) {
}
Loading
Loading