오늘은 토스 환불관련 로직을 수정했다.
우선 흘러가는 흐름은 이렇게 된다.
전체 환불 시스템 흐름
- 클라이언트 -> 환불 요청 (결제 키, 취소 이유, 금액 포함)
- 컨트롤러 -> 요청 수신, 사용자 인증 확인
- 서비스 -> 기본 유효성 검사 ( 결제 존재, 중복 환불 아님 등)
- 서비스 -> 다이아 사용량 화인 (FIFO 원칙)
- 모든 결제 내역 조회 (미환불 건만)
- 총 구매 다이아와 현재 다이아 비교
- 시간순으로 (FIFO) 다이아 사용 여부 확인
- 환불하려는 결제의 다이아가 사용됐는지 판단
- 서비스 -> 테스트/실제 결제 구분 처리
- 실제 결제는 토스 페이먼츠 API 호출
- 사용자 서비스 -> 다이아 차감 실행
- 저장소 -> 결제 상태 업데이트 (환불됨으로 표시)
- 클라이언트 <- 환불 결과 반환
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) 원칙을 사용하여 다이아 사용량을 계산한다.
- 총 구매한 다이아와 현재 보유한 다이아 차이로 총 사용량을 계산
- 이 결제보다 이전에 구매한 다이아 양을 계산
- 이전 구매분이 모두 사용됐다면 현재 결제분도 사용됐다고 판단
- 부분적으로 사용됐다면 정확한 사용량을 계산
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
};
실제 결제인 경우:
- 토스 페이먼츠 API를 호출하여 실제 결제 취소
- API 응답 확인
- 사용자의 다이아를 차감
- 결제 상태를 환불됨으로 업데이트
핵심 아이디어 요약
- FIFO 원칙: 먼저 구매한 다이아가 먼저 사용된다고 가정한다.
- 사용량 추적: 총 구매량과 현재 보유량의 차이로 사용량을 계산한다.
- 구매 시점 : 환불하려는 결제보다 이전에 구매한 다이아가 얼마나 있는지 확인한다.
- 사용 여부: 이전 구매분이 모두 사용되었는지, 현재 환불하려는 결제분이 얼마나 사용되었는지 계산한다.
이 로직으로 정확히 어떤 결제분의 다이아가 사용되었는지 추적하여 사용된 다이아는 환불이 불가능하도록 처리한다.
'부트캠프' 카테고리의 다른 글
| 위치 기반 흐름 정리 (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 |