diff --git a/README.md b/README.md index d7f1d47..83526a6 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ **预约(免费设备):** 选设备与时段 → 进入 `PENDING` → 教师批准为 `APPROVED` → 超时未批自动 `EXPIRED` → 用完后 `COMPLETED`。重叠的待审批可以同时存在;真正占住时段的只有已批准预约。 -**预约(计价设备,`hourlyPriceCents > 0`):** 批准后不是 `APPROVED` 而是 `AWAITING_PAYMENT`——占住时段,但只给一个很短的支付窗口;付了变 `PAID`,窗口内没付就关单、时段归还。取消已支付的预约先进 `REFUNDING`,等退款回调到账才落 `CANCELLED`。 +**预约(计价设备,`hourlyPriceCents > 0`):** 批准后不是 `APPROVED` 而是 `AWAITING_PAYMENT`——占住时段,但只给一个很短的支付窗口;付了变 `PAID`,窗口内没付就关单、时段归还。取消已支付的预约时,订单已全额退款则直接变为 `CANCELLED`;仍有可退余额则进入 `REFUNDING`,申请退还剩余金额,等退款回调到账后变为 `CANCELLED`。 **报修:** 提交工单 → 有权限的报修会取消冲突预约并让设备进入维护 → 管理员派单或维修员接单 → 处理、解决、关闭后设备恢复可预约。 diff --git a/src/main/java/com/arthur/labops/payment/PaymentService.java b/src/main/java/com/arthur/labops/payment/PaymentService.java index 07d42ab..ed43c9e 100644 --- a/src/main/java/com/arthur/labops/payment/PaymentService.java +++ b/src/main/java/com/arthur/labops/payment/PaymentService.java @@ -72,14 +72,11 @@ public void closeUnpaidOrder(Long reservationId) { @Override @Transactional - public void requestFullRefund(Long reservationId) { - PaymentOrder order = orderRepository.findByReservationIdForUpdate(reservationId).orElse(null); - if (order == null) { - return; - } + public boolean requestFullRefund(Long reservationId) { + PaymentOrder order = orderRepository.findByReservationIdForUpdate(reservationId).orElseThrow(); long refundable = order.refundableCents(); if (refundable <= 0) { - return; + return false; } // Durable and keyed. The channel call happens after this transaction // commits, and a failure there is retried rather than lost — a refund that @@ -90,5 +87,6 @@ public void requestFullRefund(Long reservationId) { PaymentTransactionType.REFUND, refundable, PaymentIdempotency.cancellationRefund(order.getOrderNo())); + return true; } } diff --git a/src/main/java/com/arthur/labops/reservation/ReservationClosureService.java b/src/main/java/com/arthur/labops/reservation/ReservationClosureService.java index 44ccd92..6f28f3a 100644 --- a/src/main/java/com/arthur/labops/reservation/ReservationClosureService.java +++ b/src/main/java/com/arthur/labops/reservation/ReservationClosureService.java @@ -56,11 +56,14 @@ public ReservationClosure close(Reservation reservation) { return ReservationClosure.CANCELLED; } case PAID -> { + if (!paymentGateway.requestFullRefund(reservation.getId())) { + reservation.completeRefund(); + return ReservationClosure.CANCELLED; + } // The slot is released now; the reservation stays open until the // refund callback lands, so the books never show a cancelled // reservation whose money is still sitting at the channel. reservation.beginRefund(); - paymentGateway.requestFullRefund(reservation.getId()); return ReservationClosure.REFUND_PENDING; } default -> { diff --git a/src/main/java/com/arthur/labops/reservation/ReservationPaymentGateway.java b/src/main/java/com/arthur/labops/reservation/ReservationPaymentGateway.java index a580e56..8dbc5d9 100644 --- a/src/main/java/com/arthur/labops/reservation/ReservationPaymentGateway.java +++ b/src/main/java/com/arthur/labops/reservation/ReservationPaymentGateway.java @@ -22,7 +22,10 @@ public interface ReservationPaymentGateway { /** * Asks the channel to refund everything still held for this reservation. - * The reservation only reaches CANCELLED when the refund callback lands. + * An already fully refunded order lets the reservation reach CANCELLED immediately; + * otherwise it waits for the refund callback. + * + * @return true if a refund is pending, false if the order is already fully refunded */ - void requestFullRefund(Long reservationId); + boolean requestFullRefund(Long reservationId); } diff --git a/src/test/java/com/arthur/labops/ChannelRefundBeforeCancellationIntegrationTest.java b/src/test/java/com/arthur/labops/ChannelRefundBeforeCancellationIntegrationTest.java new file mode 100644 index 0000000..0e9db5f --- /dev/null +++ b/src/test/java/com/arthur/labops/ChannelRefundBeforeCancellationIntegrationTest.java @@ -0,0 +1,137 @@ +package com.arthur.labops; + +import static com.arthur.labops.TestAuth.bearer; +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +import com.arthur.labops.payment.PaymentOrderRepository; +import com.arthur.labops.payment.PaymentOrderStatus; +import com.arthur.labops.payment.PaymentRequestRepository; +import com.arthur.labops.payment.PaymentService; +import com.arthur.labops.payment.PaymentTransactionType; +import com.arthur.labops.payment.channel.ChannelEntryType; +import com.arthur.labops.payment.channel.SimulatedPaymentChannel; +import com.arthur.labops.reservation.ReservationRepository; +import com.arthur.labops.reservation.ReservationStatus; +import com.fasterxml.jackson.databind.ObjectMapper; + +@SpringBootTest(properties = { + "labops.payment.channel.callback-mode=MANUAL", + "labops.payment.channel.bill-directory=target/test-channel-bills/refund-before-cancel" +}) +@AutoConfigureMockMvc +class ChannelRefundBeforeCancellationIntegrationTest { + + private static final long PRICE_CENTS = 5_000L; + + enum CancellationRoute { OWNER, FAULT_REPORT } + + @Autowired private MockMvc mockMvc; + @Autowired private ObjectMapper objectMapper; + @Autowired private SimulatedPaymentChannel channel; + @Autowired private ReservationRepository reservationRepository; + @Autowired private PaymentOrderRepository orderRepository; + @Autowired private PaymentRequestRepository requestRepository; + + private PaymentScenario scenario; + + @BeforeEach + void setUp() { + TestAuth.clearCache(); + channel.reset(); + scenario = new PaymentScenario(mockMvc, objectMapper); + } + + @ParameterizedTest + @EnumSource(CancellationRoute.class) + void fullyRefundedOrderClosesWithoutAnotherRefund(CancellationRoute route) throws Exception { + Long equipmentId = scenario.createPricedEquipment("REFUNDED-CANCEL", PRICE_CENTS); + Long reservationId = payAndRefund(equipmentId, PRICE_CENTS); + String orderNo = PaymentService.orderNoFor(reservationId); + + assertThat(orderRepository.findByOrderNo(orderNo).orElseThrow().getStatus()) + .isEqualTo(PaymentOrderStatus.REFUNDED); + cancel(route, equipmentId, reservationId, "CANCELLED"); + + assertThat(reservationRepository.findById(reservationId).orElseThrow().getStatus()) + .isEqualTo(ReservationStatus.CANCELLED); + assertThat(orderRepository.findByOrderNo(orderNo).orElseThrow()).satisfies(order -> { + assertThat(order.getStatus()).isEqualTo(PaymentOrderStatus.REFUNDED); + assertThat(order.getRefundedCents()).isEqualTo(PRICE_CENTS); + assertThat(order.netCents()).isZero(); + }); + assertThat(requestRepository.findByOrderNoOrderByIdAsc(orderNo)).isEmpty(); + assertThat(channel.deliverPending()).isZero(); + assertThat(channel.ledger()).filteredOn(entry -> entry.orderNo().equals(orderNo) + && entry.type() == ChannelEntryType.REFUND).hasSize(1); + } + + @ParameterizedTest + @EnumSource(CancellationRoute.class) + void partiallyRefundedOrderStillWaitsForTheRemainingRefund(CancellationRoute route) throws Exception { + Long equipmentId = scenario.createPricedEquipment("PARTIAL-CANCEL", PRICE_CENTS); + Long reservationId = payAndRefund(equipmentId, 2_000L); + String orderNo = PaymentService.orderNoFor(reservationId); + + cancel(route, equipmentId, reservationId, "REFUNDING"); + + assertThat(reservationRepository.findById(reservationId).orElseThrow().getStatus()) + .isEqualTo(ReservationStatus.REFUNDING); + assertThat(requestRepository.findByOrderNoOrderByIdAsc(orderNo)).singleElement() + .satisfies(request -> { + assertThat(request.getType()).isEqualTo(PaymentTransactionType.REFUND); + assertThat(request.getAmountCents()).isEqualTo(3_000L); + }); + Await.until("the remaining refund to reach the channel", () -> channel.ledger().stream() + .filter(entry -> entry.orderNo().equals(orderNo) && entry.type() == ChannelEntryType.REFUND) + .count() == 2); + assertThat(channel.deliverPending()).isEqualTo(1); + assertThat(reservationRepository.findById(reservationId).orElseThrow().getStatus()) + .isEqualTo(ReservationStatus.CANCELLED); + assertThat(orderRepository.findByOrderNo(orderNo).orElseThrow().getStatus()) + .isEqualTo(PaymentOrderStatus.REFUNDED); + } + + private Long payAndRefund(Long equipmentId, long refundCents) throws Exception { + Long reservationId = scenario.createReservation(equipmentId, 1); + scenario.approve(reservationId); + String orderNo = PaymentService.orderNoFor(reservationId); + channel.charge(orderNo, PRICE_CENTS); + assertThat(channel.deliverPending()).isEqualTo(1); + channel.refund(orderNo, refundCents); + assertThat(channel.deliverPending()).isEqualTo(1); + assertThat(reservationRepository.findById(reservationId).orElseThrow().getStatus()) + .isEqualTo(ReservationStatus.PAID); + return reservationId; + } + + private void cancel(CancellationRoute route, Long equipmentId, Long reservationId, + String expectedStatus) throws Exception { + if (route == CancellationRoute.OWNER) { + assertThat(scenario.cancelAsStudent(reservationId).get("status")).isEqualTo(expectedStatus); + } else { + mockMvc.perform(post("/api/work-orders") + .header(HttpHeaders.AUTHORIZATION, bearer(mockMvc, objectMapper, "teacher", "teacher123")) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(Map.of( + "equipmentId", equipmentId, + "title", "设备故障", + "description", "设备无法使用,需要停机检修", + "priority", "HIGH")))) + .andExpect(status().isCreated()); + } + } +}