PayUPaymentService.java

package org.petify.funding.service.payment;

import org.petify.funding.dto.PaymentRequest;
import org.petify.funding.dto.PaymentResponse;
import org.petify.funding.dto.WebhookEventDto;
import org.petify.funding.model.Currency;
import org.petify.funding.model.Donation;
import org.petify.funding.model.MaterialDonation;
import org.petify.funding.model.Payment;
import org.petify.funding.model.PaymentMethod;
import org.petify.funding.model.PaymentProvider;
import org.petify.funding.model.PaymentStatus;
import org.petify.funding.repository.DonationRepository;
import org.petify.funding.repository.PaymentRepository;
import org.petify.funding.service.DonationStatusUpdateService;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.client.RestTemplate;

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;

@Service
@RequiredArgsConstructor
@Slf4j
public class PayUPaymentService implements PaymentProviderService {

    private final PaymentRepository paymentRepository;
    private final DonationRepository donationRepository;
    private final RestTemplate restTemplate;
    private final ObjectMapper objectMapper;
    private final DonationStatusUpdateService statusUpdateService;

    @Value("${payment.payu.client-id}")
    private String clientId;

    @Value("${payment.payu.client-secret}")
    private String clientSecret;

    @Value("${payment.payu.api-url}")
    private String apiUrl;

    @Value("${payment.payu.pos-id}")
    private String posId;

    @Value("${app.webhook.base-url:http://localhost:8222}")
    private String webhookBaseUrl;

    @Value("${payment.payu.default-customer-ip:127.0.0.1}")
    private String defaultCustomerIp;

    @Override
    @Transactional
    public PaymentResponse createPayment(PaymentRequest request) {
        try {
            log.info("Creating PayU payment for donation {}", request.getDonationId());

            Donation donation = donationRepository.findById(request.getDonationId())
                    .orElseThrow(() -> new RuntimeException("Donation not found"));

            String accessToken = getAccessToken();
            Map<String, Object> orderRequest = buildOrderRequest(donation, request);

            HttpHeaders headers = new HttpHeaders();
            headers.setContentType(MediaType.APPLICATION_JSON);
            headers.setBearerAuth(accessToken);

            HttpEntity<Map<String, Object>> entity = new HttpEntity<>(orderRequest, headers);

            ResponseEntity<String> response = restTemplate.exchange(
                    apiUrl + "/api/v2_1/orders",
                    HttpMethod.POST,
                    entity,
                    String.class
            );

            JsonNode responseJson = objectMapper.readTree(response.getBody());

            if (responseJson.has("status")
                    && responseJson.get("status").has("statusCode")
                    && !"SUCCESS".equals(responseJson.get("status").get("statusCode").asText())) {
                throw new RuntimeException("PayU order creation failed: "
                        + responseJson.get("status").get("statusDesc").asText());
            }

            Payment payment = Payment.builder()
                    .donation(donation)
                    .provider(PaymentProvider.PAYU)
                    .externalId(responseJson.get("orderId").asText())
                    .status(PaymentStatus.PENDING)
                    .amount(donation.getAmount())
                    .currency(Currency.PLN)
                    .paymentMethod(determinePaymentMethod(request))
                    .checkoutUrl(responseJson.get("redirectUri").asText())
                    .metadata(createMetadata(donation))
                    .expiresAt(Instant.now().plusSeconds(900))
                    .build();

            Payment savedPayment = paymentRepository.save(payment);

            log.info("PayU payment created with ID: {} for donation {}",
                    savedPayment.getId(), donation.getId());
            return PaymentResponse.fromEntity(savedPayment);

        } catch (Exception e) {
            log.error("PayU payment creation failed", e);
            throw new RuntimeException("Payment creation failed: " + e.getMessage(), e);
        }
    }

