| | |
| | | import static org.mockito.ArgumentMatchers.any; |
| | | import static org.mockito.Mockito.clearInvocations; |
| | | import static org.mockito.Mockito.never; |
| | | import static org.mockito.Mockito.times; |
| | | import static org.mockito.Mockito.verify; |
| | | import static org.mockito.Mockito.when; |
| | | |
| | | import java.util.List; |
| | | import java.util.Map; |
| | | |
| | | import com.talentcard.server.business.payment.PaymentReconciliationService.PendingPaymentConfirmation; |
| | | import com.talentcard.server.business.payment.huifu.HuifuMiniProgramPaymentGateway; |
| | | import com.talentcard.server.business.payment.huifu.HuifuPaymentCloseCommand; |
| | | import com.talentcard.server.business.payment.huifu.HuifuPaymentGatewayException; |
| | |
| | | void prepareData() { |
| | | suffix = String.valueOf(System.nanoTime()); |
| | | // 共享测试库里可能留着历史遗留的挂起流水,只有本用例自己创建的才能落进对账窗口。 |
| | | jdbc.update("update payment_transaction set created_at = now() where status in ('CREATED', 'PREPAY_CREATED', 'CREATE_UNKNOWN', 'CANCEL_UNCONFIRMED')"); |
| | | jdbc.update("update payment_transaction set created_at = now(), last_reconciled_at = null where status in ('CREATED', 'PREPAY_CREATED', 'CREATE_UNKNOWN', 'CLOSING', 'CANCEL_UNCONFIRMED')"); |
| | | jdbc.update("insert into talent_user(openid, name, mobile, status, deleted) values (?, ?, ?, 1, 0)", |
| | | "pending-payment-openid-" + suffix, "Pending payment user", "13900000003"); |
| | | talentId = jdbc.queryForObject("select max(id) from talent_user", Long.class); |
| | |
| | | } |
| | | |
| | | @Test |
| | | void cancellingIsRefusedAndRecordedWhenClosingRevealsTheTradeWasAlreadyPaid() { |
| | | String orderNo = pendingOrder(); |
| | | String paymentNo = pendingPayment(orderNo, 2000L); |
| | | // 查单说「交易不存在」,关单却回「原订单已为终态」且原交易已成功:钱可能真的在,不能当成没收过款。 |
| | | when(paymentGateway.queryMiniProgramPayment(any())).thenReturn(providerResponse(Map.of( |
| | | "resp_code", "23000001", "resp_desc", "交易不存在", "huifu_id", huifuId))); |
| | | when(paymentGateway.closeMiniProgramPayment(any())).thenReturn(providerResponse(Map.of( |
| | | "trans_stat", "F", "resp_code", "23000000", "resp_desc", "原订单已为终态,请发起查询交易获取", |
| | | "org_trans_stat", "S", "huifu_id", huifuId))); |
| | | |
| | | // 查单拿不到金额,无法把订单落成已支付,所以不能谎称已支付;但订单也绝不能被取消。 |
| | | assertThatThrownBy(() -> pendingOrderService.cancel(orderNo, talentId)) |
| | | .isInstanceOf(BusinessException.class) |
| | | .hasMessageContaining("确认中"); |
| | | |
| | | assertThat(jdbc.queryForObject("select order_status from benefit_order where order_no = ?", Integer.class, orderNo)).isZero(); |
| | | assertThat(count("select count(*) from payment_transaction where payment_no = ? and status = 'PREPAY_CREATED'", paymentNo)).isEqualTo(1); |
| | | assertThat(openIssueCount("PAYMENT_CLOSE_CONFLICT", paymentNo)).isEqualTo(1L); |
| | | } |
| | | |
| | | @Test |
| | | void cancellingProceedsButKeepsThePrepayWatchableWhenTheProviderRefusesToCloseIt() { |
| | | String orderNo = pendingOrder(); |
| | | String paymentNo = pendingPayment(orderNo, 2000L); |
| | |
| | | } |
| | | |
| | | @Test |
| | | void cancellingIsRefusedWhileTheProviderIsStillClosingThePrepay() { |
| | | void cancellingClosesThePrepayEvenWhenTheQueryOnlySaysItIsProcessing() { |
| | | String orderNo = pendingOrder(); |
| | | pendingPayment(orderNo, 2000L); |
| | | String paymentNo = pendingPayment(orderNo, 2000L); |
| | | // 查单只答「处理中」:旧实现把它当成关单的前置门,于是关单一次都不会被调用。 |
| | | when(paymentGateway.queryMiniProgramPayment(any())).thenReturn(providerResponse(Map.of( |
| | | "trans_stat", "P", "resp_code", "00000100", "resp_desc", "交易处理中", "trans_amt", "20.00", "huifu_id", huifuId))); |
| | | when(paymentGateway.closeMiniProgramPayment(any())).thenReturn(providerResponse(Map.of( |
| | | "trans_stat", "F", "resp_code", "20000001", "resp_desc", "不允许关闭一分钟以内的订单", "huifu_id", huifuId))); |
| | | |
| | | Map<String, Object> result = pendingOrderService.cancel(orderNo, talentId); |
| | | |
| | | verify(paymentGateway, times(1)).closeMiniProgramPayment(any()); |
| | | assertThat(((Number) result.get("orderStatus")).intValue()) |
| | | .as("关单回执是明确拒绝,不是「还在处理中」,因此允许取消并继续盯防") |
| | | .isEqualTo(2); |
| | | assertThat(jdbc.queryForObject("select status from payment_transaction where payment_no = ?", String.class, paymentNo)) |
| | | .isEqualTo("CANCEL_UNCONFIRMED"); |
| | | } |
| | | |
| | | @Test |
| | | void closingReportsTheOriginalTradeSucceededAndTheQueryThenLandsTheOrderAsPaid() { |
| | | String orderNo = pendingOrder(); |
| | | String paymentNo = pendingPayment(orderNo, 2000L); |
| | | // 第一次查单说交易不存在(所以会去关单),关单回执说原交易已经成功,再查一次拿到了金额。 |
| | | when(paymentGateway.queryMiniProgramPayment(any())) |
| | | .thenReturn(providerResponse(Map.of("resp_code", "23000001", "resp_desc", "交易不存在", "huifu_id", huifuId))) |
| | | .thenReturn(providerResponse(Map.of( |
| | | "trans_stat", "S", "resp_code", "00000000", "trans_amt", "20.00", |
| | | "huifu_id", huifuId, "org_hf_seq_id", "HF-CLOSE-PAID-" + suffix))); |
| | | when(paymentGateway.closeMiniProgramPayment(any())).thenReturn(providerResponse(Map.of( |
| | | "trans_stat", "F", "resp_code", "23000000", "resp_desc", "原订单已为终态,请发起查询交易获取", |
| | | "org_trans_stat", "S", "huifu_id", huifuId))); |
| | | |
| | | assertThatThrownBy(() -> pendingOrderService.cancel(orderNo, talentId)) |
| | | .isInstanceOf(BusinessException.class) |
| | | .hasMessageContaining("已支付"); |
| | | |
| | | assertThat(jdbc.queryForObject("select order_status from benefit_order where order_no = ?", Integer.class, orderNo)) |
| | | .as("关单回执说原交易已成功,就必须落成已支付,而不是把一笔真钱留在待支付里") |
| | | .isEqualTo(1); |
| | | assertThat(jdbc.queryForObject("select status from payment_transaction where payment_no = ?", String.class, paymentNo)) |
| | | .isEqualTo("SUCCEEDED"); |
| | | } |
| | | |
| | | @Test |
| | | void cancellingWaitsForTheCloseConclusionWhenClosingRevealsTheTradeWasAlreadyPaid() { |
| | | String orderNo = pendingOrder(); |
| | | String paymentNo = pendingPayment(orderNo, 2000L); |
| | | // 查单说「交易不存在」,关单却回「原订单已为终态」且原交易已成功:钱可能真的在,不能当成没收过款。 |
| | | when(paymentGateway.queryMiniProgramPayment(any())).thenReturn(providerResponse(Map.of( |
| | | "resp_code", "23000001", "resp_desc", "交易不存在", "huifu_id", huifuId))); |
| | | when(paymentGateway.closeMiniProgramPayment(any())).thenReturn(providerResponse(Map.of( |
| | | "trans_stat", "F", "resp_code", "23000000", "resp_desc", "原订单已为终态,请发起查询交易获取", |
| | | "org_trans_stat", "S", "huifu_id", huifuId))); |
| | | |
| | | // 查单拿不到金额,无法把订单落成已支付,所以不能谎称已支付;但订单也绝不能被取消。 |
| | | Map<String, Object> result = pendingOrderService.cancel(orderNo, talentId); |
| | | |
| | | assertThat(result) |
| | | .as("关单没有结论时,取消的意图被受理但要等结论,而不是给一个永久报错") |
| | | .containsEntry("cancellationPending", true); |
| | | assertThat(jdbc.queryForObject("select order_status from benefit_order where order_no = ?", Integer.class, orderNo)).isZero(); |
| | | assertThat(jdbc.queryForObject("select status from payment_transaction where payment_no = ?", String.class, paymentNo)) |
| | | .as("关单中:既不能当已关闭(通道还可能收钱),也不该是终点") |
| | | .isEqualTo("CLOSING"); |
| | | assertThat(count("select count(*) from payment_transaction where order_no = ?", orderNo)) |
| | | .as("关单中期间只能复用,不允许新建第二张可被支付的预支付单") |
| | | .isEqualTo(1); |
| | | assertThat(openIssueCount("PAYMENT_CLOSE_CONFLICT", paymentNo)).isEqualTo(1L); |
| | | } |
| | | |
| | | @Test |
| | | void cancellingWaitsForTheCloseConclusionWhileTheProviderIsStillClosingThePrepay() { |
| | | String orderNo = pendingOrder(); |
| | | String paymentNo = pendingPayment(orderNo, 2000L); |
| | | when(paymentGateway.queryMiniProgramPayment(any())).thenReturn(providerResponse(Map.of( |
| | | "trans_stat", "I", "resp_code", "00000000", "trans_amt", "20.00", "huifu_id", huifuId))); |
| | | when(paymentGateway.closeMiniProgramPayment(any())).thenReturn(providerResponse(Map.of( |
| | | "trans_stat", "P", "resp_code", "00000100", "resp_desc", "交易处理中", "huifu_id", huifuId))); |
| | | |
| | | assertThatThrownBy(() -> pendingOrderService.cancel(orderNo, talentId)) |
| | | .isInstanceOf(BusinessException.class) |
| | | .hasMessageContaining("确认中"); |
| | | Map<String, Object> result = pendingOrderService.cancel(orderNo, talentId); |
| | | |
| | | assertThat(result).containsEntry("cancellationPending", true); |
| | | assertThat(jdbc.queryForObject("select order_status from benefit_order where order_no = ?", Integer.class, orderNo)).isZero(); |
| | | assertThat(jdbc.queryForObject("select status from payment_transaction where payment_no = ?", String.class, paymentNo)) |
| | | .isEqualTo("CLOSING"); |
| | | assertThat(jdbc.queryForObject("select close_attempts from payment_transaction where payment_no = ?", Integer.class, paymentNo)) |
| | | .as("关单重试次数要落库,否则重试上限永远到不了") |
| | | .isEqualTo(1); |
| | | } |
| | | |
| | | @Test |
| | | void cancellingIsRefusedWhenTheProviderCannotSayWhetherTheMoneyWasCollected() { |
| | | void cancellingWaitsForTheProviderWhenItCannotSayWhetherTheMoneyWasCollected() { |
| | | String orderNo = pendingOrder(); |
| | | String paymentNo = pendingPayment(orderNo, 2000L); |
| | | when(paymentGateway.queryMiniProgramPayment(any())) |
| | | .thenThrow(new HuifuPaymentGatewayException("Huifu payment query failed", true, null)); |
| | | |
| | | assertThatThrownBy(() -> pendingOrderService.cancel(orderNo, talentId)) |
| | | .isInstanceOf(BusinessException.class) |
| | | .hasMessageContaining("确认中"); |
| | | Map<String, Object> result = pendingOrderService.cancel(orderNo, talentId); |
| | | |
| | | assertThat(result).containsEntry("cancellationPending", true); |
| | | assertThat(jdbc.queryForObject("select order_status from benefit_order where order_no = ?", Integer.class, orderNo)).isZero(); |
| | | assertThat(jdbc.queryForObject("select status from payment_transaction where payment_no = ?", String.class, paymentNo)) |
| | | .as("an unanswerable payment question must not destroy the only record of the money") |
| | |
| | | } |
| | | |
| | | @Test |
| | | void retryingAPaymentIsRefusedWhenTheProviderCannotSayWhetherTheMoneyWasCollected() { |
| | | void retryingAPaymentReportsWaitingWhenTheProviderCannotSayWhetherTheMoneyWasCollected() { |
| | | String orderNo = pendingOrder(); |
| | | pendingPayment(orderNo, 2000L); |
| | | when(paymentGateway.queryMiniProgramPayment(any())) |
| | | .thenThrow(new HuifuPaymentGatewayException("Huifu payment query failed", true, null)); |
| | | |
| | | assertThatThrownBy(() -> paymentService.beginMiniProgramPayment(orderNo, talentId, 1, "retry-key-" + suffix)) |
| | | .isInstanceOf(BusinessException.class) |
| | | .hasMessageContaining("确认中"); |
| | | Map<String, Object> result = paymentService.beginMiniProgramPayment(orderNo, talentId, 1, "retry-key-" + suffix); |
| | | |
| | | assertThat(result) |
| | | .as("问不出来的不确定只属于这一次支付尝试,不该把用户堵在一句报错上") |
| | | .containsEntry("paymentStatus", "PAYMENT_PROCESSING"); |
| | | verify(paymentGateway, never()).createMiniProgramPayment(any()); |
| | | assertThat(jdbc.queryForObject("select order_status from benefit_order where order_no = ?", Integer.class, orderNo)).isZero(); |
| | | } |
| | | |
| | | @Test |
| | | void askingToPayAgainReusesTheStoredPrepayWithoutAskingTheProvider() { |
| | | String orderNo = pendingOrder(); |
| | | String paymentNo = pendingPayment(orderNo, 2000L, "PREPAY_CREATED", "{\"appId\":\"wx-test\",\"timeStamp\":\"1\"}"); |
| | | |
| | | Map<String, Object> result = paymentService.beginMiniProgramPayment(orderNo, talentId, 1, "reuse-key-" + suffix); |
| | | |
| | | assertThat(result) |
| | | .as("本地已经持有可用的支付参数时直接把这张预支付单交回去——查通道只会得到「处理中」这种没有结论的答复") |
| | | .containsEntry("paymentStatus", "PAYMENT_PENDING") |
| | | .containsEntry("paymentNo", paymentNo); |
| | | assertThat(result.get("paymentParams")).isNotNull(); |
| | | verify(paymentGateway, never()).queryMiniProgramPayment(any()); |
| | | verify(paymentGateway, never()).createMiniProgramPayment(any()); |
| | | assertThat(count("select count(*) from payment_transaction where order_no = ?", orderNo)) |
| | | .as("复用不新建支付流水") |
| | | .isEqualTo(1); |
| | | } |
| | | |
| | | @Test |
| | | void askingToPayAgainIsRefusedWhenAnUndecidedPrepayCannotBeReused() { |
| | | String orderNo = pendingOrder(); |
| | | // 先取消,通道答「处理中」→ 关单没有结论,这次尝试停在 CLOSING 且订单仍待支付; |
| | | // 它没有留下可复用的支付参数,因此既不能复用、也不许新建。 |
| | | String paymentNo = pendingPayment(orderNo, 2000L); |
| | | when(paymentGateway.queryMiniProgramPayment(any())).thenReturn(providerResponse(Map.of( |
| | | "trans_stat", "I", "resp_code", "00000000", "trans_amt", "20.00", "huifu_id", huifuId))); |
| | | when(paymentGateway.closeMiniProgramPayment(any())).thenReturn(providerResponse(Map.of( |
| | | "trans_stat", "P", "resp_code", "00000100", "resp_desc", "交易处理中", "huifu_id", huifuId))); |
| | | pendingOrderService.cancel(orderNo, talentId); |
| | | assertThat(jdbc.queryForObject("select status from payment_transaction where payment_no = ?", String.class, paymentNo)).isEqualTo("CLOSING"); |
| | | clearInvocations(paymentGateway); |
| | | |
| | | Map<String, Object> result = paymentService.beginMiniProgramPayment(orderNo, talentId, 1, "blocked-key-" + suffix); |
| | | |
| | | assertThat(result).containsEntry("paymentStatus", "PAYMENT_PROCESSING"); |
| | | verify(paymentGateway, never()).createMiniProgramPayment(any()); |
| | | assertThat(count("select count(*) from payment_transaction where order_no = ?", orderNo)) |
| | | .as("上一张预支付单没有被证明不可能再被支付之前,不允许给同一笔订单再建一张") |
| | | .isEqualTo(1); |
| | | assertThat(jdbc.queryForObject("select status from payment_transaction where payment_no = ?", String.class, paymentNo)) |
| | | .isEqualTo("CLOSING"); |
| | | } |
| | | |
| | | @Test |
| | | void anExpiredOrderCannotBePaidEvenWhenAPrepayIsStillStoredLocally() { |
| | | String orderNo = pendingOrder(); |
| | | pendingPayment(orderNo, 2000L, "PREPAY_CREATED", "{\"appId\":\"wx-test\",\"timeStamp\":\"1\"}"); |
| | | expireNow(orderNo); |
| | | when(paymentGateway.queryMiniProgramPayment(any())).thenReturn(providerResponse(Map.of( |
| | | "resp_code", "23000001", "resp_desc", "交易不存在", "huifu_id", huifuId))); |
| | | |
| | | assertThatThrownBy(() -> paymentService.beginMiniProgramPayment(orderNo, talentId, 1, "expired-key-" + suffix)) |
| | | .isInstanceOf(BusinessException.class) |
| | | .hasMessageContaining("超时"); |
| | | |
| | | verify(paymentGateway, never()).createMiniProgramPayment(any()); |
| | | } |
| | | |
| | | @Test |
| | | void abandoningAPaymentOnlyClosesItAndNeverJudgesWhetherMoneyArrived() { |
| | | String orderNo = pendingOrder(); |
| | | String paymentNo = pendingPayment(orderNo, 2000L); |
| | | // 客户端说「我放弃了」,而关单回执说原交易已经成功——即使如此也不能据客户端的话去查单或结算。 |
| | | when(paymentGateway.closeMiniProgramPayment(any())).thenReturn(providerResponse(Map.of( |
| | | "trans_stat", "F", "resp_code", "23000000", "resp_desc", "原订单已为终态,请发起查询交易获取", |
| | | "org_trans_stat", "S", "huifu_id", huifuId))); |
| | | |
| | | PendingPaymentConfirmation confirmation = reconciliationService.abandonPendingPayments(orderNo, paymentNo); |
| | | |
| | | verify(paymentGateway, never()).queryMiniProgramPayment(any()); |
| | | assertThat(jdbc.queryForObject("select status from payment_transaction where payment_no = ?", String.class, paymentNo)) |
| | | .as("放弃只触发关单:关单没拿到「关掉」的结论就停在关单中,绝不做任何收款判定") |
| | | .isEqualTo("CLOSING"); |
| | | assertThat(jdbc.queryForObject("select order_status from benefit_order where order_no = ?", Integer.class, orderNo)) |
| | | .as("放弃支付不是取消订单") |
| | | .isZero(); |
| | | assertThat(confirmation.collected()).isFalse(); |
| | | assertThat(openIssueCount("PAYMENT_CLOSE_CONFLICT", paymentNo)).isEqualTo(1L); |
| | | } |
| | | |
| | | @Test |
| | | void abandoningAPaymentClosesThePrepaySoTheOrderCannotBePaidTwice() { |
| | | String orderNo = pendingOrder(); |
| | | String paymentNo = pendingPayment(orderNo, 2000L); |
| | | when(paymentGateway.closeMiniProgramPayment(any())).thenReturn(providerResponse(Map.of( |
| | | "trans_stat", "S", "resp_code", "00000000", "resp_desc", "交易成功", "org_trans_stat", "P", "huifu_id", huifuId))); |
| | | |
| | | reconciliationService.abandonPendingPayments(orderNo, paymentNo); |
| | | |
| | | assertThat(jdbc.queryForObject("select status from payment_transaction where payment_no = ?", String.class, paymentNo)) |
| | | .isEqualTo("CLOSED"); |
| | | } |
| | | |
| | | @Test |
| | | void thePrepayCloseStopsRetryingAtTheCapAndEndsTheOrderWithAWatcher() { |
| | | String orderNo = pendingOrder(); |
| | | String paymentNo = pendingPayment(orderNo, 2000L); |
| | | when(paymentGateway.queryMiniProgramPayment(any())).thenReturn(providerResponse(Map.of( |
| | | "trans_stat", "I", "resp_code", "00000000", "trans_amt", "20.00", "huifu_id", huifuId))); |
| | | // 通道一直答「还在处理中」:关单永远没有结论。 |
| | | when(paymentGateway.closeMiniProgramPayment(any())).thenReturn(providerResponse(Map.of( |
| | | "trans_stat", "P", "resp_code", "00000100", "resp_desc", "交易处理中", "huifu_id", huifuId))); |
| | | |
| | | assertThat(pendingOrderService.cancel(orderNo, talentId)).containsEntry("cancellationPending", true); |
| | | assertThat(jdbc.queryForObject("select status from payment_transaction where payment_no = ?", String.class, paymentNo)) |
| | | .isEqualTo("CLOSING"); |
| | | |
| | | // 快车道每分钟一轮。每轮都把行推回窗口内(共享库里的残留行不影响本用例:断言只按自己的流水号过滤)。 |
| | | for (int round = 0; round < 3; round++) reconcileOnce(paymentNo); |
| | | |
| | | assertThat(jdbc.queryForObject("select close_attempts from payment_transaction where payment_no = ?", Integer.class, paymentNo)) |
| | | .as("关单重试有明确上限") |
| | | .isEqualTo(3); |
| | | assertThat(jdbc.queryForObject("select status from payment_transaction where payment_no = ?", String.class, paymentNo)) |
| | | .as("到上限后落成「取消未确认」,预支付单继续被盯着") |
| | | .isEqualTo("CANCEL_UNCONFIRMED"); |
| | | assertThat(jdbc.queryForObject("select order_status from benefit_order where order_no = ?", Integer.class, orderNo)) |
| | | .as("关单中不能永远挂着:到上限时订单落成已取消,不留一笔既付不了也退不出的僵尸订单") |
| | | .isEqualTo(2); |
| | | assertThat(openIssueCount("PAYMENT_CLOSE_UNCONFIRMED", paymentNo)) |
| | | .as("放弃关单要留下问题记录给人工") |
| | | .isEqualTo(1L); |
| | | |
| | | // 到上限后不再重试:再跑一轮,关单重试计数不再增长。 |
| | | reconcileOnce(paymentNo); |
| | | assertThat(jdbc.queryForObject("select close_attempts from payment_transaction where payment_no = ?", Integer.class, paymentNo)) |
| | | .isEqualTo(3); |
| | | } |
| | | |
| | | @Test |
| | | void anExpiredOrderWithAClosingOrUnconfirmedAttemptIsNotCancelledByTheDeadline() { |
| | | for (String status : List.of("CLOSING", "CANCEL_UNCONFIRMED")) { |
| | | String orderNo = pendingOrder(); |
| | | String paymentNo = pendingPayment(orderNo, 2000L, status); |
| | | expireNow(orderNo); |
| | | |
| | | pendingOrderService.expireDueOrders(talentId); |
| | | assertThat(jdbc.queryForObject("select order_status from benefit_order where order_no = ?", Integer.class, orderNo)) |
| | | .as("%s 的预支付单仍可能被支付,自动取消会把已收下的钱变成没有出口的悬账", status) |
| | | .isZero(); |
| | | |
| | | pendingOrderService.detail(orderNo, talentId); |
| | | assertThat(jdbc.queryForObject("select order_status from benefit_order where order_no = ?", Integer.class, orderNo)) |
| | | .as("单个订单的到期路径同样不能取消它(%s)", status) |
| | | .isZero(); |
| | | assertThat(jdbc.queryForObject("select status from payment_transaction where payment_no = ?", String.class, paymentNo)) |
| | | .isEqualTo(status); |
| | | } |
| | | } |
| | | |
| | | @Test |
| | | void theReconciliationRotatesInsteadOfAlwaysTakingTheOldestHundredUndecidedPayments() { |
| | | String backlogOrderNo = pendingOrder(); |
| | | Long backlogOrderId = jdbc.queryForObject("select id from benefit_order where order_no = ?", Long.class, backlogOrderNo); |
| | | // 100 条刚被处理过的历史未决流水:它们都在退避窗口内,不该再被选中。 |
| | | for (int index = 0; index < 100; index++) { |
| | | jdbc.update(""" |
| | | insert into payment_transaction(payment_no, provider, order_id, order_no, talent_id, merchant_id, |
| | | provider_huifu_id, amount, status, idempotency_key, created_at, last_reconciled_at) |
| | | values (?, 'HUIFU', ?, ?, ?, ?, ?, 2000, 'PREPAY_CREATED', ?, now() - interval 30 minute, now()) |
| | | """, "P-BACKLOG-" + suffix + "-" + index, backlogOrderId, backlogOrderNo, talentId, merchantId, huifuId, |
| | | "backlog-" + suffix + "-" + index); |
| | | } |
| | | assertThat(count("select count(*) from payment_transaction where order_no = ?", backlogOrderNo)).isEqualTo(100); |
| | | |
| | | String orderNo = pendingOrder(); |
| | | String paymentNo = pendingPayment(orderNo, 2000L); |
| | | jdbc.update("update payment_transaction set created_at = now() - interval 5 minute where payment_no = ?", paymentNo); |
| | | when(paymentGateway.queryMiniProgramPayment(any())).thenReturn(providerResponse(Map.of( |
| | | "resp_code", "23000001", "resp_desc", "交易不存在", "huifu_id", huifuId))); |
| | | |
| | | reconciliationService.reconcile(5); |
| | | |
| | | assertThat(openIssueCount("PAYMENT_PENDING_TIMEOUT", paymentNo)) |
| | | .as("刚出现的未决流水不能被历史积压挤出处理窗口——它正是死锁唯一的自愈通道") |
| | | .isEqualTo(1L); |
| | | assertThat(count("select count(*) from payment_transaction where payment_no = ? and last_reconciled_at is not null", paymentNo)) |
| | | .isEqualTo(1); |
| | | } |
| | | |
| | | @Test |
| | |
| | | /** A pending order bound to a collecting sub-merchant, created directly so the payment seam is the only subject. */ |
| | | @Test |
| | | void anExpiredOrderWithAnUndecidedPaymentIsNotCancelledByTheDeadlineSweep() { |
| | | String orderNo = expiredOrder(); |
| | | String orderNo = pendingOrder(); |
| | | String paymentNo = pendingPayment(orderNo, 2000L); |
| | | expireNow(orderNo); |
| | | |
| | | pendingOrderService.expireDueOrders(talentId); |
| | | |
| | |
| | | } |
| | | |
| | | private String pendingPayment(String orderNo, long amount, String status) { |
| | | return pendingPayment(orderNo, amount, status, null); |
| | | } |
| | | |
| | | private String pendingPayment(String orderNo, long amount, String status, String clientPaymentPayload) { |
| | | String paymentNo = "P-PENDING-" + suffix + "-" + orderSequence; |
| | | Long orderId = jdbc.queryForObject("select id from benefit_order where order_no = ?", Long.class, orderNo); |
| | | jdbc.update(""" |
| | | insert into payment_transaction(payment_no, provider, order_id, order_no, talent_id, merchant_id, |
| | | provider_huifu_id, amount, status, idempotency_key, created_at) |
| | | values (?, 'HUIFU', ?, ?, ?, ?, ?, ?, ?, ?, now()) |
| | | """, paymentNo, orderId, orderNo, talentId, merchantId, huifuId, amount, status, "pending-payment-" + paymentNo); |
| | | provider_huifu_id, amount, status, idempotency_key, created_at, |
| | | client_payment_payload) |
| | | values (?, 'HUIFU', ?, ?, ?, ?, ?, ?, ?, ?, now(), ?) |
| | | """, paymentNo, orderId, orderNo, talentId, merchantId, huifuId, amount, status, |
| | | "pending-payment-" + paymentNo, clientPaymentPayload); |
| | | return paymentNo; |
| | | } |
| | | |
| | | /** 把这一行推回对账窗口再跑一轮;断言只按本用例自己的流水号过滤,共享库里的残留行不影响结论。 */ |
| | | private void reconcileOnce(String paymentNo) { |
| | | jdbc.update(""" |
| | | update payment_transaction set created_at = now() - interval 5 minute, last_reconciled_at = null |
| | | where payment_no = ? |
| | | """, paymentNo); |
| | | reconciliationService.reconcile(5); |
| | | } |
| | | |
| | | /** |
| | | * 让订单现在就算过期。 |
| | | * |
| | | * <p>必须建完订单与支付尝试之后再推时间:到期清理任务({@code PendingOrderExpiryScheduler},测试里 |
| | | * 也开着)会在「订单已过期、但还没有任何支付流水」的那一瞬间把它当成普通超时订单取消掉。 |
| | | */ |
| | | private void expireNow(String orderNo) { |
| | | jdbc.update("update benefit_order set expires_at = now() - interval 1 minute where order_no = ?", orderNo); |
| | | } |
| | | |
| | | private Map<String, Object> providerResponse(Map<String, Object> data) { |
| | | return Map.of("data", data); |
| | | } |