package com.ruoyi.app.pay; import com.alibaba.fastjson2.JSONArray; import com.alibaba.fastjson2.JSONObject; import com.ruoyi.app.utils.linepay.LinePayClient; import com.ruoyi.app.utils.linepay.LinePayCredential; import com.ruoyi.app.utils.linepay.LinePayResponse; import com.ruoyi.common.exception.ServiceException; import com.ruoyi.system.domain.PosOrderLinePayment; import com.ruoyi.system.domain.PosOrderLineRefund; import com.ruoyi.system.domain.PosStoreLinePay; import com.ruoyi.system.service.IPosOrderLinePaymentService; import com.ruoyi.system.service.IPosOrderLineRefundService; import com.ruoyi.system.service.IPosStoreLinePayService; import org.springframework.stereotype.Service; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import java.util.Date; import java.util.Objects; import java.util.Set; import java.util.UUID; /** Executes idempotent full refunds and recovers uncertain refund outcomes by Retrieve. */ @Service public class LinePayRefundService { private static final Set RETRYABLE_CODES = Set.of("1900", "1902", "1999"); private static final Set UNCERTAIN_CODES = Set.of( "1164", "1165", "1198", "1199", "9000"); private static final Set CREDENTIAL_AUTH_FAILURE_CODES = Set.of("1104", "1105", "1106"); private final IPosOrderLinePaymentService paymentService; private final IPosOrderLineRefundService refundService; private final IPosStoreLinePayService credentialService; private final LinePayClient linePayClient; private final LinePayFactService factService; private final LinePayGatewayAuditService auditService; @Value("${line-pay.reconcile.unknown-deadline-hours:24}") private long unknownDeadlineHours = 24L; @Value("${line-pay.reconcile.row-lease-seconds:120}") private long rowLeaseSeconds = 120L; public LinePayRefundService(IPosOrderLinePaymentService paymentService, IPosOrderLineRefundService refundService, IPosStoreLinePayService credentialService, LinePayClient linePayClient, LinePayFactService factService) { this(paymentService, refundService, credentialService, linePayClient, factService, null); } @Autowired public LinePayRefundService(IPosOrderLinePaymentService paymentService, IPosOrderLineRefundService refundService, IPosStoreLinePayService credentialService, LinePayClient linePayClient, LinePayFactService factService, LinePayGatewayAuditService auditService) { this.paymentService = paymentService; this.refundService = refundService; this.credentialService = credentialService; this.linePayClient = linePayClient; this.factService = factService; this.auditService = auditService; } public String requestFullRefund(Long paymentId, String source) { PosOrderLinePayment payment = paymentService.getById(paymentId); if (payment == null || !"PAID".equals(payment.getStatus())) { throw new ServiceException("Only a captured LINE Pay payment can be refunded"); } PosOrderLineRefund refund = refundService.createIfAbsent(payment, source); if ("REFUNDED".equals(refund.getStatus())) { factService.applyRefundFact(payment.getId()); return "REFUNDED"; } if (refund.getReconcileDeadline() != null && !new Date().before(refund.getReconcileDeadline())) { if ("PROCESSING".equals(refund.getStatus())) { refundService.recoverExpiredProcessing(refund.getId(), refund.getVersion(), shortly(), unknownDeadline()); refund = refundService.getByPaymentId(paymentId); } if (refund == null) { throw new ServiceException("LINE Pay refund state requires reconciliation"); } String recovered = recoverUnknown(payment, refund, credentialFor(payment), false); if ("REFUNDED".equals(recovered)) { return recovered; } if ("MANUAL_REVIEW".equals(recovered)) { return recovered; } return markManualReview(refund.getPaymentId(), refund.getId(), refund.getVersion(), refund.getStatus()); } LinePayCredential credential = credentialFor(payment); if ("PROCESSING".equals(refund.getStatus())) { if (refund.getLeaseUntil() != null && refund.getLeaseUntil().before(new Date()) && refundService.recoverExpiredProcessing(refund.getId(), refund.getVersion(), shortly(), unknownDeadline()) == 1) { refund = refundService.getByPaymentId(paymentId); } else { return "PROCESSING"; } } if ("UNKNOWN".equals(refund.getStatus())) { return recoverUnknown(payment, refund, credential); } if (!("CREATED".equals(refund.getStatus()) || "RETRY_WAIT".equals(refund.getStatus()))) { return refund.getStatus(); } String owner = (source == null ? "LINE" : source) + "-" + UUID.randomUUID(); if (refundService.claimProcessing(refund.getId(), refund.getVersion(), refund.getStatus(), owner, leaseUntil()) != 1) { return refund.getStatus(); } long processingVersion = refund.getVersion() + 1; try { RefundEvidenceResult evidenceResult = retrieveRefundEvidence( payment, refund, credential, source); RefundEvidence existingRefund = evidenceResult.evidence(); LinePayCredential actionCredential = evidenceResult.credential(); if (existingRefund.fullRefundTransactionId() != null) { factService.applyRefundedFact(refund.getId(), payment.getId(), processingVersion, existingRefund.fullRefundTransactionId(), new Date()); return "REFUNDED"; } if (existingRefund.hasAnyRefund()) { return markManualReview(refund.getPaymentId(), refund.getId(), processingVersion, "PROCESSING"); } if (!existingRefund.definitelyNotRefunded()) { refundService.markUnknown(refund.getId(), processingVersion, shortly(), unknownDeadline()); return "UNKNOWN"; } LinePayResponse response = gatewayCall("REFUND", source, payment, refund, actionCredential.id(), () -> linePayClient.refundFull(actionCredential, payment.getTransactionId())); String refundTransactionId = response.info() == null ? null : response.info().getString("refundTransactionId"); if (response.isSuccess() && refundTransactionId != null ) { factService.applyRefundedFact(refund.getId(), payment.getId(), processingVersion, refundTransactionId, new Date()); return "REFUNDED"; } if (response != null && RETRYABLE_CODES.contains(response.returnCode())) { refundService.markRetryWait(refund.getId(), processingVersion, shortly()); return "RETRY_WAIT"; } if (response != null && response.httpStatus() >= 200 && response.httpStatus() < 300 && response.returnCode() != null && !UNCERTAIN_CODES.contains(response.returnCode())) { refundService.markFailed(refund.getId(), processingVersion); return "FAILED"; } } catch (Exception ignored) { // The gateway may have accepted the request; only Retrieve may decide its outcome. } refundService.markUnknown(refund.getId(), processingVersion, shortly(), unknownDeadline()); return "UNKNOWN"; } private LinePayCredential credentialFor(PosOrderLinePayment payment) { PosStoreLinePay credentialVersion = credentialService.getById(payment.getCredentialId()); if (credentialVersion == null) { throw new ServiceException("LINE Pay credential version not found"); } return new LinePayCredential(credentialVersion.getId(), credentialVersion.getChannelId(), credentialVersion.getChannelSecret()); } /** Creates the durable idempotent intent; the scheduled worker performs the gateway call. */ public String createRefundIntent(Long paymentId, String source) { PosOrderLinePayment payment = paymentService.getById(paymentId); if (payment == null || !"PAID".equals(payment.getStatus())) { throw new ServiceException("Only a captured LINE Pay payment can be refunded"); } return refundService.createIfAbsent(payment, source).getStatus(); } private String recoverUnknown(PosOrderLinePayment payment, PosOrderLineRefund refund, LinePayCredential credential) { return recoverUnknown(payment, refund, credential, true); } private String recoverUnknown(PosOrderLinePayment payment, PosOrderLineRefund refund, LinePayCredential credential, boolean reschedule) { try { RefundEvidence evidence = retrieveRefundEvidence( payment, refund, credential, "TASK").evidence(); if (evidence.fullRefundTransactionId() != null) { factService.applyRefundedFact(refund.getId(), payment.getId(), refund.getVersion(), evidence.fullRefundTransactionId(), new Date()); return "REFUNDED"; } if (evidence.hasAnyRefund()) { return markManualReview(refund.getPaymentId(), refund.getId(), refund.getVersion(), refund.getStatus()); } } catch (Exception ignored) { // Keep UNKNOWN; a later scheduled Retrieve can recover it. } if (reschedule) { Date deadline = refund.getReconcileDeadline() == null ? unknownDeadline() : refund.getReconcileDeadline(); refundService.rescheduleUnknown(refund.getId(), refund.getVersion(), shortly(), deadline); } return "UNKNOWN"; } private String markManualReview(Long paymentId, Long refundId, Long version, String expectedStatus) { if (refundService.markManualReview(refundId, version, expectedStatus) == 1) { return "MANUAL_REVIEW"; } PosOrderLineRefund current = refundService.getByPaymentId(paymentId); if (current == null) { throw new ServiceException("LINE Pay refund state requires reconciliation"); } if ("MANUAL_REVIEW".equals(current.getStatus()) || "REFUNDED".equals(current.getStatus())) { return current.getStatus(); } if (("UNKNOWN".equals(current.getStatus()) || "PROCESSING".equals(current.getStatus())) && refundService.markManualReview(current.getId(), current.getVersion(), current.getStatus()) == 1) { return "MANUAL_REVIEW"; } PosOrderLineRefund latest = refundService.getByPaymentId(paymentId); return latest == null ? current.getStatus() : latest.getStatus(); } private RefundEvidenceResult retrieveRefundEvidence(PosOrderLinePayment payment, PosOrderLineRefund refund, LinePayCredential credential, String source) throws Exception { LinePayResponse retrieve = gatewayCall("RETRIEVE", source, payment, refund, credential.id(), () -> linePayClient.retrieveByTransactionId(credential, payment.getTransactionId())); RefundEvidence evidence = refundEvidence(retrieve, payment); if (!isCredentialAuthFailure(retrieve)) { return new RefundEvidenceResult(evidence, credential); } PosStoreLinePay original = credentialService.getById(payment.getCredentialId()); PosStoreLinePay current = credentialService.getCurrent(payment.getStoreId()); if (original == null || current == null || Objects.equals(current.getId(), original.getId()) || !Objects.equals(current.getChannelId(), original.getChannelId()) || !Objects.equals(current.getEnvironment(), original.getEnvironment())) { return new RefundEvidenceResult(evidence, credential); } LinePayCredential currentCredential = new LinePayCredential(current.getId(), current.getChannelId(), current.getChannelSecret()); LinePayResponse proof = gatewayCall("RETRIEVE", source, payment, refund, currentCredential.id(), () -> linePayClient.retrieveByTransactionId( currentCredential, payment.getTransactionId())); RefundEvidence currentEvidence = refundEvidence(proof, payment); return currentEvidence.conclusive() ? new RefundEvidenceResult(currentEvidence, currentCredential) : new RefundEvidenceResult(evidence, credential); } private static boolean isCredentialAuthFailure(LinePayResponse response) { return response != null && CREDENTIAL_AUTH_FAILURE_CODES.contains(response.returnCode()); } private static RefundEvidence refundEvidence(LinePayResponse response, PosOrderLinePayment expected) { if (response == null || !response.isSuccess() || response.rawBody() == null) { return RefundEvidence.INCONCLUSIVE; } JSONArray info = JSONObject.parseObject(response.rawBody()).getJSONArray("info"); if (info == null || info.size() != 1) { return RefundEvidence.INCONCLUSIVE; } JSONObject payment = info.getJSONObject(0); if (payment == null || !expected.getTransactionId().equals(payment.getString("transactionId")) || !expected.getLineOrderId().equals(payment.getString("orderId")) || !"PAYMENT".equals(payment.getString("transactionType")) || !expected.getCurrency().equals(payment.getString("currency")) || !expected.getAmount().equals(paymentAmount(payment))) { return RefundEvidence.INCONCLUSIVE; } JSONArray refunds = payment.getJSONArray("refundList"); if (refunds == null || refunds.isEmpty()) { return RefundEvidence.DEFINITELY_NOT_REFUNDED; } long refundedAmount = 0L; String lastRefundTransactionId = null; for (int i = 0; i < refunds.size(); i++) { JSONObject item = refunds.getJSONObject(i); if (item == null) { return RefundEvidence.INCONCLUSIVE; } String refundTransactionId = item.getString("refundTransactionId"); Integer amount = item.getInteger("refundAmount"); if (refundTransactionId == null || amount == null || amount == 0) { return RefundEvidence.INCONCLUSIVE; } refundedAmount += Math.abs((long) amount); lastRefundTransactionId = refundTransactionId; } return new RefundEvidence(true, refundedAmount == expected.getAmount() ? lastRefundTransactionId : null, false); } private static Integer paymentAmount(JSONObject transaction) { JSONArray payInfo = transaction.getJSONArray("payInfo"); if (payInfo == null || payInfo.isEmpty()) { return null; } long total = 0L; for (int i = 0; i < payInfo.size(); i++) { JSONObject item = payInfo.getJSONObject(i); Integer amount = item == null ? null : item.getInteger("amount"); if (amount == null || amount <= 0) { return null; } total += amount; } return total <= Integer.MAX_VALUE ? (int) total : null; } private record RefundEvidence(boolean hasAnyRefund, String fullRefundTransactionId, boolean definitelyNotRefunded) { private static final RefundEvidence DEFINITELY_NOT_REFUNDED = new RefundEvidence(false, null, true); private static final RefundEvidence INCONCLUSIVE = new RefundEvidence(false, null, false); private boolean conclusive() { return hasAnyRefund || definitelyNotRefunded; } } private record RefundEvidenceResult(RefundEvidence evidence, LinePayCredential credential) { } private static Date shortly() { return new Date(System.currentTimeMillis() + 30_000L); } private T gatewayCall(String action, String source, PosOrderLinePayment payment, PosOrderLineRefund refund, LinePayGatewayAuditService.GatewayCall call) throws Exception { return gatewayCall(action, source, payment, refund, payment.getCredentialId(), call); } private T gatewayCall(String action, String source, PosOrderLinePayment payment, PosOrderLineRefund refund, Long credentialId, LinePayGatewayAuditService.GatewayCall call) throws Exception { if (auditService == null) { return call.call(); } return auditService.execute(action, source == null ? "LINE" : source, payment.getId(), refund.getId(), credentialId, payment.getStoreId(), payment.getDdId(), payment.getLineOrderId(), payment.getTransactionId(), call); } private Date unknownDeadline() { return new Date(System.currentTimeMillis() + unknownDeadlineHours * 60L * 60L * 1000L); } private Date leaseUntil() { return new Date(System.currentTimeMillis() + Math.max(60L, rowLeaseSeconds) * 1000L); } }