From 5f1edfe16e82900319af96ec02f50b6e9a2e06a5 Mon Sep 17 00:00:00 2001 From: jiminnimij <124450012+jiminnimij@users.noreply.github.com> Date: Mon, 18 May 2026 01:26:40 +0900 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20=ED=86=A0=EC=8A=A4=20=EB=B9=8C?= =?UTF-8?q?=EB=A7=81=ED=82=A4=20=EB=B0=9C=EA=B8=89=20api=20=EA=B0=9C?= =?UTF-8?q?=EB=B0=9C(card,=20authKey)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .postman/config.json | 12 ++ build.gradle | 2 + .../globals/workspace.postman_globals.json | 7 ++ .../EmailVerificationTokenRepository.java | 2 +- .../domain/auth/service/AuthService.java | 10 +- .../domain/auth/service/OauthService.java | 3 +- .../controller/BillingKeyController.java | 38 ++++++ .../studioxBe/domain/payment/dto/CardDto.java | 10 ++ .../domain/payment/dto/ThreeDsValueDto.java | 21 ++++ .../domain/payment/dto/TransferDto.java | 7 ++ .../BillingKeyAuthKeyCreateRequest.java | 20 ++++ .../request/BillingKeyCardCreateRequest.java | 70 +++++++++++ .../dto/response/BillingKeyResponse.java | 26 +++++ .../domain/payment/entity/BillingKey.java | 95 +++++++++++++++ .../domain/payment/entity/PaymentHistory.java | 71 +++++++++++ .../domain/payment/entity/Subscription.java | 93 +++++++++++++++ .../domain/payment/entity/UserPlan.java | 55 +++++++++ .../payment/entity/enums/PaymentStatus.java | 8 ++ .../domain/payment/entity/enums/Plan.java | 38 ++++++ .../entity/enums/SubscriptionStatus.java | 8 ++ .../exception/BillingKeyErrorCode.java | 25 ++++ .../exception/BillingKeyExceptionHandler.java | 8 ++ .../exception/TossPaymentErrorCode.java | 31 +++++ .../TossPaymentExceptionHandler.java | 10 ++ .../repository/BillingKeyRepository.java | 10 ++ .../repository/UserPlanRepository.java | 9 ++ .../payment/service/BillingKeyService.java | 97 +++++++++++++++ .../domain/payment/service/TossService.java | 110 ++++++++++++++++++ .../domain/payment/util/JsonUtil.java | 29 +++++ .../user/dto/response/MypageResponse.java | 5 +- .../studioxBe/domain/user/entity/User.java | 9 ++ .../domain/user/service/UserService.java | 2 +- .../studioxBe/auth/AuthServiceTest.java | 24 ++++ 33 files changed, 959 insertions(+), 6 deletions(-) create mode 100644 .postman/config.json create mode 100644 postman/globals/workspace.postman_globals.json create mode 100644 src/main/java/net/studioxai/studioxBe/domain/payment/controller/BillingKeyController.java create mode 100644 src/main/java/net/studioxai/studioxBe/domain/payment/dto/CardDto.java create mode 100644 src/main/java/net/studioxai/studioxBe/domain/payment/dto/ThreeDsValueDto.java create mode 100644 src/main/java/net/studioxai/studioxBe/domain/payment/dto/TransferDto.java create mode 100644 src/main/java/net/studioxai/studioxBe/domain/payment/dto/request/BillingKeyAuthKeyCreateRequest.java create mode 100644 src/main/java/net/studioxai/studioxBe/domain/payment/dto/request/BillingKeyCardCreateRequest.java create mode 100644 src/main/java/net/studioxai/studioxBe/domain/payment/dto/response/BillingKeyResponse.java create mode 100644 src/main/java/net/studioxai/studioxBe/domain/payment/entity/BillingKey.java create mode 100644 src/main/java/net/studioxai/studioxBe/domain/payment/entity/PaymentHistory.java create mode 100644 src/main/java/net/studioxai/studioxBe/domain/payment/entity/Subscription.java create mode 100644 src/main/java/net/studioxai/studioxBe/domain/payment/entity/UserPlan.java create mode 100644 src/main/java/net/studioxai/studioxBe/domain/payment/entity/enums/PaymentStatus.java create mode 100644 src/main/java/net/studioxai/studioxBe/domain/payment/entity/enums/Plan.java create mode 100644 src/main/java/net/studioxai/studioxBe/domain/payment/entity/enums/SubscriptionStatus.java create mode 100644 src/main/java/net/studioxai/studioxBe/domain/payment/exception/BillingKeyErrorCode.java create mode 100644 src/main/java/net/studioxai/studioxBe/domain/payment/exception/BillingKeyExceptionHandler.java create mode 100644 src/main/java/net/studioxai/studioxBe/domain/payment/exception/TossPaymentErrorCode.java create mode 100644 src/main/java/net/studioxai/studioxBe/domain/payment/exception/TossPaymentExceptionHandler.java create mode 100644 src/main/java/net/studioxai/studioxBe/domain/payment/repository/BillingKeyRepository.java create mode 100644 src/main/java/net/studioxai/studioxBe/domain/payment/repository/UserPlanRepository.java create mode 100644 src/main/java/net/studioxai/studioxBe/domain/payment/service/BillingKeyService.java create mode 100644 src/main/java/net/studioxai/studioxBe/domain/payment/service/TossService.java create mode 100644 src/main/java/net/studioxai/studioxBe/domain/payment/util/JsonUtil.java diff --git a/.postman/config.json b/.postman/config.json new file mode 100644 index 0000000..11f1548 --- /dev/null +++ b/.postman/config.json @@ -0,0 +1,12 @@ +{ + "workspace": { + "id": "872315f5-c9cf-45b5-b036-4d8f4744f8c7" + }, + "entities": { + "collections": [], + "environments": [], + "specs": [], + "flows": [], + "globals": [] + } +} \ No newline at end of file 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/postman/globals/workspace.postman_globals.json b/postman/globals/workspace.postman_globals.json new file mode 100644 index 0000000..d729011 --- /dev/null +++ b/postman/globals/workspace.postman_globals.json @@ -0,0 +1,7 @@ +{ + "id": "3b0c99c4-c814-4d64-ad11-2852237e58ac", + "name": "Globals", + "values": [], + "_postman_variable_scope": "globals", + "_postman_exported_at": "2025-11-17T03:00:19.810Z" +} \ No newline at end of file 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..79b99f3 --- /dev/null +++ b/src/main/java/net/studioxai/studioxBe/domain/payment/controller/BillingKeyController.java @@ -0,0 +1,38 @@ +package net.studioxai.studioxBe.domain.payment.controller; + +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.service.BillingKeyService; +import net.studioxai.studioxBe.global.jwt.JwtUserPrincipal; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.io.IOException; + +@RestController +@RequestMapping("/api") +@RequiredArgsConstructor +public class BillingKeyController { + BillingKeyService billingKeyService; + + @PostMapping("/v1/payment/billingKey/authKey") + public void createBillingKeyAuthKey( + @AuthenticationPrincipal JwtUserPrincipal principal, + @RequestBody BillingKeyAuthKeyCreateRequest billingKeyCreateRequest + ) throws IOException { + billingKeyService.createBillingKeyWithAuthKey(principal.userId(), billingKeyCreateRequest); + } + + @PostMapping("/v1/payment/billingKey/card") + public void createBillingKeyCard( + @AuthenticationPrincipal JwtUserPrincipal principal, + @RequestBody BillingKeyCardCreateRequest billingKeyCreateRequest + ) throws IOException { + billingKeyService.createBillingKeyWithCard(principal.userId(), billingKeyCreateRequest); + } + +} 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/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/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/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/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..77f890c --- /dev/null +++ b/src/main/java/net/studioxai/studioxBe/domain/payment/entity/PaymentHistory.java @@ -0,0 +1,71 @@ +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; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "subscription_id") + private Subscription subscription; + + @Enumerated(EnumType.STRING) + private Plan plan; + + private String orderId; + + private String paymentKey; + + private int amount; + + @Enumerated(EnumType.STRING) + private PaymentStatus status; + + private String failureCode; + + private String failureMessage; + + private LocalDateTime paidAt; + + @Builder(access = AccessLevel.PRIVATE) + private PaymentHistory(User user, Subscription subscription, Plan plan, String orderId, String paymentKey, int amout) { + this.user = user; + this.subscription = subscription; + this.plan = plan; + this.orderId = orderId; + this.amount = amout; + this.paymentKey = paymentKey; + 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(); + } +} 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..15d2906 --- /dev/null +++ b/src/main/java/net/studioxai/studioxBe/domain/payment/entity/Subscription.java @@ -0,0 +1,93 @@ +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; + + 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; + } + +} 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..1a01519 --- /dev/null +++ b/src/main/java/net/studioxai/studioxBe/domain/payment/entity/UserPlan.java @@ -0,0 +1,55 @@ +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 = plan.getCredit(); + this.storage = 0; + this.reference = 0; + this.teamSize = plan.getTeamSize(); + } + +} 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/exception/BillingKeyErrorCode.java b/src/main/java/net/studioxai/studioxBe/domain/payment/exception/BillingKeyErrorCode.java new file mode 100644 index 0000000..93b78f6 --- /dev/null +++ b/src/main/java/net/studioxai/studioxBe/domain/payment/exception/BillingKeyErrorCode.java @@ -0,0 +1,25 @@ +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", "커스텀 키가 일치하지 않습니다."); + + + 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/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/repository/BillingKeyRepository.java b/src/main/java/net/studioxai/studioxBe/domain/payment/repository/BillingKeyRepository.java new file mode 100644 index 0000000..ba8ca72 --- /dev/null +++ b/src/main/java/net/studioxai/studioxBe/domain/payment/repository/BillingKeyRepository.java @@ -0,0 +1,10 @@ +package net.studioxai.studioxBe.domain.payment.repository; + +import net.studioxai.studioxBe.domain.payment.entity.BillingKey; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface BillingKeyRepository extends JpaRepository { + +} 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..896f423 --- /dev/null +++ b/src/main/java/net/studioxai/studioxBe/domain/payment/repository/UserPlanRepository.java @@ -0,0 +1,9 @@ +package net.studioxai.studioxBe.domain.payment.repository; + +import net.studioxai.studioxBe.domain.payment.entity.UserPlan; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface UserPlanRepository extends JpaRepository { +} 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..8bda79e --- /dev/null +++ b/src/main/java/net/studioxai/studioxBe/domain/payment/service/BillingKeyService.java @@ -0,0 +1,97 @@ +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.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 JsonUtil jsonUtil; + + private final BillingKeyRepository billingKeyRepository; + + @Transactional + public void createBillingKeyWithAuthKey( + Long userId, + BillingKeyAuthKeyCreateRequest billingKeyAuthKeyCreateRequest + ) 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); + } + + @Transactional + public void createBillingKeyWithCard( + Long userId, + BillingKeyCardCreateRequest billingKeyCardCreateRequest + ) 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); + + } + + 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/TossService.java b/src/main/java/net/studioxai/studioxBe/domain/payment/service/TossService.java new file mode 100644 index 0000000..e881562 --- /dev/null +++ b/src/main/java/net/studioxai/studioxBe/domain/payment/service/TossService.java @@ -0,0 +1,110 @@ +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-id}") + private String tossClientId; + + 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.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..219c925 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 @@ -5,10 +5,11 @@ public record MypageResponse( Long userId, String username, + String customKey, String email, @ImageUrl String profileImage ) { - 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/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 From b4eec39d912a3cae194da52c456251447bf0383f Mon Sep 17 00:00:00 2001 From: jiminnimij <124450012+jiminnimij@users.noreply.github.com> Date: Mon, 18 May 2026 23:36:50 +0900 Subject: [PATCH 2/7] =?UTF-8?q?chore:=20=EB=B6=88=ED=95=84=EC=9A=94=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .postman/config.json | 12 ------------ postman/globals/workspace.postman_globals.json | 7 ------- 2 files changed, 19 deletions(-) delete mode 100644 .postman/config.json delete mode 100644 postman/globals/workspace.postman_globals.json diff --git a/.postman/config.json b/.postman/config.json deleted file mode 100644 index 11f1548..0000000 --- a/.postman/config.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "workspace": { - "id": "872315f5-c9cf-45b5-b036-4d8f4744f8c7" - }, - "entities": { - "collections": [], - "environments": [], - "specs": [], - "flows": [], - "globals": [] - } -} \ No newline at end of file diff --git a/postman/globals/workspace.postman_globals.json b/postman/globals/workspace.postman_globals.json deleted file mode 100644 index d729011..0000000 --- a/postman/globals/workspace.postman_globals.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "id": "3b0c99c4-c814-4d64-ad11-2852237e58ac", - "name": "Globals", - "values": [], - "_postman_variable_scope": "globals", - "_postman_exported_at": "2025-11-17T03:00:19.810Z" -} \ No newline at end of file From 08581f1601bbb590ac180ddcafd9186d2551f3e7 Mon Sep 17 00:00:00 2001 From: jiminnimij <124450012+jiminnimij@users.noreply.github.com> Date: Tue, 19 May 2026 15:57:21 +0900 Subject: [PATCH 3/7] =?UTF-8?q?test:=20=EB=B9=8C=EB=A7=81=ED=82=A4=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/BillingKeyController.java | 2 +- .../studioxBe/auth/OauthServiceTest.java | 4 +- .../payment/BillingKeyServiceTest.java | 228 ++++++++++++++++++ .../studioxBe/payment/TossServiceTest.java | 217 +++++++++++++++++ 4 files changed, 448 insertions(+), 3 deletions(-) create mode 100644 src/test/java/net/studioxai/studioxBe/payment/BillingKeyServiceTest.java create mode 100644 src/test/java/net/studioxai/studioxBe/payment/TossServiceTest.java 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 index 79b99f3..6c1f92a 100644 --- a/src/main/java/net/studioxai/studioxBe/domain/payment/controller/BillingKeyController.java +++ b/src/main/java/net/studioxai/studioxBe/domain/payment/controller/BillingKeyController.java @@ -17,7 +17,7 @@ @RequestMapping("/api") @RequiredArgsConstructor public class BillingKeyController { - BillingKeyService billingKeyService; + private final BillingKeyService billingKeyService; @PostMapping("/v1/payment/billingKey/authKey") public void createBillingKeyAuthKey( 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..522739d --- /dev/null +++ b/src/test/java/net/studioxai/studioxBe/payment/BillingKeyServiceTest.java @@ -0,0 +1,228 @@ +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.exception.BillingKeyExceptionHandler; +import net.studioxai.studioxBe.domain.payment.repository.BillingKeyRepository; +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.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.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 JsonUtil jsonUtil; + + @Mock + private BillingKeyRepository billingKeyRepository; + + @InjectMocks + private BillingKeyService billingKeyService; + + @Mock + private User user; + + @Test + @DisplayName("authKey로 빌링키 발급 성공 - 카드 정보 저장") + void createBillingKeyWithAuthKey_success_card() throws IOException { + // given + Long userId = 1L; + String customerKey = "customer-key"; + String billingKeyValue = "billing-key-123"; + + BillingKeyAuthKeyCreateRequest request = Mockito.mock(BillingKeyAuthKeyCreateRequest.class); + BillingKeyResponse response = Mockito.mock(BillingKeyResponse.class); + CardDto card = Mockito.mock(CardDto.class); + + given(request.customerKey()).willReturn(customerKey); + + given(userService.getUserByIdOrThrow(userId)) + .willReturn(user); + + given(user.equalsCustomerKey(customerKey)) + .willReturn(true); + + given(tossService.getResponse( + eq(request), + eq(BillingKeyResponse.class), + eq("/v1/billing/authorizations/issue") + )).willReturn(response); + + given(response.billingKey()).willReturn(billingKeyValue); + given(response.method()).willReturn("카드"); + given(response.card()).willReturn(card); + given(response.transfers()).willReturn(null); + + given(card.issuerCode()).willReturn("61"); + given(card.acquirerCode()).willReturn("31"); + given(card.number()).willReturn("123456******7890"); + + ArgumentCaptor billingKeyCaptor = + ArgumentCaptor.forClass(BillingKey.class); + + // when + billingKeyService.createBillingKeyWithAuthKey(userId, request); + + // then + verify(billingKeyRepository).save(billingKeyCaptor.capture()); + + BillingKey savedBillingKey = billingKeyCaptor.getValue(); + + assertThat(savedBillingKey.getUser()).isEqualTo(user); + assertThat(savedBillingKey.getBillingKey()).isEqualTo(billingKeyValue); + assertThat(savedBillingKey.getMethod()).isEqualTo("카드"); + + assertThat(savedBillingKey.getCardIssueCompany()).isEqualTo("61"); + assertThat(savedBillingKey.getCardAcquirerCompany()).isEqualTo("31"); + assertThat(savedBillingKey.getCardNumber()).isEqualTo("123456******7890"); + + assertThat(savedBillingKey.getBankName()).isNull(); + assertThat(savedBillingKey.getBankAccountNumber()).isNull(); + } + + @Test + @DisplayName("카드 정보로 빌링키 발급 성공 - 계좌이체 정보 저장") + void createBillingKeyWithCard_success_transfer() throws IOException { + // given + Long userId = 1L; + String customerKey = "customer-key"; + String billingKeyValue = "billing-key-456"; + + BillingKeyCardCreateRequest request = Mockito.mock(BillingKeyCardCreateRequest.class); + BillingKeyResponse response = Mockito.mock(BillingKeyResponse.class); + TransferDto transfer = Mockito.mock(TransferDto.class); + + given(request.customerKey()).willReturn(customerKey); + + given(userService.getUserByIdOrThrow(userId)) + .willReturn(user); + + given(user.equalsCustomerKey(customerKey)) + .willReturn(true); + + given(tossService.getResponse( + eq(request), + eq(BillingKeyResponse.class), + eq("/v1/billing/authorizations/card") + )).willReturn(response); + + given(response.billingKey()).willReturn(billingKeyValue); + given(response.method()).willReturn("계좌이체"); + given(response.card()).willReturn(null); + given(response.transfers()).willReturn(List.of(transfer)); + + given(transfer.bankName()).willReturn("신한은행"); + given(transfer.bankAccountNumber()).willReturn("110123456789"); + + ArgumentCaptor billingKeyCaptor = + ArgumentCaptor.forClass(BillingKey.class); + + // when + billingKeyService.createBillingKeyWithCard(userId, request); + + // then + verify(billingKeyRepository).save(billingKeyCaptor.capture()); + + BillingKey savedBillingKey = billingKeyCaptor.getValue(); + + assertThat(savedBillingKey.getUser()).isEqualTo(user); + assertThat(savedBillingKey.getBillingKey()).isEqualTo(billingKeyValue); + assertThat(savedBillingKey.getMethod()).isEqualTo("계좌이체"); + + assertThat(savedBillingKey.getBankName()).isEqualTo("신한은행"); + assertThat(savedBillingKey.getBankAccountNumber()).isEqualTo("110123456789"); + + assertThat(savedBillingKey.getCardIssueCompany()).isNull(); + assertThat(savedBillingKey.getCardAcquirerCompany()).isNull(); + assertThat(savedBillingKey.getCardNumber()).isNull(); + } + + @Test + @DisplayName("authKey 빌링키 발급 실패 - customerKey 불일치") + void createBillingKeyWithAuthKey_fail_invalidCustomerKey() throws IOException { + // given + Long userId = 1L; + String requestCustomerKey = "wrong-customer-key"; + + BillingKeyAuthKeyCreateRequest request = Mockito.mock(BillingKeyAuthKeyCreateRequest.class); + + given(request.customerKey()).willReturn(requestCustomerKey); + + given(userService.getUserByIdOrThrow(userId)) + .willReturn(user); + + given(user.equalsCustomerKey(requestCustomerKey)) + .willReturn(false); + + // when & then + assertThatThrownBy(() -> + billingKeyService.createBillingKeyWithAuthKey(userId, request) + ).isInstanceOf(BillingKeyExceptionHandler.class); + + verify(tossService, never()) + .getResponse(any(), any(), anyString()); + + verify(billingKeyRepository, never()) + .save(any(BillingKey.class)); + } + + @Test + @DisplayName("카드 빌링키 발급 실패 - customerKey 불일치") + void createBillingKeyWithCard_fail_invalidCustomerKey() throws IOException { + // given + Long userId = 1L; + String requestCustomerKey = "wrong-customer-key"; + + BillingKeyCardCreateRequest request = Mockito.mock(BillingKeyCardCreateRequest.class); + + given(request.customerKey()).willReturn(requestCustomerKey); + + given(userService.getUserByIdOrThrow(userId)) + .willReturn(user); + + given(user.equalsCustomerKey(requestCustomerKey)) + .willReturn(false); + + // when & then + assertThatThrownBy(() -> + billingKeyService.createBillingKeyWithCard(userId, request) + ).isInstanceOf(BillingKeyExceptionHandler.class); + + verify(tossService, never()) + .getResponse(any(), any(), anyString()); + + verify(billingKeyRepository, never()) + .save(any(BillingKey.class)); + } +} \ 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 From 33b4e9348a224e9f867a94e1c094fcdc4340926b Mon Sep 17 00:00:00 2001 From: jiminnimij <124450012+jiminnimij@users.noreply.github.com> Date: Tue, 19 May 2026 15:57:42 +0900 Subject: [PATCH 4/7] =?UTF-8?q?fix:=20=EB=B9=8C=EB=A7=81=ED=82=A4=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=EB=B3=80=EC=88=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../studioxBe/domain/payment/service/TossService.java | 4 ++-- .../studioxBe/domain/user/dto/response/MypageResponse.java | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) 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 index e881562..7035023 100644 --- a/src/main/java/net/studioxai/studioxBe/domain/payment/service/TossService.java +++ b/src/main/java/net/studioxai/studioxBe/domain/payment/service/TossService.java @@ -37,8 +37,8 @@ public class TossService { @Value("${toss.pay.secure-key}") private String tossSecureKey; - @Value("${toss.pay.client-id}") - private String tossClientId; + @Value("${toss.pay.client-key}") + private String tossClientKey; private final JsonUtil jsonUtil; 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 219c925..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 @@ -5,9 +5,9 @@ public record MypageResponse( Long userId, String username, - String customKey, String email, - @ImageUrl String profileImage + @ImageUrl String profileImage, + String customKey ) { public static MypageResponse create(Long userId, String username, String email, String profileImage, String customKey) { return new MypageResponse(userId, username, email, profileImage, customKey); From 1de9e0e3c4627000e3513039b84b71e815873cf5 Mon Sep 17 00:00:00 2001 From: jiminnimij <124450012+jiminnimij@users.noreply.github.com> Date: Tue, 19 May 2026 15:58:06 +0900 Subject: [PATCH 5/7] =?UTF-8?q?feat:=20=ED=99=98=EC=9C=A8=20=EC=8A=A4?= =?UTF-8?q?=EC=BC=80=EC=A4=84=EB=A7=81=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/response/ExchangeRateResponse.java | 23 ++++++ .../payment/entity/redis/ExchangeRate.java | 49 +++++++++++ .../exception/ExchangeRateErrorCode.java | 32 ++++++++ .../ExchangeRateExceptionHandler.java | 10 +++ .../repository/ExchangeRateRepository.java | 7 ++ .../payment/service/ExchangeRateService.java | 81 +++++++++++++++++++ .../Initializer/ExchangeRateInitializer.java | 19 +++++ .../schedule/ExchangeRateScheduler.java | 18 +++++ 8 files changed, 239 insertions(+) create mode 100644 src/main/java/net/studioxai/studioxBe/domain/payment/dto/response/ExchangeRateResponse.java create mode 100644 src/main/java/net/studioxai/studioxBe/domain/payment/entity/redis/ExchangeRate.java create mode 100644 src/main/java/net/studioxai/studioxBe/domain/payment/exception/ExchangeRateErrorCode.java create mode 100644 src/main/java/net/studioxai/studioxBe/domain/payment/exception/ExchangeRateExceptionHandler.java create mode 100644 src/main/java/net/studioxai/studioxBe/domain/payment/repository/ExchangeRateRepository.java create mode 100644 src/main/java/net/studioxai/studioxBe/domain/payment/service/ExchangeRateService.java create mode 100644 src/main/java/net/studioxai/studioxBe/global/Initializer/ExchangeRateInitializer.java create mode 100644 src/main/java/net/studioxai/studioxBe/global/schedule/ExchangeRateScheduler.java 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/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/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/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/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/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/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(); + } +} From 08d3596110577b95da9a03581540b2df0ef63d79 Mon Sep 17 00:00:00 2001 From: jiminnimij <124450012+jiminnimij@users.noreply.github.com> Date: Wed, 20 May 2026 11:52:05 +0900 Subject: [PATCH 6/7] =?UTF-8?q?feat:=20=EB=B9=8C=EB=A7=81=20=EC=8A=B9?= =?UTF-8?q?=EC=9D=B8=20=EB=B0=8F=20=EC=8A=A4=EC=BC=80=EC=A4=84=EB=A7=81=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/BillingKeyController.java | 23 +- .../domain/payment/dto/FailureDto.java | 7 + .../dto/request/BillingApprovalRequest.java | 51 +++++ .../dto/response/BillingApprovalResponse.java | 35 +++ .../domain/payment/entity/PaymentHistory.java | 54 +++-- .../domain/payment/entity/Subscription.java | 8 + .../exception/BillingKeyErrorCode.java | 4 +- .../exception/SubscriptionErrorCode.java | 22 ++ .../SubscriptionExceptionHandler.java | 10 + .../repository/BillingKeyRepository.java | 6 +- .../repository/PaymentHistoryRepository.java | 9 + .../repository/SubscriptionRepository.java | 27 +++ .../service/BillingKeyApprovalService.java | 128 +++++++++++ .../payment/service/BillingKeyService.java | 14 +- .../payment/service/SubscriptionService.java | 56 +++++ .../domain/payment/service/TossService.java | 4 + .../global/schedule/BillingScheduler.java | 23 ++ .../studioxBe/global/util/IpUtil.java | 34 +++ .../payment/BillingKeyServiceTest.java | 201 +++++++++--------- 19 files changed, 591 insertions(+), 125 deletions(-) create mode 100644 src/main/java/net/studioxai/studioxBe/domain/payment/dto/FailureDto.java create mode 100644 src/main/java/net/studioxai/studioxBe/domain/payment/dto/request/BillingApprovalRequest.java create mode 100644 src/main/java/net/studioxai/studioxBe/domain/payment/dto/response/BillingApprovalResponse.java create mode 100644 src/main/java/net/studioxai/studioxBe/domain/payment/exception/SubscriptionErrorCode.java create mode 100644 src/main/java/net/studioxai/studioxBe/domain/payment/exception/SubscriptionExceptionHandler.java create mode 100644 src/main/java/net/studioxai/studioxBe/domain/payment/repository/PaymentHistoryRepository.java create mode 100644 src/main/java/net/studioxai/studioxBe/domain/payment/repository/SubscriptionRepository.java create mode 100644 src/main/java/net/studioxai/studioxBe/domain/payment/service/BillingKeyApprovalService.java create mode 100644 src/main/java/net/studioxai/studioxBe/domain/payment/service/SubscriptionService.java create mode 100644 src/main/java/net/studioxai/studioxBe/global/schedule/BillingScheduler.java create mode 100644 src/main/java/net/studioxai/studioxBe/global/util/IpUtil.java 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 index 6c1f92a..1a8bcff 100644 --- a/src/main/java/net/studioxai/studioxBe/domain/payment/controller/BillingKeyController.java +++ b/src/main/java/net/studioxai/studioxBe/domain/payment/controller/BillingKeyController.java @@ -1,15 +1,15 @@ 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.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; import java.io.IOException; @@ -17,22 +17,29 @@ @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, - @RequestBody BillingKeyAuthKeyCreateRequest billingKeyCreateRequest + @RequestParam Plan plan, + @RequestBody BillingKeyAuthKeyCreateRequest billingKeyCreateRequest, + HttpServletRequest request ) throws IOException { - billingKeyService.createBillingKeyWithAuthKey(principal.userId(), billingKeyCreateRequest); + String clientIp = ipUtil.getClientIp(request); + billingKeyService.createBillingKeyWithAuthKey(principal.userId(), billingKeyCreateRequest, plan, clientIp); } @PostMapping("/v1/payment/billingKey/card") public void createBillingKeyCard( @AuthenticationPrincipal JwtUserPrincipal principal, - @RequestBody BillingKeyCardCreateRequest billingKeyCreateRequest + @RequestParam Plan plan, + @RequestBody BillingKeyCardCreateRequest billingKeyCreateRequest, + HttpServletRequest request ) throws IOException { - billingKeyService.createBillingKeyWithCard(principal.userId(), billingKeyCreateRequest); + String clientIp = ipUtil.getClientIp(request); + billingKeyService.createBillingKeyWithCard(principal.userId(), billingKeyCreateRequest, plan, clientIp); } } 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/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/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/entity/PaymentHistory.java b/src/main/java/net/studioxai/studioxBe/domain/payment/entity/PaymentHistory.java index 77f890c..0651681 100644 --- a/src/main/java/net/studioxai/studioxBe/domain/payment/entity/PaymentHistory.java +++ b/src/main/java/net/studioxai/studioxBe/domain/payment/entity/PaymentHistory.java @@ -25,36 +25,40 @@ public class PaymentHistory extends BaseEntity { @JoinColumn(name = "user_id") private User user; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "subscription_id") - private Subscription subscription; - - @Enumerated(EnumType.STRING) - private Plan plan; - private String orderId; private String paymentKey; - private int amount; + 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, Subscription subscription, Plan plan, String orderId, String paymentKey, int amout) { + private PaymentHistory(User user, String orderId, int amount) { this.user = user; - this.subscription = subscription; - this.plan = plan; this.orderId = orderId; - this.amount = amout; - this.paymentKey = paymentKey; + this.amount = amount; this.status = PaymentStatus.READY; this.paidAt = LocalDateTime.now(); } @@ -68,4 +72,28 @@ 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 index 15d2906..bc26732 100644 --- a/src/main/java/net/studioxai/studioxBe/domain/payment/entity/Subscription.java +++ b/src/main/java/net/studioxai/studioxBe/domain/payment/entity/Subscription.java @@ -90,4 +90,12 @@ 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/exception/BillingKeyErrorCode.java b/src/main/java/net/studioxai/studioxBe/domain/payment/exception/BillingKeyErrorCode.java index 93b78f6..9d3841d 100644 --- a/src/main/java/net/studioxai/studioxBe/domain/payment/exception/BillingKeyErrorCode.java +++ b/src/main/java/net/studioxai/studioxBe/domain/payment/exception/BillingKeyErrorCode.java @@ -10,8 +10,10 @@ @RequiredArgsConstructor public enum BillingKeyErrorCode implements BaseErrorCode { // 400 Bad Request - INVALID_CUSTOM_KEY(HttpStatus.BAD_REQUEST, "BILLING_KEY_400_1", "커스텀 키가 일치하지 않습니다."); + 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; 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/repository/BillingKeyRepository.java b/src/main/java/net/studioxai/studioxBe/domain/payment/repository/BillingKeyRepository.java index ba8ca72..0760ea3 100644 --- a/src/main/java/net/studioxai/studioxBe/domain/payment/repository/BillingKeyRepository.java +++ b/src/main/java/net/studioxai/studioxBe/domain/payment/repository/BillingKeyRepository.java @@ -1,10 +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/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/service/BillingKeyApprovalService.java b/src/main/java/net/studioxai/studioxBe/domain/payment/service/BillingKeyApprovalService.java new file mode 100644 index 0000000..edb8814 --- /dev/null +++ b/src/main/java/net/studioxai/studioxBe/domain/payment/service/BillingKeyApprovalService.java @@ -0,0 +1,128 @@ +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.enums.PaymentStatus; +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.exception.SubscriptionErrorCode; +import net.studioxai.studioxBe.domain.payment.exception.SubscriptionExceptionHandler; +import net.studioxai.studioxBe.domain.payment.repository.BillingKeyRepository; +import net.studioxai.studioxBe.domain.payment.repository.ExchangeRateRepository; +import net.studioxai.studioxBe.domain.payment.repository.PaymentHistoryRepository; +import net.studioxai.studioxBe.domain.payment.repository.SubscriptionRepository; +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; + + @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) + ); + + 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); + } + + 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(); + + 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(); + } + } + + + + +} 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 index 8bda79e..a85253d 100644 --- a/src/main/java/net/studioxai/studioxBe/domain/payment/service/BillingKeyService.java +++ b/src/main/java/net/studioxai/studioxBe/domain/payment/service/BillingKeyService.java @@ -8,6 +8,7 @@ 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; @@ -29,6 +30,7 @@ public class BillingKeyService { private final UserService userService; private final TossService tossService; + private final BillingKeyApprovalService billingKeyApprovalService; private final JsonUtil jsonUtil; @@ -37,7 +39,9 @@ public class BillingKeyService { @Transactional public void createBillingKeyWithAuthKey( Long userId, - BillingKeyAuthKeyCreateRequest billingKeyAuthKeyCreateRequest + BillingKeyAuthKeyCreateRequest billingKeyAuthKeyCreateRequest, + Plan plan, + String clientIp ) throws IOException { User user = userService.getUserByIdOrThrow(userId); @@ -49,12 +53,16 @@ public void createBillingKeyWithAuthKey( BillingKey billingKey = toEntity(user, response); saveBillingKey(billingKey); + + billingKeyApprovalService.approveBilling(user, plan, clientIp); } @Transactional public void createBillingKeyWithCard( Long userId, - BillingKeyCardCreateRequest billingKeyCardCreateRequest + BillingKeyCardCreateRequest billingKeyCardCreateRequest, + Plan plan, + String clientIp ) throws IOException { User user = userService.getUserByIdOrThrow(userId); @@ -67,6 +75,8 @@ public void createBillingKeyWithCard( BillingKey billingKey = toEntity(user, response); saveBillingKey(billingKey); + billingKeyApprovalService.approveBilling(user, plan, clientIp); + } private void saveBillingKey(BillingKey billingKey) { 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 index 7035023..96bc71a 100644 --- a/src/main/java/net/studioxai/studioxBe/domain/payment/service/TossService.java +++ b/src/main/java/net/studioxai/studioxBe/domain/payment/service/TossService.java @@ -103,6 +103,10 @@ private HttpURLConnection createConnection(String secretKey, String urlString) t 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/global/schedule/BillingScheduler.java b/src/main/java/net/studioxai/studioxBe/global/schedule/BillingScheduler.java new file mode 100644 index 0000000..3427f7f --- /dev/null +++ b/src/main/java/net/studioxai/studioxBe/global/schedule/BillingScheduler.java @@ -0,0 +1,23 @@ +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"); + } +} 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/payment/BillingKeyServiceTest.java b/src/test/java/net/studioxai/studioxBe/payment/BillingKeyServiceTest.java index 522739d..4bda780 100644 --- a/src/test/java/net/studioxai/studioxBe/payment/BillingKeyServiceTest.java +++ b/src/test/java/net/studioxai/studioxBe/payment/BillingKeyServiceTest.java @@ -6,8 +6,10 @@ 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; @@ -16,10 +18,7 @@ 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.InjectMocks; -import org.mockito.Mock; -import org.mockito.Mockito; +import org.mockito.*; import org.mockito.junit.jupiter.MockitoExtension; import java.io.IOException; @@ -41,6 +40,9 @@ class BillingKeyServiceTest { @Mock private TossService tossService; + @Mock + private BillingKeyApprovalService billingKeyApprovalService; + @Mock private JsonUtil jsonUtil; @@ -50,28 +52,31 @@ class BillingKeyServiceTest { @InjectMocks private BillingKeyService billingKeyService; - @Mock - private User user; + 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로 빌링키 발급 성공 - 카드 정보 저장") + @DisplayName("AuthKey로 빌링키 발급 성공 - 카드 정보 저장 후 결제 승인") void createBillingKeyWithAuthKey_success_card() throws IOException { // given - Long userId = 1L; - String customerKey = "customer-key"; - String billingKeyValue = "billing-key-123"; - + User user = Mockito.mock(User.class); BillingKeyAuthKeyCreateRequest request = Mockito.mock(BillingKeyAuthKeyCreateRequest.class); - BillingKeyResponse response = Mockito.mock(BillingKeyResponse.class); - CardDto card = Mockito.mock(CardDto.class); - given(request.customerKey()).willReturn(customerKey); + CardDto card = Mockito.mock(CardDto.class); + given(card.issuerCode()).willReturn("61"); + given(card.acquirerCode()).willReturn("31"); + given(card.number()).willReturn("12345678****1234"); - given(userService.getUserByIdOrThrow(userId)) - .willReturn(user); + 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(user.equalsCustomerKey(customerKey)) - .willReturn(true); + 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), @@ -79,57 +84,56 @@ void createBillingKeyWithAuthKey_success_card() throws IOException { eq("/v1/billing/authorizations/issue") )).willReturn(response); - given(response.billingKey()).willReturn(billingKeyValue); - given(response.method()).willReturn("카드"); - given(response.card()).willReturn(card); - given(response.transfers()).willReturn(null); - - given(card.issuerCode()).willReturn("61"); - given(card.acquirerCode()).willReturn("31"); - given(card.number()).willReturn("123456******7890"); - - ArgumentCaptor billingKeyCaptor = - ArgumentCaptor.forClass(BillingKey.class); + Plan plan = Plan.values()[0]; // when - billingKeyService.createBillingKeyWithAuthKey(userId, request); + billingKeyService.createBillingKeyWithAuthKey( + USER_ID, + request, + plan, + CLIENT_IP + ); // then - verify(billingKeyRepository).save(billingKeyCaptor.capture()); + ArgumentCaptor captor = ArgumentCaptor.forClass(BillingKey.class); + BDDMockito.then(billingKeyRepository).should().save(captor.capture()); - BillingKey savedBillingKey = billingKeyCaptor.getValue(); + BillingKey savedBillingKey = captor.getValue(); assertThat(savedBillingKey.getUser()).isEqualTo(user); - assertThat(savedBillingKey.getBillingKey()).isEqualTo(billingKeyValue); + 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("123456******7890"); - + 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("카드 정보로 빌링키 발급 성공 - 계좌이체 정보 저장") + @DisplayName("Card 정보로 빌링키 발급 성공 - 계좌이체 정보 저장 후 결제 승인") void createBillingKeyWithCard_success_transfer() throws IOException { // given - Long userId = 1L; - String customerKey = "customer-key"; - String billingKeyValue = "billing-key-456"; - + User user = Mockito.mock(User.class); BillingKeyCardCreateRequest request = Mockito.mock(BillingKeyCardCreateRequest.class); - BillingKeyResponse response = Mockito.mock(BillingKeyResponse.class); - TransferDto transfer = Mockito.mock(TransferDto.class); - given(request.customerKey()).willReturn(customerKey); + TransferDto transfer = Mockito.mock(TransferDto.class); + given(transfer.bankName()).willReturn("국민은행"); + given(transfer.bankAccountNumber()).willReturn("1234567890"); - given(userService.getUserByIdOrThrow(userId)) - .willReturn(user); + 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(user.equalsCustomerKey(customerKey)) - .willReturn(true); + 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), @@ -137,92 +141,89 @@ void createBillingKeyWithCard_success_transfer() throws IOException { eq("/v1/billing/authorizations/card") )).willReturn(response); - given(response.billingKey()).willReturn(billingKeyValue); - given(response.method()).willReturn("계좌이체"); - given(response.card()).willReturn(null); - given(response.transfers()).willReturn(List.of(transfer)); - - given(transfer.bankName()).willReturn("신한은행"); - given(transfer.bankAccountNumber()).willReturn("110123456789"); - - ArgumentCaptor billingKeyCaptor = - ArgumentCaptor.forClass(BillingKey.class); + Plan plan = Plan.values()[0]; // when - billingKeyService.createBillingKeyWithCard(userId, request); + billingKeyService.createBillingKeyWithCard( + USER_ID, + request, + plan, + CLIENT_IP + ); // then - verify(billingKeyRepository).save(billingKeyCaptor.capture()); + ArgumentCaptor captor = ArgumentCaptor.forClass(BillingKey.class); + BDDMockito.then(billingKeyRepository).should().save(captor.capture()); - BillingKey savedBillingKey = billingKeyCaptor.getValue(); + BillingKey savedBillingKey = captor.getValue(); assertThat(savedBillingKey.getUser()).isEqualTo(user); - assertThat(savedBillingKey.getBillingKey()).isEqualTo(billingKeyValue); + assertThat(savedBillingKey.getBillingKey()).isEqualTo("billing-key-transfer"); assertThat(savedBillingKey.getMethod()).isEqualTo("계좌이체"); - - assertThat(savedBillingKey.getBankName()).isEqualTo("신한은행"); - assertThat(savedBillingKey.getBankAccountNumber()).isEqualTo("110123456789"); - + 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() throws IOException { + @DisplayName("AuthKey 빌링키 발급 실패 - customerKey가 일치하지 않으면 예외 발생") + void createBillingKeyWithAuthKey_fail_invalidCustomerKey() { // given - Long userId = 1L; - String requestCustomerKey = "wrong-customer-key"; - + User user = Mockito.mock(User.class); BillingKeyAuthKeyCreateRequest request = Mockito.mock(BillingKeyAuthKeyCreateRequest.class); - given(request.customerKey()).willReturn(requestCustomerKey); + given(request.customerKey()).willReturn("wrong-customer-key"); + given(user.equalsCustomerKey("wrong-customer-key")).willReturn(false); + given(userService.getUserByIdOrThrow(USER_ID)).willReturn(user); - given(userService.getUserByIdOrThrow(userId)) - .willReturn(user); - - given(user.equalsCustomerKey(requestCustomerKey)) - .willReturn(false); + Plan plan = Plan.values()[0]; // when & then assertThatThrownBy(() -> - billingKeyService.createBillingKeyWithAuthKey(userId, request) + billingKeyService.createBillingKeyWithAuthKey( + USER_ID, + request, + plan, + CLIENT_IP + ) ).isInstanceOf(BillingKeyExceptionHandler.class); - verify(tossService, never()) - .getResponse(any(), any(), anyString()); - - verify(billingKeyRepository, never()) - .save(any(BillingKey.class)); + BDDMockito.then(tossService).shouldHaveNoInteractions(); + BDDMockito.then(billingKeyRepository).shouldHaveNoInteractions(); + BDDMockito.then(billingKeyApprovalService).shouldHaveNoInteractions(); } @Test - @DisplayName("카드 빌링키 발급 실패 - customerKey 불일치") - void createBillingKeyWithCard_fail_invalidCustomerKey() throws IOException { + @DisplayName("Card 빌링키 발급 실패 - customerKey가 일치하지 않으면 예외 발생") + void createBillingKeyWithCard_fail_invalidCustomerKey() { // given - Long userId = 1L; - String requestCustomerKey = "wrong-customer-key"; - + User user = Mockito.mock(User.class); BillingKeyCardCreateRequest request = Mockito.mock(BillingKeyCardCreateRequest.class); - given(request.customerKey()).willReturn(requestCustomerKey); + given(request.customerKey()).willReturn("wrong-customer-key"); + given(user.equalsCustomerKey("wrong-customer-key")).willReturn(false); + given(userService.getUserByIdOrThrow(USER_ID)).willReturn(user); - given(userService.getUserByIdOrThrow(userId)) - .willReturn(user); - - given(user.equalsCustomerKey(requestCustomerKey)) - .willReturn(false); + Plan plan = Plan.values()[0]; // when & then assertThatThrownBy(() -> - billingKeyService.createBillingKeyWithCard(userId, request) + billingKeyService.createBillingKeyWithCard( + USER_ID, + request, + plan, + CLIENT_IP + ) ).isInstanceOf(BillingKeyExceptionHandler.class); - verify(tossService, never()) - .getResponse(any(), any(), anyString()); - - verify(billingKeyRepository, never()) - .save(any(BillingKey.class)); + BDDMockito.then(tossService).shouldHaveNoInteractions(); + BDDMockito.then(billingKeyRepository).shouldHaveNoInteractions(); + BDDMockito.then(billingKeyApprovalService).shouldHaveNoInteractions(); } } \ No newline at end of file From 2c4d6c3a2f712689538d9bb41e7064c48cf524bc Mon Sep 17 00:00:00 2001 From: jiminnimij <124450012+jiminnimij@users.noreply.github.com> Date: Wed, 20 May 2026 13:02:06 +0900 Subject: [PATCH 7/7] =?UTF-8?q?feat:=20=EC=9C=A0=EC=A0=80=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EB=9F=89=20=EA=B0=9D=EC=B2=B4=20=EC=B4=88=EA=B8=B0?= =?UTF-8?q?=ED=99=94=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/payment/entity/Subscription.java | 3 +++ .../domain/payment/entity/UserPlan.java | 7 +++++- .../payment/exception/UserPlanErrorCode.java | 24 +++++++++++++++++++ .../exception/UserPlanExceptionHandler.java | 10 ++++++++ .../repository/UserPlanRepository.java | 4 ++++ .../service/BillingKeyApprovalService.java | 24 ++++++++++++------- .../global/schedule/BillingScheduler.java | 2 ++ 7 files changed, 65 insertions(+), 9 deletions(-) create mode 100644 src/main/java/net/studioxai/studioxBe/domain/payment/exception/UserPlanErrorCode.java create mode 100644 src/main/java/net/studioxai/studioxBe/domain/payment/exception/UserPlanExceptionHandler.java 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 index bc26732..77efc7c 100644 --- a/src/main/java/net/studioxai/studioxBe/domain/payment/entity/Subscription.java +++ b/src/main/java/net/studioxai/studioxBe/domain/payment/entity/Subscription.java @@ -37,6 +37,9 @@ public class Subscription extends BaseEntity { @Column(nullable = false) private SubscriptionStatus status; + @Column(columnDefinition="TEXT") + private String cancelReason; + private LocalDateTime startedAt; private LocalDateTime currentPeriodStart; 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 index 1a01519..dee4509 100644 --- a/src/main/java/net/studioxai/studioxBe/domain/payment/entity/UserPlan.java +++ b/src/main/java/net/studioxai/studioxBe/domain/payment/entity/UserPlan.java @@ -46,10 +46,15 @@ public static UserPlan createFree(User user) { @Builder(access = AccessLevel.PRIVATE) private UserPlan(User user, Plan plan) { this.user = user; - this.credit = plan.getCredit(); + 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/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/UserPlanRepository.java b/src/main/java/net/studioxai/studioxBe/domain/payment/repository/UserPlanRepository.java index 896f423..d4a2670 100644 --- a/src/main/java/net/studioxai/studioxBe/domain/payment/repository/UserPlanRepository.java +++ b/src/main/java/net/studioxai/studioxBe/domain/payment/repository/UserPlanRepository.java @@ -1,9 +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 index edb8814..7f90ddd 100644 --- a/src/main/java/net/studioxai/studioxBe/domain/payment/service/BillingKeyApprovalService.java +++ b/src/main/java/net/studioxai/studioxBe/domain/payment/service/BillingKeyApprovalService.java @@ -7,16 +7,11 @@ 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.BillingKeyErrorCode; -import net.studioxai.studioxBe.domain.payment.exception.BillingKeyExceptionHandler; -import net.studioxai.studioxBe.domain.payment.exception.SubscriptionErrorCode; -import net.studioxai.studioxBe.domain.payment.exception.SubscriptionExceptionHandler; -import net.studioxai.studioxBe.domain.payment.repository.BillingKeyRepository; -import net.studioxai.studioxBe.domain.payment.repository.ExchangeRateRepository; -import net.studioxai.studioxBe.domain.payment.repository.PaymentHistoryRepository; -import net.studioxai.studioxBe.domain.payment.repository.SubscriptionRepository; +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; @@ -40,6 +35,7 @@ public class BillingKeyApprovalService { 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 { @@ -49,6 +45,10 @@ public void approveBilling(User user, Plan plan, String clientIp) throws IOExcep () -> 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(); @@ -61,6 +61,8 @@ public void approveBilling(User user, Plan plan, String clientIp) throws IOExcep Subscription subscription = Subscription.createSubscription(user, plan, billingKey); subscriptionRepository.save(subscription); + + userPlan.montlyInitialize(); } private PaymentHistory savePaymentHistory(User user, Plan plan) { @@ -105,6 +107,10 @@ public void paySubscription(Long subscriptionId) throws IOException { 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() @@ -120,6 +126,8 @@ public void paySubscription(Long subscriptionId) throws IOException { if (paymentHistory.getStatus() == PaymentStatus.SUCCESS) { subscription.renew(); } + + userPlan.montlyInitialize(); } diff --git a/src/main/java/net/studioxai/studioxBe/global/schedule/BillingScheduler.java b/src/main/java/net/studioxai/studioxBe/global/schedule/BillingScheduler.java index 3427f7f..3744b9f 100644 --- a/src/main/java/net/studioxai/studioxBe/global/schedule/BillingScheduler.java +++ b/src/main/java/net/studioxai/studioxBe/global/schedule/BillingScheduler.java @@ -20,4 +20,6 @@ public void approveDailyBilling() { log.info("[BillingScheduler] daily billing finished"); } + + // TODO: 결제 실패 건 재시도 작성 }