yeg
2026-09-12 d32e0dee609ab13220908ab28a11390bede07c84
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
package com.talentcard.server.business.payment;
 
import com.talentcard.server.business.DatabaseRows;
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.HuifuPaymentGatewayException;
import com.talentcard.server.business.payment.huifu.HuifuPaymentQueryCommand;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
 
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Map;
 
/**
 * Second confirmation path for payments whose callback did not arrive or whose outcome was unknown.
 *
 * <p>A query result is validated exactly like a callback: it is described as a
 * {@link ProviderPaymentFact} and settled by {@link PaymentSettlementService}, so both paths produce
 * the same local result. A query that cannot confirm the payment leaves the transaction pending and
 * records an open issue instead of guessing.
 */
@Service
public class PaymentReconciliationService {
    private static final String PROVIDER = "HUIFU";
    private static final DateTimeFormatter REQUEST_DATE = DateTimeFormatter.ofPattern("yyyyMMdd");
    private final JdbcTemplate jdbc;
    private final HuifuMiniProgramPaymentGateway paymentGateway;
    private final HuifuRefundService refundService;
    private final PaymentSettlementService settlementService;
    private final PaymentIssueRecorder issues;
 
    public PaymentReconciliationService(JdbcTemplate jdbc, HuifuMiniProgramPaymentGateway paymentGateway, HuifuRefundService refundService,
                                        PaymentSettlementService settlementService, PaymentIssueRecorder issues) {
        this.jdbc = jdbc;
        this.paymentGateway = paymentGateway;
        this.refundService = refundService;
        this.settlementService = settlementService;
        this.issues = issues;
    }
 
    public Map<String, Object> reconcile(int pendingAfterMinutes) {
        int paymentChecked = reconcilePayments(pendingAfterMinutes);
        int refundChecked = reconcileRefunds(pendingAfterMinutes);
        return Map.of("paymentChecked", paymentChecked, "refundChecked", refundChecked);
    }
 
    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')
                  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);
        return rows.size();
    }
 
    private void reconcilePayment(Map<String, Object> transaction, int pendingAfterMinutes) {
        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;
        }
        try {
            Map<String, Object> response = paymentGateway.queryMiniProgramPayment(new HuifuPaymentQueryCommand(
                    paymentNo, huifuId, text(transaction.get("providerTransactionNo")), requestDate(transaction.get("createdAt"))));
            ProviderResult result = ProviderResult.from(response);
            if (result.success()) {
                // The query was made with the order's sub-merchant, so that number is the expected one.
                SettlementOutcome outcome = settlementService.settle(new ProviderPaymentFact(
                        paymentNo, HuifuPayload.firstNonBlank(result.huifuId(), huifuId), result.amountInCent(),
                        result.providerNo(), result.code(), result.message(), "QUERY"));
                if (outcome.accepted()) {
                    issues.resolve("PAYMENT_PENDING_TIMEOUT", paymentNo, "system");
                } else 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");
                }
            } else if (result.failed()) {
                markCreateFailed(paymentNo, result.code(), result.message());
                issues.issue("PAYMENT_PROVIDER_FAILED", paymentNo, orderNo, "payment pending", result.status(), amount, result.message());
            } else {
                issues.issue("PAYMENT_PENDING_TIMEOUT", paymentNo, orderNo, "payment completed", result.status(), amount,
                        "Payment remains pending after " + pendingAfterMinutes + " minutes");
            }
        } catch (HuifuPaymentGatewayException exception) {
            issues.issue("PAYMENT_QUERY_FAILED", paymentNo, orderNo, "payment status", "query failed", amount,
                    exception.isOutcomeUnknown() ? "Provider query outcome unknown" : "Provider query rejected");
        } catch (RuntimeException exception) {
            issues.issue("PAYMENT_QUERY_FAILED", paymentNo, orderNo, "payment status", "local error", amount, "Payment reconciliation failed");
        }
    }
 
    private void markCreateFailed(String paymentNo, String code, String message) {
        jdbc.update("""
                update payment_transaction
                set status = 'CREATE_FAILED', provider_response_code = ?, provider_response_message = ?
                where payment_no = ? and status in ('CREATED', 'PREPAY_CREATED', 'CREATE_UNKNOWN')
                """, code, message, paymentNo);
    }
 
    private int reconcileRefunds(int pendingAfterMinutes) {
        List<Map<String, Object>> rows = DatabaseRows.normalize(jdbc.queryForList("""
                select refund_no, order_no, refund_amount, created_at from order_refund
                where deleted = 0 and provider = ? and refund_status = 1
                  and created_at <= date_sub(now(), interval ? minute)
                order by id asc limit 100
                """, PROVIDER, Math.max(1, pendingAfterMinutes)));
        for (Map<String, Object> row : rows) {
            String refundNo = text(row.get("refundNo"));
            try {
                Map<String, Object> result = refundService.query(refundNo);
                if (number(result.get("refundStatus")) == 1) issues.issue("REFUND_PENDING_TIMEOUT", refundNo, text(row.get("orderNo")), "refund completed", "refund pending", number(row.get("refundAmount")), "Refund remains pending after " + pendingAfterMinutes + " minutes");
                else issues.resolve("REFUND_PENDING_TIMEOUT", refundNo, "system");
            } catch (RuntimeException exception) {
                issues.issue("REFUND_QUERY_FAILED", refundNo, text(row.get("orderNo")), "refund status", "query failed", number(row.get("refundAmount")), "Refund reconciliation failed");
            }
        }
        return rows.size();
    }
 
    private String requestDate(Object createdAt) { return createdAt instanceof LocalDateTime time ? REQUEST_DATE.format(time) : REQUEST_DATE.format(LocalDateTime.now()); }
    private long number(Object value) { return value instanceof Number number ? number.longValue() : 0; }
    private String text(Object value) { return value == null ? "" : String.valueOf(value).trim(); }
 
    @SuppressWarnings("unchecked")
    private record ProviderResult(boolean success, boolean failed, String status, String code, String message, String providerNo,
                                  String huifuId, Long amountInCent) {
        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");
            String code = HuifuPayload.code(data);
            String message = HuifuPayload.message(data);
            String providerNo = HuifuPayload.value(data, "hf_seq_id", "hfSeqId");
            String huifuId = HuifuPayload.value(data, "huifu_id", "huifuId");
            TransStatus classification = HuifuPayload.status(status);
            boolean success = classification == TransStatus.SUCCESS && HuifuPayload.successCode(code);
            // An unknown or missing status means "cannot confirm yet", not "failed".
            boolean pending = classification == TransStatus.PENDING || classification == TransStatus.UNKNOWN;
            return new ProviderResult(success, !success && !pending, status, code, message, providerNo, huifuId,
                    HuifuPayload.amountInCent(HuifuPayload.value(data, "trans_amt", "transAmt")));
        }
    }
}