PaymentService.java

package org.petify.funding.service;

import org.petify.funding.dto.PaymentChoiceRequest;
import org.petify.funding.dto.PaymentFeeCalculation;
import org.petify.funding.dto.PaymentInitializationResponse;
import org.petify.funding.dto.PaymentMethodOption;
import org.petify.funding.dto.PaymentProviderOption;
import org.petify.funding.dto.PaymentRequest;
import org.petify.funding.dto.PaymentResponse;
import org.petify.funding.dto.PaymentUiConfig;
import org.petify.funding.model.Currency;
import org.petify.funding.model.Donation;
import org.petify.funding.model.DonationStatus;
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.payment.PaymentProviderFactory;
import org.petify.funding.service.payment.PaymentProviderService;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

@Service
@RequiredArgsConstructor
@Slf4j
public class PaymentService {

    private final PaymentRepository paymentRepository;
    private final DonationRepository donationRepository;
    private final PaymentProviderFactory providerFactory;
    private final DonationStatusUpdateService statusUpdateService;

    @Transactional
    public void handleStripeWebhook(String payload, String signature) {
        PaymentProviderService stripeService = providerFactory.getProvider(PaymentProvider.STRIPE);
        stripeService.handleWebhook(payload, signature);
    }

    @Transactional
    public void handlePayUWebhook(String payload, String signature) {
        PaymentProviderService payuService = providerFactory.getProvider(PaymentProvider.PAYU);
        payuService.handleWebhook(payload, signature);
    }

    @Transactional
    public PaymentResponse cancelPayment(Long paymentId) {
        Payment payment = paymentRepository.findById(paymentId)
                .orElseThrow(() -> new RuntimeException("Payment not found"));

        if (!canCancelPayment(payment)) {
            throw new RuntimeException("Payment cannot be cancelled in current state: " + payment.getStatus());
        }

        PaymentProviderService providerService = providerFactory.getProvider(payment.getProvider());
        final PaymentResponse response = providerService.cancelPayment(payment.getExternalId());

        payment.setStatus(PaymentStatus.CANCELLED);
        paymentRepository.save(payment);

        statusUpdateService.handlePaymentStatusChange(paymentId, PaymentStatus.CANCELLED);

        log.info("Payment {} cancelled successfully", paymentId);
        return response;
    }

    public List<PaymentProviderOption> getAvailablePaymentOptions(BigDecimal amount, String userLocation) {
        List<PaymentProviderOption> options = new ArrayList<>();

        if ("PL".equals(userLocation)) {
            PaymentFeeCalculation payuFees = calculatePaymentFee(amount, PaymentProvider.PAYU);

            options.add(PaymentProviderOption.builder()
                    .provider(PaymentProvider.PAYU)
                    .displayName("PayU")
                    .recommended(true)
                    .fees(payuFees)
                    .supportedMethods(Arrays.asList(
                            PaymentMethodOption.builder()
                                    .method(PaymentMethod.BLIK)
                                    .displayName("BLIK")
                                    .requiresAdditionalInfo(true)
                                    .build(),
                            PaymentMethodOption.builder()
                                    .method(PaymentMethod.CARD)
                                    .displayName("Karta płatnicza")
                                    .requiresAdditionalInfo(false)
                                    .build(),
                            PaymentMethodOption.builder()
                                    .method(PaymentMethod.BANK_TRANSFER)
                                    .displayName("Przelew bankowy")
                                    .requiresAdditionalInfo(false)
                                    .build(),
                            PaymentMethodOption.builder()
                                    .method(PaymentMethod.GOOGLE_PAY)
                                    .displayName("Płatność Google Pay")
                                    .requiresAdditionalInfo(false)
                                    .build()
                    ))
                    .build());
        }

        PaymentFeeCalculation stripeFees = calculatePaymentFee(amount, PaymentProvider.STRIPE);

        options.add(PaymentProviderOption.builder()
                .provider(PaymentProvider.STRIPE)
                .displayName("Stripe")
                .recommended(false)
                .fees(stripeFees)
                .supportedMethods(Arrays.asList(
                        PaymentMethodOption.builder()
                                .method(PaymentMethod.CARD)
                                .displayName("Credit/Debit Card")
                                .requiresAdditionalInfo(false)
                                .build(),
                        PaymentMethodOption.builder()
                                .method(PaymentMethod.GOOGLE_PAY)
                                .displayName("Google Pay")
                                .requiresAdditionalInfo(false)
                                .build(),
                        PaymentMethodOption.builder()
                                .method(PaymentMethod.APPLE_PAY)
                                .displayName("Apple Pay")
                                .requiresAdditionalInfo(false)
                                .build()
                ))
                .build());

        markLowestFee(options);

        return options;
    }

