StripePaymentService.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.DonationType;
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.stripe.exception.SignatureVerificationException;
import com.stripe.exception.StripeException;
import com.stripe.model.Event;
import com.stripe.model.PaymentIntent;
import com.stripe.model.StripeObject;
import com.stripe.model.checkout.Session;
import com.stripe.net.Webhook;
import com.stripe.param.checkout.SessionCreateParams;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
@Service
@RequiredArgsConstructor
@Slf4j
public class StripePaymentService implements PaymentProviderService {
private final PaymentRepository paymentRepository;
private final DonationRepository donationRepository;
private final DonationStatusUpdateService statusUpdateService;
@Value("${payment.stripe.webhook-secret}")
private String webhookSecret;
@Value("${app.webhook.base-url:http://localhost:8020}")
private String webhookBaseUrl;
@Override
@Transactional
public PaymentResponse createPayment(PaymentRequest request) {
try {
log.info("Creating Stripe checkout session for donation {}", request.getDonationId());
Donation donation = donationRepository.findById(request.getDonationId())
.orElseThrow(() -> new RuntimeException("Donation not found"));
return createCheckoutSession(donation, request);
} catch (StripeException e) {
log.error("Stripe payment creation failed", e);
throw new RuntimeException("Payment creation failed: " + e.getMessage(), e);
}
}
@Override
public PaymentResponse getPaymentStatus(String externalId) {
try {
Session session = Session.retrieve(externalId);
Payment payment = paymentRepository.findByExternalId(externalId)
.orElseThrow(() -> new RuntimeException("Payment not found"));
PaymentStatus oldStatus = payment.getStatus();
PaymentStatus newStatus = mapStripeSessionStatus(session.getStatus());
payment.setStatus(newStatus);
Payment savedPayment = paymentRepository.save(payment);
if (newStatus != oldStatus) {
statusUpdateService.handlePaymentStatusChange(payment.getId(), newStatus);
}
return PaymentResponse.fromEntity(savedPayment);
} catch (StripeException e) {
log.error("Failed to get Stripe payment status", e);
throw new RuntimeException("Failed to get payment status: " + e.getMessage(), e);
}
}
@Override
public PaymentResponse cancelPayment(String externalId) {
try {
Session session = Session.retrieve(externalId);
if (session.getPaymentIntent() != null) {
PaymentIntent intent = PaymentIntent.retrieve(session.getPaymentIntent());
if (intent.getStatus().equals("requires_payment_method")
|| intent.getStatus().equals("requires_confirmation")) {
intent.cancel();
}
}
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("Stripe payment {} cancelled", payment.getId());
return PaymentResponse.fromEntity(savedPayment);
} catch (StripeException e) {
log.error("Failed to cancel Stripe payment", e);
throw new RuntimeException("Failed to cancel payment: " + e.getMessage(), e);
}
}
@Override
public PaymentResponse refundPayment(String externalId, BigDecimal amount) {
try {
Session session = Session.retrieve(externalId);
if (session.getPaymentIntent() == null) {
throw new RuntimeException("No payment intent found for session");
}
PaymentIntent intent = PaymentIntent.retrieve(session.getPaymentIntent());
com.stripe.param.RefundCreateParams refundParams =
com.stripe.param.RefundCreateParams.builder()
.setPaymentIntent(intent.getId())
.setAmount(convertToStripeAmount(amount))
.setReason(com.stripe.param.RefundCreateParams.Reason.REQUESTED_BY_CUSTOMER)
.build();
com.stripe.model.Refund refund = com.stripe.model.Refund.create(refundParams);
Payment payment = paymentRepository.findByExternalId(externalId)
.orElseThrow(() -> new RuntimeException("Payment not found"));
PaymentStatus newStatus = refund.getAmount().equals(intent.getAmount())
? PaymentStatus.REFUNDED
: PaymentStatus.PARTIALLY_REFUNDED;
payment.setStatus(newStatus);
Payment savedPayment = paymentRepository.save(payment);
statusUpdateService.handlePaymentStatusChange(payment.getId(), newStatus);
log.info("Stripe payment {} refunded (amount: {})", payment.getId(), amount);
return PaymentResponse.fromEntity(savedPayment);
} catch (StripeException e) {
log.error("Failed to refund Stripe payment", e);
throw new RuntimeException("Failed to refund payment: " + e.getMessage(), e);
}
}
@Override
@Transactional
public void handleWebhook(String payload, String signature) {
try {
Event event = Webhook.constructEvent(payload, signature, webhookSecret);
log.info("Processing Stripe webhook event: {} ({})", event.getType(), event.getId());
switch (event.getType()) {
case "checkout.session.completed" -> {
log.info("Handling checkout session completed");
handleCheckoutSessionCompleted(event);
}
case "checkout.session.expired" -> {
log.info("Handling checkout session expired");
handleCheckoutSessionExpired(event);
}
default -> {
log.debug("Unhandled Stripe event type: {}", event.getType());
}
}
} catch (SignatureVerificationException e) {
log.error("Invalid Stripe webhook signature", e);
throw new RuntimeException("Invalid webhook signature", e);
}
}
@Override
public WebhookEventDto parseWebhookEvent(String payload, String signature) {
try {
Event event = Webhook.constructEvent(payload, signature, webhookSecret);
return WebhookEventDto.builder()
.eventId(event.getId())
.eventType(event.getType())
.provider(PaymentProvider.STRIPE.getValue())
.receivedAt(Instant.now())
.processed(false)
.build();
} catch (SignatureVerificationException e) {
throw new RuntimeException("Invalid webhook signature", e);
}
}
@Override
public boolean supportsPaymentMethod(PaymentMethod method) {
return Set.of(
PaymentMethod.CARD,
PaymentMethod.GOOGLE_PAY,
PaymentMethod.APPLE_PAY,
PaymentMethod.PRZELEWY24,
PaymentMethod.BLIK,
PaymentMethod.BANK_TRANSFER
).contains(method);
}
@Override
public boolean supportsCurrency(Currency currency) {
return Set.of(Currency.USD, Currency.EUR, Currency.GBP, Currency.PLN)
.contains(currency);
}
@Override
public BigDecimal calculateFee(BigDecimal amount, Currency currency) {
BigDecimal percentageFee = amount.multiply(new BigDecimal("0.029"));
BigDecimal fixedFee = switch (currency) {
case USD -> new BigDecimal("0.30");
case EUR -> new BigDecimal("0.25");
case GBP -> new BigDecimal("0.20");
case PLN -> new BigDecimal("1.20");
};
return percentageFee.add(fixedFee);
}
private void handleCheckoutSessionCompleted(Event event) {
try {
Optional<StripeObject> stripeObjectOpt = event.getDataObjectDeserializer().getObject();
Session session = null;
if (stripeObjectOpt.isPresent() && stripeObjectOpt.get() instanceof Session) {
session = (Session) stripeObjectOpt.get();
log.info("Successfully deserialized session from event");
} else {
String sessionId = extractSessionIdFromRawData(event);
if (sessionId != null) {
session = Session.retrieve(sessionId);
log.info("Successfully retrieved session from Stripe API: {}", sessionId);
}
}
if (session == null) {
log.error("Could not get session data for event: {}", event.getId());
return;
}
processCompletedSession(session);
} catch (Exception e) {
log.error("Error processing checkout session completed event", e);
}
}
private String extractSessionIdFromRawData(Event event) {
try {
StripeObject rawObject = event.getData().getObject();
if (rawObject != null) {
String objectString = rawObject.toString();
java.util.regex.Pattern pattern = java.util.regex.Pattern.compile("\"id\"\\s*:\\s*\"(cs_test_[^\"]+)\"");
java.util.regex.Matcher matcher = pattern.matcher(objectString);
if (matcher.find()) {
return matcher.group(1);
}
}
} catch (Exception e) {
log.error("Error extracting session ID", e);
}
return null;
}
private void processCompletedSession(Session session) {
String sessionId = session.getId();
log.info("Processing completed session: {}", sessionId);
Optional<Payment> paymentOpt = paymentRepository.findByExternalId(sessionId);
if (paymentOpt.isEmpty()) {
log.error("Payment NOT FOUND for session ID: '{}'", sessionId);
return;
}
Payment payment = paymentOpt.get();
log.info("Found payment: ID={}, ExternalID='{}', DonationID={}",
payment.getId(), payment.getExternalId(), payment.getDonation().getId());
if (payment.getStatus() == PaymentStatus.SUCCEEDED) {
log.info("Payment {} already succeeded, skipping", payment.getId());
return;
}
payment.setStatus(PaymentStatus.SUCCEEDED);
paymentRepository.save(payment);
statusUpdateService.handlePaymentStatusChange(payment.getId(), PaymentStatus.SUCCEEDED);
log.info("Payment {} completed successfully for donation {}",
payment.getId(), payment.getDonation().getId());
}
private void handleCheckoutSessionExpired(Event event) {
try {
Optional<StripeObject> stripeObjectOpt = event.getDataObjectDeserializer().getObject();
Session session = null;
if (stripeObjectOpt.isPresent() && stripeObjectOpt.get() instanceof Session) {
session = (Session) stripeObjectOpt.get();
} else {
String sessionId = extractSessionIdFromRawData(event);
if (sessionId != null) {
session = Session.retrieve(sessionId);
}
}
if (session == null) {
log.error("Could not get session data for expired event: {}", event.getId());
return;
}
paymentRepository.findByExternalId(session.getId())
.ifPresent(payment -> {
if (payment.getStatus() != PaymentStatus.CANCELLED) {
payment.setStatus(PaymentStatus.CANCELLED);
paymentRepository.save(payment);
statusUpdateService.handlePaymentStatusChange(payment.getId(), PaymentStatus.CANCELLED);
log.info("Payment {} expired for donation {}",
payment.getId(), payment.getDonation().getId());
}
});
} catch (Exception e) {
log.error("Error processing checkout session expired event", e);
}
}
private PaymentResponse createCheckoutSession(Donation donation, PaymentRequest request) throws StripeException {
SessionCreateParams.LineItem lineItem = SessionCreateParams.LineItem.builder()
.setPriceData(
SessionCreateParams.LineItem.PriceData.builder()
.setCurrency(donation.getCurrency().name().toLowerCase())
.setProductData(
SessionCreateParams.LineItem.PriceData.ProductData.builder()
.setName(buildProductName(donation))
.setDescription(buildDescription(donation))
.build()
)
.setUnitAmount(convertToStripeAmount(donation.getAmount()))
.build()
)
.setQuantity(1L)
.build();
SessionCreateParams.Builder sessionBuilder = SessionCreateParams.builder()
.setMode(SessionCreateParams.Mode.PAYMENT)
.addLineItem(lineItem)
.putMetadata("donationId", String.valueOf(donation.getId()))
.putMetadata("provider", PaymentProvider.STRIPE.getValue())
.putMetadata("donationType", donation.getDonationType().name())
.putMetadata("shelterId", String.valueOf(donation.getShelterId()));
String successUrl = (request.getReturnUrl() != null && !request.getReturnUrl().trim().isEmpty())
? request.getReturnUrl() + "?session_id={CHECKOUT_SESSION_ID}&status=success"
: webhookBaseUrl + "/payments/stripe-success?session_id={CHECKOUT_SESSION_ID}";
String cancelUrl = (request.getCancelUrl() != null && !request.getCancelUrl().trim().isEmpty())
? request.getCancelUrl() + "?status=cancelled"
: webhookBaseUrl + "/payments/stripe-cancel";
sessionBuilder.setSuccessUrl(successUrl).setCancelUrl(cancelUrl);
if (donation.getPetId() != null) {
sessionBuilder.putMetadata("petId", String.valueOf(donation.getPetId()));
}
if (donation.getDonorUsername() != null) {
sessionBuilder.putMetadata("donorUsername", donation.getDonorUsername());
}
sessionBuilder
.addPaymentMethodType(SessionCreateParams.PaymentMethodType.CARD)
.addPaymentMethodType(SessionCreateParams.PaymentMethodType.BLIK)
.addPaymentMethodType(SessionCreateParams.PaymentMethodType.P24);
configureBillingInfo(sessionBuilder, donation);
Session session = Session.create(sessionBuilder.build());
Payment payment = Payment.builder()
.donation(donation)
.provider(PaymentProvider.STRIPE)
.externalId(session.getId())
.status(PaymentStatus.PENDING)
.amount(donation.getAmount())
.currency(donation.getCurrency())
.paymentMethod(PaymentMethod.CARD)
.checkoutUrl(session.getUrl())
.metadata(createMetadata(donation))
.expiresAt(Instant.ofEpochSecond(session.getExpiresAt()))
.build();
Payment savedPayment = paymentRepository.save(payment);
log.info("Stripe checkout session created with ID: {} for donation {}, checkout URL: {}",
savedPayment.getId(), donation.getId(), session.getUrl());
return PaymentResponse.fromEntity(savedPayment);
}
private void configureBillingInfo(SessionCreateParams.Builder sessionBuilder, Donation donation) {
if (donation.getDonorUsername() != null && !donation.getDonorUsername().trim().isEmpty()) {
if (donation.isDonorUsernameEmail()) {
sessionBuilder.setCustomerEmail(donation.getDonorUsername());
}
sessionBuilder.setCustomerCreation(SessionCreateParams.CustomerCreation.ALWAYS);
}
}
private String buildProductName(Donation donation) {
if (donation instanceof MaterialDonation md && md.getItemName() != null) {
return "Dotacja rzeczowa: " + md.getItemName();
}
return "Dotacja dla schroniska";
}
private Long convertToStripeAmount(BigDecimal amount) {
return amount.multiply(new BigDecimal("100")).longValue();
}
private PaymentStatus mapStripeSessionStatus(String stripeStatus) {
return switch (stripeStatus) {
case "open" -> PaymentStatus.PENDING;
case "complete" -> PaymentStatus.SUCCEEDED;
case "expired" -> PaymentStatus.CANCELLED;
default -> PaymentStatus.PENDING;
};
}
private String buildDescription(Donation donation) {
StringBuilder desc = new StringBuilder();
if (donation.getDonationType() == DonationType.MONEY) {
desc.append("Monetary donation");
} else if (donation instanceof MaterialDonation md) {
desc.append("Material donation: ").append(md.getItemName());
}
desc.append(" to animal shelter #").append(donation.getShelterId());
if (donation.getPetId() != null) {
desc.append(" for pet #").append(donation.getPetId());
}
return desc.toString();
}
private Map<String, String> createMetadata(Donation donation) {
Map<String, String> metadata = new HashMap<>();
metadata.put("donationId", String.valueOf(donation.getId()));
metadata.put("provider", PaymentProvider.STRIPE.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;
}
}