package com.talentcard.server.business.payment;
|
|
import com.talentcard.server.business.DatabaseRows;
|
import com.talentcard.server.business.payment.HuifuPaymentPayload.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, HuifuPaymentPayload.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 = HuifuPaymentPayload.value(data, "trans_stat", "transStat");
|
String code = HuifuPaymentPayload.code(data);
|
String message = HuifuPaymentPayload.message(data);
|
String providerNo = HuifuPaymentPayload.value(data, "hf_seq_id", "hfSeqId");
|
String huifuId = HuifuPaymentPayload.value(data, "huifu_id", "huifuId");
|
TransStatus classification = HuifuPaymentPayload.status(status);
|
boolean success = classification == TransStatus.SUCCESS && HuifuPaymentPayload.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,
|
HuifuPaymentPayload.amountInCent(HuifuPaymentPayload.value(data, "trans_amt", "transAmt")));
|
}
|
}
|
}
|