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
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
package com.talentcard.server.business.payment;
 
import java.util.List;
import java.util.Map;
 
import com.talentcard.server.business.DatabaseRows;
import com.talentcard.server.business.payment.huifu.HuifuPayload;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
 
/**
 * The single idempotent entry point that turns a provider-confirmed successful payment into local
 * state.
 *
 * <p>A callback and a reconciliation query must produce the same business result, so both describe
 * what the provider said as a {@link ProviderPaymentFact} and let this service decide. The fact can
 * only settle the payment transaction its request number names, and before anything changes it is
 * compared with the order's fixed collecting sub-merchant and the transaction amount. A statement
 * that cannot be verified — another merchant, a missing merchant number, a missing or different
 * amount — is recorded as an open reconciliation issue and the order is never marked paid. Repeated
 * facts for the same payment converge on the same state: the payment transaction is settled once, and
 * the order is only moved from pending to paid.
 */
@Service
public class PaymentSettlementService {
 
    static final String PROVIDER = "HUIFU";
 
    static final String ISSUE_MERCHANT_MISMATCH = "PAYMENT_MERCHANT_MISMATCH";
    static final String ISSUE_MERCHANT_UNVERIFIED = "PAYMENT_MERCHANT_UNVERIFIED";
    static final String ISSUE_AMOUNT_MISMATCH = "PAYMENT_AMOUNT_MISMATCH";
    static final String ISSUE_COLLECTOR_UNKNOWN = "PAYMENT_COLLECTOR_UNKNOWN";
    static final String ISSUE_ORDER_CANCELLED = "PAYMENT_ORDER_CANCELLED";
 
    private final JdbcTemplate jdbc;
    private final PaymentIssueRecorder issues;
 
    public PaymentSettlementService(JdbcTemplate jdbc, PaymentIssueRecorder issues) {
        this.jdbc = jdbc;
        this.issues = issues;
    }
 
