| | |
| | | package com.talentcard.server.business.payment; |
| | | |
| | | import com.talentcard.server.business.DatabaseRows; |
| | | import com.talentcard.server.business.BusinessIdGenerator; |
| | | import com.talentcard.server.business.payment.huifu.HuifuPayload; |
| | | import com.talentcard.server.business.payment.huifu.HuifuPayload.TransStatus; |
| | | import com.talentcard.server.business.payment.PaymentSettlementService.ProviderPaymentFact; |
| | | import com.talentcard.server.business.payment.PaymentSettlementService.SettlementOutcome; |
| | | import com.talentcard.server.business.payment.huifu.HuifuMiniProgramPaymentGateway; |
| | | import com.talentcard.server.business.payment.huifu.HuifuPaymentCloseCommand; |
| | | import com.talentcard.server.business.payment.huifu.HuifuPaymentGatewayException; |
| | | import com.talentcard.server.business.payment.huifu.HuifuPaymentQueryCommand; |
| | | import org.springframework.jdbc.core.JdbcTemplate; |
| | | import org.springframework.stereotype.Service; |
| | | import org.springframework.transaction.annotation.Propagation; |
| | | import org.springframework.transaction.annotation.Transactional; |
| | | |
| | | import java.time.LocalDateTime; |
| | | import java.time.format.DateTimeFormatter; |
| | |
| | | @Service |
| | | public class PaymentReconciliationService { |
| | | private static final String PROVIDER = "HUIFU"; |
| | | /** 订单已取消、但通道侧的预支付单还没确认关掉的流水:对账任务要一直重试关单,直到有结论。 */ |
| | | private static final String CANCEL_UNCONFIRMED_STATUS = "CANCEL_UNCONFIRMED"; |
| | | private static final DateTimeFormatter REQUEST_DATE = DateTimeFormatter.ofPattern("yyyyMMdd"); |
| | | private final JdbcTemplate jdbc; |
| | | private final HuifuMiniProgramPaymentGateway paymentGateway; |
| | |
| | | return Map.of("paymentChecked", paymentChecked, "refundChecked", refundChecked); |
| | | } |
| | | |
| | | /** What the provider said about the payments of one order whose local state is still undecided. */ |
| | | public enum PendingPaymentState { |
| | | /** The provider confirmed money was collected: local state now has the order paid. */ |
| | | COLLECTED, |
| | | /** The provider reports no collected payment, so local state may move on. */ |
| | | NONE, |
| | | /** The provider could not say whether money was collected, so the question stays open. */ |
| | | UNCONFIRMED |
| | | } |
| | | |
| | | public record PendingPaymentConfirmation(PendingPaymentState state, String paymentNo) { |
| | | public boolean collected() { return state == PendingPaymentState.COLLECTED; } |
| | | } |
| | | |
| | | /** |
| | | * Asks the provider about every payment of one order that local state has not decided yet. |
| | | * |
| | | * <p>Throwing an order away — cancelling it, or asking the user to pay again — needs one answer |
| | | * first: did the provider already collect money? Local state cannot answer that when the |
| | | * notification never arrived and only the provider knows, so the provider is asked instead. A |
| | | * confirmed payment settles the order; a provider that cannot answer stays unconfirmed, because |
| | | * a question about money is never answered by guessing. |
| | | * |
| | | * <p>Runs in its own transaction: the caller may roll back (a refused cancellation throws), and |
| | | * the provider's answer is a fact that has to survive that rollback. |
| | | */ |
| | | @Transactional(propagation = Propagation.REQUIRES_NEW) |
| | | public PendingPaymentConfirmation confirmPendingPayments(String orderNo) { |
| | | return decidePendingPayments(orderNo, false); |
| | | } |
| | | |
| | | /** |
| | | * 取消订单前的通道确认:除了确认这笔订单有没有收到钱,通道说没收款的预支付单还要在通道侧关掉。 |
| | | * |
| | | * <p>只把本地流水标成已关闭是不够的:用户手上那张预支付单在通道侧还活着,照样能付进来,钱收了 |
| | | * 而订单已经取消。所以取消必须以通道侧的关单结论为准——关单回执说原交易已经成功就拒绝取消, |
| | | * 说还在处理中就让用户稍后重试,只有确认关掉、或通道明确说这笔请求从未收到钱且后续也无法关单 |
| | | * (见 {@link #closePayment})时才继续取消。 |
| | | */ |
| | | @Transactional(propagation = Propagation.REQUIRES_NEW) |
| | | public PendingPaymentConfirmation closePendingPayments(String orderNo) { |
| | | return decidePendingPayments(orderNo, true); |
| | | } |
| | | |
| | | private PendingPaymentConfirmation decidePendingPayments(String orderNo, boolean closeWhenUncollected) { |
| | | List<Map<String, Object>> rows = DatabaseRows.normalize(jdbc.queryForList(""" |
| | | select p.payment_no, p.order_no, p.amount, p.status, p.provider_transaction_no, p.created_at, |
| | | coalesce(p.provider_huifu_id, o.collect_huifu_id) as huifu_id |
| | | from payment_transaction p |
| | | join benefit_order o on o.id = p.order_id |
| | | where p.provider = ? and p.order_no = ? and p.status not in ('SUCCEEDED', 'CLOSED') |
| | | order by p.id asc |
| | | """, PROVIDER, orderNo)); |
| | | PendingPaymentConfirmation confirmation = new PendingPaymentConfirmation(PendingPaymentState.NONE, ""); |
| | | for (Map<String, Object> row : rows) { |
| | | PendingPaymentConfirmation current = confirmPayment(row, closeWhenUncollected); |
| | | if (current.collected()) return current; |
| | | if (current.state() == PendingPaymentState.UNCONFIRMED) confirmation = current; |
| | | } |
| | | return confirmation; |
| | | } |
| | | |
| | | /** |
| | | * One payment attempt, judged by what the provider says about it. |
| | | * |
| | | * <p>Only the states that cannot possibly hold money count as uncollected: a trade the provider |
| | | * failed, one it registered but never started, and a request number it does not know at all. |
| | | * Everything else — money in flight, an unreadable answer, a query that never got through — |
| | | * leaves the money question open rather than closed. |
| | | */ |
| | | private PendingPaymentConfirmation confirmPayment(Map<String, Object> transaction, boolean closeWhenUncollected) { |
| | | String paymentNo = text(transaction.get("paymentNo")); |
| | | String orderNo = text(transaction.get("orderNo")); |
| | | long amount = number(transaction.get("amount")); |
| | | String huifuId = text(transaction.get("huifuId")); |
| | | if (huifuId.isBlank()) { |
| | | // Without the collecting sub-merchant the provider cannot be asked about this payment. |
| | | issues.issue(PaymentSettlementService.ISSUE_COLLECTOR_UNKNOWN, paymentNo, orderNo, "collecting sub-merchant", "", amount, |
| | | "Order has no collecting Huifu sub-merchant"); |
| | | return new PendingPaymentConfirmation(PendingPaymentState.UNCONFIRMED, paymentNo); |
| | | } |
| | | ProviderResult result; |
| | | try { |
| | | result = ProviderResult.from(paymentGateway.queryMiniProgramPayment(new HuifuPaymentQueryCommand( |
| | | paymentNo, huifuId, text(transaction.get("providerTransactionNo")), requestDate(transaction.get("createdAt"))))); |
| | | } catch (HuifuPaymentGatewayException exception) { |
| | | issues.issue("PAYMENT_QUERY_FAILED", paymentNo, orderNo, "payment status", "query failed", amount, |
| | | exception.isOutcomeUnknown() ? "Provider query outcome unknown" : "Provider query rejected"); |
| | | return new PendingPaymentConfirmation(PendingPaymentState.UNCONFIRMED, paymentNo); |
| | | } catch (RuntimeException exception) { |
| | | issues.issue("PAYMENT_QUERY_FAILED", paymentNo, orderNo, "payment status", "local error", amount, "Payment confirmation failed"); |
| | | return new PendingPaymentConfirmation(PendingPaymentState.UNCONFIRMED, paymentNo); |
| | | } |
| | | if (result.success()) { |
| | | SettlementOutcome outcome = settlementService.settle(new ProviderPaymentFact( |
| | | paymentNo, HuifuPayload.firstNonBlank(result.huifuId(), huifuId), result.amountInCent(), |
| | | result.providerNo(), result.code(), result.message(), "QUERY")); |
| | | if (outcome.accepted()) return new PendingPaymentConfirmation(PendingPaymentState.COLLECTED, paymentNo); |
| | | if (outcome == SettlementOutcome.UNMATCHED || outcome == SettlementOutcome.NOT_SETTLEABLE) { |
| | | // Every other mismatch is already recorded with its own issue type by the settlement service. |
| | | issues.issue("PAYMENT_QUERY_CONFLICT", paymentNo, orderNo, "settled payment", outcome.name(), amount, |
| | | "Provider query succeeded but the local payment could not be settled"); |
| | | } |
| | | // Money was collected but local rules refused it: it is still money, so the question stays open. |
| | | return new PendingPaymentConfirmation(PendingPaymentState.UNCONFIRMED, paymentNo); |
| | | } |
| | | if (result.notCollected()) { |
| | | return closeWhenUncollected |
| | | ? closePayment(transaction) |
| | | : new PendingPaymentConfirmation(PendingPaymentState.NONE, paymentNo); |
| | | } |
| | | return new PendingPaymentConfirmation(PendingPaymentState.UNCONFIRMED, paymentNo); |
| | | } |
| | | |
| | | /** |
| | | * 通道说这笔请求没收过钱:把预支付单在通道侧关掉,取消才算真的取消。 |
| | | * |
| | | * <p>关不掉的预支付单仍然可能被支付,所以「关单没成功」不能当作「可以放心取消」: |
| | | * <ul> |
| | | * <li>确认关单成功:本地记为已关闭,可以取消。</li> |
| | | * <li>关单回执说原交易已经成功:钱可能在,只是查单没查到——拒绝取消并记一条问题记录给人工。</li> |
| | | * <li>关单处理中:拒绝取消,让用户稍后重试(下次会重新确认)。</li> |
| | | * <li>其余拒绝(未满一分钟不允许关单、原交易不支持关单、关单请求本身失败):允许取消,但流水记为 |
| | | * {@code CANCEL_UNCONFIRMED} 而不是已关闭——对账任务仍会盯着这笔请求,万一用户后来真的付了, |
| | | * 会留下问题记录让人处理,而不是无声地变成一笔没有订单的悬账。</li> |
| | | * </ul> |
| | | */ |
| | | private PendingPaymentConfirmation closePayment(Map<String, Object> transaction) { |
| | | String paymentNo = text(transaction.get("paymentNo")); |
| | | String orderNo = text(transaction.get("orderNo")); |
| | | long amount = number(transaction.get("amount")); |
| | | String huifuId = text(transaction.get("huifuId")); |
| | | Map<String, Object> response; |
| | | try { |
| | | response = paymentGateway.closeMiniProgramPayment(new HuifuPaymentCloseCommand( |
| | | BusinessIdGenerator.next("C"), paymentNo, huifuId, text(transaction.get("providerTransactionNo")), |
| | | requestDate(transaction.get("createdAt")))); |
| | | } catch (RuntimeException exception) { |
| | | return keepWatchableAfterRefusedClose(transaction, "CLOSE_FAILED", "Provider close request failed", amount); |
| | | } |
| | | CloseResult close = CloseResult.from(response); |
| | | if (close.closed()) { |
| | | markCancelledTransaction(paymentNo, "CLOSED", close.code(), close.message()); |
| | | issues.resolve("PAYMENT_CLOSE_UNCONFIRMED", paymentNo, "system"); |
| | | return new PendingPaymentConfirmation(PendingPaymentState.NONE, paymentNo); |
| | | } |
| | | if (close.originalTradeSucceeded()) { |
| | | issues.issue("PAYMENT_CLOSE_CONFLICT", paymentNo, orderNo, "closed at provider", |
| | | "original trade already succeeded", amount, |
| | | "Provider refused to close the payment because the original trade already succeeded"); |
| | | return new PendingPaymentConfirmation(PendingPaymentState.UNCONFIRMED, paymentNo); |
| | | } |
| | | if (close.processing()) { |
| | | issues.issue("PAYMENT_CLOSE_UNCONFIRMED", paymentNo, orderNo, "closed at provider", "close processing", amount, |
| | | "Provider is still closing the payment"); |
| | | return new PendingPaymentConfirmation(PendingPaymentState.UNCONFIRMED, paymentNo); |
| | | } |
| | | return keepWatchableAfterRefusedClose(transaction, close.code(), close.message(), amount); |
| | | } |
| | | |
| | | private PendingPaymentConfirmation keepWatchableAfterRefusedClose(Map<String, Object> transaction, String code, |
| | | String message, long amount) { |
| | | String paymentNo = text(transaction.get("paymentNo")); |
| | | markCancelledTransaction(paymentNo, "CANCEL_UNCONFIRMED", code, message); |
| | | issues.issue("PAYMENT_CLOSE_UNCONFIRMED", paymentNo, text(transaction.get("orderNo")), "closed at provider", |
| | | code, amount, "Payment could not be closed at the provider; the prepay may still be payable"); |
| | | return new PendingPaymentConfirmation(PendingPaymentState.NONE, paymentNo); |
| | | } |
| | | |
| | | private void markCancelledTransaction(String paymentNo, String status, String code, String message) { |
| | | jdbc.update(""" |
| | | update payment_transaction |
| | | set status = ?, closed_at = coalesce(closed_at, now()), |
| | | provider_response_code = ?, provider_response_message = ? |
| | | where payment_no = ? and status in ('CREATED', 'PREPAY_CREATED', 'CREATE_UNKNOWN', 'CREATE_FAILED', 'CANCEL_UNCONFIRMED') |
| | | """, status, code, message, paymentNo); |
| | | } |
| | | |
| | | /** 通道对关单请求的答复。只有「关掉了」和「原交易已成功」是明确结论,其余一律当作没确认下来。 */ |
| | | private record CloseResult(String code, String message, String status, String originalStatus) { |
| | | private static CloseResult from(Map<String, Object> response) { |
| | | Map<String, Object> data = response == null ? Map.of() |
| | | : (response.get("data") instanceof Map<?, ?> map ? (Map<String, Object>) map : response); |
| | | return new CloseResult(HuifuPayload.code(data), HuifuPayload.message(data), |
| | | HuifuPayload.value(data, "trans_stat", "transStat"), |
| | | HuifuPayload.value(data, "org_trans_stat", "orgTransStat")); |
| | | } |
| | | |
| | | boolean closed() { |
| | | return "S".equalsIgnoreCase(status) && HuifuPayload.successCode(code); |
| | | } |
| | | |
| | | boolean originalTradeSucceeded() { |
| | | return "S".equalsIgnoreCase(originalStatus); |
| | | } |
| | | |
| | | boolean processing() { |
| | | return "P".equalsIgnoreCase(status) || "00000100".equals(code); |
| | | } |
| | | } |
| | | |
| | | private int reconcilePayments(int pendingAfterMinutes) { |
| | | List<Map<String, Object>> rows = DatabaseRows.normalize(jdbc.queryForList(""" |
| | | select p.payment_no, p.order_no, p.amount, p.status, p.provider_transaction_no, p.created_at, |
| | | coalesce(p.provider_huifu_id, o.collect_huifu_id) as huifu_id |
| | | from payment_transaction p |
| | | join benefit_order o on o.id = p.order_id |
| | | where p.provider = ? and p.status in ('CREATED', 'PREPAY_CREATED', 'CREATE_UNKNOWN') |
| | | where p.provider = ? and p.status in ('CREATED', 'PREPAY_CREATED', 'CREATE_UNKNOWN', 'CANCEL_UNCONFIRMED') |
| | | and p.created_at <= date_sub(now(), interval ? minute) |
| | | order by p.id asc limit 100 |
| | | """, PROVIDER, Math.max(1, pendingAfterMinutes))); |
| | | for (Map<String, Object> row : rows) reconcilePayment(row, pendingAfterMinutes); |
| | | for (Map<String, Object> row : rows) { |
| | | if (CANCEL_UNCONFIRMED_STATUS.equals(text(row.get("status")))) retryClose(row); |
| | | else reconcilePayment(row, pendingAfterMinutes); |
| | | } |
| | | return rows.size(); |
| | | } |
| | | |
| | | /** |
| | | * 关单补偿:取消订单时通道不让关(未满一分钟、通道临时拒绝、关单请求失败)而留下的 |
| | | * {@code CANCEL_UNCONFIRMED},等过了限制再关一次——不关掉,用户手上那张预支付单一直能付进来。 |
| | | * |
| | | * <p>先确认、再关单:通道说这笔请求没收过钱就关掉,落成已关闭;通道说钱已经收下了则绝不再尝试关单, |
| | | * 交给结算与问题记录把它变成一笔看得见、可退的账。两种情况都说明这一轮有了结论,把上一轮留下的 |
| | | * 关单问题记录结掉。 |
| | | */ |
| | | private void retryClose(Map<String, Object> transaction) { |
| | | String paymentNo = text(transaction.get("paymentNo")); |
| | | issues.resolve("PAYMENT_CLOSE_UNCONFIRMED", paymentNo, "system"); |
| | | confirmPayment(transaction, true); |
| | | } |
| | | |
| | | private void reconcilePayment(Map<String, Object> transaction, int pendingAfterMinutes) { |
| | |
| | | @SuppressWarnings("unchecked") |
| | | private record ProviderResult(boolean success, boolean failed, String status, String code, String message, String providerNo, |
| | | String huifuId, Long amountInCent) { |
| | | /** 汇付「交易不存在」:通道不认识这个请求流水号,也就不可能为它收过钱。 */ |
| | | private static final String TRADE_MISSING = "23000001"; |
| | | |
| | | private static ProviderResult from(Map<String, Object> response) { |
| | | Map<String, Object> data = response.get("data") instanceof Map<?, ?> map ? (Map<String, Object>) map : response; |
| | | String status = HuifuPayload.value(data, "trans_stat", "transStat"); |
| | |
| | | return new ProviderResult(success, !success && !pending, status, code, message, providerNo, huifuId, |
| | | HuifuPayload.amountInCent(HuifuPayload.value(data, "trans_amt", "transAmt"))); |
| | | } |
| | | |
| | | /** |
| | | * 通道是否明确排除了「已经收到钱」。 |
| | | * |
| | | * <p>失败的交易、只登记未开始的交易({@code I} 初始)和通道根本不认识的请求流水号,都不可能 |
| | | * 已经把钱收下;其余回执(处理中、读不出来的状态)都可能,因此不能算作已排除。 |
| | | */ |
| | | boolean notCollected() { |
| | | return failed || "I".equalsIgnoreCase(status) || TRADE_MISSING.equals(code); |
| | | } |
| | | } |
| | | } |