1 package org.petify.funding.service.payment; 2 3 import org.petify.funding.dto.PaymentRequest; 4 import org.petify.funding.dto.PaymentResponse; 5 import org.petify.funding.dto.WebhookEventDto; 6 import org.petify.funding.model.Currency; 7 import org.petify.funding.model.Donation; 8 import org.petify.funding.model.DonationType; 9 import org.petify.funding.model.MaterialDonation; 10 import org.petify.funding.model.Payment; 11 import org.petify.funding.model.PaymentMethod; 12 import org.petify.funding.model.PaymentProvider; 13 import org.petify.funding.model.PaymentStatus; 14 import org.petify.funding.repository.DonationRepository; 15 import org.petify.funding.repository.PaymentRepository; 16 import org.petify.funding.service.DonationStatusUpdateService; 17 18 import com.stripe.exception.SignatureVerificationException; 19 import com.stripe.exception.StripeException; 20 import com.stripe.model.Event; 21 import com.stripe.model.PaymentIntent; 22 import com.stripe.model.StripeObject; 23 import com.stripe.model.checkout.Session; 24 import com.stripe.net.Webhook; 25 import com.stripe.param.checkout.SessionCreateParams; 26 import lombok.RequiredArgsConstructor; 27 import lombok.extern.slf4j.Slf4j; 28 import org.springframework.beans.factory.annotation.Value; 29 import org.springframework.stereotype.Service; 30 import org.springframework.transaction.annotation.Transactional; 31 32 import java.math.BigDecimal; 33 import java.time.Instant; 34 import java.util.HashMap; 35 import java.util.Map; 36 import java.util.Optional; 37 import java.util.Set; 38 39 @Service 40 @RequiredArgsConstructor 41 @Slf4j 42 public class StripePaymentService implements PaymentProviderService { 43 44 private final PaymentRepository paymentRepository; 45 private final DonationRepository donationRepository; 46 private final DonationStatusUpdateService statusUpdateService; 47 48 @Value("${payment.stripe.webhook-secret}") 49 private String webhookSecret; 50 51 @Value("${app.webhook.base-url:http://localhost:8020}") 52 private String webhookBaseUrl; 53 54 @Override 55 @Transactional 56 public PaymentResponse createPayment(PaymentRequest request) { 57 try { 58 log.info("Creating Stripe checkout session for donation {}", request.getDonationId()); 59 60 Donation donation = donationRepository.findById(request.getDonationId()) 61 .orElseThrow(() -> new RuntimeException("Donation not found")); 62 63 return createCheckoutSession(donation, request); 64 65 } catch (StripeException e) { 66 log.error("Stripe payment creation failed", e); 67 throw new RuntimeException("Payment creation failed: " + e.getMessage(), e); 68 } 69 } 70 71 @Override 72 public PaymentResponse getPaymentStatus(String externalId) { 73 try { 74 Session session = Session.retrieve(externalId); 75 Payment payment = paymentRepository.findByExternalId(externalId) 76 .orElseThrow(() -> new RuntimeException("Payment not found")); 77 78 PaymentStatus oldStatus = payment.getStatus(); 79 PaymentStatus newStatus = mapStripeSessionStatus(session.getStatus()); 80 81 payment.setStatus(newStatus); 82 Payment savedPayment = paymentRepository.save(payment); 83 84 if (newStatus != oldStatus) { 85 statusUpdateService.handlePaymentStatusChange(payment.getId(), newStatus); 86 } 87 88 return PaymentResponse.fromEntity(savedPayment); 89 90 } catch (StripeException e) { 91 log.error("Failed to get Stripe payment status", e); 92 throw new RuntimeException("Failed to get payment status: " + e.getMessage(), e); 93 } 94 } 95 96 @Override 97 public PaymentResponse cancelPayment(String externalId) { 98 try { 99 Session session = Session.retrieve(externalId); 100 101 if (session.getPaymentIntent() != null) { 102 PaymentIntent intent = PaymentIntent.retrieve(session.getPaymentIntent()); 103 if (intent.getStatus().equals("requires_payment_method") 104 || intent.getStatus().equals("requires_confirmation")) { 105 intent.cancel(); 106 } 107 } 108 109 Payment payment = paymentRepository.findByExternalId(externalId) 110 .orElseThrow(() -> new RuntimeException("Payment not found")); 111 112 payment.setStatus(PaymentStatus.CANCELLED); 113 Payment savedPayment = paymentRepository.save(payment); 114 115 statusUpdateService.handlePaymentStatusChange(payment.getId(), PaymentStatus.CANCELLED); 116 117 log.info("Stripe payment {} cancelled", payment.getId()); 118 return PaymentResponse.fromEntity(savedPayment); 119 120 } catch (StripeException e) { 121 log.error("Failed to cancel Stripe payment", e); 122 throw new RuntimeException("Failed to cancel payment: " + e.getMessage(), e); 123 } 124 } 125 126 @Override 127 public PaymentResponse refundPayment(String externalId, BigDecimal amount) { 128 try { 129 Session session = Session.retrieve(externalId); 130 131 if (session.getPaymentIntent() == null) { 132 throw new RuntimeException("No payment intent found for session"); 133 } 134 135 PaymentIntent intent = PaymentIntent.retrieve(session.getPaymentIntent()); 136 137 com.stripe.param.RefundCreateParams refundParams = 138 com.stripe.param.RefundCreateParams.builder() 139 .setPaymentIntent(intent.getId()) 140 .setAmount(convertToStripeAmount(amount)) 141 .setReason(com.stripe.param.RefundCreateParams.Reason.REQUESTED_BY_CUSTOMER) 142 .build(); 143 144 com.stripe.model.Refund refund = com.stripe.model.Refund.create(refundParams); 145 146 Payment payment = paymentRepository.findByExternalId(externalId) 147 .orElseThrow(() -> new RuntimeException("Payment not found")); 148 149 PaymentStatus newStatus = refund.getAmount().equals(intent.getAmount()) 150 ? PaymentStatus.REFUNDED 151 : PaymentStatus.PARTIALLY_REFUNDED; 152 153 payment.setStatus(newStatus); 154 Payment savedPayment = paymentRepository.save(payment); 155 156 statusUpdateService.handlePaymentStatusChange(payment.getId(), newStatus); 157 158 log.info("Stripe payment {} refunded (amount: {})", payment.getId(), amount); 159 return PaymentResponse.fromEntity(savedPayment); 160 161 } catch (StripeException e) { 162 log.error("Failed to refund Stripe payment", e); 163 throw new RuntimeException("Failed to refund payment: " + e.getMessage(), e); 164 } 165 } 166 167 @Override 168 @Transactional 169 public void handleWebhook(String payload, String signature) { 170 try { 171 Event event = Webhook.constructEvent(payload, signature, webhookSecret); 172 173 log.info("Processing Stripe webhook event: {} ({})", event.getType(), event.getId()); 174 175 switch (event.getType()) { 176 case "checkout.session.completed" -> { 177 log.info("Handling checkout session completed"); 178 handleCheckoutSessionCompleted(event); 179 } 180 case "checkout.session.expired" -> { 181 log.info("Handling checkout session expired"); 182 handleCheckoutSessionExpired(event); 183 } 184 default -> { 185 log.debug("Unhandled Stripe event type: {}", event.getType()); 186 } 187 } 188 189 } catch (SignatureVerificationException e) { 190 log.error("Invalid Stripe webhook signature", e); 191 throw new RuntimeException("Invalid webhook signature", e); 192 } 193 } 194 195 @Override 196 public WebhookEventDto parseWebhookEvent(String payload, String signature) { 197 try { 198 Event event = Webhook.constructEvent(payload, signature, webhookSecret); 199 200 return WebhookEventDto.builder() 201 .eventId(event.getId()) 202 .eventType(event.getType()) 203 .provider(PaymentProvider.STRIPE.getValue()) 204 .receivedAt(Instant.now()) 205 .processed(false) 206 .build(); 207 208 } catch (SignatureVerificationException e) { 209 throw new RuntimeException("Invalid webhook signature", e); 210 } 211 } 212 213 @Override 214 public boolean supportsPaymentMethod(PaymentMethod method) { 215 return Set.of( 216 PaymentMethod.CARD, 217 PaymentMethod.GOOGLE_PAY, 218 PaymentMethod.APPLE_PAY, 219 PaymentMethod.PRZELEWY24, 220 PaymentMethod.BLIK, 221 PaymentMethod.BANK_TRANSFER 222 ).contains(method); 223 } 224 225 @Override 226 public boolean supportsCurrency(Currency currency) { 227 return Set.of(Currency.USD, Currency.EUR, Currency.GBP, Currency.PLN) 228 .contains(currency); 229 } 230 231 @Override 232 public BigDecimal calculateFee(BigDecimal amount, Currency currency) { 233 BigDecimal percentageFee = amount.multiply(new BigDecimal("0.029")); 234 BigDecimal fixedFee = switch (currency) { 235 case USD -> new BigDecimal("0.30"); 236 case EUR -> new BigDecimal("0.25"); 237 case GBP -> new BigDecimal("0.20"); 238 case PLN -> new BigDecimal("1.20"); 239 }; 240 return percentageFee.add(fixedFee); 241 } 242 243 private void handleCheckoutSessionCompleted(Event event) { 244 try { 245 Optional<StripeObject> stripeObjectOpt = event.getDataObjectDeserializer().getObject(); 246 247 Session session = null; 248 249 if (stripeObjectOpt.isPresent() && stripeObjectOpt.get() instanceof Session) { 250 session = (Session) stripeObjectOpt.get(); 251 log.info("Successfully deserialized session from event"); 252 } else { 253 String sessionId = extractSessionIdFromRawData(event); 254 if (sessionId != null) { 255 session = Session.retrieve(sessionId); 256 log.info("Successfully retrieved session from Stripe API: {}", sessionId); 257 } 258 } 259 260 if (session == null) { 261 log.error("Could not get session data for event: {}", event.getId()); 262 return; 263 } 264 265 processCompletedSession(session); 266 267 } catch (Exception e) { 268 log.error("Error processing checkout session completed event", e); 269 } 270 } 271 272 private String extractSessionIdFromRawData(Event event) { 273 try { 274 StripeObject rawObject = event.getData().getObject(); 275 if (rawObject != null) { 276 String objectString = rawObject.toString(); 277 java.util.regex.Pattern pattern = java.util.regex.Pattern.compile("\"id\"\\s*:\\s*\"(cs_test_[^\"]+)\""); 278 java.util.regex.Matcher matcher = pattern.matcher(objectString); 279 if (matcher.find()) { 280 return matcher.group(1); 281 } 282 } 283 } catch (Exception e) { 284 log.error("Error extracting session ID", e); 285 } 286 return null; 287 } 288 289 private void processCompletedSession(Session session) { 290 String sessionId = session.getId(); 291 log.info("Processing completed session: {}", sessionId); 292 293 Optional<Payment> paymentOpt = paymentRepository.findByExternalId(sessionId); 294 295 if (paymentOpt.isEmpty()) { 296 log.error("Payment NOT FOUND for session ID: '{}'", sessionId); 297 return; 298 } 299 300 Payment payment = paymentOpt.get(); 301 log.info("Found payment: ID={}, ExternalID='{}', DonationID={}", 302 payment.getId(), payment.getExternalId(), payment.getDonation().getId()); 303 304 if (payment.getStatus() == PaymentStatus.SUCCEEDED) { 305 log.info("Payment {} already succeeded, skipping", payment.getId()); 306 return; 307 } 308 309 payment.setStatus(PaymentStatus.SUCCEEDED); 310 paymentRepository.save(payment); 311 312 statusUpdateService.handlePaymentStatusChange(payment.getId(), PaymentStatus.SUCCEEDED); 313 314 log.info("Payment {} completed successfully for donation {}", 315 payment.getId(), payment.getDonation().getId()); 316 } 317 318 private void handleCheckoutSessionExpired(Event event) { 319 try { 320 Optional<StripeObject> stripeObjectOpt = event.getDataObjectDeserializer().getObject(); 321 322 Session session = null; 323 324 if (stripeObjectOpt.isPresent() && stripeObjectOpt.get() instanceof Session) { 325 session = (Session) stripeObjectOpt.get(); 326 } else { 327 String sessionId = extractSessionIdFromRawData(event); 328 if (sessionId != null) { 329 session = Session.retrieve(sessionId); 330 } 331 } 332 333 if (session == null) { 334 log.error("Could not get session data for expired event: {}", event.getId()); 335 return; 336 } 337 338 paymentRepository.findByExternalId(session.getId()) 339 .ifPresent(payment -> { 340 if (payment.getStatus() != PaymentStatus.CANCELLED) { 341 payment.setStatus(PaymentStatus.CANCELLED); 342 paymentRepository.save(payment); 343 statusUpdateService.handlePaymentStatusChange(payment.getId(), PaymentStatus.CANCELLED); 344 log.info("Payment {} expired for donation {}", 345 payment.getId(), payment.getDonation().getId()); 346 } 347 }); 348 349 } catch (Exception e) { 350 log.error("Error processing checkout session expired event", e); 351 } 352 } 353 354 private PaymentResponse createCheckoutSession(Donation donation, PaymentRequest request) throws StripeException { 355 356 SessionCreateParams.LineItem lineItem = SessionCreateParams.LineItem.builder() 357 .setPriceData( 358 SessionCreateParams.LineItem.PriceData.builder() 359 .setCurrency(donation.getCurrency().name().toLowerCase()) 360 .setProductData( 361 SessionCreateParams.LineItem.PriceData.ProductData.builder() 362 .setName(buildProductName(donation)) 363 .setDescription(buildDescription(donation)) 364 .build() 365 ) 366 .setUnitAmount(convertToStripeAmount(donation.getAmount())) 367 .build() 368 ) 369 .setQuantity(1L) 370 .build(); 371 372 SessionCreateParams.Builder sessionBuilder = SessionCreateParams.builder() 373 .setMode(SessionCreateParams.Mode.PAYMENT) 374 .addLineItem(lineItem) 375 .putMetadata("donationId", String.valueOf(donation.getId())) 376 .putMetadata("provider", PaymentProvider.STRIPE.getValue()) 377 .putMetadata("donationType", donation.getDonationType().name()) 378 .putMetadata("shelterId", String.valueOf(donation.getShelterId())); 379 380 String successUrl = (request.getReturnUrl() != null && !request.getReturnUrl().trim().isEmpty()) 381 ? request.getReturnUrl() + "?session_id={CHECKOUT_SESSION_ID}&status=success" 382 : webhookBaseUrl + "/payments/stripe-success?session_id={CHECKOUT_SESSION_ID}"; 383 384 String cancelUrl = (request.getCancelUrl() != null && !request.getCancelUrl().trim().isEmpty()) 385 ? request.getCancelUrl() + "?status=cancelled" 386 : webhookBaseUrl + "/payments/stripe-cancel"; 387 388 sessionBuilder.setSuccessUrl(successUrl).setCancelUrl(cancelUrl); 389 390 if (donation.getPetId() != null) { 391 sessionBuilder.putMetadata("petId", String.valueOf(donation.getPetId())); 392 } 393 if (donation.getDonorUsername() != null) { 394 sessionBuilder.putMetadata("donorUsername", donation.getDonorUsername()); 395 } 396 397 sessionBuilder 398 .addPaymentMethodType(SessionCreateParams.PaymentMethodType.CARD) 399 .addPaymentMethodType(SessionCreateParams.PaymentMethodType.BLIK) 400 .addPaymentMethodType(SessionCreateParams.PaymentMethodType.P24); 401 402 configureBillingInfo(sessionBuilder, donation); 403 404 Session session = Session.create(sessionBuilder.build()); 405 406 Payment payment = Payment.builder() 407 .donation(donation) 408 .provider(PaymentProvider.STRIPE) 409 .externalId(session.getId()) 410 .status(PaymentStatus.PENDING) 411 .amount(donation.getAmount()) 412 .currency(donation.getCurrency()) 413 .paymentMethod(PaymentMethod.CARD) 414 .checkoutUrl(session.getUrl()) 415 .metadata(createMetadata(donation)) 416 .expiresAt(Instant.ofEpochSecond(session.getExpiresAt())) 417 .build(); 418 419 Payment savedPayment = paymentRepository.save(payment); 420 421 log.info("Stripe checkout session created with ID: {} for donation {}, checkout URL: {}", 422 savedPayment.getId(), donation.getId(), session.getUrl()); 423 424 return PaymentResponse.fromEntity(savedPayment); 425 } 426 427 private void configureBillingInfo(SessionCreateParams.Builder sessionBuilder, Donation donation) { 428 if (donation.getDonorUsername() != null && !donation.getDonorUsername().trim().isEmpty()) { 429 430 if (donation.isDonorUsernameEmail()) { 431 sessionBuilder.setCustomerEmail(donation.getDonorUsername()); 432 } 433 434 sessionBuilder.setCustomerCreation(SessionCreateParams.CustomerCreation.ALWAYS); 435 } 436 } 437 438 private String buildProductName(Donation donation) { 439 if (donation instanceof MaterialDonation md && md.getItemName() != null) { 440 return "Dotacja rzeczowa: " + md.getItemName(); 441 } 442 return "Dotacja dla schroniska"; 443 } 444 445 private Long convertToStripeAmount(BigDecimal amount) { 446 return amount.multiply(new BigDecimal("100")).longValue(); 447 } 448 449 private PaymentStatus mapStripeSessionStatus(String stripeStatus) { 450 return switch (stripeStatus) { 451 case "open" -> PaymentStatus.PENDING; 452 case "complete" -> PaymentStatus.SUCCEEDED; 453 case "expired" -> PaymentStatus.CANCELLED; 454 default -> PaymentStatus.PENDING; 455 }; 456 } 457 458 private String buildDescription(Donation donation) { 459 StringBuilder desc = new StringBuilder(); 460 461 if (donation.getDonationType() == DonationType.MONEY) { 462 desc.append("Monetary donation"); 463 } else if (donation instanceof MaterialDonation md) { 464 desc.append("Material donation: ").append(md.getItemName()); 465 } 466 467 desc.append(" to animal shelter #").append(donation.getShelterId()); 468 469 if (donation.getPetId() != null) { 470 desc.append(" for pet #").append(donation.getPetId()); 471 } 472 473 return desc.toString(); 474 } 475 476 private Map<String, String> createMetadata(Donation donation) { 477 Map<String, String> metadata = new HashMap<>(); 478 metadata.put("donationId", String.valueOf(donation.getId())); 479 metadata.put("provider", PaymentProvider.STRIPE.getValue()); 480 metadata.put("donationType", donation.getDonationType().name()); 481 metadata.put("shelterId", String.valueOf(donation.getShelterId())); 482 483 if (donation.getPetId() != null) { 484 metadata.put("petId", String.valueOf(donation.getPetId())); 485 } 486 if (donation.getDonorUsername() != null) { 487 metadata.put("donorUsername", donation.getDonorUsername()); 488 } 489 490 return metadata; 491 } 492 }