diff --git a/src/main/java/ru/soune/nocopy/client/YooKassaClient.java b/src/main/java/ru/soune/nocopy/client/YooKassaClient.java new file mode 100644 index 0000000..63307ce --- /dev/null +++ b/src/main/java/ru/soune/nocopy/client/YooKassaClient.java @@ -0,0 +1,69 @@ +package ru.soune.nocopy.client; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import com.fasterxml.jackson.databind.ObjectMapper; + +@Component +@Slf4j +public class YooKassaClient { + + @Value("${yookassa.shop-id}") + private String shopId; + + @Value("${yookassa.secret-key}") + private String secretKey; + + private final HttpClient httpClient = HttpClient.newHttpClient(); + + private final ObjectMapper mapper = new ObjectMapper(); + + public boolean createAutoPayment(String paymentMethodId, double amount, String description, String userId) { + try { + String url = "https://api.yookassa.ru/v3/payments"; + String auth = shopId + ":" + secretKey; + String encodedAuth = Base64.getEncoder().encodeToString(auth.getBytes(StandardCharsets.UTF_8)); + + Map body = new HashMap<>(); + body.put("amount", Map.of("value", String.format("%.2f", amount), + "currency", "RUB")); + body.put("payment_method_id", paymentMethodId); + body.put("description", description); + body.put("capture", true); + body.put("metadata", Map.of("user_id", userId, "auto_payment", "true")); + + String requestBody = mapper.writeValueAsString(body); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(url)) + .header("Content-Type", "application/json") + .header("Authorization", "Basic " + encodedAuth) + .header("Idempotence-Key", UUID.randomUUID().toString()) + .POST(HttpRequest.BodyPublishers.ofString(requestBody)) + .build(); + + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() == 200 || response.statusCode() == 201) { + log.info("Auto payment created: {}", response.body()); + return true; + } else { + log.error("Auto payment failed: {} - {}", response.statusCode(), response.body()); + return false; + } + } catch (Exception e) { + log.error("Error creating auto payment", e); + return false; + } + } +} diff --git a/src/main/java/ru/soune/nocopy/controller/PaymentController.java b/src/main/java/ru/soune/nocopy/controller/PaymentController.java index 97658da..11fd7e3 100644 --- a/src/main/java/ru/soune/nocopy/controller/PaymentController.java +++ b/src/main/java/ru/soune/nocopy/controller/PaymentController.java @@ -52,4 +52,40 @@ public class PaymentController { log.error("Error processing payment notification", e); } } + + @PostMapping("/auto-renewal/disable") + public ResponseEntity disableAutoRenewal(@RequestParam String email) { + try { + paymentService.disableAutoRenewal(email); + return ResponseEntity.ok(Map.of("message", "Auto-renewal disabled")); + } catch (UserNotFoundException e) { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(Map.of("error", e.getMessage())); + } + } + + @DeleteMapping("/methods/{paymentMethodId}") + public ResponseEntity deletePaymentMethod(@RequestParam String email, + @PathVariable String paymentMethodId) { + try { + paymentService.deletePaymentMethod(email, paymentMethodId); + return ResponseEntity.ok(Map.of("message", "Payment method deleted")); + } catch (UserNotFoundException e) { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(Map.of("error", e.getMessage())); + } catch (RuntimeException e) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", e.getMessage())); + } + } + + @GetMapping("/methods") + public ResponseEntity getPaymentMethods(@RequestParam String email) { + try { + return ResponseEntity.ok(paymentService.getUserPaymentMethods(email)); + } catch (UserNotFoundException e) { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(Map.of("error", e.getMessage())); + } + } } diff --git a/src/main/java/ru/soune/nocopy/dto/payment/PaymentMethodDTO.java b/src/main/java/ru/soune/nocopy/dto/payment/PaymentMethodDTO.java new file mode 100644 index 0000000..9c25f1f --- /dev/null +++ b/src/main/java/ru/soune/nocopy/dto/payment/PaymentMethodDTO.java @@ -0,0 +1,16 @@ +package ru.soune.nocopy.dto.payment; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class PaymentMethodDTO { + private String paymentMethodId; + private String lastFour; + private String cardType; + private String expiryMonth; + private String expiryYear; +} diff --git a/src/main/java/ru/soune/nocopy/entity/payment/PaymentMethod.java b/src/main/java/ru/soune/nocopy/entity/payment/PaymentMethod.java new file mode 100644 index 0000000..174d9a3 --- /dev/null +++ b/src/main/java/ru/soune/nocopy/entity/payment/PaymentMethod.java @@ -0,0 +1,45 @@ +package ru.soune.nocopy.entity.payment; + + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import ru.soune.nocopy.entity.user.User; +import java.time.LocalDateTime; + +@Entity +@Table(name = "payment_methods") +@Getter @Setter @NoArgsConstructor @AllArgsConstructor +public class PaymentMethod { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(name = "payment_method_id", nullable = false, unique = true) + private String paymentMethodId; + + @Column(name = "last_four") + private String lastFour; + + @Column(name = "card_type") + private String cardType; + + @Column(name = "expiry_month") + private String expiryMonth; + + @Column(name = "expiry_year") + private String expiryYear; + + @Column(name = "active") + private boolean active = true; + + @Column(name = "created_at") + private LocalDateTime createdAt; +} diff --git a/src/main/java/ru/soune/nocopy/entity/tarif/TariffInfo.java b/src/main/java/ru/soune/nocopy/entity/tarif/TariffInfo.java index c8f647e..9c71022 100644 --- a/src/main/java/ru/soune/nocopy/entity/tarif/TariffInfo.java +++ b/src/main/java/ru/soune/nocopy/entity/tarif/TariffInfo.java @@ -37,4 +37,10 @@ public class TariffInfo { @ManyToOne @JoinColumn(name = "tariff_id") private Tariff tariff; + + @Column(name = "auto_renewal") + private boolean autoRenewal = false; + + @Column(name = "payment_method_id") + private String paymentMethodId; } \ No newline at end of file diff --git a/src/main/java/ru/soune/nocopy/repository/PaymentMethodRepository.java b/src/main/java/ru/soune/nocopy/repository/PaymentMethodRepository.java new file mode 100644 index 0000000..6f772c5 --- /dev/null +++ b/src/main/java/ru/soune/nocopy/repository/PaymentMethodRepository.java @@ -0,0 +1,12 @@ +package ru.soune.nocopy.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import ru.soune.nocopy.entity.payment.PaymentMethod; +import ru.soune.nocopy.entity.user.User; +import java.util.List; +import java.util.Optional; + +public interface PaymentMethodRepository extends JpaRepository { + List findByUserAndActiveTrue(User user); + Optional findByPaymentMethodId(String paymentMethodId); +} diff --git a/src/main/java/ru/soune/nocopy/repository/TariffInfoRepository.java b/src/main/java/ru/soune/nocopy/repository/TariffInfoRepository.java index a74bd87..6982080 100644 --- a/src/main/java/ru/soune/nocopy/repository/TariffInfoRepository.java +++ b/src/main/java/ru/soune/nocopy/repository/TariffInfoRepository.java @@ -1,9 +1,16 @@ package ru.soune.nocopy.repository; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import ru.soune.nocopy.entity.tarif.TariffInfo; +import java.time.LocalDateTime; +import java.util.List; + @Repository public interface TariffInfoRepository extends JpaRepository { + @Query("SELECT t FROM TariffInfo t WHERE t.autoRenewal = true AND t.endTariff <= :date") + List findForAutoRenewal(@Param("date") LocalDateTime date); } diff --git a/src/main/java/ru/soune/nocopy/repository/UserRepository.java b/src/main/java/ru/soune/nocopy/repository/UserRepository.java index 88c3bce..8c7e31c 100644 --- a/src/main/java/ru/soune/nocopy/repository/UserRepository.java +++ b/src/main/java/ru/soune/nocopy/repository/UserRepository.java @@ -2,6 +2,7 @@ package ru.soune.nocopy.repository; import jakarta.validation.constraints.Size; import org.springframework.data.jpa.repository.JpaRepository; +import ru.soune.nocopy.entity.tarif.TariffInfo; import ru.soune.nocopy.entity.user.User; import java.util.List; @@ -12,4 +13,5 @@ public interface UserRepository extends JpaRepository { boolean existsByPhone(@Size(min = 11, max = 14) String phone); List findByCompanyId(String companyId); long countByCompanyId(String companyId); + User findByPersonalTariffInfo(TariffInfo tariffInfo); } diff --git a/src/main/java/ru/soune/nocopy/scheduler/PaymentScheduler.java b/src/main/java/ru/soune/nocopy/scheduler/PaymentScheduler.java new file mode 100644 index 0000000..112aacb --- /dev/null +++ b/src/main/java/ru/soune/nocopy/scheduler/PaymentScheduler.java @@ -0,0 +1,70 @@ +package ru.soune.nocopy.scheduler; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; +import ru.soune.nocopy.client.YooKassaClient; +import ru.soune.nocopy.entity.tarif.Tariff; +import ru.soune.nocopy.entity.tarif.TariffInfo; +import ru.soune.nocopy.entity.user.User; +import ru.soune.nocopy.repository.TariffInfoRepository; +import ru.soune.nocopy.repository.UserRepository; +import ru.soune.nocopy.service.mail.EmailService; + +import java.time.LocalDateTime; +import java.util.List; + +@Slf4j +@Component +@RequiredArgsConstructor +public class PaymentScheduler { + + private final TariffInfoRepository tariffInfoRepository; + + private final EmailService emailService; + + private final YooKassaClient yooKassaClient; + + private final UserRepository userRepository; + + @Scheduled(cron = "0 0 1 * * *") + @Transactional + public void processAutoRenewals() { + LocalDateTime now = LocalDateTime.now(); + LocalDateTime renewalDate = now.plusDays(1); + List forRenewal = tariffInfoRepository.findForAutoRenewal(renewalDate); + + for (TariffInfo tariffInfo : forRenewal) { + try { + if (tariffInfo.getPaymentMethodId() == null) { + tariffInfo.setAutoRenewal(false); + tariffInfoRepository.save(tariffInfo); + continue; + } + + User user = userRepository.findByPersonalTariffInfo(tariffInfo); + boolean success = yooKassaClient.createAutoPayment( + tariffInfo.getPaymentMethodId(), + tariffInfo.getTariff().getPrice(), + "Auto-renewal " + tariffInfo.getTariff().getName(), + String.valueOf(user.getId())); + + Tariff tariff = tariffInfo.getTariff(); + if (success) { + tariffInfo.setStartTariff(now); + tariffInfo.setEndTariff(now.plusMonths(1)); + + tariffInfoRepository.save(tariffInfo); + + emailService.sendAutoRenewalSuccessEmail(user, tariff.getName(), tariff.getPrice(), renewalDate); + } else { + emailService.sendAutoRenewalFailedEmail(user, tariff.getName(), tariff.getPrice()); + } + } catch (Exception e) { + log.error("Error processing auto-renewal for tariffInfo: " + tariffInfo.getId(), e); + } + } + } +} diff --git a/src/main/java/ru/soune/nocopy/service/mail/EmailService.java b/src/main/java/ru/soune/nocopy/service/mail/EmailService.java index c34212d..fabb47d 100644 --- a/src/main/java/ru/soune/nocopy/service/mail/EmailService.java +++ b/src/main/java/ru/soune/nocopy/service/mail/EmailService.java @@ -19,6 +19,8 @@ import java.io.IOException; import java.nio.charset.StandardCharsets; import java.security.SecureRandom; import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.HashMap; import java.util.Map; @Service @@ -45,9 +47,6 @@ public class EmailService { @Value("${server.baseurl}") private String baseUrl; - @Value("${server.front.port}") - private String frontPort; - public void sendEmail(String from, String sendTo, String subject, String body) throws MessagingException { MimeMessage message = mailSender.createMimeMessage(); MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8"); @@ -76,6 +75,35 @@ public class EmailService { sendEmail("noreply@no-copy.ru", user.getEmail(), standartAdressFrom, htmlContent); } + public void sendAutoRenewalFailedEmail(User user, String tariffName, Double price) + throws MessagingException, IOException { + + Map templateData = new HashMap<>(); + templateData.put("username", user.getFullName()); + templateData.put("tariff_name", tariffName); + templateData.put("price", String.format("%.2f", price)); + templateData.put("support_email", "support@no-copy.ru"); + + String htmlContent = loadAndProcessTemplate(templateData, "templates/failed-template.html"); + + sendEmail("noreply@no-copy.ru", user.getEmail(), standartAdressFrom, htmlContent); + } + + public void sendAutoRenewalSuccessEmail(User user, String tariffName, Double price, LocalDateTime nextPaymentDate) + throws MessagingException, IOException { + + Map templateData = new HashMap<>(); + templateData.put("username", user.getFullName()); + templateData.put("tariff_name", tariffName); + templateData.put("price", String.format("%.2f", price)); + templateData.put("renewal_date", LocalDateTime.now().format(DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm"))); + templateData.put("next_payment_date", nextPaymentDate.format(DateTimeFormatter.ofPattern("dd.MM.yyyy"))); + + String htmlContent = loadAndProcessTemplate(templateData, "templates/auto-renewal-success.html"); + + sendEmail("noreply@no-copy.ru", user.getEmail(), standartAdressFrom, htmlContent); + } + @Transactional public EmailVerificationToken createEmailVerificationToken(AuthToken authToken) { EmailVerificationToken existingToken = emailVerificationTokenRepository diff --git a/src/main/java/ru/soune/nocopy/service/payment/PaymentService.java b/src/main/java/ru/soune/nocopy/service/payment/PaymentService.java index 81f4233..4c32ce4 100644 --- a/src/main/java/ru/soune/nocopy/service/payment/PaymentService.java +++ b/src/main/java/ru/soune/nocopy/service/payment/PaymentService.java @@ -5,7 +5,10 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import ru.soune.ReferralService; +import ru.soune.nocopy.client.YooKassaClient; +import ru.soune.nocopy.dto.payment.PaymentMethodDTO; import ru.soune.nocopy.entity.payment.Payment; +import ru.soune.nocopy.entity.payment.PaymentMethod; import ru.soune.nocopy.entity.payment.PaymentOperationType; import ru.soune.nocopy.entity.payment.PaymentStatus; import ru.soune.nocopy.entity.tarif.Tariff; @@ -15,15 +18,13 @@ import ru.soune.nocopy.entity.user.User; import ru.soune.nocopy.exception.PaymentNotFoundException; import ru.soune.nocopy.exception.TariffNotFoundException; import ru.soune.nocopy.exception.UserNotFoundException; -import ru.soune.nocopy.repository.PaymentRepository; -import ru.soune.nocopy.repository.TariffInfoRepository; -import ru.soune.nocopy.repository.TariffRepository; -import ru.soune.nocopy.repository.UserRepository; +import ru.soune.nocopy.repository.*; import ru.soune.nocopy.service.tariff.TariffInfoService; import java.time.LocalDateTime; import java.util.List; import java.util.Map; +import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -41,6 +42,10 @@ public class PaymentService { private final ReferralService referralService; + private final PaymentMethodRepository paymentMethodRepository; + + private final YooKassaClient yooKassaClient; + @Transactional public Payment createPayment(String email, Long tariffId, String operationType, String paymentUuid) { User user = userRepository.findByEmail(email); @@ -63,6 +68,72 @@ public class PaymentService { return paymentRepository.save(payment); } + private void savePaymentMethod(User user, Map paymentMethodData) { + if (paymentMethodData == null) return; + + String paymentMethodId = (String) paymentMethodData.get("id"); + if (paymentMethodRepository.findByPaymentMethodId(paymentMethodId).isPresent()) return; + + Map card = (Map) paymentMethodData.get("card"); + if (card == null) return; + + PaymentMethod pm = new PaymentMethod(); + pm.setUser(user); + pm.setPaymentMethodId(paymentMethodId); + pm.setLastFour((String) card.get("last4")); + pm.setCardType((String) card.get("card_type")); + pm.setExpiryMonth((String) card.get("expiry_month")); + pm.setExpiryYear((String) card.get("expiry_year")); + pm.setCreatedAt(LocalDateTime.now()); + + paymentMethodRepository.save(pm); + } + + @Transactional(readOnly = true) + public List getUserPaymentMethods(String email) { + User user = userRepository.findByEmail(email); + if (user == null) throw new UserNotFoundException(email); + + return paymentMethodRepository.findByUserAndActiveTrue(user) + .stream() + .map(pm -> new PaymentMethodDTO( + pm.getPaymentMethodId(), + pm.getLastFour(), + pm.getCardType(), + pm.getExpiryMonth(), + pm.getExpiryYear() + )) + .collect(Collectors.toList()); + } + + @Transactional + public void deletePaymentMethod(String email, String paymentMethodId) { + User user = userRepository.findByEmail(email); + if (user == null) throw new UserNotFoundException(email); + + PaymentMethod pm = paymentMethodRepository.findByPaymentMethodId(paymentMethodId) + .orElseThrow(() -> new RuntimeException("Payment method not found")); + + if (!pm.getUser().getId().equals(user.getId())) { + throw new RuntimeException("Access denied"); + } + + pm.setActive(false); + paymentMethodRepository.save(pm); + } + + @Transactional + public void disableAutoRenewal(String email) { + User user = userRepository.findByEmail(email); + if (user == null) throw new UserNotFoundException(email); + + TariffInfo tariffInfo = user.getActiveTariffInfo(); + if (tariffInfo != null) { + tariffInfo.setAutoRenewal(false); + tariffInfoRepository.save(tariffInfo); + } + } + @Transactional public void handlePaymentNotification(Map notification) { String eventType = (String) notification.get("event"); @@ -79,7 +150,16 @@ public class PaymentService { payment.setStatus(PaymentStatus.SUCCEEDED); payment.setCancellationReason(null); - activateTariffForUser(payment.getUser(), tariff); + + Map metadata = (Map) object.get("metadata"); + Map paymentMethod = (Map) object.get("payment_method"); + String paymentMethodId = paymentMethod != null ? (String) paymentMethod.get("id") : null; + + boolean autoRenewal = metadata != null && Boolean.parseBoolean(metadata.get("auto_renewal")); + + activateTariffForUser(payment.getUser(), tariff, autoRenewal, paymentMethodId); + + savePaymentMethod(payment.getUser(), paymentMethod); referralService.onUserAccountRefill(payment.getUser().getId(), intPrice); } else if ("payment.canceled".equals(eventType)) { @@ -123,7 +203,7 @@ public class PaymentService { new PaymentNotFoundException("Payment not found")); } - private void activateTariffForUser(User user, Tariff tariff) { + private void activateTariffForUser(User user, Tariff tariff, boolean autoRenewal, String paymentMethodId) { String type = tariff.getTariffAccountType(); TariffInfo activeTariffInfo = user.getActiveTariffInfo(); @@ -140,6 +220,8 @@ public class PaymentService { tariffInfo.setTokens(tariff.getTokens()); tariffInfo.setBoughtTokens(activeTariffInfo.getBoughtTokens()); tariffInfo.setTariff(tariff); + activeTariffInfo.setAutoRenewal(autoRenewal); + tariffInfo.setPaymentMethodId(paymentMethodId); tariffInfoService.linkUserTariffInfo(user, tariffInfoService.addTariffInfo(tariffInfo)); } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 2aa392f..a0d6d5a 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -107,6 +107,11 @@ searchapi: api-key: ${SEARCHAPI_API_KEY:5jyYZC8jSaxhZTwjMUhwtAXi} reverse-image-url: "https://searchapi.io/api/v1/search" +yookassa: + secret-key: test_0Yns_0NHV5GJf6ypJ5HC4NSfnLO8SJkw-1PwrVWsDl4 + shop-id: 1276731 + + app: email: verification: diff --git a/src/main/resources/templates/auto-renewal-success.html b/src/main/resources/templates/auto-renewal-success.html new file mode 100644 index 0000000..959fa36 --- /dev/null +++ b/src/main/resources/templates/auto-renewal-success.html @@ -0,0 +1,169 @@ + + + + + + Подписка успешно продлена + + + +
+
+ +

Подписка успешно продлена!

+
+ +
+

+ Здравствуйте, {{username}}! +

+ +

Ваша подписка была автоматически продлена. Спасибо, что остаётесь с нами!

+ +
+
+ Тариф: + {{tariff_name}} +
+
+ Сумма списания: + {{price}} ₽ +
+
+ Дата продления: + {{renewal_date}} +
+
+ Следующее списание: + {{next_payment_date}} +
+
+ +
+ 💡 Детали подписки: +
    +
  • Доступ к сервису активен до {{next_payment_date}}
  • +
  • Вы всегда можете управлять подпиской в личном кабинете
  • +
+
+ +

+ Если у вас есть вопросы, наша служба поддержки всегда готова помочь. +

+
+ + +
+ + \ No newline at end of file diff --git a/src/main/resources/templates/failed-template.html b/src/main/resources/templates/failed-template.html new file mode 100644 index 0000000..33c671f --- /dev/null +++ b/src/main/resources/templates/failed-template.html @@ -0,0 +1,170 @@ + + + + + + Автопродление подписки не удалось + + + +
+
+ +

Автопродление не удалось

+
+ +
+

+ Здравствуйте, {{username}}! +

+ +

К сожалению, автоматическое продление подписки не удалось выполнить.

+ +
+
+ Тариф: + {{tariff_name}} +
+
+ Сумма: + {{price}} ₽ +
+
+ Дата попытки: + {{attempt_date}} +
+
+ +
+ Что делать? +
    +
  • Проверьте достаточно ли средств на карте
  • +
  • Убедитесь что карта не заблокирована
  • +
  • Если проблема сохраняется, обратитесь в поддержку
  • +
+
+ +

+ После успешной оплаты доступ к сервису будет восстановлен автоматически. +

+
+ + +
+ + \ No newline at end of file