From 5551d84bf7de28f4f44f4a3dc1af4506e0ee5fdb Mon Sep 17 00:00:00 2001 From: vladp Date: Fri, 20 Feb 2026 20:51:14 +0700 Subject: [PATCH] dev comment logic for check max users --- .../nocopy/controller/PayoutController.java | 133 +++++++++++++++ .../dto/payout/AddBankTransferRequest.java | 44 +++++ .../nocopy/dto/payout/AddCardRequest.java | 19 +++ .../nocopy/dto/payout/PayoutMethodDTO.java | 20 +++ .../dto/payout/PayoutMethodResponse.java | 23 +++ .../dto/payout/PayoutMethodTypeDTO.java | 14 ++ .../nocopy/dto/payout/PayoutRequestDTO.java | 23 +++ .../nocopy/dto/payout/PayoutResponse.java | 54 ++++++ .../payout/BankTransferPayoutMethod.java | 50 ++++++ .../entity/payout/CardPayoutMethod.java | 37 ++++ .../entity/payout/InternalPayoutMethod.java | 19 +++ .../nocopy/entity/payout/PayoutMethod.java | 48 ++++++ .../nocopy/entity/payout/PayoutRequest.java | 64 +++++++ .../entity/payout/PayoutRequestStatus.java | 9 + .../nocopy/entity/payout/PayoutType.java | 17 ++ .../payout/convertor/CardNumberEncryptor.java | 74 ++++++++ .../nocopy/entity/referral/Referral.java | 3 + .../exception/DuplicateMethodException.java | 7 + .../exception/InsufficientFundsException.java | 7 + .../InvalidPayoutMethodException.java | 7 + .../PendingPayoutExistsException.java | 7 + .../repository/PayoutMethodRepository.java | 37 ++++ .../repository/PayoutRequestRepository.java | 39 +++++ .../repository/ReferralJpaRepository.java | 5 +- .../service/payment/PaymentService.java | 11 +- .../nocopy/service/payout/BalanceService.java | 94 ++++++++++ .../service/payout/PayoutMethodService.java | 143 ++++++++++++++++ .../service/payout/PayoutRequestService.java | 160 ++++++++++++++++++ .../service/referral/ReferralRepoImpl.java | 1 + .../nocopy/service/register/AuthService.java | 1 - 30 files changed, 1167 insertions(+), 3 deletions(-) create mode 100644 src/main/java/ru/soune/nocopy/controller/PayoutController.java create mode 100644 src/main/java/ru/soune/nocopy/dto/payout/AddBankTransferRequest.java create mode 100644 src/main/java/ru/soune/nocopy/dto/payout/AddCardRequest.java create mode 100644 src/main/java/ru/soune/nocopy/dto/payout/PayoutMethodDTO.java create mode 100644 src/main/java/ru/soune/nocopy/dto/payout/PayoutMethodResponse.java create mode 100644 src/main/java/ru/soune/nocopy/dto/payout/PayoutMethodTypeDTO.java create mode 100644 src/main/java/ru/soune/nocopy/dto/payout/PayoutRequestDTO.java create mode 100644 src/main/java/ru/soune/nocopy/dto/payout/PayoutResponse.java create mode 100644 src/main/java/ru/soune/nocopy/entity/payout/BankTransferPayoutMethod.java create mode 100644 src/main/java/ru/soune/nocopy/entity/payout/CardPayoutMethod.java create mode 100644 src/main/java/ru/soune/nocopy/entity/payout/InternalPayoutMethod.java create mode 100644 src/main/java/ru/soune/nocopy/entity/payout/PayoutMethod.java create mode 100644 src/main/java/ru/soune/nocopy/entity/payout/PayoutRequest.java create mode 100644 src/main/java/ru/soune/nocopy/entity/payout/PayoutRequestStatus.java create mode 100644 src/main/java/ru/soune/nocopy/entity/payout/PayoutType.java create mode 100644 src/main/java/ru/soune/nocopy/entity/payout/convertor/CardNumberEncryptor.java create mode 100644 src/main/java/ru/soune/nocopy/exception/DuplicateMethodException.java create mode 100644 src/main/java/ru/soune/nocopy/exception/InsufficientFundsException.java create mode 100644 src/main/java/ru/soune/nocopy/exception/InvalidPayoutMethodException.java create mode 100644 src/main/java/ru/soune/nocopy/exception/PendingPayoutExistsException.java create mode 100644 src/main/java/ru/soune/nocopy/repository/PayoutMethodRepository.java create mode 100644 src/main/java/ru/soune/nocopy/repository/PayoutRequestRepository.java create mode 100644 src/main/java/ru/soune/nocopy/service/payout/BalanceService.java create mode 100644 src/main/java/ru/soune/nocopy/service/payout/PayoutMethodService.java create mode 100644 src/main/java/ru/soune/nocopy/service/payout/PayoutRequestService.java diff --git a/src/main/java/ru/soune/nocopy/controller/PayoutController.java b/src/main/java/ru/soune/nocopy/controller/PayoutController.java new file mode 100644 index 0000000..bad323d --- /dev/null +++ b/src/main/java/ru/soune/nocopy/controller/PayoutController.java @@ -0,0 +1,133 @@ +package ru.soune.nocopy.controller; + + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import ru.soune.nocopy.dto.payout.*; +import ru.soune.nocopy.entity.payout.BankTransferPayoutMethod; +import ru.soune.nocopy.entity.payout.CardPayoutMethod; +import ru.soune.nocopy.entity.payout.PayoutRequest; +import ru.soune.nocopy.entity.payout.PayoutType; +import ru.soune.nocopy.service.payout.PayoutMethodService; +import ru.soune.nocopy.service.payout.PayoutRequestService; + +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +@RestController +@RequestMapping("/api/payouts") +@RequiredArgsConstructor +@Slf4j +public class PayoutController { + + private final PayoutRequestService payoutRequestService; + + private final PayoutMethodService payoutMethodService; + + /* + Create payout + */ + @PostMapping("/create-request") + public ResponseEntity createPayoutRequest(@RequestParam Long userId, + @Valid @RequestBody PayoutRequestDTO request) { + PayoutRequest payoutRequest = payoutRequestService.createPayoutRequest(userId, request); + + return ResponseEntity.status(HttpStatus.CREATED).body(payoutRequest); + } + + /* + Completed payout + */ + @PostMapping("/requests/{requestId}/process") + public ResponseEntity processPayoutRequest(@PathVariable Long requestId) { + PayoutRequest processed = payoutRequestService.processRequest(requestId); + + return ResponseEntity.ok(processed); + } + + /* + Cancel payout + */ + @PostMapping("/requests/{requestId}/{userId}/cancel") + public ResponseEntity cancelPayoutRequest(@PathVariable Long userId, @PathVariable Long requestId) { + PayoutRequest cancelled = payoutRequestService.cancelPayoutRequest(userId, requestId); + + return ResponseEntity.ok(cancelled); + } + + /* + Get user payouts + */ + @GetMapping("/requests/{userId}") + public ResponseEntity> getUserRequests(@PathVariable Long userId) { + return ResponseEntity.ok(payoutRequestService.getUserPayoutRequests(userId)); + } + + /* + Get payout by id + */ + @GetMapping("/requests/{requestId}/{userId}") + public ResponseEntity getPayoutRequest(@PathVariable Long userId, @PathVariable Long requestId) { + return ResponseEntity.ok(payoutRequestService.getPayoutRequest(userId, requestId)); + } + + /* + Get payout by id + */ + @GetMapping("/payout-method/{userId}") + public ResponseEntity> getUserMethods(@PathVariable Long userId) { + return ResponseEntity.ok(payoutMethodService.getUserMethods(userId)); + } + + /* + Get payout-methods + */ + @GetMapping("/payout-method") + public ResponseEntity> getAvailableMethodTypes() { + List types = Arrays.stream(PayoutType.values()) + .map(type -> PayoutMethodTypeDTO.builder() + .code(type.name()) + .name(type.getDisplayName()) + .build()) + .collect(Collectors.toList()); + + return ResponseEntity.ok(types); + } + /* + add card method by userId + */ + @PostMapping("/payout-method/card/{userId}") + public ResponseEntity addCardMethod(@PathVariable Long userId, + @Valid @RequestBody AddCardRequest request) { + CardPayoutMethod method = payoutMethodService.addCardMethod(userId, request); + return ResponseEntity.status(HttpStatus.CREATED) + .body(payoutMethodService.convertToDto(method)); + } + + /* + add bank payout-method + */ + @PostMapping("/payout-method/bank-transfer/{userId}") + public ResponseEntity addBankTransferMethod(@PathVariable Long userId, + @Valid @RequestBody AddBankTransferRequest request) { + BankTransferPayoutMethod method = payoutMethodService.addBankTransferMethod(userId, request); + return ResponseEntity.status(HttpStatus.CREATED) + .body(payoutMethodService.convertToDto(method)); + } + + + /* + set default bank payout-method + */ + @PutMapping("/payout-method/{userId}/{methodId}/default") + public ResponseEntity setDefaultMethod(@PathVariable Long userId, @PathVariable Long methodId) { + + payoutMethodService.setDefaultMethod(userId, methodId); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/ru/soune/nocopy/dto/payout/AddBankTransferRequest.java b/src/main/java/ru/soune/nocopy/dto/payout/AddBankTransferRequest.java new file mode 100644 index 0000000..39a5db3 --- /dev/null +++ b/src/main/java/ru/soune/nocopy/dto/payout/AddBankTransferRequest.java @@ -0,0 +1,44 @@ +package ru.soune.nocopy.dto.payout; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AddBankTransferRequest { + + @NotBlank(message = "Bank name is required") + @Size(min = 2, max = 200, message = "Bank name must be between 2 and 200 characters") + private String bankName; + + @NotBlank(message = "BIC is required") + @Pattern(regexp = "^[0-9]{9}$", message = "BIC must contain 9 digits") + private String bic; + + @Pattern(regexp = "^[0-9]{20}$", message = "Correspondent account must contain 20 digits") + private String correspondentAccount; + + @NotBlank(message = "Account number is required") + @Pattern(regexp = "^[0-9]{20}$", message = "Account number must contain 20 digits") + private String accountNumber; + + @NotBlank(message = "Account holder name is required") + @Size(min = 3, max = 200, message = "Account holder name must be between 3 and 200 characters") + private String accountHolder; + + @Pattern(regexp = "^[0-9]{10}$|^[0-9]{12}$", message = "INN must contain 10 or 12 digits") + private String inn; + + @Pattern(regexp = "^[0-9]{9}$", message = "KPP must contain 9 digits") + private String kpp; + + @Builder.Default + private boolean saveAsDefault = false; +} \ No newline at end of file diff --git a/src/main/java/ru/soune/nocopy/dto/payout/AddCardRequest.java b/src/main/java/ru/soune/nocopy/dto/payout/AddCardRequest.java new file mode 100644 index 0000000..0f9f355 --- /dev/null +++ b/src/main/java/ru/soune/nocopy/dto/payout/AddCardRequest.java @@ -0,0 +1,19 @@ +package ru.soune.nocopy.dto.payout; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AddCardRequest { + + @NotBlank(message = "Card number required") + @Pattern(regexp = "^[0-9]{16}$", message = "Card number 16 cipher") + private String cardNumber; +} diff --git a/src/main/java/ru/soune/nocopy/dto/payout/PayoutMethodDTO.java b/src/main/java/ru/soune/nocopy/dto/payout/PayoutMethodDTO.java new file mode 100644 index 0000000..8eadf89 --- /dev/null +++ b/src/main/java/ru/soune/nocopy/dto/payout/PayoutMethodDTO.java @@ -0,0 +1,20 @@ +package ru.soune.nocopy.dto.payout; + + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PayoutMethodDTO { + private Long id; + private String type; + private String displayName; + private String maskedDetails; + private boolean isDefault; + private boolean isVerified; +} diff --git a/src/main/java/ru/soune/nocopy/dto/payout/PayoutMethodResponse.java b/src/main/java/ru/soune/nocopy/dto/payout/PayoutMethodResponse.java new file mode 100644 index 0000000..df72994 --- /dev/null +++ b/src/main/java/ru/soune/nocopy/dto/payout/PayoutMethodResponse.java @@ -0,0 +1,23 @@ +package ru.soune.nocopy.dto.payout; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import java.time.LocalDateTime; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PayoutMethodResponse { + private Long id; + private String type; + private String displayName; + private String maskedDetails; + private boolean isDefault; + private boolean isVerified; + private LocalDateTime createdAt; + private String bankName; + private String cardBrand; +} diff --git a/src/main/java/ru/soune/nocopy/dto/payout/PayoutMethodTypeDTO.java b/src/main/java/ru/soune/nocopy/dto/payout/PayoutMethodTypeDTO.java new file mode 100644 index 0000000..d1f0884 --- /dev/null +++ b/src/main/java/ru/soune/nocopy/dto/payout/PayoutMethodTypeDTO.java @@ -0,0 +1,14 @@ +package ru.soune.nocopy.dto.payout; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PayoutMethodTypeDTO { + private String code; + private String name; +} diff --git a/src/main/java/ru/soune/nocopy/dto/payout/PayoutRequestDTO.java b/src/main/java/ru/soune/nocopy/dto/payout/PayoutRequestDTO.java new file mode 100644 index 0000000..609a3ae --- /dev/null +++ b/src/main/java/ru/soune/nocopy/dto/payout/PayoutRequestDTO.java @@ -0,0 +1,23 @@ +package ru.soune.nocopy.dto.payout; + +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import java.math.BigDecimal; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PayoutRequestDTO { + + @NotNull(message = "Sum of pay is required") + @DecimalMin(value = "1000.00", message = "Min sum for pay 1000") + private BigDecimal amount; + + @NotNull(message = "Способ выплаты обязателен") + private Long payoutMethodId; +} diff --git a/src/main/java/ru/soune/nocopy/dto/payout/PayoutResponse.java b/src/main/java/ru/soune/nocopy/dto/payout/PayoutResponse.java new file mode 100644 index 0000000..243db58 --- /dev/null +++ b/src/main/java/ru/soune/nocopy/dto/payout/PayoutResponse.java @@ -0,0 +1,54 @@ +package ru.soune.nocopy.dto.payout; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import ru.soune.nocopy.entity.payout.PayoutRequest; +import ru.soune.nocopy.entity.payout.PayoutRequestStatus; +import ru.soune.nocopy.entity.payout.PayoutType; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor + +public class PayoutResponse { + private Long id; + private BigDecimal amount; + private String formattedAmount; + private PayoutRequestStatus status; + private PayoutType payoutType; + private String payoutMethodDisplay; + private LocalDateTime createdAt; + private String formattedCreatedAt; + private LocalDateTime processedAt; + private LocalDateTime completedAt; + private String transactionId; + private String rejectionReason; + private boolean canCancel; + + public static PayoutResponse fromEntity(PayoutRequest request) { + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm"); + + return PayoutResponse.builder() + .id(request.getId()) + .amount(request.getAmount()) + .formattedAmount(String.format("%.2f ₽", request.getAmount())) + .status(request.getStatus()) + .payoutType(request.getPayoutType()) + .payoutMethodDisplay(request.getPayoutMethod() != null ? + request.getPayoutMethod().getDisplayName() : null) + .createdAt(request.getCreatedAt()) + .formattedCreatedAt(request.getCreatedAt() != null ? + request.getCreatedAt().format(formatter) : null) + .processedAt(request.getProcessAt()) + .completedAt(request.getCompletedAt()) + .rejectionReason(request.getRejectionReason()) + .canCancel(request.getStatus() == PayoutRequestStatus.PENDING) + .build(); + } +} diff --git a/src/main/java/ru/soune/nocopy/entity/payout/BankTransferPayoutMethod.java b/src/main/java/ru/soune/nocopy/entity/payout/BankTransferPayoutMethod.java new file mode 100644 index 0000000..fca64ee --- /dev/null +++ b/src/main/java/ru/soune/nocopy/entity/payout/BankTransferPayoutMethod.java @@ -0,0 +1,50 @@ +package ru.soune.nocopy.entity.payout; + +import jakarta.persistence.Column; +import jakarta.persistence.DiscriminatorValue; +import jakarta.persistence.Entity; +import lombok.*; + +@Entity +@DiscriminatorValue("BANK_TRANSFER") +@NoArgsConstructor +@AllArgsConstructor +@Getter @Setter +public class BankTransferPayoutMethod extends PayoutMethod { + + @Column(name = "bank_name") + private String bankName; + + @Column(name = "bic") + private String bic; + + @Column(name = "correspondent_account") + private String correspondentAccount; + + @Column(name = "account_number") + private String accountNumber; + + @Column(name = "account_holder") + private String accountHolder; + + @Column(name = "inn") + private String inn; + + @Column(name = "kpp") + private String kpp; + + @Override + public String getDisplayName() { + return String.format("Tax %s in %s", getMaskedAccount(), bankName); + } + + @Override + public String getMaskedDetails() { + return getMaskedAccount(); + } + + private String getMaskedAccount() { + if (accountNumber == null || accountNumber.length() < 4) return ""; + return "..." + accountNumber.substring(accountNumber.length() - 4); + } +} diff --git a/src/main/java/ru/soune/nocopy/entity/payout/CardPayoutMethod.java b/src/main/java/ru/soune/nocopy/entity/payout/CardPayoutMethod.java new file mode 100644 index 0000000..053fc91 --- /dev/null +++ b/src/main/java/ru/soune/nocopy/entity/payout/CardPayoutMethod.java @@ -0,0 +1,37 @@ +package ru.soune.nocopy.entity.payout; + +import jakarta.persistence.Column; +import jakarta.persistence.Convert; +import jakarta.persistence.DiscriminatorValue; +import jakarta.persistence.Entity; +import lombok.*; +import ru.soune.nocopy.entity.payout.convertor.CardNumberEncryptor; + +@Entity +@DiscriminatorValue("CARD") +@NoArgsConstructor +@AllArgsConstructor +@Setter @Getter +public class CardPayoutMethod extends PayoutMethod { + @Convert(converter = CardNumberEncryptor.class) + @Column(name = "card_number", nullable = false) + private String cardNumber; + + @Column(name = "card_hash", unique = true) + private String cardHash; + + @Override + public String getDisplayName() { + return "Card: " + getMaskedCardNumber(); + } + + @Override + public String getMaskedDetails() { + return getMaskedCardNumber(); + } + + private String getMaskedCardNumber() { + if (cardNumber == null || cardNumber.length() < 4) return ""; + return "****" + cardNumber.substring(cardNumber.length() - 4); + } +} diff --git a/src/main/java/ru/soune/nocopy/entity/payout/InternalPayoutMethod.java b/src/main/java/ru/soune/nocopy/entity/payout/InternalPayoutMethod.java new file mode 100644 index 0000000..9b3c8ff --- /dev/null +++ b/src/main/java/ru/soune/nocopy/entity/payout/InternalPayoutMethod.java @@ -0,0 +1,19 @@ +package ru.soune.nocopy.entity.payout; + +import jakarta.persistence.DiscriminatorValue; +import jakarta.persistence.Entity; + +@Entity +@DiscriminatorValue("INTERNAL") +public class InternalPayoutMethod extends PayoutMethod { + + @Override + public String getDisplayName() { + return "Internal balance"; + } + + @Override + public String getMaskedDetails() { + return "Account balance"; + } +} diff --git a/src/main/java/ru/soune/nocopy/entity/payout/PayoutMethod.java b/src/main/java/ru/soune/nocopy/entity/payout/PayoutMethod.java new file mode 100644 index 0000000..77c91d3 --- /dev/null +++ b/src/main/java/ru/soune/nocopy/entity/payout/PayoutMethod.java @@ -0,0 +1,48 @@ +package ru.soune.nocopy.entity.payout; + +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; +import ru.soune.nocopy.entity.user.User; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "payout_methods") +@Inheritance(strategy = InheritanceType.SINGLE_TABLE) +@DiscriminatorColumn(name = "method_type") +@Data +@NoArgsConstructor +@AllArgsConstructor +public class PayoutMethod { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(name = "method_type", insertable = false, updatable = false) + private String methodType; + + @Column(name = "is_default") + private boolean isDefault; + + @CreationTimestamp + private LocalDateTime createdAt; + + @UpdateTimestamp + private LocalDateTime updatedAt; + + + public String getDisplayName() { + return null; + } + + public String getMaskedDetails() { + return null; + } +} diff --git a/src/main/java/ru/soune/nocopy/entity/payout/PayoutRequest.java b/src/main/java/ru/soune/nocopy/entity/payout/PayoutRequest.java new file mode 100644 index 0000000..231f8d0 --- /dev/null +++ b/src/main/java/ru/soune/nocopy/entity/payout/PayoutRequest.java @@ -0,0 +1,64 @@ +package ru.soune.nocopy.entity.payout; +import com.fasterxml.jackson.annotation.JsonIgnore; +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; +import ru.soune.nocopy.entity.user.User; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + + +@Entity +@Table(name = "payout_requests") +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Getter +public class PayoutRequest { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "payout_method_id") + @JsonIgnore + private PayoutMethod payoutMethod; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + @JsonIgnore + private User user; + + @Column(nullable = false) + private BigDecimal amount; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private PayoutRequestStatus status; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, name = "payout_type") + @JsonIgnore + private PayoutType payoutType; + + @Column(name = "process_at") + private LocalDateTime processAt; + + @Column(name = "completed_at") + private LocalDateTime completedAt; + + @Column(name = "rejection_reason") + private String rejectionReason; + + @CreationTimestamp + @Column(name = "created_at", updatable = false) + private LocalDateTime createdAt; + + @UpdateTimestamp + @Column(name = "updated_at") + private LocalDateTime updatedAt; +} diff --git a/src/main/java/ru/soune/nocopy/entity/payout/PayoutRequestStatus.java b/src/main/java/ru/soune/nocopy/entity/payout/PayoutRequestStatus.java new file mode 100644 index 0000000..bf5f013 --- /dev/null +++ b/src/main/java/ru/soune/nocopy/entity/payout/PayoutRequestStatus.java @@ -0,0 +1,9 @@ +package ru.soune.nocopy.entity.payout; + +public enum PayoutRequestStatus { + PENDING, + PROCESSING, + COMPLETED, + REJECTED, + CANCELLED +} diff --git a/src/main/java/ru/soune/nocopy/entity/payout/PayoutType.java b/src/main/java/ru/soune/nocopy/entity/payout/PayoutType.java new file mode 100644 index 0000000..b7095bc --- /dev/null +++ b/src/main/java/ru/soune/nocopy/entity/payout/PayoutType.java @@ -0,0 +1,17 @@ +package ru.soune.nocopy.entity.payout; + +public enum PayoutType { + CARD("card"), + BANK_TRANSFER("bank_transfer"), + INTERNAL("internal"); + + private final String displayName; + + PayoutType(String displayName) { + this.displayName = displayName; + } + + public String getDisplayName() { + return displayName; + } +} diff --git a/src/main/java/ru/soune/nocopy/entity/payout/convertor/CardNumberEncryptor.java b/src/main/java/ru/soune/nocopy/entity/payout/convertor/CardNumberEncryptor.java new file mode 100644 index 0000000..46b95b1 --- /dev/null +++ b/src/main/java/ru/soune/nocopy/entity/payout/convertor/CardNumberEncryptor.java @@ -0,0 +1,74 @@ +package ru.soune.nocopy.entity.payout.convertor; + +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +import javax.crypto.Cipher; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.util.Base64; + +@Converter +public class CardNumberEncryptor implements AttributeConverter { + + private static final String ALGORITHM = "AES/GCM/NoPadding"; + private static final int GCM_TAG_LENGTH = 128; + private static final int GCM_IV_LENGTH = 12; + + private static final byte[] FIXED_KEY = "12345678901234567890123456789012".getBytes(); + + @Override + public String convertToDatabaseColumn(String cardNumber) { + if (cardNumber == null) return null; + + try { + SecretKeySpec key = new SecretKeySpec(FIXED_KEY, "AES"); + + byte[] iv = new byte[GCM_IV_LENGTH]; + new SecureRandom().nextBytes(iv); + + Cipher cipher = Cipher.getInstance(ALGORITHM); + GCMParameterSpec parameterSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv); + cipher.init(Cipher.ENCRYPT_MODE, key, parameterSpec); + + byte[] encrypted = cipher.doFinal(cardNumber.getBytes(StandardCharsets.UTF_8)); + + byte[] result = new byte[iv.length + encrypted.length]; + System.arraycopy(iv, 0, result, 0, iv.length); + System.arraycopy(encrypted, 0, result, iv.length, encrypted.length); + + return Base64.getEncoder().encodeToString(result); + + } catch (Exception e) { + throw new RuntimeException("Failed to encrypt card number", e); + } + } + + @Override + public String convertToEntityAttribute(String dbData) { + if (dbData == null) return null; + + try { + byte[] decoded = Base64.getDecoder().decode(dbData); + + byte[] iv = new byte[GCM_IV_LENGTH]; + byte[] encrypted = new byte[decoded.length - GCM_IV_LENGTH]; + System.arraycopy(decoded, 0, iv, 0, iv.length); + System.arraycopy(decoded, iv.length, encrypted, 0, encrypted.length); + + SecretKeySpec key = new SecretKeySpec(FIXED_KEY, "AES"); + + Cipher cipher = Cipher.getInstance(ALGORITHM); + GCMParameterSpec parameterSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv); + cipher.init(Cipher.DECRYPT_MODE, key, parameterSpec); + + byte[] decrypted = cipher.doFinal(encrypted); + return new String(decrypted, StandardCharsets.UTF_8); + + } catch (Exception e) { + throw new RuntimeException("Failed to decrypt card number", e); + } + } +} \ No newline at end of file diff --git a/src/main/java/ru/soune/nocopy/entity/referral/Referral.java b/src/main/java/ru/soune/nocopy/entity/referral/Referral.java index b03dc4a..a978652 100644 --- a/src/main/java/ru/soune/nocopy/entity/referral/Referral.java +++ b/src/main/java/ru/soune/nocopy/entity/referral/Referral.java @@ -25,6 +25,9 @@ public class Referral { @Column(name = "total_income") private int totalIncome = 0; + @Column(name = "available_income") + private int availableIncome = 0; + @Column(name = "is_active") private boolean isActive = false; diff --git a/src/main/java/ru/soune/nocopy/exception/DuplicateMethodException.java b/src/main/java/ru/soune/nocopy/exception/DuplicateMethodException.java new file mode 100644 index 0000000..3897426 --- /dev/null +++ b/src/main/java/ru/soune/nocopy/exception/DuplicateMethodException.java @@ -0,0 +1,7 @@ +package ru.soune.nocopy.exception; + +public class DuplicateMethodException extends RuntimeException { + public DuplicateMethodException(String message) { + super(message); + } +} diff --git a/src/main/java/ru/soune/nocopy/exception/InsufficientFundsException.java b/src/main/java/ru/soune/nocopy/exception/InsufficientFundsException.java new file mode 100644 index 0000000..6d43734 --- /dev/null +++ b/src/main/java/ru/soune/nocopy/exception/InsufficientFundsException.java @@ -0,0 +1,7 @@ +package ru.soune.nocopy.exception; + +public class InsufficientFundsException extends RuntimeException { + public InsufficientFundsException(String message) { + super(message); + } +} diff --git a/src/main/java/ru/soune/nocopy/exception/InvalidPayoutMethodException.java b/src/main/java/ru/soune/nocopy/exception/InvalidPayoutMethodException.java new file mode 100644 index 0000000..8be2b6d --- /dev/null +++ b/src/main/java/ru/soune/nocopy/exception/InvalidPayoutMethodException.java @@ -0,0 +1,7 @@ +package ru.soune.nocopy.exception; + +public class InvalidPayoutMethodException extends RuntimeException { + public InvalidPayoutMethodException(String message) { + super(message); + } +} diff --git a/src/main/java/ru/soune/nocopy/exception/PendingPayoutExistsException.java b/src/main/java/ru/soune/nocopy/exception/PendingPayoutExistsException.java new file mode 100644 index 0000000..cba5885 --- /dev/null +++ b/src/main/java/ru/soune/nocopy/exception/PendingPayoutExistsException.java @@ -0,0 +1,7 @@ +package ru.soune.nocopy.exception; + +public class PendingPayoutExistsException extends RuntimeException { + public PendingPayoutExistsException(String message) { + super(message); + } +} diff --git a/src/main/java/ru/soune/nocopy/repository/PayoutMethodRepository.java b/src/main/java/ru/soune/nocopy/repository/PayoutMethodRepository.java new file mode 100644 index 0000000..1b8effd --- /dev/null +++ b/src/main/java/ru/soune/nocopy/repository/PayoutMethodRepository.java @@ -0,0 +1,37 @@ +package ru.soune.nocopy.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; +import ru.soune.nocopy.entity.payout.PayoutMethod; +import ru.soune.nocopy.entity.user.User; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface PayoutMethodRepository extends JpaRepository { + + Optional findByIdAndUserId(Long id, Long userId); + + @Query("SELECT p FROM PayoutMethod p WHERE p.user.Id = :userId AND TYPE(p) = :type") + Optional findByUserAndType(@Param("userId") Long userId, + @Param("type") Class type); + + List findByUserId(Long userId); + + Optional findByUserIdAndIsDefaultTrue(Long userId); + + long countByUserId(Long userId); + + @Query("SELECT COUNT(c) > 0 FROM CardPayoutMethod c WHERE c.cardHash = :cardHash") + boolean existsByCardHash(@Param("cardHash") String cardHash); + + @Modifying + @Query("UPDATE PayoutMethod p SET p.isDefault = false WHERE p.user.id = :userId AND p.id != :methodId") + void clearOtherDefault(@Param("userId") Long userId, @Param("methodId") Long methodId); + + Long user(User user); +} diff --git a/src/main/java/ru/soune/nocopy/repository/PayoutRequestRepository.java b/src/main/java/ru/soune/nocopy/repository/PayoutRequestRepository.java new file mode 100644 index 0000000..e6d90b0 --- /dev/null +++ b/src/main/java/ru/soune/nocopy/repository/PayoutRequestRepository.java @@ -0,0 +1,39 @@ +package ru.soune.nocopy.repository; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +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.payout.PayoutRequest; +import ru.soune.nocopy.entity.payout.PayoutRequestStatus; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +@Repository +public interface PayoutRequestRepository extends JpaRepository { + + List findByUserIdOrderByCreatedAtDesc(Long userId); + + Page findByUserId(Long userId, Pageable pageable); + + Optional findByIdAndUserId(Long id, Long userId); + + @Query("SELECT COALESCE(SUM(pr.amount), 0) FROM PayoutRequest pr " + + "WHERE pr.user.Id = :userId AND pr.status = 'COMPLETED'") + BigDecimal getTotalPayoutAmountByUser(@Param("userId") Long userId); + + boolean existsByUserIdAndStatusIn(Long userId, List statuses); + + List findByStatusOrderByCreatedAtAsc(PayoutRequestStatus status); + + Page findByStatus(PayoutRequestStatus status, Pageable pageable); + + @Query("SELECT pr FROM PayoutRequest pr WHERE pr.status = 'PENDING' " + + "AND pr.createdAt < :threshold") + List findPendingOlderThan(@Param("threshold") LocalDateTime threshold); +} diff --git a/src/main/java/ru/soune/nocopy/repository/ReferralJpaRepository.java b/src/main/java/ru/soune/nocopy/repository/ReferralJpaRepository.java index edbdd48..c90eba9 100644 --- a/src/main/java/ru/soune/nocopy/repository/ReferralJpaRepository.java +++ b/src/main/java/ru/soune/nocopy/repository/ReferralJpaRepository.java @@ -24,7 +24,10 @@ public interface ReferralJpaRepository extends JpaRepository { List findByInviterId(Long inviterId, Pageable pageable); @Modifying - @Query("UPDATE Referral r SET r.totalIncome = r.totalIncome + :amount WHERE r.userId = :userId") + @Query("UPDATE Referral r SET " + + "r.availableIncome = r.availableIncome + :amount ," + + "r.totalIncome = r.totalIncome + :amount " + + "WHERE r.userId = :userId") void increaseIncome(@Param("userId") Long userId, @Param("amount") int amount); @Modifying 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 7030802..8a05b74 100644 --- a/src/main/java/ru/soune/nocopy/service/payment/PaymentService.java +++ b/src/main/java/ru/soune/nocopy/service/payment/PaymentService.java @@ -4,6 +4,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import ru.soune.ReferralService; import ru.soune.nocopy.entity.payment.Payment; import ru.soune.nocopy.entity.payment.PaymentOperationType; import ru.soune.nocopy.entity.payment.PaymentStatus; @@ -38,6 +39,8 @@ public class PaymentService { private final TariffInfoService tariffInfoService; + private final ReferralService referralService; + @Transactional public Payment createPayment(String email, Long tariffId, String operationType, String paymentUuid) { User user = userRepository.findByEmail(email); @@ -70,9 +73,15 @@ public class PaymentService { .orElseThrow(() -> new PaymentNotFoundException("Payment not found")); if ("payment.succeeded".equals(eventType)) { + Tariff tariff = payment.getTariff(); + double price = tariff.getPrice(); + int intPrice = (int) Math.round(price); + payment.setStatus(PaymentStatus.SUCCEEDED); payment.setCancellationReason(null); - activateTariffForUser(payment.getUser(), payment.getTariff()); + activateTariffForUser(payment.getUser(), tariff); + + referralService.onUserAccountRefill(payment.getUser().getId(), intPrice); } else if ("payment.canceled".equals(eventType)) { payment.setStatus(PaymentStatus.CANCELED); diff --git a/src/main/java/ru/soune/nocopy/service/payout/BalanceService.java b/src/main/java/ru/soune/nocopy/service/payout/BalanceService.java new file mode 100644 index 0000000..62b357c --- /dev/null +++ b/src/main/java/ru/soune/nocopy/service/payout/BalanceService.java @@ -0,0 +1,94 @@ +package ru.soune.nocopy.service.payout; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import ru.soune.nocopy.entity.referral.Referral; +import ru.soune.nocopy.entity.user.User; +import ru.soune.nocopy.exception.InsufficientFundsException; +import ru.soune.nocopy.repository.ReferralJpaRepository; + +import java.math.BigDecimal; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class BalanceService { + + private final ReferralJpaRepository referralJpaRepository; + + public boolean hasSufficientFunds(User user, BigDecimal amount) { + Referral referral = referralJpaRepository.findByUserId(user.getId()); + BigDecimal available = BigDecimal.valueOf(referral.getAvailableIncome()); + + return available.compareTo(amount) >= 0; + } + + public BigDecimal getBalance(User user) { + Referral referral = referralJpaRepository.findByUserId(user.getId()); + return BigDecimal.valueOf(referral.getAvailableIncome()); + } + + public BigDecimal getFrozenBalance(User user) { + Referral referral = referralJpaRepository.findByUserId(user.getId()); + return BigDecimal.valueOf(referral.getHoldBalance()); + } + + @Transactional + public void freezeAmount(User user, BigDecimal amount) { + Referral referral = referralJpaRepository.findByUserId(user.getId()); + BigDecimal available = BigDecimal.valueOf(referral.getAvailableIncome()); + + if (available.compareTo(amount) < 0) { + throw new InsufficientFundsException( + String.format("Not have amount. Amount have: %.2f", available) + ); + } + + BigDecimal newAvailable = available.subtract(amount); + BigDecimal newHold = BigDecimal.valueOf(referral.getHoldBalance()).add(amount); + + referral.setAvailableIncome(newAvailable.intValue()); + referral.setHoldBalance(newHold.intValue()); + + referralJpaRepository.save(referral); + + log.info("Amount freeze. Have: {}, Frozen: {}", newAvailable, newHold); + } + + @Transactional + public void unfreezeAmount(Long userId, BigDecimal amount) { + log.info("Unfreeze {} for user {}", amount, userId); + + Referral referral = referralJpaRepository.findByUserId(userId); + BigDecimal hold = BigDecimal.valueOf(referral.getHoldBalance()); + + if (hold.compareTo(amount) < 0) { + throw new IllegalStateException( + String.format("Not have freeze amount. Frozen: %.2f", hold) + ); + } + + BigDecimal newAvailable = BigDecimal.valueOf(referral.getAvailableIncome()).add(amount); + BigDecimal newHold = hold.subtract(amount); + + referral.setAvailableIncome(newAvailable.intValue()); + referral.setHoldBalance(newHold.intValue()); + + referralJpaRepository.save(referral); + + log.info("Amount frozen. Have: {}, Frozen: {}", newAvailable, newHold); + } + + public void withdrawAmount(Long userId, BigDecimal amount) { + Referral referral = referralJpaRepository.findByUserId(userId); + + if (referral.getHoldBalance() < amount.intValue()) { + throw new IllegalStateException("Not have amount"); + } + + referral.setHoldBalance(referral.getHoldBalance() - amount.intValue()); + } +} \ No newline at end of file diff --git a/src/main/java/ru/soune/nocopy/service/payout/PayoutMethodService.java b/src/main/java/ru/soune/nocopy/service/payout/PayoutMethodService.java new file mode 100644 index 0000000..023f9b9 --- /dev/null +++ b/src/main/java/ru/soune/nocopy/service/payout/PayoutMethodService.java @@ -0,0 +1,143 @@ +package ru.soune.nocopy.service.payout; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import ru.soune.nocopy.dto.payout.AddBankTransferRequest; +import ru.soune.nocopy.dto.payout.AddCardRequest; +import ru.soune.nocopy.dto.payout.PayoutMethodDTO; +import ru.soune.nocopy.entity.payout.BankTransferPayoutMethod; +import ru.soune.nocopy.entity.payout.CardPayoutMethod; +import ru.soune.nocopy.entity.payout.InternalPayoutMethod; +import ru.soune.nocopy.entity.payout.PayoutMethod; +import ru.soune.nocopy.entity.user.User; +import ru.soune.nocopy.exception.DuplicateMethodException; +import ru.soune.nocopy.repository.PayoutMethodRepository; +import ru.soune.nocopy.repository.UserRepository; +import ru.soune.nocopy.service.user.UserService; + +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Base64; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class PayoutMethodService { + + private final PayoutMethodRepository payoutMethodRepository; + + private final UserRepository userRepository; + + @Transactional + public void initiatedStandartPayoutMethods(User user) { + createInternalMethod(user); + } + + @Transactional + public InternalPayoutMethod createInternalMethod(User user) { + if (payoutMethodRepository.findByUserAndType(user.getId(), InternalPayoutMethod.class).isPresent()) { + return (InternalPayoutMethod) payoutMethodRepository.findByUserAndType( + user.getId(), InternalPayoutMethod.class).get(); + } + + InternalPayoutMethod internalMethod = new InternalPayoutMethod(); + internalMethod.setUser(user); + internalMethod.setDefault(true); + + return payoutMethodRepository.save(internalMethod); + } + + public List getUserMethods(long userId) { + return payoutMethodRepository.findByUserId(userId) + .stream() + .map(this::convertToDto) + .collect(Collectors.toList()); + } + + @Transactional + public void setDefaultMethod(Long userId, Long methodId) { + payoutMethodRepository.clearOtherDefault(userId, methodId); + + PayoutMethod method = payoutMethodRepository.findByIdAndUserId(methodId, userId) + .orElseThrow(() -> new IllegalArgumentException("Not found method")); + method.setDefault(true); + + payoutMethodRepository.save(method); + } + + @Transactional + public BankTransferPayoutMethod addBankTransferMethod(Long userId, AddBankTransferRequest request) { + User user = userRepository.findById(userId).orElseThrow(); + + BankTransferPayoutMethod bankMethod = new BankTransferPayoutMethod(); + bankMethod.setUser(user); + bankMethod.setBankName(request.getBankName()); + bankMethod.setBic(request.getBic()); + bankMethod.setCorrespondentAccount(request.getCorrespondentAccount()); + bankMethod.setAccountNumber(request.getAccountNumber()); + bankMethod.setAccountHolder(request.getAccountHolder()); + bankMethod.setInn(request.getInn()); + bankMethod.setKpp(request.getKpp()); + bankMethod.setDefault(payoutMethodRepository.countByUserId(user.getId()) == 0); + + return payoutMethodRepository.save(bankMethod); + } + + @Transactional + public CardPayoutMethod addCardMethod(Long userId, AddCardRequest request) { + String cardHash = generateCardHash(request.getCardNumber()); + + if (payoutMethodRepository.existsByCardHash(cardHash)) { + throw new DuplicateMethodException("Card was added"); + } + + User user = userRepository.findById(userId).orElseThrow(); + + CardPayoutMethod cardMethod = new CardPayoutMethod(); + cardMethod.setUser(user); + cardMethod.setCardNumber(request.getCardNumber()); + cardMethod.setCardHash(cardHash); + cardMethod.setDefault(payoutMethodRepository.countByUserId(user.getId()) == 0); + + CardPayoutMethod saved = payoutMethodRepository.save(cardMethod); + log.info("Added user card: {}", user.getId()); + + return saved; + } + + public PayoutMethod getDefaultMethod(User user) { + return payoutMethodRepository.findByUserIdAndIsDefaultTrue(user.getId()) + .orElseGet(() -> { + List methods = payoutMethodRepository.findByUserId(user.getId()); + if (methods.isEmpty()) { + return createInternalMethod(user); + } + return methods.get(0); + }); + } + + public PayoutMethodDTO convertToDto(PayoutMethod method) { + return PayoutMethodDTO.builder() + .id(method.getId()) + .type(method.getClass().getSimpleName().replace("PayoutMethod", "")) + .displayName(method.getDisplayName()) + .maskedDetails(method.getMaskedDetails()) + .isDefault(method.isDefault()) + .build(); + } + + private String generateCardHash(String cardNumber) { + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + byte[] hash = md.digest(cardNumber.getBytes()); + return Base64.getEncoder().encodeToString(hash); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException("Failed generate hash", e); + } + } +} diff --git a/src/main/java/ru/soune/nocopy/service/payout/PayoutRequestService.java b/src/main/java/ru/soune/nocopy/service/payout/PayoutRequestService.java new file mode 100644 index 0000000..1db44cf --- /dev/null +++ b/src/main/java/ru/soune/nocopy/service/payout/PayoutRequestService.java @@ -0,0 +1,160 @@ +package ru.soune.nocopy.service.payout; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import ru.soune.ReferralService; +import ru.soune.nocopy.dto.payout.PayoutRequestDTO; +import ru.soune.nocopy.entity.payout.PayoutMethod; +import ru.soune.nocopy.entity.payout.PayoutRequest; +import ru.soune.nocopy.entity.payout.PayoutRequestStatus; +import ru.soune.nocopy.entity.payout.PayoutType; +import ru.soune.nocopy.entity.user.User; +import ru.soune.nocopy.exception.InsufficientFundsException; +import ru.soune.nocopy.exception.InvalidPayoutMethodException; +import ru.soune.nocopy.exception.PendingPayoutExistsException; +import ru.soune.nocopy.repository.PayoutMethodRepository; +import ru.soune.nocopy.repository.PayoutRequestRepository; +import ru.soune.nocopy.repository.UserRepository; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +public class PayoutRequestService { + private final PayoutRequestRepository payoutRequestRepository; + + private final PayoutMethodRepository payoutMethodRepository; + + private final BalanceService balanceService; + + private final UserRepository userRepository; + + private static final BigDecimal MIN_PAYOUT_AMOUNT = new BigDecimal("1000.00"); + + private static final List BLOCKING_STATUSES = Arrays.asList(PayoutRequestStatus.PENDING, + PayoutRequestStatus.PROCESSING); + + @Transactional + public PayoutRequest createPayoutRequest(Long userId, PayoutRequestDTO request) { + log.info("Create request for money {}: sum {}", userId, request.getAmount()); + + validatePayoutAmount(request.getAmount()); + + User user = userRepository.findById(userId).orElseThrow(); + + if (!balanceService.hasSufficientFunds(user, request.getAmount())) { + throw new InsufficientFundsException(String.format("Not have money for payout. Have: %.2f", + balanceService.getBalance(user))); + } + + if (hasActivePayoutRequests(user)) { + throw new PendingPayoutExistsException("Have active payout. Wait for end."); + } + + PayoutMethod payoutMethod = payoutMethodRepository + .findByIdAndUserId(request.getPayoutMethodId(), user.getId()) + .orElseThrow(() -> new InvalidPayoutMethodException("Not found payout method")); + + PayoutType payoutType = determinePayoutType(payoutMethod); + + PayoutRequest payoutRequest = PayoutRequest.builder() + .user(user) + .payoutMethod(payoutMethod) + .amount(request.getAmount()) + .status(PayoutRequestStatus.PENDING) + .payoutType(payoutType) + .build(); + + PayoutRequest savedRequest = payoutRequestRepository.save(payoutRequest); + + balanceService.freezeAmount(user, request.getAmount()); + + log.info("Payout requset created: ID={}, user={}, sum={}, status={}", + savedRequest.getId(), user.getId(), request.getAmount(), savedRequest.getStatus()); + + return savedRequest; + } + + private void validatePayoutAmount(BigDecimal amount) { + if (amount == null) { + throw new IllegalArgumentException("Sum is null"); + } + + if (amount.compareTo(MIN_PAYOUT_AMOUNT) < 0) { + throw new IllegalArgumentException( + String.format("Min sum for payout: %.2f rub.", MIN_PAYOUT_AMOUNT) + ); + } + + } + + private boolean hasActivePayoutRequests(User user) { + return payoutRequestRepository.existsByUserIdAndStatusIn(user.getId(), BLOCKING_STATUSES); + } + + private PayoutType determinePayoutType(PayoutMethod payoutMethod) { + String className = payoutMethod.getClass().getSimpleName(); + + switch (className) { + case "CardPayoutMethod": + return PayoutType.CARD; + case "BankTransferPayoutMethod": + return PayoutType.BANK_TRANSFER; + case "InternalPayoutMethod": + return PayoutType.INTERNAL; + default: + throw new InvalidPayoutMethodException("Unknown method"); + } + } + + @Transactional(readOnly = true) + public List getUserPayoutRequests(Long userId) { + return payoutRequestRepository.findByUserIdOrderByCreatedAtDesc(userId); + } + + @Transactional(readOnly = true) + public PayoutRequest getPayoutRequest(Long userId, Long requestId) { + return payoutRequestRepository.findByIdAndUserId(requestId, userId) + .orElseThrow(() -> new IllegalArgumentException("Payout not found")); + } + + @Transactional + public PayoutRequest cancelPayoutRequest(Long userId, Long requestId) { + PayoutRequest request = getPayoutRequest(userId, requestId); + + if (request.getStatus() != PayoutRequestStatus.PENDING) { + throw new IllegalStateException( + "Can't cnacel: " + request.getStatus() + ); + } + + request.setStatus(PayoutRequestStatus.CANCELLED); + + balanceService.unfreezeAmount(userId, request.getAmount()); + + return payoutRequestRepository.save(request); + } + + @Transactional + public PayoutRequest processRequest(Long requestId) { + PayoutRequest request = payoutRequestRepository.findById(requestId) + .orElseThrow(); + + if (request.getStatus() != PayoutRequestStatus.PENDING) { + throw new IllegalStateException("Can't proceed in status: " + request.getStatus()); + } + + request.setStatus(PayoutRequestStatus.COMPLETED); + request.setProcessAt(LocalDateTime.now()); + + balanceService.withdrawAmount(request.getUser().getId(), request.getAmount()); + + return payoutRequestRepository.save(request); + } +} diff --git a/src/main/java/ru/soune/nocopy/service/referral/ReferralRepoImpl.java b/src/main/java/ru/soune/nocopy/service/referral/ReferralRepoImpl.java index 59f67db..c210ed3 100644 --- a/src/main/java/ru/soune/nocopy/service/referral/ReferralRepoImpl.java +++ b/src/main/java/ru/soune/nocopy/service/referral/ReferralRepoImpl.java @@ -151,6 +151,7 @@ public class ReferralRepoImpl implements ReferralRepo { referral.setInviterId(entity.getInviter()); referral.setLevelId(entity.getCurrentLevel()); referral.setTotalIncome(entity.getTotalIncome()); + referral.setAvailableIncome(entity.getAvailableIncome()); referral.setHoldBalance(entity.getHoldBalance()); referral.setActive(entity.getActive()); diff --git a/src/main/java/ru/soune/nocopy/service/register/AuthService.java b/src/main/java/ru/soune/nocopy/service/register/AuthService.java index 7baa057..576ced2 100644 --- a/src/main/java/ru/soune/nocopy/service/register/AuthService.java +++ b/src/main/java/ru/soune/nocopy/service/register/AuthService.java @@ -11,7 +11,6 @@ import ru.soune.nocopy.dto.register.LoginAnswer; import ru.soune.nocopy.dto.register.LoginRequest; import ru.soune.nocopy.dto.register.RegRequest; import ru.soune.nocopy.entity.company.Company; -import ru.soune.nocopy.entity.tarif.Tariff; import ru.soune.nocopy.entity.tarif.TariffInfo; import ru.soune.nocopy.entity.tarif.TariffType; import ru.soune.nocopy.entity.user.AuthToken;