    @Transactional
    public PaymentInitializationResponse initializePayment(Long donationId, PaymentChoiceRequest request) {
        Donation donation = donationRepository.findById(donationId)
                .orElseThrow(() -> new RuntimeException("Donation not found"));

        validateDonationCanAcceptPayment(donation);

        donation.incrementPaymentAttempts();

        if (donation.getStatus() == DonationStatus.PENDING) {
            donation.setStatus(DonationStatus.PENDING);
            donationRepository.save(donation);
        }

        PaymentProviderService providerService = providerFactory.getProvider(request.getProvider());

        PaymentRequest paymentRequest = PaymentRequest.builder()
                .donationId(donationId)
                .preferredProvider(request.getProvider())
                .preferredMethod(request.getMethod())
                .returnUrl(request.getReturnUrl())
                .cancelUrl(request.getCancelUrl())
                .blikCode(request.getBlikCode())
                .build();

        PaymentResponse payment = providerService.createPayment(paymentRequest);

        PaymentUiConfig uiConfig = buildUiConfig(request.getProvider(), payment);

        return PaymentInitializationResponse.builder()
                .payment(payment)
                .uiConfig(uiConfig)
                .build();
    }

    public PaymentFeeCalculation calculatePaymentFee(BigDecimal amount, PaymentProvider provider) {
        PaymentProviderService providerService = providerFactory.getProvider(provider);
        BigDecimal fee = providerService.calculateFee(amount, Currency.PLN);
        BigDecimal netAmount = amount.subtract(fee);

        return PaymentFeeCalculation.builder()
                .grossAmount(amount)
                .feeAmount(fee)
                .netAmount(netAmount)
                .provider(provider)
                .currency(Currency.PLN)
                .feePercentage(fee.divide(amount, 4, RoundingMode.HALF_UP)
                        .multiply(new BigDecimal("100")))
                .build();
    }

    public PaymentResponse getPaymentById(Long paymentId) {
        Payment payment = paymentRepository.findById(paymentId)
                .orElseThrow(() -> new RuntimeException("Payment not found"));
        return PaymentResponse.fromEntity(payment);
    }

    public List<PaymentResponse> getPaymentsByDonation(Long donationId) {
        List<Payment> payments = paymentRepository.findByDonationId(donationId);
        return payments.stream()
                .map(PaymentResponse::fromEntity)
                .collect(Collectors.toList());
    }

    @Transactional
    public PaymentResponse refundPayment(Long paymentId, BigDecimal amount) {
        Payment payment = paymentRepository.findById(paymentId)
                .orElseThrow(() -> new RuntimeException("Payment not found"));

        if (payment.getStatus() != PaymentStatus.SUCCEEDED) {
            throw new RuntimeException("Can only refund successful payments");
        }

        BigDecimal refundAmount = amount != null ? amount : payment.getAmount();

        if (refundAmount.compareTo(payment.getAmount()) > 0) {
            throw new RuntimeException("Refund amount cannot exceed payment amount");
        }

        PaymentProviderService providerService = providerFactory.getProvider(payment.getProvider());
        final PaymentResponse response = providerService.refundPayment(payment.getExternalId(), refundAmount);

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

        payment.setStatus(newStatus);
        paymentRepository.save(payment);

        statusUpdateService.handlePaymentStatusChange(paymentId, newStatus);

        log.info("Payment {} refunded (amount: {})", paymentId, refundAmount);
        return response;
    }

    @Transactional
    public void updatePaymentStatus(Long paymentId, PaymentStatus newStatus) {
        Payment payment = paymentRepository.findById(paymentId)
                .orElseThrow(() -> new RuntimeException("Payment not found"));

        final PaymentStatus oldStatus = payment.getStatus();
        payment.setStatus(newStatus);
        paymentRepository.save(payment);

        statusUpdateService.handlePaymentStatusChange(paymentId, newStatus);

        log.info("Payment {} status updated from {} to {}", paymentId, oldStatus, newStatus);
    }

