diff --git a/build.gradle b/build.gradle index f04ab85..f9c95f7 100644 --- a/build.gradle +++ b/build.gradle @@ -65,6 +65,8 @@ dependencies { // prometheus implementation 'io.micrometer:micrometer-registry-prometheus' + + implementation 'com.googlecode.json-simple:json-simple:1.1.1' } tasks.named('test') { diff --git a/src/main/java/net/studioxai/studioxBe/domain/auth/repository/EmailVerificationTokenRepository.java b/src/main/java/net/studioxai/studioxBe/domain/auth/repository/EmailVerificationTokenRepository.java index 5dcbb5c..e01bb95 100644 --- a/src/main/java/net/studioxai/studioxBe/domain/auth/repository/EmailVerificationTokenRepository.java +++ b/src/main/java/net/studioxai/studioxBe/domain/auth/repository/EmailVerificationTokenRepository.java @@ -5,5 +5,5 @@ import org.springframework.stereotype.Repository; @Repository -public interface EmailVerificationTokenRepository extends CrudRepository { + public interface EmailVerificationTokenRepository extends CrudRepository { } diff --git a/src/main/java/net/studioxai/studioxBe/domain/auth/service/AuthService.java b/src/main/java/net/studioxai/studioxBe/domain/auth/service/AuthService.java index 981b732..75e011f 100644 --- a/src/main/java/net/studioxai/studioxBe/domain/auth/service/AuthService.java +++ b/src/main/java/net/studioxai/studioxBe/domain/auth/service/AuthService.java @@ -5,13 +5,14 @@ import net.studioxai.studioxBe.domain.auth.dto.request.LoginRequest; import net.studioxai.studioxBe.domain.auth.dto.request.PasswordResetRequest; import net.studioxai.studioxBe.domain.auth.dto.request.SignUpRequest; -import net.studioxai.studioxBe.domain.auth.dto.response.EmailValidationResponse; import net.studioxai.studioxBe.domain.auth.dto.response.LoginResponse; import net.studioxai.studioxBe.domain.auth.dto.response.TokenResponse; import net.studioxai.studioxBe.domain.auth.entity.VerifiedEmailCode; import net.studioxai.studioxBe.domain.auth.repository.VerifiedEmailCodeRepository; import net.studioxai.studioxBe.domain.folder.entity.Folder; import net.studioxai.studioxBe.domain.folder.service.FolderService; +import net.studioxai.studioxBe.domain.payment.entity.UserPlan; +import net.studioxai.studioxBe.domain.payment.repository.UserPlanRepository; import net.studioxai.studioxBe.domain.user.entity.enums.RegisterPath; import net.studioxai.studioxBe.domain.user.entity.User; import net.studioxai.studioxBe.domain.auth.exception.AuthErrorCode; @@ -42,6 +43,7 @@ public class AuthService { public static final String DEFAULT_PROFILE_IMAGE_URL = "profile-example.com"; private final VerifiedEmailCodeRepository verifiedEmailCodeRepository; + private final UserPlanRepository userPlanRepository; @Transactional public void resetPassword(PasswordResetRequest passwordResetRequest) { @@ -83,6 +85,7 @@ public LoginResponse signUp(SignUpRequest signUpRequest) { userRepository.saveAndFlush(user); provisioningFolder(user); + provisionPlan(user); return buildLoginResponse(user); } @@ -103,6 +106,11 @@ protected void provisioningFolder(User user) { String folderName = user.getUsername(); Folder folder = folderService.createRootFolder(folderName, user); } + @Transactional + protected void provisionPlan(User user) { + UserPlan userPlan = UserPlan.createFree(user); + userPlanRepository.save(userPlan); + } public User getUserByEmailOrThrow(String email) { return userRepository.findByEmail(email).orElseThrow( diff --git a/src/main/java/net/studioxai/studioxBe/domain/auth/service/OauthService.java b/src/main/java/net/studioxai/studioxBe/domain/auth/service/OauthService.java index 097ce9a..e578c94 100644 --- a/src/main/java/net/studioxai/studioxBe/domain/auth/service/OauthService.java +++ b/src/main/java/net/studioxai/studioxBe/domain/auth/service/OauthService.java @@ -86,8 +86,9 @@ private User findOrCreateGoogleUser(GoogleUserInfoResponse userInfo) { passwordEncoder.encode(UUID.randomUUID().toString()), resolveProfileImage(userInfo) ); - userRepository.save(user); + userRepository.saveAndFlush(user); authService.provisioningFolder(user); + authService.provisionPlan(user); return user; }); } diff --git a/src/main/java/net/studioxai/studioxBe/domain/payment/controller/BillingKeyController.java b/src/main/java/net/studioxai/studioxBe/domain/payment/controller/BillingKeyController.java new file mode 100644 index 0000000..1a8bcff --- /dev/null +++ b/src/main/java/net/studioxai/studioxBe/domain/payment/controller/BillingKeyController.java @@ -0,0 +1,45 @@ +package net.studioxai.studioxBe.domain.payment.controller; + +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import net.studioxai.studioxBe.domain.payment.dto.request.BillingKeyAuthKeyCreateRequest; +import net.studioxai.studioxBe.domain.payment.dto.request.BillingKeyCardCreateRequest; +import net.studioxai.studioxBe.domain.payment.entity.enums.Plan; +import net.studioxai.studioxBe.domain.payment.service.BillingKeyService; +import net.studioxai.studioxBe.global.jwt.JwtUserPrincipal; +import net.studioxai.studioxBe.global.util.IpUtil; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.io.IOException; + +@RestController +@RequestMapping("/api") +@RequiredArgsConstructor +public class BillingKeyController { + private final IpUtil ipUtil; + private final BillingKeyService billingKeyService; + + @PostMapping("/v1/payment/billingKey/authKey") + public void createBillingKeyAuthKey( + @AuthenticationPrincipal JwtUserPrincipal principal, + @RequestParam Plan plan, + @RequestBody BillingKeyAuthKeyCreateRequest billingKeyCreateRequest, + HttpServletRequest request + ) throws IOException { + String clientIp = ipUtil.getClientIp(request); + billingKeyService.createBillingKeyWithAuthKey(principal.userId(), billingKeyCreateRequest, plan, clientIp); + } + + @PostMapping("/v1/payment/billingKey/card") + public void createBillingKeyCard( + @AuthenticationPrincipal JwtUserPrincipal principal, + @RequestParam Plan plan, + @RequestBody BillingKeyCardCreateRequest billingKeyCreateRequest, + HttpServletRequest request + ) throws IOException { + String clientIp = ipUtil.getClientIp(request); + billingKeyService.createBillingKeyWithCard(principal.userId(), billingKeyCreateRequest, plan, clientIp); + } + +} diff --git a/src/main/java/net/studioxai/studioxBe/domain/payment/dto/CardDto.java b/src/main/java/net/studioxai/studioxBe/domain/payment/dto/CardDto.java new file mode 100644 index 0000000..9af0d49 --- /dev/null +++ b/src/main/java/net/studioxai/studioxBe/domain/payment/dto/CardDto.java @@ -0,0 +1,10 @@ +package net.studioxai.studioxBe.domain.payment.dto; + +public record CardDto( + String issuerCode, + String acquirerCode, + String number, + String cardType, + String ownerType +) { +} diff --git a/src/main/java/net/studioxai/studioxBe/domain/payment/dto/FailureDto.java b/src/main/java/net/studioxai/studioxBe/domain/payment/dto/FailureDto.java new file mode 100644 index 0000000..adea13e --- /dev/null +++ b/src/main/java/net/studioxai/studioxBe/domain/payment/dto/FailureDto.java @@ -0,0 +1,7 @@ +package net.studioxai.studioxBe.domain.payment.dto; + +public record FailureDto( + String code, + String message +) { +} diff --git a/src/main/java/net/studioxai/studioxBe/domain/payment/dto/ThreeDsValueDto.java b/src/main/java/net/studioxai/studioxBe/domain/payment/dto/ThreeDsValueDto.java new file mode 100644 index 0000000..d5c47a9 --- /dev/null +++ b/src/main/java/net/studioxai/studioxBe/domain/payment/dto/ThreeDsValueDto.java @@ -0,0 +1,21 @@ +package net.studioxai.studioxBe.domain.payment.dto; + +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +public record ThreeDsValueDto ( + @Size(max = 2048, message = "masking 값은 2048자 이하여야 합니다.") + @Pattern( + regexp = "^[A-Za-z0-9+/=_\\-.*]*$", + message = "masking 값에 허용되지 않는 문자가 포함되어 있습니다." + ) + String masking, + + @Size(max = 2048, message = "plain 값은 2048자 이하여야 합니다.") + @Pattern( + regexp = "^[A-Za-z0-9+/=_\\-.*]*$", + message = "plain 값에 허용되지 않는 문자가 포함되어 있습니다." + ) + String plain +) { +} \ No newline at end of file diff --git a/src/main/java/net/studioxai/studioxBe/domain/payment/dto/TransferDto.java b/src/main/java/net/studioxai/studioxBe/domain/payment/dto/TransferDto.java new file mode 100644 index 0000000..344e729 --- /dev/null +++ b/src/main/java/net/studioxai/studioxBe/domain/payment/dto/TransferDto.java @@ -0,0 +1,7 @@ +package net.studioxai.studioxBe.domain.payment.dto; + +public record TransferDto( + String bankName, + String bankAccountNumber +) { +} diff --git a/src/main/java/net/studioxai/studioxBe/domain/payment/dto/request/BillingApprovalRequest.java b/src/main/java/net/studioxai/studioxBe/domain/payment/dto/request/BillingApprovalRequest.java new file mode 100644 index 0000000..4dd6b96 --- /dev/null +++ b/src/main/java/net/studioxai/studioxBe/domain/payment/dto/request/BillingApprovalRequest.java @@ -0,0 +1,51 @@ +package net.studioxai.studioxBe.domain.payment.dto.request; + +import jakarta.validation.constraints.NotBlank; +import net.studioxai.studioxBe.domain.payment.entity.BillingKey; +import net.studioxai.studioxBe.domain.payment.entity.PaymentHistory; +import net.studioxai.studioxBe.domain.payment.entity.enums.Plan; +import net.studioxai.studioxBe.domain.user.entity.User; + +import java.math.BigDecimal; + +public record BillingApprovalRequest( + @NotBlank + String billingKey, + @NotBlank + long amount, + @NotBlank + String customerKey, + @NotBlank + String orderId, + @NotBlank + String orderName, + String customerEmail, + String customerName, + String customerIp, + int taxFreeAmount, + int taxExemptionAmount +) { + public static BillingApprovalRequest of( + User user, + Plan plan, + long amount, + BillingKey billingKey, + PaymentHistory paymentHistory, + String customerIp, + int taxFreeAmount, + int taxExemptionAmount + ) { + return new BillingApprovalRequest( + billingKey.getBillingKey(), + amount, + user.getCustomerKey(), + paymentHistory.getOrderId(), + plan.name(), + user.getEmail(), + user.getUsername(), + customerIp, + taxFreeAmount, + taxExemptionAmount + ); + } +} diff --git a/src/main/java/net/studioxai/studioxBe/domain/payment/dto/request/BillingKeyAuthKeyCreateRequest.java b/src/main/java/net/studioxai/studioxBe/domain/payment/dto/request/BillingKeyAuthKeyCreateRequest.java new file mode 100644 index 0000000..9cbb509 --- /dev/null +++ b/src/main/java/net/studioxai/studioxBe/domain/payment/dto/request/BillingKeyAuthKeyCreateRequest.java @@ -0,0 +1,20 @@ +package net.studioxai.studioxBe.domain.payment.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +public record BillingKeyAuthKeyCreateRequest( + @NotBlank(message = "authKey는 필수입니다.") + @Size(max = 300, message = "authKey는 300자 이하여야 합니다.") + String authKey, + + @NotBlank(message = "customerKey는 필수입니다.") + @Size(min = 2, max = 300, message = "customerKey는 2자 이상 300자 이하여야 합니다.") + @Pattern( + regexp = "^[A-Za-z0-9\\-_=\\.@]+$", + message = "customerKey는 영문, 숫자, -, _, =, ., @ 만 사용할 수 있습니다." + ) + String customerKey +) { +} diff --git a/src/main/java/net/studioxai/studioxBe/domain/payment/dto/request/BillingKeyCardCreateRequest.java b/src/main/java/net/studioxai/studioxBe/domain/payment/dto/request/BillingKeyCardCreateRequest.java new file mode 100644 index 0000000..96b2908 --- /dev/null +++ b/src/main/java/net/studioxai/studioxBe/domain/payment/dto/request/BillingKeyCardCreateRequest.java @@ -0,0 +1,70 @@ +package net.studioxai.studioxBe.domain.payment.dto.request; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import net.studioxai.studioxBe.domain.payment.dto.ThreeDsValueDto; + +public record BillingKeyCardCreateRequest ( + @NotBlank(message = "카드 만료 월은 필수입니다.") + @Pattern( + regexp = "^(0[1-9]|1[0-2])$", + message = "카드 만료 월은 01~12 형식이어야 합니다." + ) + String cardExpirationMonth, + + @NotBlank(message = "카드 만료 연도는 필수입니다.") + @Pattern( + regexp = "^\\d{2}$", + message = "카드 만료 연도는 YY 형식의 숫자 2자리여야 합니다." + ) + String cardExpirationYear, + + @NotBlank(message = "카드 번호는 필수입니다.") + @Pattern( + regexp = "^\\d{12,19}$", + message = "카드 번호는 숫자 12~19자리여야 합니다." + ) + String cardNumber, + + @NotBlank(message = "카드 비밀번호 앞 2자리는 필수입니다.") + @Pattern( + regexp = "^\\d{2}$", + message = "카드 비밀번호는 숫자 2자리여야 합니다." + ) + String cardPassword, + + @NotBlank(message = "고객 식별번호는 필수입니다.") + @Pattern( + regexp = "^(\\d{6}|\\d{10})$", + message = "고객 식별번호는 생년월일 6자리 또는 사업자등록번호 10자리여야 합니다." + ) + String customerIdentityNumber, + + @NotBlank(message = "customerKey는 필수입니다.") + @Size(min = 2, max = 50, message = "customerKey는 2자 이상 50자 이하여야 합니다.") + @Pattern( + regexp = "^[A-Za-z0-9\\-_=\\.@]+$", + message = "customerKey는 영문, 숫자, -, _, =, ., @ 만 사용할 수 있습니다." + ) + String customerKey, + + @Email(message = "이메일 형식이 올바르지 않습니다.") + @Size(max = 100, message = "이메일은 100자 이하여야 합니다.") + String customerEmail, + + @Size(max = 100, message = "고객 이름은 100자 이하여야 합니다.") + String customerName, + + @Valid + ThreeDsValueDto cavv, + + @Valid + ThreeDsValueDto eci, + + @Valid + ThreeDsValueDto xid +) { +} diff --git a/src/main/java/net/studioxai/studioxBe/domain/payment/dto/response/BillingApprovalResponse.java b/src/main/java/net/studioxai/studioxBe/domain/payment/dto/response/BillingApprovalResponse.java new file mode 100644 index 0000000..a1a2516 --- /dev/null +++ b/src/main/java/net/studioxai/studioxBe/domain/payment/dto/response/BillingApprovalResponse.java @@ -0,0 +1,35 @@ +package net.studioxai.studioxBe.domain.payment.dto.response; + +import net.studioxai.studioxBe.domain.payment.dto.CardDto; +import net.studioxai.studioxBe.domain.payment.dto.FailureDto; + +import java.math.BigDecimal; + +public record BillingApprovalResponse ( + String version, + String paymentKey, + String type, + String orderId, + String orderName, + String mId, + String currency, + String method, + BigDecimal totalAmount, + String balanceAmount, + String status, + String requestedAt, + String approvedAt, + boolean useEscrow, + String lastTransactionKey, + int suppliedAmount, + int vat, + boolean cultureExpense, + int taxFreeAmount, + int taxExemptionAmount, + CardDto card, + boolean isPartialCancelable, + String country, + FailureDto failure +) { + +} diff --git a/src/main/java/net/studioxai/studioxBe/domain/payment/dto/response/BillingKeyResponse.java b/src/main/java/net/studioxai/studioxBe/domain/payment/dto/response/BillingKeyResponse.java new file mode 100644 index 0000000..0afcd28 --- /dev/null +++ b/src/main/java/net/studioxai/studioxBe/domain/payment/dto/response/BillingKeyResponse.java @@ -0,0 +1,26 @@ +package net.studioxai.studioxBe.domain.payment.dto.response; + +import com.fasterxml.jackson.annotation.JsonProperty; +import net.studioxai.studioxBe.domain.payment.dto.CardDto; +import net.studioxai.studioxBe.domain.payment.dto.TransferDto; + +import java.util.List; + +public record BillingKeyResponse ( + @JsonProperty("mId") + String mId, + + String customerKey, + + String authenticatedAt, + + String method, + + String billingKey, + + CardDto card, + + List transfers +) { + +} diff --git a/src/main/java/net/studioxai/studioxBe/domain/payment/dto/response/ExchangeRateResponse.java b/src/main/java/net/studioxai/studioxBe/domain/payment/dto/response/ExchangeRateResponse.java new file mode 100644 index 0000000..824bb1f --- /dev/null +++ b/src/main/java/net/studioxai/studioxBe/domain/payment/dto/response/ExchangeRateResponse.java @@ -0,0 +1,23 @@ +package net.studioxai.studioxBe.domain.payment.dto.response; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.math.BigDecimal; +import java.util.Map; + +public record ExchangeRateResponse( + String result, + + @JsonProperty("time_last_update_utc") + String timeLastUpdateUtc, + + @JsonProperty("time_next_update_utc") + String timeNextUpdateUtc, + + @JsonProperty("base_code") + String baseCode, + + @JsonProperty("conversion_rates") + Map conversionRates +) { +} diff --git a/src/main/java/net/studioxai/studioxBe/domain/payment/entity/BillingKey.java b/src/main/java/net/studioxai/studioxBe/domain/payment/entity/BillingKey.java new file mode 100644 index 0000000..15330a6 --- /dev/null +++ b/src/main/java/net/studioxai/studioxBe/domain/payment/entity/BillingKey.java @@ -0,0 +1,95 @@ +package net.studioxai.studioxBe.domain.payment.entity; + +import jakarta.persistence.*; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import net.studioxai.studioxBe.domain.user.entity.User; +import net.studioxai.studioxBe.global.entity.BaseEntity; + +@Entity +@Getter +@NoArgsConstructor +@Table(name = "billing_keys") +public class BillingKey extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "billing_key_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(nullable = false, unique = true) + private String billingKey; + + @Column(nullable = true) + private String method; + + @Column(nullable = true) + private String bankName; + + @Column(nullable = true) + private String bankAccountNumber; + + @Column(nullable = true) + private String cardIssueCompany; + + @Column(nullable = true) + private String cardAcquirerCompany; + + @Column(nullable = true) + private String cardNumber; + + @Column(nullable = false) + private boolean isActive; + + public static BillingKey create( + User user, + String billingKey, + String method, + String bankName, + String bankAccountNumber, + String cardIssueCompany, + String cardAcquirerCompany, + String cardNumber + ) { + return BillingKey.builder() + .user(user) + .billingKey(billingKey) + .method(method) + .bankName(bankName) + .bankAccountNumber(bankAccountNumber) + .cardIssueCompany(cardIssueCompany) + .cardAcquirerCompany(cardAcquirerCompany) + .cardNumber(cardNumber) + .build(); + } + + @Builder + private BillingKey( + User user, + String billingKey, + String method, + String bankName, + String bankAccountNumber, + String cardIssueCompany, + String cardAcquirerCompany, + String cardNumber + ) { + this.user = user; + this.billingKey = billingKey; + this.method = method; + this.bankName = bankName; + this.bankAccountNumber = bankAccountNumber; + this.cardIssueCompany = cardIssueCompany; + this.cardAcquirerCompany = cardAcquirerCompany; + this.cardNumber = cardNumber; + this.isActive = true; + } + + public void deactivate() { + this.isActive = false; + } +} diff --git a/src/main/java/net/studioxai/studioxBe/domain/payment/entity/PaymentHistory.java b/src/main/java/net/studioxai/studioxBe/domain/payment/entity/PaymentHistory.java new file mode 100644 index 0000000..0651681 --- /dev/null +++ b/src/main/java/net/studioxai/studioxBe/domain/payment/entity/PaymentHistory.java @@ -0,0 +1,99 @@ +package net.studioxai.studioxBe.domain.payment.entity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import net.studioxai.studioxBe.domain.payment.entity.enums.PaymentStatus; +import net.studioxai.studioxBe.domain.payment.entity.enums.Plan; +import net.studioxai.studioxBe.domain.user.entity.User; +import net.studioxai.studioxBe.global.entity.BaseEntity; + +import java.time.LocalDateTime; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class PaymentHistory extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "payment_history_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; + + private String orderId; + + private String paymentKey; + + private long amount; + + private String method; + + @Enumerated(EnumType.STRING) + private PaymentStatus status; + + private String requestedAt; + + private String approvedAt; + + private String failureCode; + + private String failureMessage; + + private LocalDateTime paidAt; + + public static PaymentHistory createPaymentHistory(User user, String orderId, int amount) { + return PaymentHistory.builder() + .user(user) + .orderId(orderId) + .amount(amount) + .build(); + } + + @Builder(access = AccessLevel.PRIVATE) + private PaymentHistory(User user, String orderId, int amount) { + this.user = user; + this.orderId = orderId; + this.amount = amount; + this.status = PaymentStatus.READY; + this.paidAt = LocalDateTime.now(); + } + + public void markAsSuccess() { + this.status = PaymentStatus.SUCCESS; + this.paidAt = LocalDateTime.now(); + } + + public void markAsFail() { + this.status = PaymentStatus.FAILED; + this.paidAt = LocalDateTime.now(); + } + + public void updatePaymentResult( + String paymentKey, + long amount, + String method, + PaymentStatus status, + String requestedAt, + String approvedAt, + String failureCode, + String failureMessage + ) { + this.paymentKey = paymentKey; + this.amount = amount; + this.method = method; + this.status = status; + this.requestedAt = requestedAt; + this.approvedAt = approvedAt; + this.failureCode = failureCode; + this.failureMessage = failureMessage; + + if (status == PaymentStatus.SUCCESS || status == PaymentStatus.FAILED) { + this.paidAt = LocalDateTime.now(); + } + } +} diff --git a/src/main/java/net/studioxai/studioxBe/domain/payment/entity/Subscription.java b/src/main/java/net/studioxai/studioxBe/domain/payment/entity/Subscription.java new file mode 100644 index 0000000..77efc7c --- /dev/null +++ b/src/main/java/net/studioxai/studioxBe/domain/payment/entity/Subscription.java @@ -0,0 +1,104 @@ +package net.studioxai.studioxBe.domain.payment.entity; + +import jakarta.persistence.*; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import net.studioxai.studioxBe.domain.payment.entity.enums.Plan; +import net.studioxai.studioxBe.domain.payment.entity.enums.SubscriptionStatus; +import net.studioxai.studioxBe.domain.user.entity.User; +import net.studioxai.studioxBe.global.entity.BaseEntity; + +import java.time.LocalDateTime; + +@Entity +@Getter +@NoArgsConstructor +@Table(name = "subscriptions") +public class Subscription extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "subscription_id") + private Long id; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private Plan plan; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "billing_key_id") + private BillingKey billingKey; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private SubscriptionStatus status; + + @Column(columnDefinition="TEXT") + private String cancelReason; + + private LocalDateTime startedAt; + + private LocalDateTime currentPeriodStart; + + private LocalDateTime currentPeriodEnd; + + private LocalDateTime nextBillingAt; + + private LocalDateTime canceledAt; + + @Column(nullable = false) + private boolean cancelAtPeriodEnd; + + @Builder + private Subscription(User user, Plan plan, BillingKey billingKey) { + LocalDateTime now = LocalDateTime.now(); + + this.user = user; + this.plan = plan; + this.billingKey = billingKey; + this.status = SubscriptionStatus.ACTIVE; + this.startedAt = now; + this.currentPeriodStart = now; + this.currentPeriodEnd = now.plusMonths(1); + this.nextBillingAt = now.plusMonths(1); + this.cancelAtPeriodEnd = false; + } + + public void changePlan(Plan plan) { + this.plan = plan; + } + + public void renew() { + LocalDateTime now = LocalDateTime.now(); + this.status = SubscriptionStatus.ACTIVE; + this.currentPeriodStart = now; + this.currentPeriodEnd = now.plusMonths(1); + this.nextBillingAt = now.plusMonths(1); + } + + public void markPastDue() { + this.status = SubscriptionStatus.PAST_DUE; + } + + public void cancelAtPeriodEnd() { + this.cancelAtPeriodEnd = true; + this.canceledAt = LocalDateTime.now(); + } + + public void expire() { + this.status = SubscriptionStatus.EXPIRED; + } + + public static Subscription createSubscription(User user, Plan plan, BillingKey billingKey) { + return Subscription.builder() + .user(user) + .plan(plan) + .billingKey(billingKey) + .build(); + } + +} diff --git a/src/main/java/net/studioxai/studioxBe/domain/payment/entity/UserPlan.java b/src/main/java/net/studioxai/studioxBe/domain/payment/entity/UserPlan.java new file mode 100644 index 0000000..dee4509 --- /dev/null +++ b/src/main/java/net/studioxai/studioxBe/domain/payment/entity/UserPlan.java @@ -0,0 +1,60 @@ +package net.studioxai.studioxBe.domain.payment.entity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import net.studioxai.studioxBe.domain.payment.entity.enums.Plan; +import net.studioxai.studioxBe.domain.user.entity.User; +import net.studioxai.studioxBe.global.entity.BaseEntity; +import org.springframework.security.core.parameters.P; + +@Entity +@Getter +@NoArgsConstructor +@Table(name = "user_plans") +public class UserPlan extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "user_plan_id") + private Long id; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(name = "credit", nullable = false) + private int credit; + + @Column(name = "storage", nullable = false) + private long storage; + + @Column(name = "reference", nullable = false) + private int reference; + + @Column(name = "team_size", nullable = false) + private int teamSize; + + public static UserPlan createFree(User user) { + return UserPlan.builder() + .user(user) + .plan(Plan.FREE) + .build(); + } + + @Builder(access = AccessLevel.PRIVATE) + private UserPlan(User user, Plan plan) { + this.user = user; + this.credit = 0; + this.storage = 0; + this.reference = 0; + this.teamSize = plan.getTeamSize(); + } + + public void montlyInitialize() { + this.credit = 0; + this.reference = 0; + } + +} diff --git a/src/main/java/net/studioxai/studioxBe/domain/payment/entity/enums/PaymentStatus.java b/src/main/java/net/studioxai/studioxBe/domain/payment/entity/enums/PaymentStatus.java new file mode 100644 index 0000000..9adb42f --- /dev/null +++ b/src/main/java/net/studioxai/studioxBe/domain/payment/entity/enums/PaymentStatus.java @@ -0,0 +1,8 @@ +package net.studioxai.studioxBe.domain.payment.entity.enums; + +public enum PaymentStatus { + READY, + SUCCESS, + FAILED, + CANCELED +} diff --git a/src/main/java/net/studioxai/studioxBe/domain/payment/entity/enums/Plan.java b/src/main/java/net/studioxai/studioxBe/domain/payment/entity/enums/Plan.java new file mode 100644 index 0000000..45364f5 --- /dev/null +++ b/src/main/java/net/studioxai/studioxBe/domain/payment/entity/enums/Plan.java @@ -0,0 +1,38 @@ +package net.studioxai.studioxBe.domain.payment.entity.enums; + +import lombok.Getter; + +@Getter +public enum Plan { + FREE(0, 100, mbToByte(5), true, false, false, 0, 1), + BASIC(8, 300, mbToByte(10), false, true, false, 15, 1), + STANDARD(24, 900, mbToByte(50), false, true, true, -1, 1), + PRO(48, 3000, mbToByte(200), false, true, true, -1, 5) + ; + + private final int price; + private final int credit; + private final long storageLimit; + private final boolean hasWatermark; + private final boolean canChat; + private final boolean canVersioning; + private final int maxReferences; + private final int teamSize; + + Plan(int price, int credit, long storageLimit, boolean hasWatermark, boolean canChat, boolean canVersioning, int maxReferences, int teamSize) { + this.price = price; + this.credit = credit; + this.storageLimit = storageLimit; + this.hasWatermark = hasWatermark; + this.canChat = canChat; + this.canVersioning = canVersioning; + this.maxReferences = maxReferences; + this.teamSize = teamSize; + } + + public static long mbToByte(long value) { + return value * 1024 * 1024; + } + + +} diff --git a/src/main/java/net/studioxai/studioxBe/domain/payment/entity/enums/SubscriptionStatus.java b/src/main/java/net/studioxai/studioxBe/domain/payment/entity/enums/SubscriptionStatus.java new file mode 100644 index 0000000..02368ea --- /dev/null +++ b/src/main/java/net/studioxai/studioxBe/domain/payment/entity/enums/SubscriptionStatus.java @@ -0,0 +1,8 @@ +package net.studioxai.studioxBe.domain.payment.entity.enums; + +public enum SubscriptionStatus { + ACTIVE, + CANCELED, + PAST_DUE, + EXPIRED +} diff --git a/src/main/java/net/studioxai/studioxBe/domain/payment/entity/redis/ExchangeRate.java b/src/main/java/net/studioxai/studioxBe/domain/payment/entity/redis/ExchangeRate.java new file mode 100644 index 0000000..7d6f8ed --- /dev/null +++ b/src/main/java/net/studioxai/studioxBe/domain/payment/entity/redis/ExchangeRate.java @@ -0,0 +1,49 @@ +package net.studioxai.studioxBe.domain.payment.entity.redis; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.Id; +import lombok.Builder; +import org.springframework.data.redis.core.RedisHash; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@RedisHash(value = "exchangeRate", timeToLive = 90000) +public class ExchangeRate { + @Id + private String id; // 예: USD:KRW + + private String baseCurrency; + + private String targetCurrency; + + private BigDecimal rate; + + private String timeLastUpdateUtc; + + private String timeNextUpdateUtc; + + private LocalDateTime fetchedAt; + + @Builder + public ExchangeRate( + String baseCurrency, + String targetCurrency, + BigDecimal rate, + String timeLastUpdateUtc, + String timeNextUpdateUtc, + LocalDateTime fetchedAt + ) { + this.id = baseCurrency + ":" + targetCurrency; + this.baseCurrency = baseCurrency; + this.targetCurrency = targetCurrency; + this.rate = rate; + this.timeLastUpdateUtc = timeLastUpdateUtc; + this.timeNextUpdateUtc = timeNextUpdateUtc; + this.fetchedAt = fetchedAt; + } +} diff --git a/src/main/java/net/studioxai/studioxBe/domain/payment/exception/BillingKeyErrorCode.java b/src/main/java/net/studioxai/studioxBe/domain/payment/exception/BillingKeyErrorCode.java new file mode 100644 index 0000000..9d3841d --- /dev/null +++ b/src/main/java/net/studioxai/studioxBe/domain/payment/exception/BillingKeyErrorCode.java @@ -0,0 +1,27 @@ +package net.studioxai.studioxBe.domain.payment.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import net.studioxai.studioxBe.global.dto.ErrorReason; +import net.studioxai.studioxBe.global.error.BaseErrorCode; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum BillingKeyErrorCode implements BaseErrorCode { + // 400 Bad Request + INVALID_CUSTOM_KEY(HttpStatus.BAD_REQUEST, "BILLING_KEY_400_1", "커스텀 키가 일치하지 않습니다."), + + // 404 Not Found + NOT_FOUND_BILLING_KEY(HttpStatus.NOT_FOUND, "BILLING_KEY_404_1", "빌링키를 찾을 수 없습니다."); + + private final HttpStatus status; + private final String code; + private final String reason; + + @Override + public ErrorReason getErrorReason() { + return ErrorReason.of(status.value(), code, reason); + } +} + diff --git a/src/main/java/net/studioxai/studioxBe/domain/payment/exception/BillingKeyExceptionHandler.java b/src/main/java/net/studioxai/studioxBe/domain/payment/exception/BillingKeyExceptionHandler.java new file mode 100644 index 0000000..792c0bd --- /dev/null +++ b/src/main/java/net/studioxai/studioxBe/domain/payment/exception/BillingKeyExceptionHandler.java @@ -0,0 +1,8 @@ +package net.studioxai.studioxBe.domain.payment.exception; + +import net.studioxai.studioxBe.global.error.BaseErrorCode; +import net.studioxai.studioxBe.global.error.BaseErrorException; + +public class BillingKeyExceptionHandler extends BaseErrorException { + public BillingKeyExceptionHandler(BaseErrorCode baseErrorCode) { super(baseErrorCode); } +} diff --git a/src/main/java/net/studioxai/studioxBe/domain/payment/exception/ExchangeRateErrorCode.java b/src/main/java/net/studioxai/studioxBe/domain/payment/exception/ExchangeRateErrorCode.java new file mode 100644 index 0000000..027c298 --- /dev/null +++ b/src/main/java/net/studioxai/studioxBe/domain/payment/exception/ExchangeRateErrorCode.java @@ -0,0 +1,32 @@ +package net.studioxai.studioxBe.domain.payment.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import net.studioxai.studioxBe.global.dto.ErrorReason; +import net.studioxai.studioxBe.global.error.BaseErrorCode; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum ExchangeRateErrorCode implements BaseErrorCode { + // 400 Bad Request + INVALID_API_CALL(HttpStatus.BAD_REQUEST, "EXCHANTE_RATE_400_1", "환율 API 호출에 실패했습니다."), + + + // 404 Not Found + NOT_FOUND_KRW(HttpStatus.NOT_FOUND, "EXCHANGE_RATE_404_1", "환율 응답에 KRW 값이 없습니다."), + NOT_FOUND_EXCHANGE_RATE(HttpStatus.NOT_FOUND, "EXCHANGE_RATE_404_2", "저장된 환율 정보가 없습니다."), + NOT_FOUND_RESPONSE(HttpStatus.NOT_FOUND, "EXCHANGE_RATE_404_3", "환율 API 응답이 비어있습니다."), + NOT_FOUND_CONVERSION_RATE(HttpStatus.NOT_FOUND, "EXCHANGE_RATE_404_4", "환율 API 응답에 conversion-rates가 없습니다."); + + private final HttpStatus status; + private final String code; + private final String reason; + + @Override + public ErrorReason getErrorReason() { + return ErrorReason.of(status.value(), code, reason); + } +} + + diff --git a/src/main/java/net/studioxai/studioxBe/domain/payment/exception/ExchangeRateExceptionHandler.java b/src/main/java/net/studioxai/studioxBe/domain/payment/exception/ExchangeRateExceptionHandler.java new file mode 100644 index 0000000..b7da678 --- /dev/null +++ b/src/main/java/net/studioxai/studioxBe/domain/payment/exception/ExchangeRateExceptionHandler.java @@ -0,0 +1,10 @@ +package net.studioxai.studioxBe.domain.payment.exception; + +import net.studioxai.studioxBe.global.error.BaseErrorCode; +import net.studioxai.studioxBe.global.error.BaseErrorException; + +public class ExchangeRateExceptionHandler extends BaseErrorException { + public ExchangeRateExceptionHandler(BaseErrorCode baseErrorCode) { + super(baseErrorCode); + } +} diff --git a/src/main/java/net/studioxai/studioxBe/domain/payment/exception/SubscriptionErrorCode.java b/src/main/java/net/studioxai/studioxBe/domain/payment/exception/SubscriptionErrorCode.java new file mode 100644 index 0000000..004d9b8 --- /dev/null +++ b/src/main/java/net/studioxai/studioxBe/domain/payment/exception/SubscriptionErrorCode.java @@ -0,0 +1,22 @@ +package net.studioxai.studioxBe.domain.payment.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import net.studioxai.studioxBe.global.dto.ErrorReason; +import net.studioxai.studioxBe.global.error.BaseErrorCode; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum SubscriptionErrorCode implements BaseErrorCode { + NOT_FOUND_SUBSCRIPTION(HttpStatus.NOT_FOUND, "SUBSCRIPTION_404_1", "해당 id의 구독을 찾을 수 없습니다."); + + private final HttpStatus status; + private final String code; + private final String reason; + + @Override + public ErrorReason getErrorReason() { + return ErrorReason.of(status.value(), code, reason); + } +} diff --git a/src/main/java/net/studioxai/studioxBe/domain/payment/exception/SubscriptionExceptionHandler.java b/src/main/java/net/studioxai/studioxBe/domain/payment/exception/SubscriptionExceptionHandler.java new file mode 100644 index 0000000..67ec718 --- /dev/null +++ b/src/main/java/net/studioxai/studioxBe/domain/payment/exception/SubscriptionExceptionHandler.java @@ -0,0 +1,10 @@ +package net.studioxai.studioxBe.domain.payment.exception; + +import net.studioxai.studioxBe.global.error.BaseErrorCode; +import net.studioxai.studioxBe.global.error.BaseErrorException; + +public class SubscriptionExceptionHandler extends BaseErrorException { + public SubscriptionExceptionHandler(BaseErrorCode baseErrorCode) { + super(baseErrorCode); + } +} diff --git a/src/main/java/net/studioxai/studioxBe/domain/payment/exception/TossPaymentErrorCode.java b/src/main/java/net/studioxai/studioxBe/domain/payment/exception/TossPaymentErrorCode.java new file mode 100644 index 0000000..0b61d02 --- /dev/null +++ b/src/main/java/net/studioxai/studioxBe/domain/payment/exception/TossPaymentErrorCode.java @@ -0,0 +1,31 @@ +package net.studioxai.studioxBe.domain.payment.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import net.studioxai.studioxBe.global.dto.ErrorReason; +import net.studioxai.studioxBe.global.error.BaseErrorCode; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum TossPaymentErrorCode implements BaseErrorCode { + // 400 BAD_REQUEST + INVALID_PAYMENT_REQUEST(HttpStatus.BAD_REQUEST, "PAYMENT_400_1", "결제 요청 값이 올바르지 않습니다."), + + // 502 BAD_GATEWAY + TOSS_REQUEST_FAILED(HttpStatus.BAD_GATEWAY, "PAYMENT_502_1", "토스페이먼츠 요청에 실패했습니다."), + TOSS_RESPONSE_PARSE_FAILED(HttpStatus.BAD_GATEWAY, "PAYMENT_502_2", "토스페이먼츠 응답 처리에 실패했습니다."), + TOSS_EMPTY_RESPONSE(HttpStatus.BAD_GATEWAY, "PAYMENT_502_3", "토스페이먼츠 응답이 비어 있습니다."), + + // 500 INTERNAL_SERVER_ERROR + PAYMENT_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "PAYMENT_500_1", "결제 처리 중 서버 오류가 발생했습니다."); + + private final HttpStatus status; + private final String code; + private final String reason; + + @Override + public ErrorReason getErrorReason() { + return ErrorReason.of(status.value(), code, reason); + } +} diff --git a/src/main/java/net/studioxai/studioxBe/domain/payment/exception/TossPaymentExceptionHandler.java b/src/main/java/net/studioxai/studioxBe/domain/payment/exception/TossPaymentExceptionHandler.java new file mode 100644 index 0000000..4b5f012 --- /dev/null +++ b/src/main/java/net/studioxai/studioxBe/domain/payment/exception/TossPaymentExceptionHandler.java @@ -0,0 +1,10 @@ +package net.studioxai.studioxBe.domain.payment.exception; + +import net.studioxai.studioxBe.global.error.BaseErrorCode; +import net.studioxai.studioxBe.global.error.BaseErrorException; + +public class TossPaymentExceptionHandler extends BaseErrorException { + public TossPaymentExceptionHandler(BaseErrorCode baseErrorCode) { + super(baseErrorCode); + } +} diff --git a/src/main/java/net/studioxai/studioxBe/domain/payment/exception/UserPlanErrorCode.java b/src/main/java/net/studioxai/studioxBe/domain/payment/exception/UserPlanErrorCode.java new file mode 100644 index 0000000..ce8fc47 --- /dev/null +++ b/src/main/java/net/studioxai/studioxBe/domain/payment/exception/UserPlanErrorCode.java @@ -0,0 +1,24 @@ +package net.studioxai.studioxBe.domain.payment.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import net.studioxai.studioxBe.domain.payment.entity.UserPlan; +import net.studioxai.studioxBe.global.dto.ErrorReason; +import net.studioxai.studioxBe.global.error.BaseErrorCode; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum UserPlanErrorCode implements BaseErrorCode { + USER_PLAN_NOT_FOUNT(HttpStatus.NOT_FOUND, "USER_PLAN_404_1", "user plan이 아직 생성되지 않은 유저입니다."), + ; + + private final HttpStatus status; + private final String code; + private final String reason; + + @Override + public ErrorReason getErrorReason() { + return ErrorReason.of(status.value(), code, reason); + } +} diff --git a/src/main/java/net/studioxai/studioxBe/domain/payment/exception/UserPlanExceptionHandler.java b/src/main/java/net/studioxai/studioxBe/domain/payment/exception/UserPlanExceptionHandler.java new file mode 100644 index 0000000..169e26f --- /dev/null +++ b/src/main/java/net/studioxai/studioxBe/domain/payment/exception/UserPlanExceptionHandler.java @@ -0,0 +1,10 @@ +package net.studioxai.studioxBe.domain.payment.exception; + +import net.studioxai.studioxBe.global.error.BaseErrorCode; +import net.studioxai.studioxBe.global.error.BaseErrorException; + +public class UserPlanExceptionHandler extends BaseErrorException { + public UserPlanExceptionHandler(BaseErrorCode baseErrorCode) { + super(baseErrorCode); + } +} diff --git a/src/main/java/net/studioxai/studioxBe/domain/payment/repository/BillingKeyRepository.java b/src/main/java/net/studioxai/studioxBe/domain/payment/repository/BillingKeyRepository.java new file mode 100644 index 0000000..0760ea3 --- /dev/null +++ b/src/main/java/net/studioxai/studioxBe/domain/payment/repository/BillingKeyRepository.java @@ -0,0 +1,14 @@ +package net.studioxai.studioxBe.domain.payment.repository; + +import net.studioxai.studioxBe.domain.payment.entity.BillingKey; +import net.studioxai.studioxBe.domain.payment.entity.enums.Plan; +import net.studioxai.studioxBe.domain.user.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface BillingKeyRepository extends JpaRepository { + Optional findByUser(User user); +} diff --git a/src/main/java/net/studioxai/studioxBe/domain/payment/repository/ExchangeRateRepository.java b/src/main/java/net/studioxai/studioxBe/domain/payment/repository/ExchangeRateRepository.java new file mode 100644 index 0000000..a3b2d41 --- /dev/null +++ b/src/main/java/net/studioxai/studioxBe/domain/payment/repository/ExchangeRateRepository.java @@ -0,0 +1,7 @@ +package net.studioxai.studioxBe.domain.payment.repository; + +import net.studioxai.studioxBe.domain.payment.entity.redis.ExchangeRate; +import org.springframework.data.repository.CrudRepository; + +public interface ExchangeRateRepository extends CrudRepository { +} \ No newline at end of file diff --git a/src/main/java/net/studioxai/studioxBe/domain/payment/repository/PaymentHistoryRepository.java b/src/main/java/net/studioxai/studioxBe/domain/payment/repository/PaymentHistoryRepository.java new file mode 100644 index 0000000..d6ad1eb --- /dev/null +++ b/src/main/java/net/studioxai/studioxBe/domain/payment/repository/PaymentHistoryRepository.java @@ -0,0 +1,9 @@ +package net.studioxai.studioxBe.domain.payment.repository; + +import net.studioxai.studioxBe.domain.payment.entity.PaymentHistory; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface PaymentHistoryRepository extends JpaRepository { +} diff --git a/src/main/java/net/studioxai/studioxBe/domain/payment/repository/SubscriptionRepository.java b/src/main/java/net/studioxai/studioxBe/domain/payment/repository/SubscriptionRepository.java new file mode 100644 index 0000000..30c9605 --- /dev/null +++ b/src/main/java/net/studioxai/studioxBe/domain/payment/repository/SubscriptionRepository.java @@ -0,0 +1,27 @@ +package net.studioxai.studioxBe.domain.payment.repository; + +import net.studioxai.studioxBe.domain.payment.entity.Subscription; +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 java.time.LocalDateTime; +import java.util.List; + +public interface SubscriptionRepository extends JpaRepository { + @Query(""" + select s + from Subscription s + join fetch s.user u + join fetch s.billingKey b + where s.status = 'ACTIVE' + and s.nextBillingAt < :before + order by s.nextBillingAt asc + """) + List findDailyBillingTargets( + @Param("before") LocalDateTime before, + Pageable pageable + ); +} + diff --git a/src/main/java/net/studioxai/studioxBe/domain/payment/repository/UserPlanRepository.java b/src/main/java/net/studioxai/studioxBe/domain/payment/repository/UserPlanRepository.java new file mode 100644 index 0000000..d4a2670 --- /dev/null +++ b/src/main/java/net/studioxai/studioxBe/domain/payment/repository/UserPlanRepository.java @@ -0,0 +1,13 @@ +package net.studioxai.studioxBe.domain.payment.repository; + +import net.studioxai.studioxBe.domain.payment.entity.UserPlan; +import net.studioxai.studioxBe.domain.user.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface UserPlanRepository extends JpaRepository { + Optional findByUser(User user); +} diff --git a/src/main/java/net/studioxai/studioxBe/domain/payment/service/BillingKeyApprovalService.java b/src/main/java/net/studioxai/studioxBe/domain/payment/service/BillingKeyApprovalService.java new file mode 100644 index 0000000..7f90ddd --- /dev/null +++ b/src/main/java/net/studioxai/studioxBe/domain/payment/service/BillingKeyApprovalService.java @@ -0,0 +1,136 @@ +package net.studioxai.studioxBe.domain.payment.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import net.studioxai.studioxBe.domain.payment.dto.request.BillingApprovalRequest; +import net.studioxai.studioxBe.domain.payment.dto.response.BillingApprovalResponse; +import net.studioxai.studioxBe.domain.payment.entity.BillingKey; +import net.studioxai.studioxBe.domain.payment.entity.PaymentHistory; +import net.studioxai.studioxBe.domain.payment.entity.Subscription; +import net.studioxai.studioxBe.domain.payment.entity.UserPlan; +import net.studioxai.studioxBe.domain.payment.entity.enums.PaymentStatus; +import net.studioxai.studioxBe.domain.payment.entity.enums.Plan; +import net.studioxai.studioxBe.domain.payment.exception.*; +import net.studioxai.studioxBe.domain.payment.repository.*; +import net.studioxai.studioxBe.domain.user.entity.User; +import net.studioxai.studioxBe.global.util.IpUtil; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.io.IOException; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.UUID; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +@Slf4j +public class BillingKeyApprovalService { + private final IpUtil ipUtil; + + private final TossService tossService; + private final ExchangeRateService exchangeRateService; + + private final PaymentHistoryRepository paymentHistoryRepository; + private final BillingKeyRepository billingKeyRepository; + private final SubscriptionRepository subscriptionRepository; + private final UserPlanRepository userPlanRepository; + + @Transactional + public void approveBilling(User user, Plan plan, String clientIp) throws IOException { + PaymentHistory paymentHistory = savePaymentHistory(user, plan); + + BillingKey billingKey = billingKeyRepository.findByUser(user).orElseThrow( + () -> new BillingKeyExceptionHandler(BillingKeyErrorCode.NOT_FOUND_BILLING_KEY) + ); + + UserPlan userPlan = userPlanRepository.findByUser(user).orElseThrow( + () -> new UserPlanExceptionHandler(UserPlanErrorCode.USER_PLAN_NOT_FOUNT) + ); + + long amount = exchangeRateService.getKrwRate() + .setScale(0, RoundingMode.HALF_UP) + .longValue(); + + BillingApprovalRequest request = BillingApprovalRequest.of(user, plan, amount, billingKey, paymentHistory, clientIp, 0, 0); + + BillingApprovalResponse response = tossService.getResponse(request, BillingApprovalResponse.class, "/v1/billing/"+billingKey.getBillingKey()); + + updatePaymentHistory(paymentHistory, response); + + Subscription subscription = Subscription.createSubscription(user, plan, billingKey); + subscriptionRepository.save(subscription); + + userPlan.montlyInitialize(); + } + + private PaymentHistory savePaymentHistory(User user, Plan plan) { + String orderId = UUID.randomUUID().toString(); + PaymentHistory paymentHistory = PaymentHistory.createPaymentHistory(user, orderId, plan.getPrice()); + paymentHistoryRepository.save(paymentHistory); + return paymentHistory; + } + + private void updatePaymentHistory(PaymentHistory paymentHistory, BillingApprovalResponse response) { + PaymentStatus paymentStatus = + "DONE".equals(response.status()) + ? PaymentStatus.SUCCESS + : PaymentStatus.FAILED; + + String failureCode = response.failure() != null + ? response.failure().code() + : null; + + String failureMessage = response.failure() != null + ? response.failure().message() + : null; + + paymentHistory.updatePaymentResult( + response.paymentKey(), + response.totalAmount().setScale(0, RoundingMode.HALF_UP).longValue(), + response.method(), + paymentStatus, + response.requestedAt(), + response.approvedAt(), + failureCode, + failureMessage + ); + } + + public void paySubscription(Long subscriptionId) throws IOException { + Subscription subscription = subscriptionRepository.findById(subscriptionId).orElseThrow( + () -> new SubscriptionExceptionHandler(SubscriptionErrorCode.NOT_FOUND_SUBSCRIPTION) + ); + + User user = subscription.getUser(); + Plan plan = subscription.getPlan(); + BillingKey billingKey = subscription.getBillingKey(); + + UserPlan userPlan = userPlanRepository.findByUser(user).orElseThrow( + () -> new UserPlanExceptionHandler(UserPlanErrorCode.USER_PLAN_NOT_FOUNT) + ); + + PaymentHistory paymentHistory = savePaymentHistory(user, plan); + + long amount = exchangeRateService.getKrwRate() + .setScale(0, RoundingMode.HALF_UP) + .longValue(); + + BillingApprovalRequest request = BillingApprovalRequest.of(user, plan, amount, billingKey, paymentHistory, null, 0, 0); + + BillingApprovalResponse response = tossService.getResponse(request, BillingApprovalResponse.class, "/v1/billing/"+billingKey.getBillingKey()); + + updatePaymentHistory(paymentHistory, response); + + if (paymentHistory.getStatus() == PaymentStatus.SUCCESS) { + subscription.renew(); + } + + userPlan.montlyInitialize(); + } + + + + +} diff --git a/src/main/java/net/studioxai/studioxBe/domain/payment/service/BillingKeyService.java b/src/main/java/net/studioxai/studioxBe/domain/payment/service/BillingKeyService.java new file mode 100644 index 0000000..a85253d --- /dev/null +++ b/src/main/java/net/studioxai/studioxBe/domain/payment/service/BillingKeyService.java @@ -0,0 +1,107 @@ +package net.studioxai.studioxBe.domain.payment.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import net.studioxai.studioxBe.domain.payment.dto.CardDto; +import net.studioxai.studioxBe.domain.payment.dto.TransferDto; +import net.studioxai.studioxBe.domain.payment.dto.request.BillingKeyAuthKeyCreateRequest; +import net.studioxai.studioxBe.domain.payment.dto.request.BillingKeyCardCreateRequest; +import net.studioxai.studioxBe.domain.payment.dto.response.BillingKeyResponse; +import net.studioxai.studioxBe.domain.payment.entity.BillingKey; +import net.studioxai.studioxBe.domain.payment.entity.enums.Plan; +import net.studioxai.studioxBe.domain.payment.exception.BillingKeyErrorCode; +import net.studioxai.studioxBe.domain.payment.exception.BillingKeyExceptionHandler; +import net.studioxai.studioxBe.domain.payment.repository.BillingKeyRepository; +import net.studioxai.studioxBe.domain.payment.util.JsonUtil; +import net.studioxai.studioxBe.domain.user.entity.User; +import net.studioxai.studioxBe.domain.user.service.UserService; +import net.studioxai.studioxBe.global.jwt.JwtUserPrincipal; +import org.json.simple.JSONObject; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.io.IOException; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +@Slf4j +public class BillingKeyService { + private final UserService userService; + private final TossService tossService; + private final BillingKeyApprovalService billingKeyApprovalService; + + private final JsonUtil jsonUtil; + + private final BillingKeyRepository billingKeyRepository; + + @Transactional + public void createBillingKeyWithAuthKey( + Long userId, + BillingKeyAuthKeyCreateRequest billingKeyAuthKeyCreateRequest, + Plan plan, + String clientIp + ) throws IOException { + User user = userService.getUserByIdOrThrow(userId); + + if(!user.equalsCustomerKey(billingKeyAuthKeyCreateRequest.customerKey())) { + throw new BillingKeyExceptionHandler(BillingKeyErrorCode.INVALID_CUSTOM_KEY); + }; + + BillingKeyResponse response = tossService.getResponse(billingKeyAuthKeyCreateRequest, BillingKeyResponse.class, "/v1/billing/authorizations/issue"); + + BillingKey billingKey = toEntity(user, response); + saveBillingKey(billingKey); + + billingKeyApprovalService.approveBilling(user, plan, clientIp); + } + + @Transactional + public void createBillingKeyWithCard( + Long userId, + BillingKeyCardCreateRequest billingKeyCardCreateRequest, + Plan plan, + String clientIp + ) throws IOException { + User user = userService.getUserByIdOrThrow(userId); + + if(!user.equalsCustomerKey(billingKeyCardCreateRequest.customerKey())) { + throw new BillingKeyExceptionHandler(BillingKeyErrorCode.INVALID_CUSTOM_KEY); + }; + + BillingKeyResponse response = tossService.getResponse(billingKeyCardCreateRequest, BillingKeyResponse.class, "/v1/billing/authorizations/card"); + + BillingKey billingKey = toEntity(user, response); + saveBillingKey(billingKey); + + billingKeyApprovalService.approveBilling(user, plan, clientIp); + + } + + private void saveBillingKey(BillingKey billingKey) { + billingKeyRepository.save(billingKey); + } + + + private BillingKey toEntity(User user, BillingKeyResponse response) { + CardDto card = response.card(); + + TransferDto transfer = + response.transfers() != null && !response.transfers().isEmpty() + ? response.transfers().get(0) + : null; + + return BillingKey.create( + user, + response.billingKey(), + response.method(), + transfer != null ? transfer.bankName() : null, + transfer != null ? transfer.bankAccountNumber() : null, + card != null ? card.issuerCode() : null, + card != null ? card.acquirerCode() : null, + card != null ? card.number() : null + ); + } + +} diff --git a/src/main/java/net/studioxai/studioxBe/domain/payment/service/ExchangeRateService.java b/src/main/java/net/studioxai/studioxBe/domain/payment/service/ExchangeRateService.java new file mode 100644 index 0000000..ff365a9 --- /dev/null +++ b/src/main/java/net/studioxai/studioxBe/domain/payment/service/ExchangeRateService.java @@ -0,0 +1,81 @@ +package net.studioxai.studioxBe.domain.payment.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import net.studioxai.studioxBe.domain.payment.dto.response.ExchangeRateResponse; +import net.studioxai.studioxBe.domain.payment.entity.redis.ExchangeRate; +import net.studioxai.studioxBe.domain.payment.exception.ExchangeRateErrorCode; +import net.studioxai.studioxBe.domain.payment.exception.ExchangeRateExceptionHandler; +import net.studioxai.studioxBe.domain.payment.repository.ExchangeRateRepository; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestClient; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ExchangeRateService { + @Value("${exchange-rate.api-key}") + private String apiKey; + + private final ExchangeRateRepository exchangeRateRepository; + + private final RestClient restClient = RestClient.create("https://v6.exchangerate-api.com/v6"); + + private static final String BASE_CURRENCY = "USD"; + private static final String TARGET_CURRENCY = "KRW"; + private static final String EXCHANGE_RATE_ID = BASE_CURRENCY + ":" + TARGET_CURRENCY; + + public void saveRate() { + ExchangeRateResponse response = restClient.get() + .uri("/{apiKey}/latest/{baseCurrency}", apiKey, BASE_CURRENCY) + .retrieve() + .body(ExchangeRateResponse.class); + + validateResponse(response); + + BigDecimal krwRate = response.conversionRates().get(TARGET_CURRENCY); + + if (krwRate == null) { + throw new ExchangeRateExceptionHandler(ExchangeRateErrorCode.NOT_FOUND_KRW); + } + + ExchangeRate exchangeRate = ExchangeRate.builder() + .baseCurrency(response.baseCode()) + .targetCurrency(TARGET_CURRENCY) + .rate(krwRate) + .timeLastUpdateUtc(response.timeLastUpdateUtc()) + .timeNextUpdateUtc(response.timeNextUpdateUtc()) + .fetchedAt(LocalDateTime.now()) + .build(); + + exchangeRateRepository.save(exchangeRate); + + log.info("[ExchangeRate] USD -> KRW 환율 Redis 저장 완료. rate={}", krwRate); + } + + public BigDecimal getKrwRate() { + ExchangeRate exchangeRate = exchangeRateRepository.findById(EXCHANGE_RATE_ID) + .orElseThrow(() -> new ExchangeRateExceptionHandler(ExchangeRateErrorCode.NOT_FOUND_EXCHANGE_RATE)); + + return exchangeRate.getRate(); + } + + private void validateResponse(ExchangeRateResponse response) { + if (response == null) { + throw new ExchangeRateExceptionHandler(ExchangeRateErrorCode.NOT_FOUND_RESPONSE); + } + + if (!"success".equals(response.result())) { + log.error("환율 API 호출에 실패했습니다. result=" + response.result()); + throw new ExchangeRateExceptionHandler(ExchangeRateErrorCode.INVALID_API_CALL); + } + + if (response.conversionRates() == null) { + throw new ExchangeRateExceptionHandler(ExchangeRateErrorCode.NOT_FOUND_CONVERSION_RATE); + } + } +} diff --git a/src/main/java/net/studioxai/studioxBe/domain/payment/service/SubscriptionService.java b/src/main/java/net/studioxai/studioxBe/domain/payment/service/SubscriptionService.java new file mode 100644 index 0000000..cdf1469 --- /dev/null +++ b/src/main/java/net/studioxai/studioxBe/domain/payment/service/SubscriptionService.java @@ -0,0 +1,56 @@ +package net.studioxai.studioxBe.domain.payment.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import net.studioxai.studioxBe.domain.payment.entity.Subscription; +import net.studioxai.studioxBe.domain.payment.repository.SubscriptionRepository; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +public class SubscriptionService { + private static final int BATCH_SIZE = 100; + + private final SubscriptionRepository subscriptionRepository; + private final BillingKeyApprovalService billingKeyApprovalService; + + public void approveTodaySubscriptions() { + ZoneId zoneId = ZoneId.of("Asia/Seoul"); + + LocalDate today = LocalDate.now(zoneId); + LocalDateTime startOfTomorrow = today.plusDays(1).atStartOfDay(); + + while (true) { + List targets = subscriptionRepository.findDailyBillingTargets( + startOfTomorrow, + PageRequest.of(0, BATCH_SIZE) + ); + + if (targets.isEmpty()) { + break; + } + + log.info("[Billing] daily billing targets size={}", targets.size()); + + for (Subscription subscription : targets) { + try { + billingKeyApprovalService.paySubscription(subscription.getId()); + } catch (Exception e) { + log.error( + "[Billing] failed to approve subscription. subscriptionId={}", + subscription.getId(), + e + ); + } + } + } + } + +} diff --git a/src/main/java/net/studioxai/studioxBe/domain/payment/service/TossService.java b/src/main/java/net/studioxai/studioxBe/domain/payment/service/TossService.java new file mode 100644 index 0000000..96bc71a --- /dev/null +++ b/src/main/java/net/studioxai/studioxBe/domain/payment/service/TossService.java @@ -0,0 +1,114 @@ +package net.studioxai.studioxBe.domain.payment.service; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import net.studioxai.studioxBe.domain.payment.exception.TossPaymentErrorCode; +import net.studioxai.studioxBe.domain.payment.exception.TossPaymentExceptionHandler; +import net.studioxai.studioxBe.domain.payment.util.JsonUtil; +import org.json.simple.parser.JSONParser; +import org.json.simple.parser.ParseException; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import org.json.simple.JSONObject; + +import java.io.*; +import java.net.HttpURLConnection; +import java.net.MalformedURLException; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Map; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +@Slf4j +public class TossService { + @Value("${toss.pay.base-url}") + private String baseUrl; + + @Value("${toss.pay.secret-key}") + private String tossSecretKey; + + @Value("${toss.pay.secure-key}") + private String tossSecureKey; + + @Value("${toss.pay.client-key}") + private String tossClientKey; + + private final JsonUtil jsonUtil; + + public T getResponse(Object dto, Class dtoClass, String uriPath) throws IOException { + JSONObject requestJsonObject = jsonUtil.toJSONObject(dto); + JSONObject responseJsonObject= sendRequest(requestJsonObject, uriPath); + return jsonUtil.toDto(responseJsonObject, dtoClass); + } + + public JSONObject sendRequest(JSONObject requestData, String uriPath) throws IOException { + try { + String requestUrl = baseUrl + uriPath; + HttpURLConnection connection = createConnection(tossSecretKey, requestUrl); + + try (OutputStream os = connection.getOutputStream()) { + os.write(requestData.toString().getBytes(StandardCharsets.UTF_8)); + } + + int responseCode = connection.getResponseCode(); + boolean isSuccess = responseCode >= 200 && responseCode < 300; + + InputStream responseStream = isSuccess + ? connection.getInputStream() + : connection.getErrorStream(); + + if (responseStream == null) { + throw new TossPaymentExceptionHandler(TossPaymentErrorCode.TOSS_EMPTY_RESPONSE); + } + + try (Reader reader = new InputStreamReader(responseStream, StandardCharsets.UTF_8)) { + JSONObject response = (JSONObject) new JSONParser().parse(reader); + + if (!isSuccess) { + log.warn( + "토스페이먼츠 요청 실패. status={}, response={}", + responseCode, + response + ); + + throw new TossPaymentExceptionHandler(TossPaymentErrorCode.TOSS_REQUEST_FAILED); + } + + return response; + } + + } catch (TossPaymentExceptionHandler e) { + throw e; + + } catch (ParseException e) { + log.error("토스페이먼츠 응답 파싱 실패", e); + throw new TossPaymentExceptionHandler(TossPaymentErrorCode.TOSS_RESPONSE_PARSE_FAILED); + + } catch (IOException e) { + log.error("토스페이먼츠 통신 실패", e); + throw new TossPaymentExceptionHandler(TossPaymentErrorCode.TOSS_REQUEST_FAILED); + } + } + + private HttpURLConnection createConnection(String secretKey, String urlString) throws IOException, MalformedURLException { + URL url = new URL(urlString); + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + connection.setRequestProperty("Authorization", "Basic " + Base64.getEncoder().encodeToString((secretKey + ":").getBytes(StandardCharsets.UTF_8))); + connection.setRequestProperty("Content-Type", "application/json"); + connection.setRequestMethod("POST"); + + connection.setConnectTimeout(5_000); + connection.setReadTimeout(70_000); + + connection.setDoOutput(true); + return connection; + } + +} diff --git a/src/main/java/net/studioxai/studioxBe/domain/payment/util/JsonUtil.java b/src/main/java/net/studioxai/studioxBe/domain/payment/util/JsonUtil.java new file mode 100644 index 0000000..47a3148 --- /dev/null +++ b/src/main/java/net/studioxai/studioxBe/domain/payment/util/JsonUtil.java @@ -0,0 +1,29 @@ +package net.studioxai.studioxBe.domain.payment.util; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.json.simple.JSONObject; +import org.springframework.stereotype.Component; + +import java.util.Map; + +@Component +@RequiredArgsConstructor +public class JsonUtil { + + private final ObjectMapper objectMapper; + + public JSONObject toJSONObject(Object dto) { + Map map = objectMapper.convertValue( + dto, + new TypeReference>() {} + ); + + return new JSONObject(map); + } + + public T toDto(JSONObject jsonObject, Class dtoClass) { + return objectMapper.convertValue(jsonObject, dtoClass); + } +} diff --git a/src/main/java/net/studioxai/studioxBe/domain/user/dto/response/MypageResponse.java b/src/main/java/net/studioxai/studioxBe/domain/user/dto/response/MypageResponse.java index 03a2ba0..3386169 100644 --- a/src/main/java/net/studioxai/studioxBe/domain/user/dto/response/MypageResponse.java +++ b/src/main/java/net/studioxai/studioxBe/domain/user/dto/response/MypageResponse.java @@ -6,9 +6,10 @@ public record MypageResponse( Long userId, String username, String email, - @ImageUrl String profileImage + @ImageUrl String profileImage, + String customKey ) { - public static MypageResponse create(Long userId, String username, String email, String profileImage) { - return new MypageResponse(userId, username, email, profileImage); + public static MypageResponse create(Long userId, String username, String email, String profileImage, String customKey) { + return new MypageResponse(userId, username, email, profileImage, customKey); } } diff --git a/src/main/java/net/studioxai/studioxBe/domain/user/entity/User.java b/src/main/java/net/studioxai/studioxBe/domain/user/entity/User.java index 2ecd6a5..837aa31 100644 --- a/src/main/java/net/studioxai/studioxBe/domain/user/entity/User.java +++ b/src/main/java/net/studioxai/studioxBe/domain/user/entity/User.java @@ -54,8 +54,12 @@ public class User extends BaseEntity { @Column(name = "email_verified_at", nullable = true) private LocalDateTime emailVerifiedAt; + @Column(name = "customer_key", nullable = false) + private String customerKey; + @Builder(access = AccessLevel.PRIVATE) private User(RegisterPath registerPath, String email, String googleSub, String password, String profileImage, String username, boolean isEmailVerified, LocalDateTime emailVerifiedAt) { + this.customerKey = java.util.UUID.randomUUID().toString(); this.registerPath = registerPath; this.email = email; this.googleSub = googleSub; @@ -90,6 +94,10 @@ public void updatePassword(String encodedPassword) { this.password = encodedPassword; } + public boolean equalsCustomerKey(String customerKey) { + return this.customerKey.equals(customerKey); + } + public static User createGoogleUser(String googleSub, String email, String username, String encodedPassword, String profileImage) { return User.builder() .registerPath(RegisterPath.GOOGLE) @@ -103,4 +111,5 @@ public static User createGoogleUser(String googleSub, String email, String usern .build(); } + } diff --git a/src/main/java/net/studioxai/studioxBe/domain/user/service/UserService.java b/src/main/java/net/studioxai/studioxBe/domain/user/service/UserService.java index 04a6a72..c692a20 100644 --- a/src/main/java/net/studioxai/studioxBe/domain/user/service/UserService.java +++ b/src/main/java/net/studioxai/studioxBe/domain/user/service/UserService.java @@ -37,7 +37,7 @@ public User getUserByEmailOrThrow(String email) { public MypageResponse findUserDetail(Long userId) { User user = getUserByIdOrThrow(userId); - return MypageResponse.create(user.getId(), user.getUsername(), user.getEmail(), user.getProfileImage()); + return MypageResponse.create(user.getId(), user.getUsername(), user.getEmail(), user.getProfileImage(), user.getCustomerKey()); } diff --git a/src/main/java/net/studioxai/studioxBe/global/Initializer/ExchangeRateInitializer.java b/src/main/java/net/studioxai/studioxBe/global/Initializer/ExchangeRateInitializer.java new file mode 100644 index 0000000..f8f6500 --- /dev/null +++ b/src/main/java/net/studioxai/studioxBe/global/Initializer/ExchangeRateInitializer.java @@ -0,0 +1,19 @@ +package net.studioxai.studioxBe.global.Initializer; + +import lombok.RequiredArgsConstructor; +import net.studioxai.studioxBe.domain.payment.service.ExchangeRateService; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class ExchangeRateInitializer { + + private final ExchangeRateService exchangeRateService; + + @EventListener(ApplicationReadyEvent.class) + public void initExchangeRate() { + exchangeRateService.saveRate(); + } +} \ No newline at end of file diff --git a/src/main/java/net/studioxai/studioxBe/global/schedule/BillingScheduler.java b/src/main/java/net/studioxai/studioxBe/global/schedule/BillingScheduler.java new file mode 100644 index 0000000..3744b9f --- /dev/null +++ b/src/main/java/net/studioxai/studioxBe/global/schedule/BillingScheduler.java @@ -0,0 +1,25 @@ +package net.studioxai.studioxBe.global.schedule; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import net.studioxai.studioxBe.domain.payment.service.SubscriptionService; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class BillingScheduler { + private final SubscriptionService subscriptionService; + + @Scheduled(cron = "0 0 3 * * *", zone = "Asia/Seoul") + public void approveDailyBilling() { + log.info("[BillingScheduler] daily billing started"); + + subscriptionService.approveTodaySubscriptions(); + + log.info("[BillingScheduler] daily billing finished"); + } + + // TODO: 결제 실패 건 재시도 작성 +} diff --git a/src/main/java/net/studioxai/studioxBe/global/schedule/ExchangeRateScheduler.java b/src/main/java/net/studioxai/studioxBe/global/schedule/ExchangeRateScheduler.java new file mode 100644 index 0000000..6130718 --- /dev/null +++ b/src/main/java/net/studioxai/studioxBe/global/schedule/ExchangeRateScheduler.java @@ -0,0 +1,18 @@ +package net.studioxai.studioxBe.global.schedule; + +import lombok.RequiredArgsConstructor; +import net.studioxai.studioxBe.domain.payment.service.ExchangeRateService; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class ExchangeRateScheduler { + + private final ExchangeRateService exchangeRateService; + + @Scheduled(cron = "0 0 9 * * *", zone = "Asia/Seoul") + public void saveExchangeRate() { + exchangeRateService.saveRate(); + } +} diff --git a/src/main/java/net/studioxai/studioxBe/global/util/IpUtil.java b/src/main/java/net/studioxai/studioxBe/global/util/IpUtil.java new file mode 100644 index 0000000..0116110 --- /dev/null +++ b/src/main/java/net/studioxai/studioxBe/global/util/IpUtil.java @@ -0,0 +1,34 @@ +package net.studioxai.studioxBe.global.util; + +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +@Component +public class IpUtil { + public String getClientIp(HttpServletRequest request) { + String ip = request.getHeader("X-Forwarded-For"); + + if (StringUtils.hasText(ip) && !"unknown".equalsIgnoreCase(ip)) { + // X-Forwarded-For: client, proxy1, proxy2 + return ip.split(",")[0].trim(); + } + + ip = request.getHeader("X-Real-IP"); + if (StringUtils.hasText(ip) && !"unknown".equalsIgnoreCase(ip)) { + return ip; + } + + ip = request.getHeader("Proxy-Client-IP"); + if (StringUtils.hasText(ip) && !"unknown".equalsIgnoreCase(ip)) { + return ip; + } + + ip = request.getHeader("WL-Proxy-Client-IP"); + if (StringUtils.hasText(ip) && !"unknown".equalsIgnoreCase(ip)) { + return ip; + } + + return request.getRemoteAddr(); + } +} diff --git a/src/test/java/net/studioxai/studioxBe/auth/AuthServiceTest.java b/src/test/java/net/studioxai/studioxBe/auth/AuthServiceTest.java index c75381c..6b2ae51 100644 --- a/src/test/java/net/studioxai/studioxBe/auth/AuthServiceTest.java +++ b/src/test/java/net/studioxai/studioxBe/auth/AuthServiceTest.java @@ -5,6 +5,9 @@ import net.studioxai.studioxBe.domain.auth.dto.response.LoginResponse; import net.studioxai.studioxBe.domain.auth.dto.response.TokenResponse; import net.studioxai.studioxBe.domain.folder.service.FolderService; +import net.studioxai.studioxBe.domain.payment.entity.UserPlan; +import net.studioxai.studioxBe.domain.payment.entity.enums.Plan; +import net.studioxai.studioxBe.domain.payment.repository.UserPlanRepository; import net.studioxai.studioxBe.domain.user.entity.enums.RegisterPath; import net.studioxai.studioxBe.domain.user.entity.User; import net.studioxai.studioxBe.domain.auth.exception.AuthErrorCode; @@ -36,6 +39,9 @@ public class AuthServiceTest { @Mock private UserRepository userRepository; + @Mock + private UserPlanRepository userPlanRepository; + @Mock private PasswordEncoder passwordEncoder; @@ -54,6 +60,9 @@ public class AuthServiceTest { @Captor private ArgumentCaptor userCaptor; + @Captor + private ArgumentCaptor userPlanCaptor; + @Mock private FolderService folderService; @@ -195,6 +204,7 @@ void signUp_success() { String rawPassword = "plain"; String encodedPassword = "Encoded12341234"; Long newUserId = 10L; + Long newUserPlanId = 10L; String accessToken = "new-access"; String refreshToken = "new-refresh"; @@ -211,6 +221,13 @@ void signUp_success() { return saved; }); + BDDMockito.given(userPlanRepository.save(any(UserPlan.class))) + .willAnswer(invocation -> { + UserPlan saved = invocation.getArgument(0); + ReflectionTestUtils.setField(saved, "id", newUserPlanId); + return saved; + }); + BDDMockito.given(jwtProvider.createAccessToken(newUserId)).willReturn(accessToken); BDDMockito.given(jwtProvider.createRefreshToken(newUserId)).willReturn(refreshToken); @@ -239,6 +256,13 @@ void signUp_success() { Mockito.verify(jwtProvider).createAccessToken(newUserId); Mockito.verify(jwtProvider).createRefreshToken(newUserId); Mockito.verify(tokenService).saveRefreshToken(refreshToken, newUserId); + + Mockito.verify(userPlanRepository).save(userPlanCaptor.capture()); + + UserPlan savedUserPlan = userPlanCaptor.getValue(); + + Assertions.assertThat(savedUserPlan.getId()).isEqualTo(newUserPlanId); + Assertions.assertThat(savedUserPlan.getUser()).isEqualTo(savedUser); } @Test diff --git a/src/test/java/net/studioxai/studioxBe/auth/OauthServiceTest.java b/src/test/java/net/studioxai/studioxBe/auth/OauthServiceTest.java index bb1c0b7..d1e4a1c 100644 --- a/src/test/java/net/studioxai/studioxBe/auth/OauthServiceTest.java +++ b/src/test/java/net/studioxai/studioxBe/auth/OauthServiceTest.java @@ -134,7 +134,7 @@ void googleLogin_success_newUser() { given(userRepository.findByEmail("google@test.com")).willReturn(Optional.empty()); given(passwordEncoder.encode(anyString())).willReturn("encoded-password"); - given(userRepository.save(any(User.class))) + given(userRepository.saveAndFlush(any(User.class))) .willAnswer(invocation -> { User saved = invocation.getArgument(0); ReflectionTestUtils.setField(saved, "id", userId); @@ -156,7 +156,7 @@ void googleLogin_success_newUser() { assertThat(result.accessToken()).isEqualTo("access-token"); assertThat(result.refreshToken()).isEqualTo("refresh-token"); - verify(userRepository).save(any(User.class)); + verify(userRepository).saveAndFlush(any(User.class)); verify(passwordEncoder).encode(anyString()); verify(authService).issueTokens(userId); } diff --git a/src/test/java/net/studioxai/studioxBe/payment/BillingKeyServiceTest.java b/src/test/java/net/studioxai/studioxBe/payment/BillingKeyServiceTest.java new file mode 100644 index 0000000..4bda780 --- /dev/null +++ b/src/test/java/net/studioxai/studioxBe/payment/BillingKeyServiceTest.java @@ -0,0 +1,229 @@ +package net.studioxai.studioxBe.payment; + +import net.studioxai.studioxBe.domain.payment.dto.CardDto; +import net.studioxai.studioxBe.domain.payment.dto.TransferDto; +import net.studioxai.studioxBe.domain.payment.dto.request.BillingKeyAuthKeyCreateRequest; +import net.studioxai.studioxBe.domain.payment.dto.request.BillingKeyCardCreateRequest; +import net.studioxai.studioxBe.domain.payment.dto.response.BillingKeyResponse; +import net.studioxai.studioxBe.domain.payment.entity.BillingKey; +import net.studioxai.studioxBe.domain.payment.entity.enums.Plan; +import net.studioxai.studioxBe.domain.payment.exception.BillingKeyExceptionHandler; +import net.studioxai.studioxBe.domain.payment.repository.BillingKeyRepository; +import net.studioxai.studioxBe.domain.payment.service.BillingKeyApprovalService; +import net.studioxai.studioxBe.domain.payment.service.BillingKeyService; +import net.studioxai.studioxBe.domain.payment.service.TossService; +import net.studioxai.studioxBe.domain.payment.util.JsonUtil; +import net.studioxai.studioxBe.domain.user.entity.User; +import net.studioxai.studioxBe.domain.user.service.UserService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.*; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.io.IOException; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class BillingKeyServiceTest { + + @Mock + private UserService userService; + + @Mock + private TossService tossService; + + @Mock + private BillingKeyApprovalService billingKeyApprovalService; + + @Mock + private JsonUtil jsonUtil; + + @Mock + private BillingKeyRepository billingKeyRepository; + + @InjectMocks + private BillingKeyService billingKeyService; + + private static final Long USER_ID = 1L; + private static final String CUSTOMER_KEY = "customer-key-123"; + private static final String CLIENT_IP = "127.0.0.1"; + + @Test + @DisplayName("AuthKey로 빌링키 발급 성공 - 카드 정보 저장 후 결제 승인") + void createBillingKeyWithAuthKey_success_card() throws IOException { + // given + User user = Mockito.mock(User.class); + BillingKeyAuthKeyCreateRequest request = Mockito.mock(BillingKeyAuthKeyCreateRequest.class); + + CardDto card = Mockito.mock(CardDto.class); + given(card.issuerCode()).willReturn("61"); + given(card.acquirerCode()).willReturn("31"); + given(card.number()).willReturn("12345678****1234"); + + BillingKeyResponse response = Mockito.mock(BillingKeyResponse.class); + given(response.billingKey()).willReturn("billing-key-123"); + given(response.method()).willReturn("카드"); + given(response.card()).willReturn(card); + given(response.transfers()).willReturn(null); + + given(request.customerKey()).willReturn(CUSTOMER_KEY); + given(user.equalsCustomerKey(CUSTOMER_KEY)).willReturn(true); + given(userService.getUserByIdOrThrow(USER_ID)).willReturn(user); + + given(tossService.getResponse( + eq(request), + eq(BillingKeyResponse.class), + eq("/v1/billing/authorizations/issue") + )).willReturn(response); + + Plan plan = Plan.values()[0]; + + // when + billingKeyService.createBillingKeyWithAuthKey( + USER_ID, + request, + plan, + CLIENT_IP + ); + + // then + ArgumentCaptor captor = ArgumentCaptor.forClass(BillingKey.class); + BDDMockito.then(billingKeyRepository).should().save(captor.capture()); + + BillingKey savedBillingKey = captor.getValue(); + + assertThat(savedBillingKey.getUser()).isEqualTo(user); + assertThat(savedBillingKey.getBillingKey()).isEqualTo("billing-key-123"); + assertThat(savedBillingKey.getMethod()).isEqualTo("카드"); + assertThat(savedBillingKey.getCardIssueCompany()).isEqualTo("61"); + assertThat(savedBillingKey.getCardAcquirerCompany()).isEqualTo("31"); + assertThat(savedBillingKey.getCardNumber()).isEqualTo("12345678****1234"); + assertThat(savedBillingKey.getBankName()).isNull(); + assertThat(savedBillingKey.getBankAccountNumber()).isNull(); + + BDDMockito.then(billingKeyApprovalService) + .should() + .approveBilling(user, plan, CLIENT_IP); + } + + @Test + @DisplayName("Card 정보로 빌링키 발급 성공 - 계좌이체 정보 저장 후 결제 승인") + void createBillingKeyWithCard_success_transfer() throws IOException { + // given + User user = Mockito.mock(User.class); + BillingKeyCardCreateRequest request = Mockito.mock(BillingKeyCardCreateRequest.class); + + TransferDto transfer = Mockito.mock(TransferDto.class); + given(transfer.bankName()).willReturn("국민은행"); + given(transfer.bankAccountNumber()).willReturn("1234567890"); + + BillingKeyResponse response = Mockito.mock(BillingKeyResponse.class); + given(response.billingKey()).willReturn("billing-key-transfer"); + given(response.method()).willReturn("계좌이체"); + given(response.card()).willReturn(null); + given(response.transfers()).willReturn(List.of(transfer)); + + given(request.customerKey()).willReturn(CUSTOMER_KEY); + given(user.equalsCustomerKey(CUSTOMER_KEY)).willReturn(true); + given(userService.getUserByIdOrThrow(USER_ID)).willReturn(user); + + given(tossService.getResponse( + eq(request), + eq(BillingKeyResponse.class), + eq("/v1/billing/authorizations/card") + )).willReturn(response); + + Plan plan = Plan.values()[0]; + + // when + billingKeyService.createBillingKeyWithCard( + USER_ID, + request, + plan, + CLIENT_IP + ); + + // then + ArgumentCaptor captor = ArgumentCaptor.forClass(BillingKey.class); + BDDMockito.then(billingKeyRepository).should().save(captor.capture()); + + BillingKey savedBillingKey = captor.getValue(); + + assertThat(savedBillingKey.getUser()).isEqualTo(user); + assertThat(savedBillingKey.getBillingKey()).isEqualTo("billing-key-transfer"); + assertThat(savedBillingKey.getMethod()).isEqualTo("계좌이체"); + assertThat(savedBillingKey.getBankName()).isEqualTo("국민은행"); + assertThat(savedBillingKey.getBankAccountNumber()).isEqualTo("1234567890"); + assertThat(savedBillingKey.getCardIssueCompany()).isNull(); + assertThat(savedBillingKey.getCardAcquirerCompany()).isNull(); + assertThat(savedBillingKey.getCardNumber()).isNull(); + + BDDMockito.then(billingKeyApprovalService) + .should() + .approveBilling(user, plan, CLIENT_IP); + } + + @Test + @DisplayName("AuthKey 빌링키 발급 실패 - customerKey가 일치하지 않으면 예외 발생") + void createBillingKeyWithAuthKey_fail_invalidCustomerKey() { + // given + User user = Mockito.mock(User.class); + BillingKeyAuthKeyCreateRequest request = Mockito.mock(BillingKeyAuthKeyCreateRequest.class); + + given(request.customerKey()).willReturn("wrong-customer-key"); + given(user.equalsCustomerKey("wrong-customer-key")).willReturn(false); + given(userService.getUserByIdOrThrow(USER_ID)).willReturn(user); + + Plan plan = Plan.values()[0]; + + // when & then + assertThatThrownBy(() -> + billingKeyService.createBillingKeyWithAuthKey( + USER_ID, + request, + plan, + CLIENT_IP + ) + ).isInstanceOf(BillingKeyExceptionHandler.class); + + BDDMockito.then(tossService).shouldHaveNoInteractions(); + BDDMockito.then(billingKeyRepository).shouldHaveNoInteractions(); + BDDMockito.then(billingKeyApprovalService).shouldHaveNoInteractions(); + } + + @Test + @DisplayName("Card 빌링키 발급 실패 - customerKey가 일치하지 않으면 예외 발생") + void createBillingKeyWithCard_fail_invalidCustomerKey() { + // given + User user = Mockito.mock(User.class); + BillingKeyCardCreateRequest request = Mockito.mock(BillingKeyCardCreateRequest.class); + + given(request.customerKey()).willReturn("wrong-customer-key"); + given(user.equalsCustomerKey("wrong-customer-key")).willReturn(false); + given(userService.getUserByIdOrThrow(USER_ID)).willReturn(user); + + Plan plan = Plan.values()[0]; + + // when & then + assertThatThrownBy(() -> + billingKeyService.createBillingKeyWithCard( + USER_ID, + request, + plan, + CLIENT_IP + ) + ).isInstanceOf(BillingKeyExceptionHandler.class); + + BDDMockito.then(tossService).shouldHaveNoInteractions(); + BDDMockito.then(billingKeyRepository).shouldHaveNoInteractions(); + BDDMockito.then(billingKeyApprovalService).shouldHaveNoInteractions(); + } +} \ No newline at end of file diff --git a/src/test/java/net/studioxai/studioxBe/payment/TossServiceTest.java b/src/test/java/net/studioxai/studioxBe/payment/TossServiceTest.java new file mode 100644 index 0000000..cc1a0b4 --- /dev/null +++ b/src/test/java/net/studioxai/studioxBe/payment/TossServiceTest.java @@ -0,0 +1,217 @@ +package net.studioxai.studioxBe.payment; + +import com.sun.net.httpserver.HttpServer; +import net.studioxai.studioxBe.domain.payment.exception.TossPaymentExceptionHandler; +import net.studioxai.studioxBe.domain.payment.service.TossService; +import net.studioxai.studioxBe.domain.payment.util.JsonUtil; +import org.json.simple.JSONObject; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class TossServiceTest { + + @Mock + private JsonUtil jsonUtil; + + private TossService tossService; + + private HttpServer server; + + private String baseUrl; + + private static final String TOSS_SECRET_KEY = "test_sk_123"; + + @BeforeEach + void setUp() throws IOException { + tossService = new TossService(jsonUtil); + + server = HttpServer.create(new InetSocketAddress(0), 0); + server.start(); + + baseUrl = "http://localhost:" + server.getAddress().getPort(); + + ReflectionTestUtils.setField(tossService, "baseUrl", baseUrl); + ReflectionTestUtils.setField(tossService, "tossSecretKey", TOSS_SECRET_KEY); + } + + @AfterEach + void tearDown() { + server.stop(0); + } + + @Test + @DisplayName("sendRequest 성공 - Toss 응답 JSON을 반환한다") + void sendRequest_success() throws IOException { + // given + AtomicReference authorizationHeader = new AtomicReference<>(); + AtomicReference contentTypeHeader = new AtomicReference<>(); + AtomicReference requestBody = new AtomicReference<>(); + + registerResponse( + "/v1/test", + 200, + "{\"billingKey\":\"billing-key-123\",\"method\":\"카드\"}", + authorizationHeader, + contentTypeHeader, + requestBody + ); + + JSONObject requestData = new JSONObject(); + requestData.put("customerKey", "customer-key"); + + String expectedAuthorization = "Basic " + Base64.getEncoder() + .encodeToString((TOSS_SECRET_KEY + ":").getBytes(StandardCharsets.UTF_8)); + + // when + JSONObject response = tossService.sendRequest(requestData, "/v1/test"); + + // then + assertThat(response.get("billingKey")).isEqualTo("billing-key-123"); + assertThat(response.get("method")).isEqualTo("카드"); + + assertThat(authorizationHeader.get()).isEqualTo(expectedAuthorization); + assertThat(contentTypeHeader.get()).contains("application/json"); + assertThat(requestBody.get()).contains("\"customerKey\":\"customer-key\""); + } + + @Test + @DisplayName("sendRequest 실패 - Toss가 4xx 응답을 반환하면 예외가 발생한다") + void sendRequest_fail_whenTossReturns4xx() { + // given + registerResponse( + "/v1/fail", + 400, + "{\"code\":\"INVALID_REQUEST\",\"message\":\"잘못된 요청입니다.\"}", + new AtomicReference<>(), + new AtomicReference<>(), + new AtomicReference<>() + ); + + JSONObject requestData = new JSONObject(); + requestData.put("customerKey", "customer-key"); + + // when & then + assertThatThrownBy(() -> tossService.sendRequest(requestData, "/v1/fail")) + .isInstanceOf(TossPaymentExceptionHandler.class); + } + + @Test + @DisplayName("sendRequest 실패 - Toss 응답이 JSON 형식이 아니면 예외가 발생한다") + void sendRequest_fail_whenResponseIsInvalidJson() { + // given + registerResponse( + "/v1/invalid-json", + 200, + "this-is-not-json", + new AtomicReference<>(), + new AtomicReference<>(), + new AtomicReference<>() + ); + + JSONObject requestData = new JSONObject(); + requestData.put("customerKey", "customer-key"); + + // when & then + assertThatThrownBy(() -> tossService.sendRequest(requestData, "/v1/invalid-json")) + .isInstanceOf(TossPaymentExceptionHandler.class); + } + + @Test + @DisplayName("getResponse 성공 - DTO를 JSON으로 변환하고 응답 JSON을 DTO로 변환한다") + void getResponse_success() throws IOException { + // given + TestRequest requestDto = new TestRequest("customer-key"); + + JSONObject requestJson = new JSONObject(); + requestJson.put("customerKey", "customer-key"); + + TestResponse expectedResponse = new TestResponse("billing-key-123"); + + given(jsonUtil.toJSONObject(requestDto)) + .willReturn(requestJson); + + given(jsonUtil.toDto(any(JSONObject.class), eq(TestResponse.class))) + .willReturn(expectedResponse); + + registerResponse( + "/v1/get-response-test", + 200, + "{\"billingKey\":\"billing-key-123\"}", + new AtomicReference<>(), + new AtomicReference<>(), + new AtomicReference<>() + ); + + // when + TestResponse result = tossService.getResponse( + requestDto, + TestResponse.class, + "/v1/get-response-test" + ); + + // then + assertThat(result).isEqualTo(expectedResponse); + + verify(jsonUtil).toJSONObject(requestDto); + + ArgumentCaptor responseCaptor = ArgumentCaptor.forClass(JSONObject.class); + verify(jsonUtil).toDto(responseCaptor.capture(), eq(TestResponse.class)); + + JSONObject capturedResponseJson = responseCaptor.getValue(); + assertThat(capturedResponseJson.get("billingKey")).isEqualTo("billing-key-123"); + } + + private void registerResponse( + String path, + int statusCode, + String responseBody, + AtomicReference authorizationHeader, + AtomicReference contentTypeHeader, + AtomicReference requestBody + ) { + server.createContext(path, exchange -> { + authorizationHeader.set(exchange.getRequestHeaders().getFirst("Authorization")); + contentTypeHeader.set(exchange.getRequestHeaders().getFirst("Content-Type")); + + String body = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); + requestBody.set(body); + + byte[] responseBytes = responseBody.getBytes(StandardCharsets.UTF_8); + + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(statusCode, responseBytes.length); + + try (OutputStream os = exchange.getResponseBody()) { + os.write(responseBytes); + } + }); + } + + private record TestRequest(String customerKey) { + } + + private record TestResponse(String billingKey) { + } +} \ No newline at end of file