    @Override
    public PaymentResponse getPaymentStatus(String externalId) {
        try {
            String accessToken = getAccessToken();

            HttpHeaders headers = new HttpHeaders();
            headers.setBearerAuth(accessToken);

            HttpEntity<String> entity = new HttpEntity<>(headers);

            ResponseEntity<String> response = restTemplate.exchange(
                    apiUrl + "/api/v2_1/orders/" + externalId,
                    HttpMethod.GET,
                    entity,
                    String.class
            );

            JsonNode responseJson = objectMapper.readTree(response.getBody());
            JsonNode orderJson = responseJson.get("orders").get(0);

            Payment payment = paymentRepository.findByExternalId(externalId)
                    .orElseThrow(() -> new RuntimeException("Payment not found"));

            PaymentStatus oldStatus = payment.getStatus();
            PaymentStatus newStatus = mapPayUStatus(orderJson.get("status").asText());

            payment.setStatus(newStatus);
            Payment savedPayment = paymentRepository.save(payment);

            if (newStatus != oldStatus) {
                statusUpdateService.handlePaymentStatusChange(payment.getId(), newStatus);
            }

            return PaymentResponse.fromEntity(savedPayment);

        } catch (Exception e) {
            log.error("Failed to get PayU payment status", e);
            throw new RuntimeException("Failed to get payment status: " + e.getMessage(), e);
        }
    }

    @Override
    public PaymentResponse cancelPayment(String externalId) {
        try {
            String accessToken = getAccessToken();

            Map<String, Object> cancelRequest = new HashMap<>();
            cancelRequest.put("orderId", externalId);

            HttpHeaders headers = new HttpHeaders();
            headers.setContentType(MediaType.APPLICATION_JSON);
            headers.setBearerAuth(accessToken);

            HttpEntity<Map<String, Object>> entity = new HttpEntity<>(cancelRequest, headers);

            restTemplate.exchange(
                    apiUrl + "/api/v2_1/orders/" + externalId,
                    HttpMethod.DELETE,
                    entity,
                    String.class
            );

            Payment payment = paymentRepository.findByExternalId(externalId)
                    .orElseThrow(() -> new RuntimeException("Payment not found"));

            payment.setStatus(PaymentStatus.CANCELLED);
            Payment savedPayment = paymentRepository.save(payment);

            statusUpdateService.handlePaymentStatusChange(payment.getId(), PaymentStatus.CANCELLED);

            log.info("PayU payment {} cancelled", payment.getId());
            return PaymentResponse.fromEntity(savedPayment);

        } catch (Exception e) {
            log.error("Failed to cancel PayU payment", e);
            throw new RuntimeException("Failed to cancel payment: " + e.getMessage(), e);
        }
    }

    @Override
    public PaymentResponse refundPayment(String externalId, BigDecimal amount) {
        try {
            String accessToken = getAccessToken();

            Map<String, Object> refundRequest = new HashMap<>();
            refundRequest.put("refund", Map.of(
                    "description", "Refund for donation",
                    "amount", convertToPayUAmount(amount)
            ));

            HttpHeaders headers = new HttpHeaders();
            headers.setContentType(MediaType.APPLICATION_JSON);
            headers.setBearerAuth(accessToken);

            HttpEntity<Map<String, Object>> entity = new HttpEntity<>(refundRequest, headers);

            restTemplate.exchange(
                    apiUrl + "/api/v2_1/orders/" + externalId + "/refunds",
                    HttpMethod.POST,
                    entity,
                    String.class
            );

            Payment payment = paymentRepository.findByExternalId(externalId)
                    .orElseThrow(() -> new RuntimeException("Payment not found"));

            PaymentStatus newStatus = amount.compareTo(payment.getAmount()) == 0
                    ? PaymentStatus.REFUNDED
                    : PaymentStatus.PARTIALLY_REFUNDED;

            payment.setStatus(newStatus);
            Payment savedPayment = paymentRepository.save(payment);

            statusUpdateService.handlePaymentStatusChange(payment.getId(), newStatus);

            log.info("PayU payment {} refunded (amount: {})", payment.getId(), amount);
            return PaymentResponse.fromEntity(savedPayment);

        } catch (Exception e) {
            log.error("Failed to refund PayU payment", e);
            throw new RuntimeException("Failed to refund payment: " + e.getMessage(), e);
        }
    }

    @Override
    @Transactional
    public void handleWebhook(String payload, String signature) {
        try {
            log.info("Processing PayU webhook");

            JsonNode webhookData = objectMapper.readTree(payload);
            JsonNode orderData = webhookData.get("order");

            if (orderData != null) {
                String orderId = orderData.get("orderId").asText();
                String status = orderData.get("status").asText();

                paymentRepository.findByExternalId(orderId)
                        .ifPresent(payment -> {
                            PaymentStatus oldStatus = payment.getStatus();
                            PaymentStatus newStatus = mapPayUStatus(status);

                            if (oldStatus != newStatus) {
                                payment.setStatus(newStatus);
                                paymentRepository.save(payment);

                                statusUpdateService.handlePaymentStatusChange(payment.getId(), newStatus);

                                log.info("PayU payment {} status updated to {} for donation {}",
                                        payment.getId(), newStatus, payment.getDonation().getId());
                            }
                        });
            }

        } catch (Exception e) {
            log.error("Failed to process PayU webhook", e);
            throw new RuntimeException("Failed to process webhook", e);
        }
    }

