부트캠프

79일차 TIL (토스 구현 3일차)

ohs020105 2025. 3. 6. 16:29

오늘은 토스 환불관련 로직을 수정했다.

 

우선 흘러가는 흐름은 이렇게 된다.

전체 환불 시스템 흐름

  1. 클라이언트 -> 환불 요청 (결제 키, 취소 이유, 금액 포함)
  2. 컨트롤러 -> 요청 수신, 사용자 인증 확인
  3. 서비스 -> 기본 유효성 검사 ( 결제 존재, 중복 환불 아님 등)
  4. 서비스 -> 다이아 사용량 화인 (FIFO 원칙)
  5. 모든 결제 내역 조회 (미환불 건만)
  6. 총 구매 다이아와 현재 다이아 비교
  7. 시간순으로 (FIFO) 다이아 사용 여부 확인
  8. 환불하려는 결제의 다이아가 사용됐는지 판단
  9. 서비스 -> 테스트/실제 결제 구분 처리
  10. 실제 결제는 토스 페이먼츠 API 호출
  11. 사용자 서비스 -> 다이아 차감 실행
  12. 저장소 -> 결제 상태 업데이트 (환불됨으로 표시)
  13. 클라이언트 <- 환불 결과 반환

1.환불 요청 처리 메서드

async refundPayment(paymentKey: string, cancelReason: string, userId: number, amount: number) {
  try {
    // 테스트 결제 여부 확인
    const isTestPayment = paymentKey.startsWith('test_') || paymentKey.startsWith('tgen_');

    // 결제 정보 조회
    const payment = await this.paymentRepository.findOne({ where: { paymentKey } });
    if (!payment) {
      throw new Error('결제 정보를 찾을 수 없습니다.');
    }
    
    if (payment.isRefunded) {
      throw new Error('이미 환불된 결제입니다.');
    }
    if (payment.amount < amount) {
      throw new Error('환불 금액이 결제 금액보다 클 수 없습니다.');
    }

저기서 테스트 결제 여부 확인이란 현재 사업자번호가 없으면 테스트로밖에 사용할 수 없어서 현재는 우선 저렇게 저장해놨다. 저렇게 해두면 테스트앱키 할때는 test_******** 이런식으로 등록이 되어있다. 그걸 확인한다는 거다.

 

여기 메서드에서는 

  • 테스트 결제 인지 확인
  • 결제 정보가 존재하는지 확인
  • 이미 환불된 결제인지 확인
  • 환불 금액이 적절한지 확인

해준다.

 

2. 다이아(재화) 검사 및 사용자 검증 

    // 상품명에서 다이아 수량 추출
    const diamondAmount = this.extractDiamondAmount(payment.itemName);
    
    if (diamondAmount <= 0) {
      throw new Error('올바르지 않은 상품명입니다.');
    }

    // 사용자 정보 조회
    const user = await this.userService.findById(userId);
    if (!user) {
      throw new Error('사용자 정보를 찾을 수 없습니다.');
    }

    // 현재 보유 다이아가 환불할 다이아보다 적은 경우
    if (user.pink_dia < diamondAmount) {
      throw new Error('보유한 다이아가 환불할 수량보다 적습니다.');
    }

 

이 부분에서는 :

 

  • 상품명에서 다이아(재화) 수량을 추출한다.
  • 사용자가 존재하는지 조회한다.
  • 사용자의 현재 다이아(재화) 보유량이 환불할려는 다이아보다 많은지 확인한다.

3. 다이아(재화) 사용 여부 확인 ( 여기서 유료재화를 사용하면 환불이 안되게 만들기 위해 해준다. )

    // 다이아 사용 여부 확인
    const usedDiamonds = await this.getUsedDiamonds(userId, payment);
    
    if (usedDiamonds > 0) {
      throw new Error(`이미 ${usedDiamonds}개의 다이아몬드가 사용되어 환불이 불가능합니다.`);
    }

 

이 메서드에서 getUsedDiamonds 메서드를 호출하여 해당 결제의 다이아가 사용되었는지 확인함.

 

4. 다이아 사용량 계산 로직 

private async getUsedDiamonds(userId: number, payment: Payment): Promise<number> {
  try {
    // 1. 환불하려는 결제의 다이아 수량 확인
    const purchasedDiamonds = this.extractDiamondAmount(payment.itemName);
    
    if (purchasedDiamonds <= 0) {
      return 0; 
    }

    // 2. 사용자 현재 다이아 수량 확인
    const user = await this.userService.findById(userId);
    if (!user) {
      throw new NotFoundException('사용자를 찾을 수 없습니다.');
    }

    // 3. 사용자의 모든 환불되지 않은 결제 내역 조회
    const allPayments = await this.paymentRepository.find({
      where: {
        userId,
        isRefunded: false
      },
      order: {
        createdAt: 'ASC' // 시간순 정렬
      }
    });

    // 4. 총 구매한 다이아 수량 계산
    let totalPurchasedDiamonds = 0;
    for (const p of allPayments) {
      const diamondAmount = this.extractDiamondAmount(p.itemName);
      totalPurchasedDiamonds += diamondAmount;
    }

    // 5. 전체 사용된 다이아 수량 = 총 구매 - 현재 보유량
    const totalUsedDiamonds = totalPurchasedDiamonds - user.pink_dia;

    // 6. 이 결제보다 먼저 한 결제의 총 다이아 수량 계산 (FIFO 원칙 적용)
    let diamondsPurchasedBefore = 0;
    for (const p of allPayments) {
      if (p.createdAt < payment.createdAt) {
        diamondsPurchasedBefore += this.extractDiamondAmount(p.itemName);
      }
    }

    // 7. 먼저 사용한 다이아는 먼저 구매한 다이아라고 가정 (FIFO)
    let usedFromThisPayment = 0;
    
    if (totalUsedDiamonds <= diamondsPurchasedBefore) {
      // 이전 구매분 다이아만 사용됨, 이 결제 다이아는 사용 안됨
      usedFromThisPayment = 0;
    } else {
      // 이전 구매분 + 현재 결제 다이아의 일부 사용됨
      usedFromThisPayment = Math.min(
        purchasedDiamonds,  // 최대 이 결제에서 구매한 다이아 수량까지만
        totalUsedDiamonds - diamondsPurchasedBefore  // 이전 구매분 사용 후 남은 사용량
      );
    }

    return Math.max(0, usedFromThisPayment); // 음수는 0으로 처리
  } catch (error) {
    // 오류 발생 시 안전하게 0으로 처리 (환불 가능하도록)
    return 0;
  }
}

 

이 메서드는 FIFO(Fires In, Firest Out) 원칙을 사용하여 다이아 사용량을 계산한다.

 

  1. 총 구매한 다이아와 현재 보유한 다이아 차이로 총 사용량을 계산
  2. 이 결제보다 이전에 구매한 다이아 양을 계산
  3. 이전 구매분이 모두 사용됐다면 현재 결제분도 사용됐다고 판단
  4. 부분적으로 사용됐다면 정확한 사용량을 계산 

5. 환불 처리 ( 테스트 결제 ) 

 

    if (isTestPayment) {
      // 다이아 차감
      await this.userService.deductDiamond(userId, diamondAmount);

      // 결제 내역의 환불 상태 업데이트
      await this.paymentRepository.update(
        { paymentKey },
        { isRefunded: true }
      );

      return {
        success: true,
        message: '테스트 결제 환불이 완료되었습니다.',
        refundAmount: amount,
        deductedDiamond: diamondAmount
      };
    }

 

테스트 결제인 경우: 

  • 사용자의 다이아를 차감
  • 결제 상태를 환불됨으로 업데이트

6.환불 처리 (실제 결제)

 

    // 실제 결제인 경우 토스 페이먼츠 API 호출
    const secretKey = this.configService.get('TOSS_SECRET_KEY');
    if (!secretKey) {
      throw new Error('토스 페이먼츠 시크릿 키가 설정되지 않았습니다. 환경 변수를 확인해주세요.');
    }

    const response = await fetch(
      `https://api.tosspayments.com/v1/payments/${paymentKey}/cancel`,
      {
        method: 'POST',
        headers: {
          'Authorization': `Basic ${Buffer.from(secretKey + ':').toString('base64')}`,
          'Content-Type': 'application/json'
        },
        body: JSON.stringify({
          cancelReason,
          amount
        })
      }
    );

    let responseData;
    try {
      const responseText = await response.text();
      responseData = JSON.parse(responseText);
    } catch (e) {
      throw new Error('환불 처리 중 오류가 발생했습니다.');
    }

    if (!response.ok) {
      throw new Error(responseData.message || '환불 처리 중 오류가 발생했습니다.');
    }

    // 다이아 차감
    await this.userService.deductDiamond(userId, diamondAmount);

    // 결제 내역의 환불 상태 업데이트
    await this.paymentRepository.update(
      { paymentKey },
      { isRefunded: true }
    );

    return {
      success: true,
      message: '환불이 완료되었습니다.',
      refundAmount: amount,
      deductedDiamond: diamondAmount
    };

 

실제 결제인 경우:

 

  1. 토스 페이먼츠 API를 호출하여 실제 결제 취소
  2. API 응답 확인
  3. 사용자의 다이아를 차감
  4. 결제 상태를 환불됨으로 업데이트

핵심 아이디어 요약

  1. FIFO 원칙: 먼저 구매한 다이아가 먼저 사용된다고 가정한다.
  2. 사용량 추적: 총 구매량과 현재 보유량의 차이로 사용량을 계산한다.
  3. 구매 시점 : 환불하려는 결제보다 이전에 구매한 다이아가 얼마나 있는지 확인한다.
  4. 사용 여부: 이전 구매분이 모두 사용되었는지, 현재 환불하려는 결제분이 얼마나 사용되었는지 계산한다.

이 로직으로 정확히 어떤 결제분의 다이아가 사용되었는지 추적하여 사용된 다이아는 환불이 불가능하도록 처리한다.

'부트캠프' 카테고리의 다른 글

위치 기반 흐름 정리  (0) 2025.04.13
80일차 TIL  (0) 2025.03.07
78일차 TIL (토스 결제 구현 2일차)  (1) 2025.03.04
토스 결제 api 적용하기 1일차.  (0) 2025.03.03
77일차 TIL  (0) 2025.03.03