View Javadoc
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 }