This commit is contained in:
@@ -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<PayoutRequest> 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<PayoutRequest> processPayoutRequest(@PathVariable Long requestId) {
|
||||||
|
PayoutRequest processed = payoutRequestService.processRequest(requestId);
|
||||||
|
|
||||||
|
return ResponseEntity.ok(processed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Cancel payout
|
||||||
|
*/
|
||||||
|
@PostMapping("/requests/{requestId}/{userId}/cancel")
|
||||||
|
public ResponseEntity<PayoutRequest> 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<List<PayoutRequest>> getUserRequests(@PathVariable Long userId) {
|
||||||
|
return ResponseEntity.ok(payoutRequestService.getUserPayoutRequests(userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Get payout by id
|
||||||
|
*/
|
||||||
|
@GetMapping("/requests/{requestId}/{userId}")
|
||||||
|
public ResponseEntity<PayoutRequest> getPayoutRequest(@PathVariable Long userId, @PathVariable Long requestId) {
|
||||||
|
return ResponseEntity.ok(payoutRequestService.getPayoutRequest(userId, requestId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Get payout by id
|
||||||
|
*/
|
||||||
|
@GetMapping("/payout-method/{userId}")
|
||||||
|
public ResponseEntity<List<PayoutMethodDTO>> getUserMethods(@PathVariable Long userId) {
|
||||||
|
return ResponseEntity.ok(payoutMethodService.getUserMethods(userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Get payout-methods
|
||||||
|
*/
|
||||||
|
@GetMapping("/payout-method")
|
||||||
|
public ResponseEntity<List<PayoutMethodTypeDTO>> getAvailableMethodTypes() {
|
||||||
|
List<PayoutMethodTypeDTO> 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<PayoutMethodDTO> 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<PayoutMethodDTO> 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<Void> setDefaultMethod(@PathVariable Long userId, @PathVariable Long methodId) {
|
||||||
|
|
||||||
|
payoutMethodService.setDefaultMethod(userId, methodId);
|
||||||
|
return ResponseEntity.ok().build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package ru.soune.nocopy.entity.payout;
|
||||||
|
|
||||||
|
public enum PayoutRequestStatus {
|
||||||
|
PENDING,
|
||||||
|
PROCESSING,
|
||||||
|
COMPLETED,
|
||||||
|
REJECTED,
|
||||||
|
CANCELLED
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, String> {
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,9 @@ public class Referral {
|
|||||||
@Column(name = "total_income")
|
@Column(name = "total_income")
|
||||||
private int totalIncome = 0;
|
private int totalIncome = 0;
|
||||||
|
|
||||||
|
@Column(name = "available_income")
|
||||||
|
private int availableIncome = 0;
|
||||||
|
|
||||||
@Column(name = "is_active")
|
@Column(name = "is_active")
|
||||||
private boolean isActive = false;
|
private boolean isActive = false;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package ru.soune.nocopy.exception;
|
||||||
|
|
||||||
|
public class DuplicateMethodException extends RuntimeException {
|
||||||
|
public DuplicateMethodException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package ru.soune.nocopy.exception;
|
||||||
|
|
||||||
|
public class InsufficientFundsException extends RuntimeException {
|
||||||
|
public InsufficientFundsException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package ru.soune.nocopy.exception;
|
||||||
|
|
||||||
|
public class InvalidPayoutMethodException extends RuntimeException {
|
||||||
|
public InvalidPayoutMethodException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package ru.soune.nocopy.exception;
|
||||||
|
|
||||||
|
public class PendingPayoutExistsException extends RuntimeException {
|
||||||
|
public PendingPayoutExistsException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<PayoutMethod, String> {
|
||||||
|
|
||||||
|
Optional<PayoutMethod> findByIdAndUserId(Long id, Long userId);
|
||||||
|
|
||||||
|
@Query("SELECT p FROM PayoutMethod p WHERE p.user.Id = :userId AND TYPE(p) = :type")
|
||||||
|
Optional<PayoutMethod> findByUserAndType(@Param("userId") Long userId,
|
||||||
|
@Param("type") Class<? extends PayoutMethod> type);
|
||||||
|
|
||||||
|
List<PayoutMethod> findByUserId(Long userId);
|
||||||
|
|
||||||
|
Optional<PayoutMethod> 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);
|
||||||
|
}
|
||||||
@@ -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<PayoutRequest, Long> {
|
||||||
|
|
||||||
|
List<PayoutRequest> findByUserIdOrderByCreatedAtDesc(Long userId);
|
||||||
|
|
||||||
|
Page<PayoutRequest> findByUserId(Long userId, Pageable pageable);
|
||||||
|
|
||||||
|
Optional<PayoutRequest> 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<PayoutRequestStatus> statuses);
|
||||||
|
|
||||||
|
List<PayoutRequest> findByStatusOrderByCreatedAtAsc(PayoutRequestStatus status);
|
||||||
|
|
||||||
|
Page<PayoutRequest> findByStatus(PayoutRequestStatus status, Pageable pageable);
|
||||||
|
|
||||||
|
@Query("SELECT pr FROM PayoutRequest pr WHERE pr.status = 'PENDING' " +
|
||||||
|
"AND pr.createdAt < :threshold")
|
||||||
|
List<PayoutRequest> findPendingOlderThan(@Param("threshold") LocalDateTime threshold);
|
||||||
|
}
|
||||||
@@ -24,7 +24,10 @@ public interface ReferralJpaRepository extends JpaRepository<Referral, Long> {
|
|||||||
List<Referral> findByInviterId(Long inviterId, Pageable pageable);
|
List<Referral> findByInviterId(Long inviterId, Pageable pageable);
|
||||||
|
|
||||||
@Modifying
|
@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);
|
void increaseIncome(@Param("userId") Long userId, @Param("amount") int amount);
|
||||||
|
|
||||||
@Modifying
|
@Modifying
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import lombok.RequiredArgsConstructor;
|
|||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import ru.soune.ReferralService;
|
||||||
import ru.soune.nocopy.entity.payment.Payment;
|
import ru.soune.nocopy.entity.payment.Payment;
|
||||||
import ru.soune.nocopy.entity.payment.PaymentOperationType;
|
import ru.soune.nocopy.entity.payment.PaymentOperationType;
|
||||||
import ru.soune.nocopy.entity.payment.PaymentStatus;
|
import ru.soune.nocopy.entity.payment.PaymentStatus;
|
||||||
@@ -38,6 +39,8 @@ public class PaymentService {
|
|||||||
|
|
||||||
private final TariffInfoService tariffInfoService;
|
private final TariffInfoService tariffInfoService;
|
||||||
|
|
||||||
|
private final ReferralService referralService;
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public Payment createPayment(String email, Long tariffId, String operationType, String paymentUuid) {
|
public Payment createPayment(String email, Long tariffId, String operationType, String paymentUuid) {
|
||||||
User user = userRepository.findByEmail(email);
|
User user = userRepository.findByEmail(email);
|
||||||
@@ -70,9 +73,15 @@ public class PaymentService {
|
|||||||
.orElseThrow(() -> new PaymentNotFoundException("Payment not found"));
|
.orElseThrow(() -> new PaymentNotFoundException("Payment not found"));
|
||||||
|
|
||||||
if ("payment.succeeded".equals(eventType)) {
|
if ("payment.succeeded".equals(eventType)) {
|
||||||
|
Tariff tariff = payment.getTariff();
|
||||||
|
double price = tariff.getPrice();
|
||||||
|
int intPrice = (int) Math.round(price);
|
||||||
|
|
||||||
payment.setStatus(PaymentStatus.SUCCEEDED);
|
payment.setStatus(PaymentStatus.SUCCEEDED);
|
||||||
payment.setCancellationReason(null);
|
payment.setCancellationReason(null);
|
||||||
activateTariffForUser(payment.getUser(), payment.getTariff());
|
activateTariffForUser(payment.getUser(), tariff);
|
||||||
|
|
||||||
|
referralService.onUserAccountRefill(payment.getUser().getId(), intPrice);
|
||||||
} else if ("payment.canceled".equals(eventType)) {
|
} else if ("payment.canceled".equals(eventType)) {
|
||||||
payment.setStatus(PaymentStatus.CANCELED);
|
payment.setStatus(PaymentStatus.CANCELED);
|
||||||
|
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<PayoutMethodDTO> 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<PayoutMethod> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<PayoutRequestStatus> 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<PayoutRequest> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -151,6 +151,7 @@ public class ReferralRepoImpl implements ReferralRepo {
|
|||||||
referral.setInviterId(entity.getInviter());
|
referral.setInviterId(entity.getInviter());
|
||||||
referral.setLevelId(entity.getCurrentLevel());
|
referral.setLevelId(entity.getCurrentLevel());
|
||||||
referral.setTotalIncome(entity.getTotalIncome());
|
referral.setTotalIncome(entity.getTotalIncome());
|
||||||
|
referral.setAvailableIncome(entity.getAvailableIncome());
|
||||||
referral.setHoldBalance(entity.getHoldBalance());
|
referral.setHoldBalance(entity.getHoldBalance());
|
||||||
referral.setActive(entity.getActive());
|
referral.setActive(entity.getActive());
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import ru.soune.nocopy.dto.register.LoginAnswer;
|
|||||||
import ru.soune.nocopy.dto.register.LoginRequest;
|
import ru.soune.nocopy.dto.register.LoginRequest;
|
||||||
import ru.soune.nocopy.dto.register.RegRequest;
|
import ru.soune.nocopy.dto.register.RegRequest;
|
||||||
import ru.soune.nocopy.entity.company.Company;
|
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.TariffInfo;
|
||||||
import ru.soune.nocopy.entity.tarif.TariffType;
|
import ru.soune.nocopy.entity.tarif.TariffType;
|
||||||
import ru.soune.nocopy.entity.user.AuthToken;
|
import ru.soune.nocopy.entity.user.AuthToken;
|
||||||
|
|||||||
Reference in New Issue
Block a user