diff --git a/.gitignore b/.gitignore index 9dcfeb9c..93e48470 100644 --- a/.gitignore +++ b/.gitignore @@ -122,3 +122,4 @@ application-*.yml !application.yml # End of https://www.toptal.com/developers/gitignore/api/gradle +/module-application/src/main/resources/application-local-payment.yml diff --git a/module-application/build.gradle b/module-application/build.gradle index 0faad254..86ecc015 100644 --- a/module-application/build.gradle +++ b/module-application/build.gradle @@ -1,7 +1,14 @@ +repositories { + maven { url 'https://jitpack.io' } +} + dependencies { // Web implementation 'org.springframework.boot:spring-boot-starter-web' + // I-AM-PORT + implementation 'com.github.iamport:iamport-rest-client-java:0.2.23' + // Test testImplementation 'org.springframework.boot:spring-boot-starter-test' diff --git a/module-application/src/main/java/com/devtoon/jtoon/payment/presentation/PaymentInfoController.java b/module-application/src/main/java/com/devtoon/jtoon/payment/presentation/PaymentInfoController.java new file mode 100644 index 00000000..34ff16b3 --- /dev/null +++ b/module-application/src/main/java/com/devtoon/jtoon/payment/presentation/PaymentInfoController.java @@ -0,0 +1,30 @@ +package com.devtoon.jtoon.payment.presentation; + +import java.io.IOException; + +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 com.devtoon.jtoon.payment.application.PaymentInfoService; +import com.devtoon.jtoon.payment.request.PaymentInfoDto; +import com.siot.IamportRestClient.exception.IamportResponseException; +import com.siot.IamportRestClient.response.IamportResponse; +import com.siot.IamportRestClient.response.Payment; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/payments") +public class PaymentInfoController { + private final PaymentInfoService paymentInfoService; + + @PostMapping + public IamportResponse verifyIamport(@RequestBody PaymentInfoDto paymentInfoDto) + throws IamportResponseException, IOException { + IamportResponse iamportResponse = paymentInfoService.paymentLookup(paymentInfoDto.impUid()); + return paymentInfoService.verifyIamport(iamportResponse, paymentInfoDto); + } +} diff --git a/module-application/src/main/resources/templates/payments.html b/module-application/src/main/resources/templates/payments.html new file mode 100644 index 00000000..9cda53b8 --- /dev/null +++ b/module-application/src/main/resources/templates/payments.html @@ -0,0 +1,91 @@ + + + + + Sample Payment + + + + + + + + + +이름:
+이메일:
+전화번호:
+ +상품: +
+ +결제 방법: +
+ +결제사: +

