|
|
@@ -0,0 +1,685 @@
|
|
|
+package com.ruoyi.app.pay;
|
|
|
+
|
|
|
+import com.alibaba.fastjson2.JSONObject;
|
|
|
+import com.alibaba.fastjson2.JSONArray;
|
|
|
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
|
|
+import com.ruoyi.app.pay.dto.LinePayCreateResult;
|
|
|
+import com.ruoyi.app.pay.dto.LinePayQueryResult;
|
|
|
+import com.ruoyi.app.utils.linepay.LinePayClient;
|
|
|
+import com.ruoyi.app.utils.linepay.LinePayCredential;
|
|
|
+import com.ruoyi.app.utils.linepay.LinePayRequest;
|
|
|
+import com.ruoyi.app.utils.linepay.LinePayResponse;
|
|
|
+import com.ruoyi.common.exception.ServiceException;
|
|
|
+import com.ruoyi.system.domain.PosOrder;
|
|
|
+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.IPosOrderService;
|
|
|
+import com.ruoyi.system.service.IPosStoreLinePayService;
|
|
|
+import org.springframework.dao.DuplicateKeyException;
|
|
|
+import org.springframework.beans.factory.annotation.Autowired;
|
|
|
+import org.springframework.stereotype.Service;
|
|
|
+import org.springframework.beans.factory.annotation.Value;
|
|
|
+
|
|
|
+import java.util.Comparator;
|
|
|
+import java.util.Date;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Locale;
|
|
|
+import java.util.Objects;
|
|
|
+import java.util.Set;
|
|
|
+import java.util.UUID;
|
|
|
+
|
|
|
+/** LINE Pay create, query, confirm, retrieval and payment-fact orchestration. */
|
|
|
+@Service
|
|
|
+public class LinePayService {
|
|
|
+
|
|
|
+ public static final String PAY_TYPE_LINE = "3";
|
|
|
+ private static final List<String> DEFINITE_REQUEST_FAILURE_CODES = List.of(
|
|
|
+ "1104", "1105", "1106", "1124", "1178", "1183", "1194", "2101", "2102");
|
|
|
+ private static final Set<String> CREDENTIAL_AUTH_FAILURE_CODES = Set.of("1104", "1105", "1106");
|
|
|
+ @Value("${line-pay.reconcile.auth-deadline-minutes:30}")
|
|
|
+ private long authDeadlineMinutes = 30L;
|
|
|
+ @Value("${line-pay.reconcile.unknown-deadline-hours:24}")
|
|
|
+ private long unknownDeadlineHours = 24L;
|
|
|
+ @Value("${line-pay.reconcile.row-lease-seconds:120}")
|
|
|
+ private long rowLeaseSeconds = 120L;
|
|
|
+
|
|
|
+ private final IPosOrderService orderService;
|
|
|
+ private final IPosStoreLinePayService credentialService;
|
|
|
+ private final IPosOrderLinePaymentService paymentService;
|
|
|
+ private final IPosOrderLineRefundService refundService;
|
|
|
+ private final LinePayClient linePayClient;
|
|
|
+ private final LinePayFactService factService;
|
|
|
+ private final LinePayGatewayAuditService auditService;
|
|
|
+ private final PaymentCreateGuardService createGuard;
|
|
|
+
|
|
|
+ public LinePayService(IPosOrderService orderService,
|
|
|
+ IPosStoreLinePayService credentialService,
|
|
|
+ IPosOrderLinePaymentService paymentService,
|
|
|
+ IPosOrderLineRefundService refundService,
|
|
|
+ LinePayClient linePayClient,
|
|
|
+ LinePayFactService factService) {
|
|
|
+ this(orderService, credentialService, paymentService, refundService,
|
|
|
+ linePayClient, factService, null, null);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Autowired
|
|
|
+ public LinePayService(IPosOrderService orderService,
|
|
|
+ IPosStoreLinePayService credentialService,
|
|
|
+ IPosOrderLinePaymentService paymentService,
|
|
|
+ IPosOrderLineRefundService refundService,
|
|
|
+ LinePayClient linePayClient,
|
|
|
+ LinePayFactService factService,
|
|
|
+ LinePayGatewayAuditService auditService,
|
|
|
+ PaymentCreateGuardService createGuard) {
|
|
|
+ this.orderService = orderService;
|
|
|
+ this.credentialService = credentialService;
|
|
|
+ this.paymentService = paymentService;
|
|
|
+ this.refundService = refundService;
|
|
|
+ this.linePayClient = linePayClient;
|
|
|
+ this.factService = factService;
|
|
|
+ this.auditService = auditService;
|
|
|
+ this.createGuard = createGuard;
|
|
|
+ }
|
|
|
+
|
|
|
+ public LinePayCreateResult create(Long userId, String ddId) {
|
|
|
+ if (createGuard != null) {
|
|
|
+ return createGuard.withLock(ddId, () -> createUnderLock(userId, ddId));
|
|
|
+ }
|
|
|
+ return createUnderLock(userId, ddId);
|
|
|
+ }
|
|
|
+
|
|
|
+ private LinePayCreateResult createUnderLock(Long userId, String ddId) {
|
|
|
+ PosOrder order = requirePayableOrder(userId, ddId);
|
|
|
+ requireSingleStoreOrder(order);
|
|
|
+ PosStoreLinePay credential = credentialService.getEnabledCurrent(order.getMdId());
|
|
|
+ if (credential == null) {
|
|
|
+ throw new ServiceException("LINE Pay is not enabled for this store");
|
|
|
+ }
|
|
|
+ PosOrderLinePayment active = paymentService.getActiveByDdId(ddId);
|
|
|
+ if (active != null) {
|
|
|
+ return createResult(active, true);
|
|
|
+ }
|
|
|
+
|
|
|
+ IntentSelection selection = createIntent(order, credential);
|
|
|
+ PosOrderLinePayment intent = selection.payment();
|
|
|
+ if (!selection.created()) {
|
|
|
+ return createResult(intent, true);
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ LinePayResponse response = gatewayCall("REQUEST", "APP", intent,
|
|
|
+ () -> linePayClient.request(toCredential(credential),
|
|
|
+ new LinePayRequest(intent.getLineOrderId(), ddId,
|
|
|
+ "Order " + ddId, order.getAmount(), "TWD")));
|
|
|
+ if (!response.isSuccess() || response.transactionId() == null) {
|
|
|
+ if (isDefiniteRequestFailure(response)) {
|
|
|
+ if (paymentService.markTerminal(intent.getId(), intent.getVersion(),
|
|
|
+ "REQUESTING", "FAILED") == 1) {
|
|
|
+ intent.setStatus("FAILED");
|
|
|
+ intent.setActiveDdId(null);
|
|
|
+ return createResult(intent, false);
|
|
|
+ }
|
|
|
+ PosOrderLinePayment latest = paymentService.getById(intent.getId());
|
|
|
+ if (latest != null) {
|
|
|
+ return createResult(latest, true);
|
|
|
+ }
|
|
|
+ throw new ServiceException("LINE Pay payment state requires reconciliation");
|
|
|
+ }
|
|
|
+ paymentService.markRequestUnknown(intent.getId(), intent.getVersion(),
|
|
|
+ shortly(), unknownDeadline());
|
|
|
+ intent.setStatus("REQUEST_UNKNOWN");
|
|
|
+ return createResult(intent, false);
|
|
|
+ }
|
|
|
+ JSONObject paymentUrl = response.info() == null
|
|
|
+ ? null : response.info().getJSONObject("paymentUrl");
|
|
|
+ String web = paymentUrl == null ? null : paymentUrl.getString("web");
|
|
|
+ String app = paymentUrl == null ? null : paymentUrl.getString("app");
|
|
|
+ if (web == null || paymentService.markRequestSucceeded(intent.getId(), intent.getVersion(),
|
|
|
+ response.transactionId(), web, app, shortly(), authDeadline()) != 1) {
|
|
|
+ paymentService.markRequestUnknown(intent.getId(), intent.getVersion(),
|
|
|
+ shortly(), unknownDeadline());
|
|
|
+ intent.setStatus("REQUEST_UNKNOWN");
|
|
|
+ return createResult(intent, false);
|
|
|
+ }
|
|
|
+ intent.setTransactionId(response.transactionId());
|
|
|
+ intent.setPaymentUrlWeb(web);
|
|
|
+ intent.setPaymentUrlApp(app);
|
|
|
+ intent.setStatus("WAITING_AUTH");
|
|
|
+ return createResult(intent, false);
|
|
|
+ } catch (Exception unknown) {
|
|
|
+ paymentService.markRequestUnknown(intent.getId(), intent.getVersion(),
|
|
|
+ shortly(), unknownDeadline());
|
|
|
+ intent.setStatus("REQUEST_UNKNOWN");
|
|
|
+ return createResult(intent, false);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ public LinePayQueryResult query(Long userId, String ddId) {
|
|
|
+ PosOrder order = requireOwnedOrder(userId, ddId);
|
|
|
+ List<PosOrderLinePayment> attempts = paymentService.getByDdId(ddId);
|
|
|
+ PosOrderLinePayment selected = selectForApp(order, attempts);
|
|
|
+ LinePayQueryResult result = new LinePayQueryResult();
|
|
|
+ result.setDdId(ddId);
|
|
|
+ result.setOrderPayStatus(order.getPayStatus());
|
|
|
+ if (selected != null) {
|
|
|
+ result.setPaymentId(selected.getId());
|
|
|
+ result.setPaymentStatus(selected.getStatus());
|
|
|
+ result.setTransactionId(selected.getTransactionId());
|
|
|
+ result.setUpdatedAt(selected.getUpdateTime());
|
|
|
+ PosOrderLineRefund refund = refundService.getByPaymentId(selected.getId());
|
|
|
+ result.setRefundStatus(refund == null ? null : refund.getStatus());
|
|
|
+ }
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+
|
|
|
+ public PosOrderLinePayment findByLineOrderId(String lineOrderId, String transactionId) {
|
|
|
+ PosOrderLinePayment payment = paymentService.getByLineOrderId(lineOrderId);
|
|
|
+ if (payment == null) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ if (transactionId != null && payment.getTransactionId() != null
|
|
|
+ && !transactionId.equals(payment.getTransactionId())) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ return payment;
|
|
|
+ }
|
|
|
+
|
|
|
+ public String reconcilePayment(Long paymentId, String source) {
|
|
|
+ return reconcilePayment(paymentId, source, null);
|
|
|
+ }
|
|
|
+
|
|
|
+ public String reconcilePayment(Long paymentId, String source, String reconcileLeaseOwner) {
|
|
|
+ PosOrderLinePayment payment = paymentService.getById(paymentId);
|
|
|
+ if (payment == null) {
|
|
|
+ throw new ServiceException("LINE Pay payment not found");
|
|
|
+ }
|
|
|
+ if (payment.getReconcileDeadline() != null
|
|
|
+ && !new Date().before(payment.getReconcileDeadline())) {
|
|
|
+ return finalDeadlineReconcile(payment, source);
|
|
|
+ }
|
|
|
+ PosStoreLinePay credential = credentialService.getById(payment.getCredentialId());
|
|
|
+ if (credential == null) {
|
|
|
+ throw new ServiceException("LINE Pay credential version not found");
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ RetrieveResult retrieveResult = retrieveWithCredentialRecovery(
|
|
|
+ payment, source, credential, toCredential(credential));
|
|
|
+ LinePayResponse retrieve = retrieveResult.response();
|
|
|
+ LinePayCredential gatewayCredential = retrieveResult.credential();
|
|
|
+ JSONObject captured = capturedPayment(retrieve, payment);
|
|
|
+ if (captured != null) {
|
|
|
+ String fullRefundTransactionId = fullRefundTransactionId(captured, payment.getAmount());
|
|
|
+ if (fullRefundTransactionId != null) {
|
|
|
+ factService.applyPaidAndRefundedFact(payment.getId(),
|
|
|
+ captured.getString("transactionId"), paymentProvider(retrieve), new Date(),
|
|
|
+ fullRefundTransactionId, new Date());
|
|
|
+ return "REFUNDED";
|
|
|
+ }
|
|
|
+ if (hasRefundEvidence(captured)) {
|
|
|
+ if (isLocallyRefunded(payment.getId())) {
|
|
|
+ return "REFUNDED";
|
|
|
+ }
|
|
|
+ factService.applyPaidWithRefundReviewFact(payment.getId(),
|
|
|
+ captured.getString("transactionId"), paymentProvider(retrieve), new Date());
|
|
|
+ return "MANUAL_REVIEW";
|
|
|
+ }
|
|
|
+ factService.applyPaidFact(payment.getId(), captured.getString("transactionId"),
|
|
|
+ paymentProvider(retrieve), new Date());
|
|
|
+ return "PAID";
|
|
|
+ }
|
|
|
+ if (!("1150".equals(retrieve.returnCode()) || retrieve.isSuccess())) {
|
|
|
+ return payment.getStatus();
|
|
|
+ }
|
|
|
+ if (!"1150".equals(retrieve.returnCode())
|
|
|
+ || payment.getTransactionId() == null
|
|
|
+ || !canCheckOrConfirm(payment.getStatus())) {
|
|
|
+ return payment.getStatus();
|
|
|
+ }
|
|
|
+ LinePayResponse check = gatewayCall("CHECK", source, payment,
|
|
|
+ () -> linePayClient.check(gatewayCredential, payment.getTransactionId()));
|
|
|
+ return handleCheck(payment, gatewayCredential, check, source, reconcileLeaseOwner);
|
|
|
+ } catch (Exception unknown) {
|
|
|
+ if ("CONFIRMING".equals(payment.getStatus())) {
|
|
|
+ paymentService.markConfirmUnknown(payment.getId(), payment.getVersion(),
|
|
|
+ shortly(), unknownDeadline());
|
|
|
+ return "CONFIRM_UNKNOWN";
|
|
|
+ }
|
|
|
+ return payment.getStatus();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private String finalDeadlineReconcile(PosOrderLinePayment payment, String source) {
|
|
|
+ PosStoreLinePay credential = credentialService.getById(payment.getCredentialId());
|
|
|
+ if (credential == null) {
|
|
|
+ paymentService.markManualReview(payment.getId(), payment.getVersion(), payment.getStatus());
|
|
|
+ return "MANUAL_REVIEW";
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ RetrieveResult retrieveResult = retrieveWithCredentialRecovery(
|
|
|
+ payment, source, credential, toCredential(credential));
|
|
|
+ LinePayCredential gatewayCredential = retrieveResult.credential();
|
|
|
+ LinePayResponse retrieve = retrieveResult.response();
|
|
|
+ JSONObject captured = capturedPayment(retrieve, payment);
|
|
|
+ if (captured != null) {
|
|
|
+ String fullRefundTransactionId = fullRefundTransactionId(captured, payment.getAmount());
|
|
|
+ if (fullRefundTransactionId != null) {
|
|
|
+ factService.applyPaidAndRefundedFact(payment.getId(),
|
|
|
+ captured.getString("transactionId"), paymentProvider(retrieve), new Date(),
|
|
|
+ fullRefundTransactionId, new Date());
|
|
|
+ return "REFUNDED";
|
|
|
+ }
|
|
|
+ if (hasRefundEvidence(captured)) {
|
|
|
+ if (isLocallyRefunded(payment.getId())) {
|
|
|
+ return "REFUNDED";
|
|
|
+ }
|
|
|
+ factService.applyPaidWithRefundReviewFact(payment.getId(),
|
|
|
+ captured.getString("transactionId"), paymentProvider(retrieve), new Date());
|
|
|
+ return "MANUAL_REVIEW";
|
|
|
+ }
|
|
|
+ factService.applyPaidFact(payment.getId(), captured.getString("transactionId"),
|
|
|
+ paymentProvider(retrieve), new Date());
|
|
|
+ return "PAID";
|
|
|
+ }
|
|
|
+ if ("1150".equals(retrieve.returnCode()) && payment.getTransactionId() != null
|
|
|
+ && canCheckOrConfirm(payment.getStatus())) {
|
|
|
+ LinePayResponse check = gatewayCall("CHECK", source, payment,
|
|
|
+ () -> linePayClient.check(gatewayCredential, payment.getTransactionId()));
|
|
|
+ if ("0121".equals(check.returnCode()) || "0122".equals(check.returnCode())) {
|
|
|
+ String terminal = "0121".equals(check.returnCode())
|
|
|
+ ? "CANCELLED_OR_EXPIRED" : "FAILED";
|
|
|
+ paymentService.markTerminal(payment.getId(), payment.getVersion(),
|
|
|
+ payment.getStatus(), terminal);
|
|
|
+ return terminal;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } catch (Exception ignored) {
|
|
|
+ // The final read was inconclusive; stop automatic scanning for manual review.
|
|
|
+ }
|
|
|
+ paymentService.markManualReview(payment.getId(), payment.getVersion(), payment.getStatus());
|
|
|
+ return "MANUAL_REVIEW";
|
|
|
+ }
|
|
|
+
|
|
|
+ private String handleCheck(PosOrderLinePayment payment, LinePayCredential credential,
|
|
|
+ LinePayResponse check, String source,
|
|
|
+ String reconcileLeaseOwner) throws Exception {
|
|
|
+ String code = check.returnCode();
|
|
|
+ if ("0000".equals(code)) {
|
|
|
+ return payment.getStatus();
|
|
|
+ }
|
|
|
+ if ("0121".equals(code) || "0122".equals(code)) {
|
|
|
+ String terminal = "0121".equals(code) ? "CANCELLED_OR_EXPIRED" : "FAILED";
|
|
|
+ paymentService.markTerminal(payment.getId(), payment.getVersion(), payment.getStatus(), terminal);
|
|
|
+ return terminal;
|
|
|
+ }
|
|
|
+ if ("0123".equals(code)) {
|
|
|
+ LinePayResponse retrieve = gatewayCall("RETRIEVE", source, payment,
|
|
|
+ () -> payment.getTransactionId() == null
|
|
|
+ ? linePayClient.retrieveByOrderId(credential, payment.getLineOrderId())
|
|
|
+ : linePayClient.retrieveByTransactionId(credential, payment.getTransactionId()));
|
|
|
+ JSONObject captured = capturedPayment(retrieve, payment);
|
|
|
+ if (captured != null) {
|
|
|
+ String fullRefundTransactionId = fullRefundTransactionId(captured, payment.getAmount());
|
|
|
+ if (fullRefundTransactionId != null) {
|
|
|
+ factService.applyPaidAndRefundedFact(payment.getId(),
|
|
|
+ captured.getString("transactionId"), paymentProvider(retrieve), new Date(),
|
|
|
+ fullRefundTransactionId, new Date());
|
|
|
+ return "REFUNDED";
|
|
|
+ }
|
|
|
+ if (hasRefundEvidence(captured)) {
|
|
|
+ if (isLocallyRefunded(payment.getId())) {
|
|
|
+ return "REFUNDED";
|
|
|
+ }
|
|
|
+ factService.applyPaidWithRefundReviewFact(payment.getId(),
|
|
|
+ captured.getString("transactionId"), paymentProvider(retrieve), new Date());
|
|
|
+ return "MANUAL_REVIEW";
|
|
|
+ }
|
|
|
+ factService.applyPaidFact(payment.getId(), captured.getString("transactionId"),
|
|
|
+ paymentProvider(retrieve), new Date());
|
|
|
+ return "PAID";
|
|
|
+ }
|
|
|
+ return payment.getStatus();
|
|
|
+ }
|
|
|
+ if (!"0110".equals(code)) {
|
|
|
+ return payment.getStatus();
|
|
|
+ }
|
|
|
+ if (!canCheckOrConfirm(payment.getStatus())) {
|
|
|
+ return payment.getStatus();
|
|
|
+ }
|
|
|
+ PosOrder order = orderService.getOne(new QueryWrapper<PosOrder>().eq("dd_id", payment.getDdId()));
|
|
|
+ if (order == null || !isPayableOrderState(order)) {
|
|
|
+ paymentService.markAuthDoneOrderCancelled(payment.getId(), payment.getVersion(),
|
|
|
+ payment.getStatus(), shortly(), authDeadline());
|
|
|
+ return "AUTH_DONE_ORDER_CANCELLED";
|
|
|
+ }
|
|
|
+ if (!LinePayService.PAY_TYPE_LINE.equals(order.getPayType())
|
|
|
+ || Long.valueOf(1L).equals(order.getPayStatus())
|
|
|
+ || !order.getMdId().equals(payment.getStoreId())
|
|
|
+ || !order.getAmount().equals(payment.getAmount())) {
|
|
|
+ return payment.getStatus();
|
|
|
+ }
|
|
|
+ String leaseOwner = reconcileLeaseOwner == null
|
|
|
+ ? (source == null ? "LINE" : source) + "-" + UUID.randomUUID()
|
|
|
+ : reconcileLeaseOwner;
|
|
|
+ if (paymentService.claimConfirm(payment.getId(), payment.getVersion(), payment.getStatus(),
|
|
|
+ leaseOwner, leaseUntil()) != 1) {
|
|
|
+ return payment.getStatus();
|
|
|
+ }
|
|
|
+ LinePayResponse confirm;
|
|
|
+ try {
|
|
|
+ confirm = gatewayCall("CONFIRM", source, payment,
|
|
|
+ () -> linePayClient.confirm(credential, payment.getTransactionId(),
|
|
|
+ payment.getAmount(), payment.getCurrency()));
|
|
|
+ } catch (Exception unknown) {
|
|
|
+ paymentService.markConfirmUnknown(payment.getId(), payment.getVersion() + 1,
|
|
|
+ shortly(), unknownDeadline());
|
|
|
+ return "CONFIRM_UNKNOWN";
|
|
|
+ }
|
|
|
+ if (confirm.isSuccess()) {
|
|
|
+ if (!confirmedPayment(confirm, payment)) {
|
|
|
+ paymentService.markConfirmUnknown(payment.getId(), payment.getVersion() + 1,
|
|
|
+ shortly(), unknownDeadline());
|
|
|
+ return "CONFIRM_UNKNOWN";
|
|
|
+ }
|
|
|
+ factService.applyPaidFact(payment.getId(), payment.getTransactionId(),
|
|
|
+ paymentProvider(confirm), new Date());
|
|
|
+ return "PAID";
|
|
|
+ }
|
|
|
+ paymentService.markConfirmUnknown(payment.getId(), payment.getVersion() + 1,
|
|
|
+ shortly(), unknownDeadline());
|
|
|
+ return "CONFIRM_UNKNOWN";
|
|
|
+ }
|
|
|
+
|
|
|
+ private static JSONObject capturedPayment(LinePayResponse response, PosOrderLinePayment payment) {
|
|
|
+ if (response == null || !response.isSuccess()) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ JSONObject root = JSONObject.parseObject(response.rawBody());
|
|
|
+ JSONArray info = root.getJSONArray("info");
|
|
|
+ if (info == null || info.size() != 1) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ JSONObject transaction = info.getJSONObject(0);
|
|
|
+ String gatewayTransactionId = transaction.getString("transactionId");
|
|
|
+ boolean transactionMatches = payment.getTransactionId() == null
|
|
|
+ ? gatewayTransactionId != null
|
|
|
+ : payment.getTransactionId().equals(gatewayTransactionId);
|
|
|
+ Integer gatewayAmount = paymentAmount(transaction);
|
|
|
+ return transactionMatches
|
|
|
+ && payment.getLineOrderId().equals(transaction.getString("orderId"))
|
|
|
+ && payment.getCurrency().equals(transaction.getString("currency"))
|
|
|
+ && "PAYMENT".equals(transaction.getString("transactionType"))
|
|
|
+ && gatewayAmount != null && gatewayAmount.equals(payment.getAmount())
|
|
|
+ ? transaction : null;
|
|
|
+ }
|
|
|
+
|
|
|
+ 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 static String fullRefundTransactionId(JSONObject transaction, int expectedAmount) {
|
|
|
+ JSONArray refunds = transaction.getJSONArray("refundList");
|
|
|
+ if (refunds == null || refunds.isEmpty()) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ long total = 0L;
|
|
|
+ String lastTransactionId = null;
|
|
|
+ for (int i = 0; i < refunds.size(); i++) {
|
|
|
+ JSONObject refund = refunds.getJSONObject(i);
|
|
|
+ Integer amount = refund == null ? null : refund.getInteger("refundAmount");
|
|
|
+ String transactionId = refund == null ? null : refund.getString("refundTransactionId");
|
|
|
+ if (amount == null || amount == 0 || transactionId == null) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ total += Math.abs((long) amount);
|
|
|
+ lastTransactionId = transactionId;
|
|
|
+ }
|
|
|
+ return total == expectedAmount ? lastTransactionId : null;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static boolean hasRefundEvidence(JSONObject transaction) {
|
|
|
+ JSONArray refunds = transaction.getJSONArray("refundList");
|
|
|
+ return refunds != null && !refunds.isEmpty();
|
|
|
+ }
|
|
|
+
|
|
|
+ private static boolean canCheckOrConfirm(String status) {
|
|
|
+ return "WAITING_AUTH".equals(status) || "READY_CONFIRM".equals(status);
|
|
|
+ }
|
|
|
+
|
|
|
+ private static String paymentProvider(LinePayResponse response) {
|
|
|
+ if (response == null || response.rawBody() == null) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ JSONObject root = JSONObject.parseObject(response.rawBody());
|
|
|
+ JSONObject objectInfo = root.getJSONObject("info");
|
|
|
+ if (objectInfo != null) {
|
|
|
+ return objectInfo.getString("paymentProvider");
|
|
|
+ }
|
|
|
+ JSONArray info = root.getJSONArray("info");
|
|
|
+ return info != null && info.size() == 1
|
|
|
+ ? info.getJSONObject(0).getString("paymentProvider") : null;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static boolean confirmedPayment(LinePayResponse response, PosOrderLinePayment payment) {
|
|
|
+ if (response == null || !response.isSuccess() || response.rawBody() == null) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ JSONObject info = JSONObject.parseObject(response.rawBody()).getJSONObject("info");
|
|
|
+ if (info == null || !payment.getTransactionId().equals(info.getString("transactionId"))) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ String orderId = info.getString("orderId");
|
|
|
+ String currency = info.getString("currency");
|
|
|
+ return (orderId == null || payment.getLineOrderId().equals(orderId))
|
|
|
+ && (currency == null || payment.getCurrency().equals(currency))
|
|
|
+ && payment.getAmount().equals(paymentAmount(info));
|
|
|
+ }
|
|
|
+
|
|
|
+ private IntentSelection createIntent(PosOrder order, PosStoreLinePay credential) {
|
|
|
+ for (int attempt = 0; attempt < 3; attempt++) {
|
|
|
+ try {
|
|
|
+ return new IntentSelection(paymentService.createRequesting(
|
|
|
+ String.valueOf(order.getDdId()), newLineOrderId(), credential.getId(),
|
|
|
+ order.getMdId(), order.getAmount(), "TWD", unknownDeadline()), true);
|
|
|
+ } catch (DuplicateKeyException duplicate) {
|
|
|
+ PosOrderLinePayment concurrent = paymentService.getActiveByDdId(String.valueOf(order.getDdId()));
|
|
|
+ if (concurrent != null) {
|
|
|
+ return new IntentSelection(concurrent, false);
|
|
|
+ }
|
|
|
+ if (attempt == 2) {
|
|
|
+ throw duplicate;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ throw new IllegalStateException("Unable to create LINE Pay attempt");
|
|
|
+ }
|
|
|
+
|
|
|
+ private record IntentSelection(PosOrderLinePayment payment, boolean created) {
|
|
|
+ }
|
|
|
+
|
|
|
+ private PosOrder requirePayableOrder(Long userId, String ddId) {
|
|
|
+ PosOrder order = requireOwnedOrder(userId, ddId);
|
|
|
+ if (!PAY_TYPE_LINE.equals(order.getPayType()) || Long.valueOf(1L).equals(order.getPayStatus())
|
|
|
+ || !isPayableOrderState(order) || order.getAmount() == null
|
|
|
+ || order.getAmount() <= 0 || order.getMdId() == null) {
|
|
|
+ throw new ServiceException("Order is not payable by LINE Pay");
|
|
|
+ }
|
|
|
+ return order;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static boolean isPayableOrderState(PosOrder order) {
|
|
|
+ return order.getState() != null && (order.getState() == 0L || order.getState() == 1L)
|
|
|
+ && (order.getAfterSaleStatus() == null || order.getAfterSaleStatus() == 0L);
|
|
|
+ }
|
|
|
+
|
|
|
+ private static boolean isDefiniteRequestFailure(LinePayResponse response) {
|
|
|
+ if (response == null || response.httpStatus() < 200 || response.httpStatus() >= 300
|
|
|
+ || response.returnCode() == null || response.isSuccess()) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ return DEFINITE_REQUEST_FAILURE_CODES.contains(response.returnCode());
|
|
|
+ }
|
|
|
+
|
|
|
+ private Date leaseUntil() {
|
|
|
+ return new Date(System.currentTimeMillis() + Math.max(60L, rowLeaseSeconds) * 1000L);
|
|
|
+ }
|
|
|
+
|
|
|
+ private PosOrder requireOwnedOrder(Long userId, String ddId) {
|
|
|
+ if (userId == null || ddId == null || ddId.trim().isEmpty()) {
|
|
|
+ throw new ServiceException("Invalid order");
|
|
|
+ }
|
|
|
+ PosOrder order = orderService.getOne(new QueryWrapper<PosOrder>().eq("dd_id", ddId));
|
|
|
+ if (order == null || order.getUserId() == null || !order.getUserId().equals(userId)) {
|
|
|
+ throw new ServiceException("Order not found or forbidden");
|
|
|
+ }
|
|
|
+ return order;
|
|
|
+ }
|
|
|
+
|
|
|
+ private void requireSingleStoreOrder(PosOrder order) {
|
|
|
+ String parentDdId = order.getParentDdId() == null
|
|
|
+ ? String.valueOf(order.getDdId()) : order.getParentDdId();
|
|
|
+ long count = orderService.count(new QueryWrapper<PosOrder>().eq("parent_dd_id", parentDdId));
|
|
|
+ if (count > 1) {
|
|
|
+ throw new ServiceException("LINE Pay supports single-store orders only");
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private PosOrderLinePayment selectForApp(PosOrder order, List<PosOrderLinePayment> attempts) {
|
|
|
+ if (attempts == null || attempts.isEmpty()) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ List<PosOrderLinePayment> paid = attempts.stream()
|
|
|
+ .filter(payment -> "PAID".equals(payment.getStatus())).toList();
|
|
|
+ List<PosOrderLinePayment> unrefunded = paid.stream().filter(payment -> {
|
|
|
+ PosOrderLineRefund refund = refundService.getByPaymentId(payment.getId());
|
|
|
+ return refund == null || !"REFUNDED".equals(refund.getStatus());
|
|
|
+ }).toList();
|
|
|
+ if (Long.valueOf(1L).equals(order.getPayStatus()) && unrefunded.size() == 1) {
|
|
|
+ return unrefunded.get(0);
|
|
|
+ }
|
|
|
+ if (Long.valueOf(2L).equals(order.getPayStatus()) && !paid.isEmpty()) {
|
|
|
+ return paid.stream().filter(payment -> {
|
|
|
+ PosOrderLineRefund refund = refundService.getByPaymentId(payment.getId());
|
|
|
+ return refund != null && "REFUNDED".equals(refund.getStatus());
|
|
|
+ }).findFirst().orElse(paid.get(0));
|
|
|
+ }
|
|
|
+ if (unrefunded.size() > 1) {
|
|
|
+ PosOrderLinePayment manual = unrefunded.get(0);
|
|
|
+ manual.setStatus("MANUAL_REVIEW");
|
|
|
+ return manual;
|
|
|
+ }
|
|
|
+ if (paid.size() == 1) {
|
|
|
+ return paid.get(0);
|
|
|
+ }
|
|
|
+ return attempts.stream().filter(payment -> payment.getActiveDdId() != null).findFirst()
|
|
|
+ .orElseGet(() -> attempts.stream().max(Comparator
|
|
|
+ .comparing(PosOrderLinePayment::getCreateTime,
|
|
|
+ Comparator.nullsFirst(Date::compareTo))
|
|
|
+ .thenComparing(PosOrderLinePayment::getId,
|
|
|
+ Comparator.nullsFirst(Long::compareTo))).orElse(null));
|
|
|
+ }
|
|
|
+
|
|
|
+ private static LinePayCredential toCredential(PosStoreLinePay credential) {
|
|
|
+ return new LinePayCredential(credential.getId(), credential.getChannelId(),
|
|
|
+ credential.getChannelSecret());
|
|
|
+ }
|
|
|
+
|
|
|
+ private RetrieveResult retrieveWithCredentialRecovery(PosOrderLinePayment payment, String source,
|
|
|
+ PosStoreLinePay originalVersion,
|
|
|
+ LinePayCredential originalCredential)
|
|
|
+ throws Exception {
|
|
|
+ LinePayResponse original = retrieve(payment, source, originalCredential);
|
|
|
+ if (!isCredentialAuthFailure(original)) {
|
|
|
+ return new RetrieveResult(original, originalCredential);
|
|
|
+ }
|
|
|
+ PosStoreLinePay current = credentialService.getCurrent(payment.getStoreId());
|
|
|
+ if (current == null || Objects.equals(current.getId(), originalVersion.getId())
|
|
|
+ || !Objects.equals(current.getChannelId(), originalVersion.getChannelId())
|
|
|
+ || !Objects.equals(current.getEnvironment(), originalVersion.getEnvironment())) {
|
|
|
+ return new RetrieveResult(original, originalCredential);
|
|
|
+ }
|
|
|
+ LinePayCredential currentCredential = toCredential(current);
|
|
|
+ LinePayResponse proof = retrieve(payment, source, currentCredential);
|
|
|
+ return capturedPayment(proof, payment) == null
|
|
|
+ ? new RetrieveResult(original, originalCredential)
|
|
|
+ : new RetrieveResult(proof, currentCredential);
|
|
|
+ }
|
|
|
+
|
|
|
+ private LinePayResponse retrieve(PosOrderLinePayment payment, String source,
|
|
|
+ LinePayCredential credential) throws Exception {
|
|
|
+ return gatewayCall("RETRIEVE", source, payment, credential.id(),
|
|
|
+ () -> payment.getTransactionId() == null
|
|
|
+ ? linePayClient.retrieveByOrderId(credential, payment.getLineOrderId())
|
|
|
+ : linePayClient.retrieveByTransactionId(credential, payment.getTransactionId()));
|
|
|
+ }
|
|
|
+
|
|
|
+ private static boolean isCredentialAuthFailure(LinePayResponse response) {
|
|
|
+ return response != null && CREDENTIAL_AUTH_FAILURE_CODES.contains(response.returnCode());
|
|
|
+ }
|
|
|
+
|
|
|
+ private boolean isLocallyRefunded(Long paymentId) {
|
|
|
+ PosOrderLineRefund refund = refundService.getByPaymentId(paymentId);
|
|
|
+ return refund != null && "REFUNDED".equals(refund.getStatus());
|
|
|
+ }
|
|
|
+
|
|
|
+ private record RetrieveResult(LinePayResponse response, LinePayCredential credential) {
|
|
|
+ }
|
|
|
+
|
|
|
+ private static LinePayCreateResult createResult(PosOrderLinePayment payment, boolean reused) {
|
|
|
+ LinePayCreateResult result = new LinePayCreateResult();
|
|
|
+ result.setDdId(payment.getDdId());
|
|
|
+ result.setPaymentId(payment.getId());
|
|
|
+ result.setLineOrderId(payment.getLineOrderId());
|
|
|
+ result.setTransactionId(payment.getTransactionId());
|
|
|
+ result.setPaymentUrl(payment.getPaymentUrlWeb());
|
|
|
+ result.setStatus(payment.getStatus());
|
|
|
+ result.setReusedAttempt(reused);
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static String newLineOrderId() {
|
|
|
+ return "LP" + UUID.randomUUID().toString().replace("-", "").toUpperCase(Locale.ROOT);
|
|
|
+ }
|
|
|
+
|
|
|
+ private <T> T gatewayCall(String action, String source, PosOrderLinePayment payment,
|
|
|
+ LinePayGatewayAuditService.GatewayCall<T> call) throws Exception {
|
|
|
+ return gatewayCall(action, source, payment, payment.getCredentialId(), call);
|
|
|
+ }
|
|
|
+
|
|
|
+ private <T> T gatewayCall(String action, String source, PosOrderLinePayment payment,
|
|
|
+ Long credentialId,
|
|
|
+ LinePayGatewayAuditService.GatewayCall<T> call) throws Exception {
|
|
|
+ if (auditService == null) {
|
|
|
+ return call.call();
|
|
|
+ }
|
|
|
+ return auditService.execute(action, source == null ? "LINE" : source,
|
|
|
+ payment.getId(), null, credentialId, payment.getStoreId(),
|
|
|
+ payment.getDdId(), payment.getLineOrderId(), payment.getTransactionId(), call);
|
|
|
+ }
|
|
|
+
|
|
|
+ private static Date shortly() {
|
|
|
+ return new Date(System.currentTimeMillis() + 30_000L);
|
|
|
+ }
|
|
|
+
|
|
|
+ private Date authDeadline() {
|
|
|
+ return new Date(System.currentTimeMillis() + authDeadlineMinutes * 60L * 1000L);
|
|
|
+ }
|
|
|
+
|
|
|
+ private Date unknownDeadline() {
|
|
|
+ return new Date(System.currentTimeMillis() + unknownDeadlineHours * 60L * 60L * 1000L);
|
|
|
+ }
|
|
|
+}
|