LinePayRefundService.java 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365
  1. package com.ruoyi.app.pay;
  2. import com.alibaba.fastjson2.JSONArray;
  3. import com.alibaba.fastjson2.JSONObject;
  4. import com.ruoyi.app.utils.linepay.LinePayClient;
  5. import com.ruoyi.app.utils.linepay.LinePayCredential;
  6. import com.ruoyi.app.utils.linepay.LinePayResponse;
  7. import com.ruoyi.common.exception.ServiceException;
  8. import com.ruoyi.system.domain.PosOrderLinePayment;
  9. import com.ruoyi.system.domain.PosOrderLineRefund;
  10. import com.ruoyi.system.domain.PosStoreLinePay;
  11. import com.ruoyi.system.service.IPosOrderLinePaymentService;
  12. import com.ruoyi.system.service.IPosOrderLineRefundService;
  13. import com.ruoyi.system.service.IPosStoreLinePayService;
  14. import org.springframework.stereotype.Service;
  15. import org.springframework.beans.factory.annotation.Autowired;
  16. import org.springframework.beans.factory.annotation.Value;
  17. import java.util.Date;
  18. import java.util.Objects;
  19. import java.util.Set;
  20. import java.util.UUID;
  21. /** Executes idempotent full refunds and recovers uncertain refund outcomes by Retrieve. */
  22. @Service
  23. public class LinePayRefundService {
  24. private static final Set<String> RETRYABLE_CODES = Set.of("1900", "1902", "1999");
  25. private static final Set<String> UNCERTAIN_CODES = Set.of(
  26. "1164", "1165", "1198", "1199", "9000");
  27. private static final Set<String> CREDENTIAL_AUTH_FAILURE_CODES = Set.of("1104", "1105", "1106");
  28. private final IPosOrderLinePaymentService paymentService;
  29. private final IPosOrderLineRefundService refundService;
  30. private final IPosStoreLinePayService credentialService;
  31. private final LinePayClient linePayClient;
  32. private final LinePayFactService factService;
  33. private final LinePayGatewayAuditService auditService;
  34. @Value("${line-pay.reconcile.unknown-deadline-hours:24}")
  35. private long unknownDeadlineHours = 24L;
  36. @Value("${line-pay.reconcile.row-lease-seconds:120}")
  37. private long rowLeaseSeconds = 120L;
  38. public LinePayRefundService(IPosOrderLinePaymentService paymentService,
  39. IPosOrderLineRefundService refundService,
  40. IPosStoreLinePayService credentialService,
  41. LinePayClient linePayClient,
  42. LinePayFactService factService) {
  43. this(paymentService, refundService, credentialService, linePayClient, factService, null);
  44. }
  45. @Autowired
  46. public LinePayRefundService(IPosOrderLinePaymentService paymentService,
  47. IPosOrderLineRefundService refundService,
  48. IPosStoreLinePayService credentialService,
  49. LinePayClient linePayClient,
  50. LinePayFactService factService,
  51. LinePayGatewayAuditService auditService) {
  52. this.paymentService = paymentService;
  53. this.refundService = refundService;
  54. this.credentialService = credentialService;
  55. this.linePayClient = linePayClient;
  56. this.factService = factService;
  57. this.auditService = auditService;
  58. }
  59. public String requestFullRefund(Long paymentId, String source) {
  60. PosOrderLinePayment payment = paymentService.getById(paymentId);
  61. if (payment == null || !"PAID".equals(payment.getStatus())) {
  62. throw new ServiceException("Only a captured LINE Pay payment can be refunded");
  63. }
  64. PosOrderLineRefund refund = refundService.createIfAbsent(payment, source);
  65. if ("REFUNDED".equals(refund.getStatus())) {
  66. factService.applyRefundFact(payment.getId());
  67. return "REFUNDED";
  68. }
  69. if (refund.getReconcileDeadline() != null
  70. && !new Date().before(refund.getReconcileDeadline())) {
  71. if ("PROCESSING".equals(refund.getStatus())) {
  72. refundService.recoverExpiredProcessing(refund.getId(), refund.getVersion(),
  73. shortly(), unknownDeadline());
  74. refund = refundService.getByPaymentId(paymentId);
  75. }
  76. if (refund == null) {
  77. throw new ServiceException("LINE Pay refund state requires reconciliation");
  78. }
  79. String recovered = recoverUnknown(payment, refund, credentialFor(payment), false);
  80. if ("REFUNDED".equals(recovered)) {
  81. return recovered;
  82. }
  83. if ("MANUAL_REVIEW".equals(recovered)) {
  84. return recovered;
  85. }
  86. return markManualReview(refund.getPaymentId(), refund.getId(), refund.getVersion(),
  87. refund.getStatus());
  88. }
  89. LinePayCredential credential = credentialFor(payment);
  90. if ("PROCESSING".equals(refund.getStatus())) {
  91. if (refund.getLeaseUntil() != null && refund.getLeaseUntil().before(new Date())
  92. && refundService.recoverExpiredProcessing(refund.getId(), refund.getVersion(),
  93. shortly(), unknownDeadline()) == 1) {
  94. refund = refundService.getByPaymentId(paymentId);
  95. } else {
  96. return "PROCESSING";
  97. }
  98. }
  99. if ("UNKNOWN".equals(refund.getStatus())) {
  100. return recoverUnknown(payment, refund, credential);
  101. }
  102. if (!("CREATED".equals(refund.getStatus()) || "RETRY_WAIT".equals(refund.getStatus()))) {
  103. return refund.getStatus();
  104. }
  105. String owner = (source == null ? "LINE" : source) + "-" + UUID.randomUUID();
  106. if (refundService.claimProcessing(refund.getId(), refund.getVersion(), refund.getStatus(),
  107. owner, leaseUntil()) != 1) {
  108. return refund.getStatus();
  109. }
  110. long processingVersion = refund.getVersion() + 1;
  111. try {
  112. RefundEvidenceResult evidenceResult = retrieveRefundEvidence(
  113. payment, refund, credential, source);
  114. RefundEvidence existingRefund = evidenceResult.evidence();
  115. LinePayCredential actionCredential = evidenceResult.credential();
  116. if (existingRefund.fullRefundTransactionId() != null) {
  117. factService.applyRefundedFact(refund.getId(), payment.getId(), processingVersion,
  118. existingRefund.fullRefundTransactionId(), new Date());
  119. return "REFUNDED";
  120. }
  121. if (existingRefund.hasAnyRefund()) {
  122. return markManualReview(refund.getPaymentId(), refund.getId(), processingVersion,
  123. "PROCESSING");
  124. }
  125. if (!existingRefund.definitelyNotRefunded()) {
  126. refundService.markUnknown(refund.getId(), processingVersion, shortly(), unknownDeadline());
  127. return "UNKNOWN";
  128. }
  129. LinePayResponse response = gatewayCall("REFUND", source, payment, refund,
  130. actionCredential.id(),
  131. () -> linePayClient.refundFull(actionCredential, payment.getTransactionId()));
  132. String refundTransactionId = response.info() == null
  133. ? null : response.info().getString("refundTransactionId");
  134. if (response.isSuccess() && refundTransactionId != null
  135. ) {
  136. factService.applyRefundedFact(refund.getId(), payment.getId(), processingVersion,
  137. refundTransactionId, new Date());
  138. return "REFUNDED";
  139. }
  140. if (response != null && RETRYABLE_CODES.contains(response.returnCode())) {
  141. refundService.markRetryWait(refund.getId(), processingVersion, shortly());
  142. return "RETRY_WAIT";
  143. }
  144. if (response != null && response.httpStatus() >= 200 && response.httpStatus() < 300
  145. && response.returnCode() != null
  146. && !UNCERTAIN_CODES.contains(response.returnCode())) {
  147. refundService.markFailed(refund.getId(), processingVersion);
  148. return "FAILED";
  149. }
  150. } catch (Exception ignored) {
  151. // The gateway may have accepted the request; only Retrieve may decide its outcome.
  152. }
  153. refundService.markUnknown(refund.getId(), processingVersion, shortly(), unknownDeadline());
  154. return "UNKNOWN";
  155. }
  156. private LinePayCredential credentialFor(PosOrderLinePayment payment) {
  157. PosStoreLinePay credentialVersion = credentialService.getById(payment.getCredentialId());
  158. if (credentialVersion == null) {
  159. throw new ServiceException("LINE Pay credential version not found");
  160. }
  161. return new LinePayCredential(credentialVersion.getId(), credentialVersion.getChannelId(),
  162. credentialVersion.getChannelSecret());
  163. }
  164. /** Creates the durable idempotent intent; the scheduled worker performs the gateway call. */
  165. public String createRefundIntent(Long paymentId, String source) {
  166. PosOrderLinePayment payment = paymentService.getById(paymentId);
  167. if (payment == null || !"PAID".equals(payment.getStatus())) {
  168. throw new ServiceException("Only a captured LINE Pay payment can be refunded");
  169. }
  170. return refundService.createIfAbsent(payment, source).getStatus();
  171. }
  172. private String recoverUnknown(PosOrderLinePayment payment, PosOrderLineRefund refund,
  173. LinePayCredential credential) {
  174. return recoverUnknown(payment, refund, credential, true);
  175. }
  176. private String recoverUnknown(PosOrderLinePayment payment, PosOrderLineRefund refund,
  177. LinePayCredential credential, boolean reschedule) {
  178. try {
  179. RefundEvidence evidence = retrieveRefundEvidence(
  180. payment, refund, credential, "TASK").evidence();
  181. if (evidence.fullRefundTransactionId() != null) {
  182. factService.applyRefundedFact(refund.getId(), payment.getId(), refund.getVersion(),
  183. evidence.fullRefundTransactionId(), new Date());
  184. return "REFUNDED";
  185. }
  186. if (evidence.hasAnyRefund()) {
  187. return markManualReview(refund.getPaymentId(), refund.getId(), refund.getVersion(),
  188. refund.getStatus());
  189. }
  190. } catch (Exception ignored) {
  191. // Keep UNKNOWN; a later scheduled Retrieve can recover it.
  192. }
  193. if (reschedule) {
  194. Date deadline = refund.getReconcileDeadline() == null
  195. ? unknownDeadline() : refund.getReconcileDeadline();
  196. refundService.rescheduleUnknown(refund.getId(), refund.getVersion(), shortly(), deadline);
  197. }
  198. return "UNKNOWN";
  199. }
  200. private String markManualReview(Long paymentId, Long refundId, Long version,
  201. String expectedStatus) {
  202. if (refundService.markManualReview(refundId, version, expectedStatus) == 1) {
  203. return "MANUAL_REVIEW";
  204. }
  205. PosOrderLineRefund current = refundService.getByPaymentId(paymentId);
  206. if (current == null) {
  207. throw new ServiceException("LINE Pay refund state requires reconciliation");
  208. }
  209. if ("MANUAL_REVIEW".equals(current.getStatus()) || "REFUNDED".equals(current.getStatus())) {
  210. return current.getStatus();
  211. }
  212. if (("UNKNOWN".equals(current.getStatus()) || "PROCESSING".equals(current.getStatus()))
  213. && refundService.markManualReview(current.getId(), current.getVersion(),
  214. current.getStatus()) == 1) {
  215. return "MANUAL_REVIEW";
  216. }
  217. PosOrderLineRefund latest = refundService.getByPaymentId(paymentId);
  218. return latest == null ? current.getStatus() : latest.getStatus();
  219. }
  220. private RefundEvidenceResult retrieveRefundEvidence(PosOrderLinePayment payment,
  221. PosOrderLineRefund refund,
  222. LinePayCredential credential,
  223. String source) throws Exception {
  224. LinePayResponse retrieve = gatewayCall("RETRIEVE", source, payment, refund,
  225. credential.id(),
  226. () -> linePayClient.retrieveByTransactionId(credential, payment.getTransactionId()));
  227. RefundEvidence evidence = refundEvidence(retrieve, payment);
  228. if (!isCredentialAuthFailure(retrieve)) {
  229. return new RefundEvidenceResult(evidence, credential);
  230. }
  231. PosStoreLinePay original = credentialService.getById(payment.getCredentialId());
  232. PosStoreLinePay current = credentialService.getCurrent(payment.getStoreId());
  233. if (original == null || current == null || Objects.equals(current.getId(), original.getId())
  234. || !Objects.equals(current.getChannelId(), original.getChannelId())
  235. || !Objects.equals(current.getEnvironment(), original.getEnvironment())) {
  236. return new RefundEvidenceResult(evidence, credential);
  237. }
  238. LinePayCredential currentCredential = new LinePayCredential(current.getId(),
  239. current.getChannelId(), current.getChannelSecret());
  240. LinePayResponse proof = gatewayCall("RETRIEVE", source, payment, refund,
  241. currentCredential.id(),
  242. () -> linePayClient.retrieveByTransactionId(
  243. currentCredential, payment.getTransactionId()));
  244. RefundEvidence currentEvidence = refundEvidence(proof, payment);
  245. return currentEvidence.conclusive()
  246. ? new RefundEvidenceResult(currentEvidence, currentCredential)
  247. : new RefundEvidenceResult(evidence, credential);
  248. }
  249. private static boolean isCredentialAuthFailure(LinePayResponse response) {
  250. return response != null && CREDENTIAL_AUTH_FAILURE_CODES.contains(response.returnCode());
  251. }
  252. private static RefundEvidence refundEvidence(LinePayResponse response, PosOrderLinePayment expected) {
  253. if (response == null || !response.isSuccess() || response.rawBody() == null) {
  254. return RefundEvidence.INCONCLUSIVE;
  255. }
  256. JSONArray info = JSONObject.parseObject(response.rawBody()).getJSONArray("info");
  257. if (info == null || info.size() != 1) {
  258. return RefundEvidence.INCONCLUSIVE;
  259. }
  260. JSONObject payment = info.getJSONObject(0);
  261. if (payment == null || !expected.getTransactionId().equals(payment.getString("transactionId"))
  262. || !expected.getLineOrderId().equals(payment.getString("orderId"))
  263. || !"PAYMENT".equals(payment.getString("transactionType"))
  264. || !expected.getCurrency().equals(payment.getString("currency"))
  265. || !expected.getAmount().equals(paymentAmount(payment))) {
  266. return RefundEvidence.INCONCLUSIVE;
  267. }
  268. JSONArray refunds = payment.getJSONArray("refundList");
  269. if (refunds == null || refunds.isEmpty()) {
  270. return RefundEvidence.DEFINITELY_NOT_REFUNDED;
  271. }
  272. long refundedAmount = 0L;
  273. String lastRefundTransactionId = null;
  274. for (int i = 0; i < refunds.size(); i++) {
  275. JSONObject item = refunds.getJSONObject(i);
  276. if (item == null) {
  277. return RefundEvidence.INCONCLUSIVE;
  278. }
  279. String refundTransactionId = item.getString("refundTransactionId");
  280. Integer amount = item.getInteger("refundAmount");
  281. if (refundTransactionId == null || amount == null || amount == 0) {
  282. return RefundEvidence.INCONCLUSIVE;
  283. }
  284. refundedAmount += Math.abs((long) amount);
  285. lastRefundTransactionId = refundTransactionId;
  286. }
  287. return new RefundEvidence(true,
  288. refundedAmount == expected.getAmount() ? lastRefundTransactionId : null, false);
  289. }
  290. private static Integer paymentAmount(JSONObject transaction) {
  291. JSONArray payInfo = transaction.getJSONArray("payInfo");
  292. if (payInfo == null || payInfo.isEmpty()) {
  293. return null;
  294. }
  295. long total = 0L;
  296. for (int i = 0; i < payInfo.size(); i++) {
  297. JSONObject item = payInfo.getJSONObject(i);
  298. Integer amount = item == null ? null : item.getInteger("amount");
  299. if (amount == null || amount <= 0) {
  300. return null;
  301. }
  302. total += amount;
  303. }
  304. return total <= Integer.MAX_VALUE ? (int) total : null;
  305. }
  306. private record RefundEvidence(boolean hasAnyRefund, String fullRefundTransactionId,
  307. boolean definitelyNotRefunded) {
  308. private static final RefundEvidence DEFINITELY_NOT_REFUNDED =
  309. new RefundEvidence(false, null, true);
  310. private static final RefundEvidence INCONCLUSIVE = new RefundEvidence(false, null, false);
  311. private boolean conclusive() {
  312. return hasAnyRefund || definitelyNotRefunded;
  313. }
  314. }
  315. private record RefundEvidenceResult(RefundEvidence evidence, LinePayCredential credential) {
  316. }
  317. private static Date shortly() {
  318. return new Date(System.currentTimeMillis() + 30_000L);
  319. }
  320. private <T> T gatewayCall(String action, String source, PosOrderLinePayment payment,
  321. PosOrderLineRefund refund,
  322. LinePayGatewayAuditService.GatewayCall<T> call) throws Exception {
  323. return gatewayCall(action, source, payment, refund, payment.getCredentialId(), call);
  324. }
  325. private <T> T gatewayCall(String action, String source, PosOrderLinePayment payment,
  326. PosOrderLineRefund refund, Long credentialId,
  327. LinePayGatewayAuditService.GatewayCall<T> call) throws Exception {
  328. if (auditService == null) {
  329. return call.call();
  330. }
  331. return auditService.execute(action, source == null ? "LINE" : source,
  332. payment.getId(), refund.getId(), credentialId, payment.getStoreId(),
  333. payment.getDdId(), payment.getLineOrderId(), payment.getTransactionId(), call);
  334. }
  335. private Date unknownDeadline() {
  336. return new Date(System.currentTimeMillis() + unknownDeadlineHours * 60L * 60L * 1000L);
  337. }
  338. private Date leaseUntil() {
  339. return new Date(System.currentTimeMillis() + Math.max(60L, rowLeaseSeconds) * 1000L);
  340. }
  341. }