| | |
| | | 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 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 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) { |
| | | 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) { |
| | |
| | | |
| | | private int reconcilePayments(int pendingAfterMinutes) { |
| | | List<Map<String, Object>> rows = DatabaseRows.normalize(jdbc.queryForList(""" |
| | | select payment_no, order_no, amount, status, provider_transaction_no, created_at |
| | | from payment_transaction |
| | | where provider = ? and status in ('CREATED', 'PREPAY_CREATED', 'CREATE_UNKNOWN') |
| | | and created_at <= date_sub(now(), interval ? minute) |
| | | order by id asc limit 100 |
| | | 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, text(transaction.get("providerTransactionNo")), requestDate(transaction.get("createdAt")))); |
| | | paymentNo, huifuId, text(transaction.get("providerTransactionNo")), requestDate(transaction.get("createdAt")))); |
| | | ProviderResult result = ProviderResult.from(response); |
| | | if (result.success()) { |
| | | jdbc.update(""" |
| | | update payment_transaction p join benefit_order o on o.id = p.order_id |
| | | set p.status = 'SUCCEEDED', p.provider_transaction_no = coalesce(?, p.provider_transaction_no), |
| | | p.provider_response_code = ?, p.provider_response_message = ?, p.paid_at = coalesce(p.paid_at, now()), |
| | | o.order_status = case when o.order_status = 0 then 1 else o.order_status end |
| | | where p.payment_no = ? and p.status in ('CREATED', 'PREPAY_CREATED', 'CREATE_UNKNOWN') |
| | | """, result.providerNo(), result.code(), result.message(), paymentNo); |
| | | resolve("PAYMENT_PENDING_TIMEOUT", paymentNo, "system"); |
| | | // 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()) { |
| | | 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')", result.code(), result.message(), paymentNo); |
| | | issue("PAYMENT_PROVIDER_FAILED", paymentNo, text(transaction.get("orderNo")), "payment pending", result.status(), number(transaction.get("amount")), result.message()); |
| | | markCreateFailed(paymentNo, result.code(), result.message()); |
| | | issues.issue("PAYMENT_PROVIDER_FAILED", paymentNo, orderNo, "payment pending", result.status(), amount, result.message()); |
| | | } else { |
| | | issue("PAYMENT_PENDING_TIMEOUT", paymentNo, text(transaction.get("orderNo")), "payment completed", result.status(), number(transaction.get("amount")), "Payment remains pending after " + pendingAfterMinutes + " minutes"); |
| | | issues.issue("PAYMENT_PENDING_TIMEOUT", paymentNo, orderNo, "payment completed", result.status(), amount, |
| | | "Payment remains pending after " + pendingAfterMinutes + " minutes"); |
| | | } |
| | | } catch (HuifuPaymentGatewayException exception) { |
| | | issue("PAYMENT_QUERY_FAILED", paymentNo, text(transaction.get("orderNo")), "payment status", "query failed", number(transaction.get("amount")), exception.isOutcomeUnknown() ? "Provider query outcome unknown" : "Provider query rejected"); |
| | | issues.issue("PAYMENT_QUERY_FAILED", paymentNo, orderNo, "payment status", "query failed", amount, |
| | | exception.isOutcomeUnknown() ? "Provider query outcome unknown" : "Provider query rejected"); |
| | | } catch (RuntimeException exception) { |
| | | issue("PAYMENT_QUERY_FAILED", paymentNo, text(transaction.get("orderNo")), "payment status", "local error", number(transaction.get("amount")), "Payment reconciliation failed"); |
| | | 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) { |
| | |
| | | String refundNo = text(row.get("refundNo")); |
| | | try { |
| | | Map<String, Object> result = refundService.query(refundNo); |
| | | if (number(result.get("refundStatus")) == 1) issue("REFUND_PENDING_TIMEOUT", refundNo, text(row.get("orderNo")), "refund completed", "refund pending", number(row.get("refundAmount")), "Refund remains pending after " + pendingAfterMinutes + " minutes"); |
| | | else resolve("REFUND_PENDING_TIMEOUT", refundNo, "system"); |
| | | 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) { |
| | | issue("REFUND_QUERY_FAILED", refundNo, text(row.get("orderNo")), "refund status", "query failed", number(row.get("refundAmount")), "Refund reconciliation failed"); |
| | | issues.issue("REFUND_QUERY_FAILED", refundNo, text(row.get("orderNo")), "refund status", "query failed", number(row.get("refundAmount")), "Refund reconciliation failed"); |
| | | } |
| | | } |
| | | return rows.size(); |
| | | } |
| | | |
| | | public void issue(String type, String businessNo, String orderNo, String expected, String actual, long amount, String message) { |
| | | jdbc.update(""" |
| | | insert into payment_reconciliation_issue(issue_type, provider, business_no, order_no, expected_status, actual_status, amount, detail_message) |
| | | values (?, ?, ?, ?, ?, ?, ?, ?) |
| | | on duplicate key update detected_at = now(), expected_status = values(expected_status), actual_status = values(actual_status), amount = values(amount), detail_message = values(detail_message) |
| | | """, type, PROVIDER, businessNo, emptyToNull(orderNo), emptyToNull(expected), emptyToNull(actual), amount, truncate(message)); |
| | | } |
| | | |
| | | public void resolve(String type, String businessNo, String by) { |
| | | jdbc.update("update payment_reconciliation_issue set status = 'resolved', resolved_at = now(), resolved_by = ? where issue_type = ? and provider = ? and business_no = ? and status = 'open'", by, type, PROVIDER, businessNo); |
| | | } |
| | | |
| | | 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(); } |
| | | private String emptyToNull(String value) { return value == null || value.isBlank() ? null : value; } |
| | | private String truncate(String value) { if (value == null) return ""; return value.length() > 500 ? value.substring(0, 500) : value; } |
| | | |
| | | @SuppressWarnings("unchecked") |
| | | private record ProviderResult(boolean success, boolean failed, String status, String code, String message, String providerNo) { |
| | | 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 = value(data, "trans_stat", "transStat"); |
| | | String code = value(data, "sub_resp_code", "resp_code", "code"); |
| | | String message = value(data, "sub_resp_desc", "resp_desc", "message"); |
| | | String providerNo = value(data, "hf_seq_id", "hfSeqId"); |
| | | boolean codeSuccess = "00000000".equals(code) || "10000".equals(code) || "0".equals(code) || "SUCCESS".equalsIgnoreCase(code); |
| | | boolean success = codeSuccess && ("S".equalsIgnoreCase(status) || "SUCCESS".equalsIgnoreCase(status) || "PAID".equalsIgnoreCase(status)); |
| | | boolean pending = "P".equalsIgnoreCase(status) || "PROCESSING".equalsIgnoreCase(status) || "PENDING".equalsIgnoreCase(status) || status.isBlank(); |
| | | return new ProviderResult(success, !success && !pending, status, code, message, providerNo); |
| | | 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"))); |
| | | } |
| | | private static String value(Map<String, Object> values, String... keys) { for (String key : keys) if (values.get(key) != null) return String.valueOf(values.get(key)).trim(); return ""; } |
| | | } |
| | | } |