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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`。

**报修:** 提交工单 → 有权限的报修会取消冲突预约并让设备进入维护 → 管理员派单或维修员接单 → 处理、解决、关闭后设备恢复可预约。

Expand Down
10 changes: 4 additions & 6 deletions src/main/java/com/arthur/labops/payment/PaymentService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -90,5 +87,6 @@ public void requestFullRefund(Long reservationId) {
PaymentTransactionType.REFUND,
refundable,
PaymentIdempotency.cancellationRefund(order.getOrderNo()));
return true;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 -> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Original file line number Diff line number Diff line change
@@ -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());
}
}
}
Loading