    @Transactional
    public SettlementOutcome settle(ProviderPaymentFact fact) {
        List<Map<String, Object>> rows = DatabaseRows.normalize(jdbc.queryForList("""
                select p.id, p.order_id, p.order_no, p.status, p.amount, p.provider_huifu_id,
                       o.order_status, o.collect_huifu_id
                from payment_transaction p
                join benefit_order o on o.id = p.order_id
                where p.provider = ? and p.payment_no = ?
                limit 1 for update
                """, PROVIDER, fact.paymentNo()));
        if (rows.isEmpty()) return SettlementOutcome.UNMATCHED;
 
        Map<String, Object> payment = rows.get(0);
        long paymentId = number(payment.get("id"));
        String orderNo = text(payment.get("orderNo"));
        long amountInCent = number(payment.get("amount"));
        boolean alreadySucceeded = "SUCCEEDED".equals(text(payment.get("status")));
        boolean closed = "CLOSED".equals(text(payment.get("status")));
 
        if (!fact.amountMatches(amountInCent)) {
            issues.issue(ISSUE_AMOUNT_MISMATCH, fact.paymentNo(), orderNo, String.valueOf(amountInCent),
                    fact.amountText(), amountInCent, "Provider amount is missing or does not match the local payment amount");
            return alreadySucceeded ? SettlementOutcome.ALREADY_SETTLED : SettlementOutcome.AMOUNT_MISMATCH;
        }
 
        String expectedHuifuId = HuifuPayload.firstNonBlank(text(payment.get("providerHuifuId")), text(payment.get("collectHuifuId")));
        if (expectedHuifuId.isBlank()) {
            issues.issue(ISSUE_COLLECTOR_UNKNOWN, fact.paymentNo(), orderNo, "collecting sub-merchant", fact.huifuId(),
                    amountInCent, "Order has no collecting Huifu sub-merchant");
            return alreadySucceeded ? SettlementOutcome.ALREADY_SETTLED : SettlementOutcome.COLLECTOR_UNKNOWN;
        }
        if (fact.huifuId().isBlank()) {
            // A success statement without a merchant number cannot be attributed to this sub-merchant.
            issues.issue(ISSUE_MERCHANT_UNVERIFIED, fact.paymentNo(), orderNo, expectedHuifuId, "",
                    amountInCent, "Provider merchant number is missing");
            return alreadySucceeded ? SettlementOutcome.ALREADY_SETTLED : SettlementOutcome.MERCHANT_UNVERIFIED;
        }
        if (!expectedHuifuId.equals(fact.huifuId())) {
            issues.issue(ISSUE_MERCHANT_MISMATCH, fact.paymentNo(), orderNo, expectedHuifuId, fact.huifuId(),
                    amountInCent, "Provider merchant number does not match the order's collecting sub-merchant");
            return alreadySucceeded ? SettlementOutcome.ALREADY_SETTLED : SettlementOutcome.MERCHANT_MISMATCH;
        }
 
        if (closed) {
            // The talent user cancelled while the provider still completed the payment. The money
            // exists but the order must stay cancelled until the refund path settles it.
            issues.issue(ISSUE_ORDER_CANCELLED, fact.paymentNo(), orderNo, "cancelled", "provider confirmed " + fact.source(),
                    amountInCent, "Payment succeeded after the local order was cancelled");
            return SettlementOutcome.CLOSED_LATE_SUCCESS;
        }
 
        if (!alreadySucceeded) {
            int paymentUpdated = jdbc.update("""
                    update payment_transaction
                    set status = 'SUCCEEDED', provider_transaction_no = coalesce(nullif(?, ''), provider_transaction_no),
                        provider_response_code = ?, provider_response_message = ?, paid_at = coalesce(paid_at, now())
                    where id = ? and status in ('CREATED', 'CREATE_FAILED', 'PREPAY_CREATED', 'CREATE_UNKNOWN')
                    """, fact.providerTransactionNo(), fact.responseCode(), fact.responseMessage(), paymentId);
            if (paymentUpdated == 0) return SettlementOutcome.NOT_SETTLEABLE;
        }
 
        long orderStatus = number(payment.get("orderStatus"));
        if (orderStatus == 1) return alreadySucceeded ? SettlementOutcome.ALREADY_SETTLED : SettlementOutcome.SETTLED;
        if (orderStatus != 0) {
            issues.issue(ISSUE_ORDER_CANCELLED, fact.paymentNo(), orderNo, "cancelled", "provider confirmed " + fact.source(),
                    amountInCent, "Payment succeeded after the local order was cancelled");
            return SettlementOutcome.CLOSED_LATE_SUCCESS;
        }
        // STORE_CONSUME orders are fulfilled at payment time, so the query path must verify them
        // exactly like the callback path does.
        jdbc.update("""
                update benefit_order
                set order_status = 1,
                    verify_status = case when order_type = 'STORE_CONSUME' then 1 else verify_status end
                where id = ? and order_status = 0
                """, payment.get("orderId"));
        return alreadySucceeded ? SettlementOutcome.ALREADY_SETTLED : SettlementOutcome.SETTLED;
    }
 
    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(); }
 
    /** What the provider said about one payment, normalised so callbacks and queries look the same. */
    public record ProviderPaymentFact(
            String paymentNo,
            String huifuId,
            Long amountInCent,
            String providerTransactionNo,
            String responseCode,
            String responseMessage,
            String source
    ) {
        public ProviderPaymentFact {
            paymentNo = paymentNo == null ? "" : paymentNo.trim();
            huifuId = huifuId == null ? "" : huifuId.trim();
            providerTransactionNo = providerTransactionNo == null ? "" : providerTransactionNo.trim();
            responseCode = responseCode == null ? "" : responseCode.trim();
            responseMessage = responseMessage == null ? "" : responseMessage.trim();
            source = source == null ? "" : source.trim();
        }
 
        boolean amountMatches(long localAmountInCent) {
            return amountInCent != null && amountInCent == localAmountInCent;
        }
 
        String amountText() { return amountInCent == null ? "" : String.valueOf(amountInCent); }
    }
 
    public enum SettlementOutcome {
        /** The provider fact was accepted and the order is now paid. */
        SETTLED,
        /** The payment was already settled; nothing changed. */
        ALREADY_SETTLED,
        /** No local payment transaction matches the provider request number. */
        UNMATCHED,
        /** The provider merchant number is not the order's collecting sub-merchant. */
        MERCHANT_MISMATCH,
        /** The success statement carries no merchant number, so it cannot be attributed. */
        MERCHANT_UNVERIFIED,
        /** The provider amount is missing or different from the local payment amount. */
        AMOUNT_MISMATCH,
        /** The order carries no collecting sub-merchant, so the fact cannot be trusted. */
        COLLECTOR_UNKNOWN,
        /** The payment succeeded after the local order was cancelled or closed. */
        CLOSED_LATE_SUCCESS,
        /** The payment transaction is in a state that cannot be settled. */
        NOT_SETTLEABLE;
 
        /** Whether local state now reflects a provider-confirmed successful payment. */
        public boolean accepted() { return this == SETTLED || this == ALREADY_SETTLED; }
    }
}