+ + + + diff --git a/module-domain/build.gradle b/module-domain/build.gradle index 46b573ed..fb095f8a 100644 --- a/module-domain/build.gradle +++ b/module-domain/build.gradle @@ -1,8 +1,18 @@ +repositories { + maven { url 'https://jitpack.io' } +} + dependencies { // JPA implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation project(path: ':module-domain-smtp') + // I-AM-PORT + implementation 'com.github.iamport:iamport-rest-client-java:0.2.23' + + // Bean Validation + implementation 'org.springframework.boot:spring-boot-starter-validation' + // Test testImplementation 'org.springframework.boot:spring-boot-starter-test' diff --git a/module-domain/src/main/java/com/devtoon/jtoon/member/repository/MemberRepository.java b/module-domain/src/main/java/com/devtoon/jtoon/member/repository/MemberRepository.java index 4bf13bf8..11bca2bd 100644 --- a/module-domain/src/main/java/com/devtoon/jtoon/member/repository/MemberRepository.java +++ b/module-domain/src/main/java/com/devtoon/jtoon/member/repository/MemberRepository.java @@ -1,5 +1,7 @@ package com.devtoon.jtoon.member.repository; +import java.util.Optional; + import org.springframework.data.jpa.repository.JpaRepository; import com.devtoon.jtoon.member.entity.Member; diff --git a/module-domain/src/main/java/com/devtoon/jtoon/member/request/SignUpDto.java b/module-domain/src/main/java/com/devtoon/jtoon/member/request/SignUpDto.java index 3cf58d70..fed0846d 100644 --- a/module-domain/src/main/java/com/devtoon/jtoon/member/request/SignUpDto.java +++ b/module-domain/src/main/java/com/devtoon/jtoon/member/request/SignUpDto.java @@ -6,6 +6,7 @@ import com.devtoon.jtoon.member.entity.LoginType; import com.devtoon.jtoon.member.entity.Member; import com.devtoon.jtoon.member.entity.Role; + import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Pattern; diff --git a/module-domain/src/main/java/com/devtoon/jtoon/payment/application/PaymentInfoService.java b/module-domain/src/main/java/com/devtoon/jtoon/payment/application/PaymentInfoService.java new file mode 100644 index 00000000..73e38e44 --- /dev/null +++ b/module-domain/src/main/java/com/devtoon/jtoon/payment/application/PaymentInfoService.java @@ -0,0 +1,76 @@ +package com.devtoon.jtoon.payment.application; + +import java.io.IOException; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.devtoon.jtoon.member.entity.Member; +import com.devtoon.jtoon.member.repository.MemberRepository; +import com.devtoon.jtoon.payment.entity.PaymentInfo; +import com.devtoon.jtoon.payment.repository.PaymentInfoRepository; +import com.devtoon.jtoon.payment.request.PaymentInfoDto; +import com.siot.IamportRestClient.IamportClient; +import com.siot.IamportRestClient.exception.IamportResponseException; +import com.siot.IamportRestClient.response.IamportResponse; +import com.siot.IamportRestClient.response.Payment; + +@Service +@Transactional(readOnly = true) +public class PaymentInfoService { + + @Value("${pg.kg-inicis.rest-api-key}") + private String REST_API_KEY; + + @Value("${pg.kg-inicis.rest-api-secret}") + private String REST_API_SECRET; + + private final IamportClient iamportClient; + private final MemberRepository memberRepository; + private final PaymentInfoRepository paymentInfoRepository; + + public PaymentInfoService(MemberRepository memberRepository, PaymentInfoRepository paymentInfoRepository) { + this.iamportClient = new IamportClient(REST_API_KEY, REST_API_SECRET); + this.paymentInfoRepository = paymentInfoRepository; + this.memberRepository = memberRepository; + } + + public IamportResponse paymentLookup(String impUid) throws IamportResponseException, IOException { + return iamportClient.paymentByImpUid(impUid); + } + + @Transactional + public IamportResponse verifyIamport( + IamportResponse iamportResponse, + PaymentInfoDto paymentInfoDto + ) { + validateImpUid(paymentInfoDto); + validateMerchantUid(paymentInfoDto); + validateAmount(iamportResponse, paymentInfoDto.amount()); + Member member = memberRepository.findByPhone(paymentInfoDto.buyerPhone()) + .orElseThrow(() -> new RuntimeException("member is not found")); + PaymentInfo paymentInfo = paymentInfoDto.toEntity(member); + paymentInfoRepository.save(paymentInfo); + + return iamportResponse; + } + + private void validateAmount(IamportResponse iamportResponse, int amount) { + if (iamportResponse.getResponse().getAmount().intValue() != amount) { + throw new RuntimeException("verify iamport exception"); + } + } + + private void validateMerchantUid(PaymentInfoDto paymentInfoDto) { + if (paymentInfoRepository.existsByMerchantUid(paymentInfoDto.merchantUid())) { + throw new RuntimeException("merchantUid duplicate"); + } + } + + private void validateImpUid(PaymentInfoDto paymentInfoDto) { + if (paymentInfoRepository.existsByImpUid(paymentInfoDto.impUid())) { + throw new RuntimeException("impUid duplicate"); + } + } +} diff --git a/module-domain/src/main/java/com/devtoon/jtoon/payment/entity/PG.java b/module-domain/src/main/java/com/devtoon/jtoon/payment/entity/PG.java new file mode 100644 index 00000000..edfcf1dd --- /dev/null +++ b/module-domain/src/main/java/com/devtoon/jtoon/payment/entity/PG.java @@ -0,0 +1,32 @@ +package com.devtoon.jtoon.payment.entity; + +import java.util.Arrays; +import java.util.Collections; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Collectors; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public enum PG { + KCP("KG_이니시스"); + + private String name; + + private static final Map PG_MAP; + + static { + PG_MAP = Collections.unmodifiableMap(Arrays.stream(values()) + .collect(Collectors.toMap(PG::getName, Function.identity()))); + } + + public static PG from(String name) { + return Optional.ofNullable(PG_MAP.get(name)) + .orElseThrow(() -> new RuntimeException("현재 서버에 등록되지 않은 Payment Gateway 입니다.")); + } +} diff --git a/module-domain/src/main/java/com/devtoon/jtoon/payment/entity/PaymentInfo.java b/module-domain/src/main/java/com/devtoon/jtoon/payment/entity/PaymentInfo.java new file mode 100644 index 00000000..e1452739 --- /dev/null +++ b/module-domain/src/main/java/com/devtoon/jtoon/payment/entity/PaymentInfo.java @@ -0,0 +1,94 @@ +package com.devtoon.jtoon.payment.entity; + +import java.util.Objects; + +import com.devtoon.jtoon.member.entity.Member; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "payments") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class PaymentInfo { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "payment_id") + private Long id; + + @Column(name = "imp_uid", length = 100, nullable = false, unique = true, updatable = false) + private String impUid; // 포트원 결제 고유번호 + + @Column(name = "pay_method", length = 100, nullable = false, unique = true, updatable = false) + private String merchantUid; // 가맹점 주문번호 + + @Column(name = "pg", length = 20, nullable = false) + private PG pg; // 결제사 + + @Column(name = "pay_method", length = 20, nullable = false) + private String payMethod; // 결제 방법 + + @Column(name = "product_name", length = 15, nullable = false) + private String productName; // 상품명 + + @Column(name = "amount", nullable = false) + private int amount; // 결제 금액 + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) + private Member member; + + @Builder + private PaymentInfo( + String impUid, + String merchantUid, + PG pg, + String payMethod, + String productName, + int amount, + Member member + ) { + validateFieldNotNull(impUid, merchantUid, pg, payMethod, productName, amount, member); + this.impUid = impUid; + this.pg = pg; + this.payMethod = payMethod; + this.merchantUid = merchantUid; + this.productName = productName; + this.amount = amount; + this.member = member; + } + + private void validateFieldNotNull( + String impUid, + String merchantUid, + PG pg, + String payMethod, + String productName, + int amount, + Member member + ) { + Objects.requireNonNull(impUid, "impUid is null"); + Objects.requireNonNull(merchantUid, "merchantUid is null"); + Objects.requireNonNull(pg, "pg is null"); + Objects.requireNonNull(payMethod, "payMethod is null"); + Objects.requireNonNull(productName, "productName is null"); + Objects.requireNonNull(member, "member is null"); + + if (amount <= 0) { + throw new RuntimeException("amount is zero or negative number"); + } + } +} diff --git a/module-domain/src/main/java/com/devtoon/jtoon/payment/repository/PaymentInfoRepository.java b/module-domain/src/main/java/com/devtoon/jtoon/payment/repository/PaymentInfoRepository.java new file mode 100644 index 00000000..b4c6d12e --- /dev/null +++ b/module-domain/src/main/java/com/devtoon/jtoon/payment/repository/PaymentInfoRepository.java @@ -0,0 +1,14 @@ +package com.devtoon.jtoon.payment.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import com.devtoon.jtoon.payment.entity.PaymentInfo; + +@Repository +public interface PaymentInfoRepository extends JpaRepository { + + boolean existsByImpUid(String impUid); + + boolean existsByMerchantUid(String merchantUid); +} diff --git a/module-domain/src/main/java/com/devtoon/jtoon/payment/request/PaymentInfoDto.java b/module-domain/src/main/java/com/devtoon/jtoon/payment/request/PaymentInfoDto.java new file mode 100644 index 00000000..e79bd67b --- /dev/null +++ b/module-domain/src/main/java/com/devtoon/jtoon/payment/request/PaymentInfoDto.java @@ -0,0 +1,38 @@ +package com.devtoon.jtoon.payment.request; + +import static com.devtoon.jtoon.global.util.RegExp.*; + +import com.devtoon.jtoon.member.entity.Member; +import com.devtoon.jtoon.payment.entity.PG; +import com.devtoon.jtoon.payment.entity.PaymentInfo; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +public record PaymentInfoDto( + @NotBlank String impUid, + @NotBlank String merchantUid, + @NotBlank String pg, + @NotBlank String payMethod, + @NotBlank String productName, + @Min(1) int amount, + @Pattern(regexp = EMAIL_PATTERN) String buyerEmail, + @NotEmpty @Size(max = 10) String buyerName, + @Pattern(regexp = PHONE_PATTERN) String buyerPhone +) { + + public PaymentInfo toEntity(Member member) { + return PaymentInfo.builder() + .impUid(this.impUid) + .merchantUid(this.merchantUid) + .pg(PG.from(this.pg)) + .payMethod(this.payMethod) + .productName(this.productName) + .amount(this.amount) + .member(member) + .build(); + } +}