    private void validateDonationCanAcceptPayment(Donation donation) {
        if (!donation.canAcceptNewPayment()) {
            if (donation.getStatus() == DonationStatus.COMPLETED) {
                throw new RuntimeException("Cannot create payment for completed donation");
            }
            if (donation.getStatus() == DonationStatus.FAILED) {
                throw new RuntimeException("Cannot create payment for failed donation");
            }
            if (donation.getStatus() == DonationStatus.CANCELLED) {
                throw new RuntimeException("Cannot create payment for cancelled donation");
            }
            if (donation.hasReachedMaxPaymentAttempts()) {
                throw new RuntimeException("Maximum payment attempts reached (3)");
            }
            if (donation.hasPendingPayments()) {
                throw new RuntimeException("Donation already has an active payment in progress");
            }
        }
    }

    private PaymentUiConfig buildUiConfig(PaymentProvider provider, PaymentResponse payment) {
        return switch (provider) {
            case PAYU -> PaymentUiConfig.builder()
                    .provider(PaymentProvider.PAYU)
                    .hasNativeSDK(false)
                    .sdkConfiguration(String.format("""
                        {
                            "merchantPosId": "300746",
                            "environment": "sandbox",
                            "orderId": "%s"
                        }
                        """, payment.getExternalId()))
                    .build();

            case STRIPE -> PaymentUiConfig.builder()
                    .provider(PaymentProvider.STRIPE)
                    .hasNativeSDK(false)
                    .sdkConfiguration(String.format("""
                        {
                            "publishableKey": "pk_test_...",
                            "clientSecret": "%s",
                            "appearance": {
                                "theme": "stripe"
                            }
                        }
                        """, payment.getClientSecret() != null ? payment.getClientSecret() : ""))
                    .build();
        };
    }

    private void markLowestFee(List<PaymentProviderOption> options) {
        if (options.isEmpty()) {
            return;
        }

        BigDecimal lowestFee = options.stream()
                .map(option -> option.getFees().getFeeAmount())
                .min(BigDecimal::compareTo)
                .orElse(BigDecimal.ZERO);

        log.info("Lowest fee among providers: {}", lowestFee);
    }

    public List<String> getSupportedPaymentMethods(PaymentProvider provider) {
        PaymentProviderService providerService = providerFactory.getProvider(provider);

        return Arrays.stream(PaymentMethod.values())
                .filter(providerService::supportsPaymentMethod)
                .map(Enum::name)
                .collect(Collectors.toList());
    }

    public Map<String, Object> getPaymentProvidersHealth() {
        Map<String, Object> health = new HashMap<>();

        for (PaymentProvider provider : PaymentProvider.values()) {
            Map<String, Object> providerHealth = new HashMap<>();

            try {
                PaymentProviderService service = providerFactory.getProvider(provider);
                boolean available = service.supportsCurrency(Currency.PLN);

                providerHealth.put("status", available ? "UP" : "LIMITED");
                providerHealth.put("available", available);
                providerHealth.put("supportsPLN", available);

                List<String> supportedMethods = Arrays.stream(PaymentMethod.values())
                        .filter(service::supportsPaymentMethod)
                        .map(Enum::name)
                        .collect(Collectors.toList());
                providerHealth.put("supportedMethods", supportedMethods);

            } catch (Exception e) {
                providerHealth.put("status", "DOWN");
                providerHealth.put("available", false);
                providerHealth.put("error", e.getMessage());
            }

            health.put(provider.name().toLowerCase(), providerHealth);
        }

        return health;
    }

    public Page<PaymentResponse> getUserPaymentHistory(Pageable pageable, String status) {
        String username = getCurrentUsername();

        Page<Payment> payments;
        if (status != null && !status.isEmpty()) {
            PaymentStatus paymentStatus = PaymentStatus.valueOf(status.toUpperCase());
            payments = paymentRepository.findByDonation_DonorUsernameAndStatusOrderByCreatedAtDesc(
                    username, paymentStatus, pageable);
        } else {
            payments = paymentRepository.findByDonation_DonorUsernameOrderByCreatedAtDesc(
                    username, pageable);
        }

        return payments.map(PaymentResponse::fromEntity);
    }

    private String getCurrentUsername() {
        var authentication = SecurityContextHolder.getContext().getAuthentication();
        if (authentication != null && authentication.getPrincipal() instanceof Jwt jwt) {
            return jwt.getSubject();
        }
        return authentication != null ? authentication.getName() : null;
    }

    private boolean canCancelPayment(Payment payment) {
        return payment.getStatus() == PaymentStatus.PENDING
                || payment.getStatus() == PaymentStatus.PROCESSING;
    }
}