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; }
|
}
|
}
|