    @Override
    public WebhookEventDto parseWebhookEvent(String payload, String signature) {
        try {
            JsonNode webhookData = objectMapper.readTree(payload);
            JsonNode orderData = webhookData.get("order");

            return WebhookEventDto.builder()
                    .eventId(UUID.randomUUID().toString())
                    .eventType("payment_status_change")
                    .provider(PaymentProvider.PAYU.getValue())
                    .externalPaymentId(orderData.get("orderId").asText())
                    .receivedAt(Instant.now())
                    .processed(false)
                    .eventData(objectMapper.convertValue(webhookData, Map.class))
                    .build();

        } catch (Exception e) {
            throw new RuntimeException("Failed to parse webhook event", e);
        }
    }

    @Override
    public boolean supportsPaymentMethod(PaymentMethod method) {
        return Set.of(
                PaymentMethod.CARD,
                PaymentMethod.BLIK,
                PaymentMethod.BANK_TRANSFER
        ).contains(method);
    }

    @Override
    public boolean supportsCurrency(Currency currency) {
        return currency == Currency.PLN;
    }

    @Override
    public BigDecimal calculateFee(BigDecimal amount, Currency currency) {
        return amount.multiply(new BigDecimal("0.019"));
    }

    private String getAccessToken() {
        try {
            HttpHeaders headers = new HttpHeaders();
            headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);

            String body = "grant_type=client_credentials"
                    + "&client_id=" + clientId
                    + "&client_secret=" + clientSecret;

            HttpEntity<String> entity = new HttpEntity<>(body, headers);

            ResponseEntity<String> response = restTemplate.exchange(
                    apiUrl + "/pl/standard/user/oauth/authorize",
                    HttpMethod.POST,
                    entity,
                    String.class
            );

            JsonNode responseJson = objectMapper.readTree(response.getBody());
            return responseJson.get("access_token").asText();

        } catch (Exception e) {
            log.error("Failed to get PayU access token", e);
            throw new RuntimeException("Failed to authenticate with PayU", e);
        }
    }

    private Map<String, Object> buildOrderRequest(Donation donation, PaymentRequest request) {
        Map<String, Object> orderRequest = new HashMap<>();

        orderRequest.put("notifyUrl", webhookBaseUrl + "/payments/webhook/payu");
        orderRequest.put("customerIp", defaultCustomerIp);
        orderRequest.put("merchantPosId", posId);
        orderRequest.put("description", buildDescription(donation));
        orderRequest.put("currencyCode", "PLN");
        orderRequest.put("totalAmount", convertToPayUAmount(donation.getAmount()));

        String extOrderId = "d" + donation.getId() + "_" + (System.nanoTime() % 100000);
        orderRequest.put("extOrderId", extOrderId);

        Map<String, String> buyer = buildBuyerInfo(donation);
        if (!buyer.isEmpty()) {
            orderRequest.put("buyer", buyer);
        }

        orderRequest.put("products", buildProducts(donation));

        configurePaymentMethods(orderRequest, request);

        String continueUrl = request.getReturnUrl();
        if (continueUrl == null || continueUrl.trim().isEmpty()) {
            continueUrl = webhookBaseUrl + "/payment/success";
        }
        orderRequest.put("continueUrl", continueUrl);

        return orderRequest;
    }

    private String buildDescription(Donation donation) {
        StringBuilder desc = new StringBuilder();

        if (donation instanceof MaterialDonation md) {
            String itemName = md.getItemName();
            if (itemName != null && !itemName.trim().isEmpty()) {
                desc.append("Dotacja rzeczowa: ").append(itemName);
            } else {
                desc.append("Dotacja rzeczowa");
            }
        } else {
            desc.append("Dotacja pieniężna");
        }

        desc.append(" dla schroniska #").append(donation.getShelterId());

        if (donation.getPetId() != null) {
            desc.append(" dla zwierzęcia #").append(donation.getPetId());
        }

        return desc.toString();
    }

    private Map<String, String> buildBuyerInfo(Donation donation) {
        Map<String, String> buyer = new HashMap<>();

        if (donation.getDonorUsername() != null && !donation.getDonorUsername().trim().isEmpty()) {
            if (donation.isDonorUsernameEmail()) {
                buyer.put("email", donation.getDonorUsername());
                buyer.put("firstName", "Darczyńca");
                buyer.put("lastName", "Anonimowy");
            } else {
                buyer.put("firstName", donation.getDonorUsername());
                buyer.put("lastName", "");
            }
        }

        return buyer;
    }

    private List<Map<String, Object>> buildProducts(Donation donation) {
        List<Map<String, Object>> products = new ArrayList<>();
        Map<String, Object> product = new HashMap<>();

        if (donation instanceof MaterialDonation md) {
            String itemName = md.getItemName();
            if (itemName == null || itemName.trim().isEmpty()) {
                itemName = "Dotacja rzeczowa";
            }

            BigDecimal unitPrice = md.getUnitPrice();
            Integer quantity = md.getQuantity();

            if (unitPrice == null) {
                throw new IllegalArgumentException("Material donation unit price cannot be null");
            }

            if (quantity == null || quantity <= 0) {
                quantity = 1;
            }

            product.put("name", itemName);
            product.put("unitPrice", convertToPayUAmount(unitPrice));
            product.put("quantity", quantity);

        } else {
            product.put("name", "Dotacja pieniężna dla schroniska");
            product.put("unitPrice", convertToPayUAmount(donation.getAmount()));
            product.put("quantity", 1);
        }

        products.add(product);
        return products;
    }

    private void configurePaymentMethods(Map<String, Object> orderRequest, PaymentRequest request) {
        if (request.getPreferredMethod() == PaymentMethod.BLIK
                && request.getBlikCode() != null
                && !request.getBlikCode().trim().isEmpty()
                && request.getBlikCode().matches("\\d{6}")) {

            Map<String, Object> payMethods = new HashMap<>();
            Map<String, Object> blikMethod = new HashMap<>();
            blikMethod.put("type", "PBL");
            blikMethod.put("value", "blik");
            payMethods.put("payMethod", blikMethod);
            payMethods.put("authorizationCode", request.getBlikCode());

            orderRequest.put("payMethods", payMethods);

            log.info("Configured BLIK direct payment with authorization code");
            return;
        }
        log.info("Using PayU universal checkout - user will choose payment method in PayU interface");
    }

    private String convertToPayUAmount(BigDecimal amount) {
        if (amount == null) {
            throw new IllegalArgumentException("Amount cannot be null");
        }

        BigDecimal amountInGrosze = amount.multiply(new BigDecimal("100"));

        if (amountInGrosze.compareTo(BigDecimal.ZERO) <= 0) {
            throw new IllegalArgumentException("Amount must be positive");
        }

        if (amountInGrosze.compareTo(new BigDecimal("999999999")) > 0) {
            throw new IllegalArgumentException("Amount too large for PayU");
        }

        return amountInGrosze.setScale(0, RoundingMode.HALF_UP).toPlainString();
    }

    private PaymentStatus mapPayUStatus(String payuStatus) {
        return switch (payuStatus.toLowerCase()) {
            case "new", "pending" -> PaymentStatus.PENDING;
            case "waiting_for_confirmation" -> PaymentStatus.PROCESSING;
            case "completed" -> PaymentStatus.SUCCEEDED;
            case "canceled" -> PaymentStatus.CANCELLED;
            case "rejected" -> PaymentStatus.FAILED;
            default -> PaymentStatus.PENDING;
        };
    }

    private PaymentMethod determinePaymentMethod(PaymentRequest request) {
        if (request.getPreferredMethod() != null) {
            return request.getPreferredMethod();
        }
        return PaymentMethod.CARD;
    }

    private Map<String, String> createMetadata(Donation donation) {
        Map<String, String> metadata = new HashMap<>();
        metadata.put("donationId", String.valueOf(donation.getId()));
        metadata.put("provider", PaymentProvider.PAYU.getValue());
        metadata.put("donationType", donation.getDonationType().name());
        metadata.put("shelterId", String.valueOf(donation.getShelterId()));

        if (donation.getPetId() != null) {
            metadata.put("petId", String.valueOf(donation.getPetId()));
        }
        if (donation.getDonorUsername() != null) {
            metadata.put("donorUsername", donation.getDonorUsername());
        }

        return metadata;
    }
}