From 1db3a03ffd6e82e6fd5f450f6875b4020116631e Mon Sep 17 00:00:00 2001 From: Jaybe Date: Sun, 11 May 2025 17:27:39 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B2=B0=EC=A0=9C,=20=EA=B5=AC=EB=8F=85=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/controllers/featureController.js | 124 +- backend/controllers/publicController.js | 31 + backend/controllers/subscriptionController.js | 394 ++- backend/models/PaymentRequest.js | 82 + backend/models/index.js | 3 + backend/routes/subscriptionRoutes.js | 31 +- frontend/package-lock.json | 40 + frontend/package.json | 1 + frontend/src/App.vue | 42 +- .../components/event/EventDetailsDialog.vue | 25 +- frontend/src/router/index.js | 18 + frontend/src/services/api.js | 37 +- frontend/src/services/eventService.js | 9 +- frontend/src/stores/clubStore.js | 61 +- frontend/src/views/club/EventManagement.vue | 30 +- .../subscription/SubscriptionManagement.vue | 2263 ++++++++++++++++- frontend/src/views/payment/PaymentFail.vue | 132 + frontend/src/views/payment/PaymentSuccess.vue | 133 + 18 files changed, 3276 insertions(+), 180 deletions(-) create mode 100644 backend/models/PaymentRequest.js create mode 100644 frontend/src/views/payment/PaymentFail.vue create mode 100644 frontend/src/views/payment/PaymentSuccess.vue diff --git a/backend/controllers/featureController.js b/backend/controllers/featureController.js index 15bc09c..bab3cee 100644 --- a/backend/controllers/featureController.js +++ b/backend/controllers/featureController.js @@ -1,4 +1,4 @@ -const { Feature, ClubFeature, Club } = require('../models'); +const { Feature, ClubFeature, Club, ClubSubscription, SubscriptionPlan, SubscriptionPlanFeature } = require('../models'); const { Op } = require('sequelize'); // 전체 기능 목록 조회 @@ -20,7 +20,7 @@ exports.getFeatures = async (req, res) => { // 사용 가능한 기능 목록 조회 exports.getAvailableFeatures = async (req, res) => { try { - const { clubId } = req.query; + const { clubId } = req.body; if (!clubId) { return res.status(400).json({ message: '클럽 ID가 필요합니다.' }); @@ -47,10 +47,40 @@ exports.getAvailableFeatures = async (req, res) => { const activeFeatureIds = activeFeatures.map(af => af.featureId); - // 기능 목록에 활성화 상태 추가 + // 현재 구독 중인 플랜에 포함된 기능 조회 + const { ClubSubscription, SubscriptionPlan, SubscriptionPlanFeature } = require('../models'); + + // 현재 활성화된 구독 조회 + const activeSubscription = await ClubSubscription.findOne({ + where: { + clubId, + status: 'active', + endDate: { + [Op.gt]: new Date() + } + }, + attributes: ['planId'] + }); + + let planFeatureIds = []; + + // 현재 구독 플랜이 있는 경우, 해당 플랜에 포함된 기능 ID 목록 조회 + if (activeSubscription) { + const planFeatures = await SubscriptionPlanFeature.findAll({ + where: { + planId: activeSubscription.planId + }, + attributes: ['featureId'] + }); + + planFeatureIds = planFeatures.map(pf => pf.featureId); + } + + // 기능 목록에 활성화 상태 및 플랜 포함 여부 추가 const formattedFeatures = features.map(feature => ({ ...feature.toJSON(), - isActive: activeFeatureIds.includes(feature.id) + isActive: activeFeatureIds.includes(feature.id), + isIncludedInPlan: planFeatureIds.includes(feature.id) })); res.json(formattedFeatures); @@ -63,7 +93,7 @@ exports.getAvailableFeatures = async (req, res) => { // 활성화된 기능 목록 조회 exports.getActiveFeatures = async (req, res) => { try { - const { clubId } = req.query; + const { clubId } = req.body; if (!clubId) { return res.status(400).json({ message: '클럽 ID가 필요합니다.' }); @@ -80,10 +110,19 @@ exports.getActiveFeatures = async (req, res) => { model: Feature, attributes: ['name', 'description'] }], - attributes: ['id', 'featureId', 'purchaseDate', 'expiryDate'] + attributes: ['id', 'featureId', 'createdAt', 'expiryDate'] }); - res.json(activeFeatures); + // createdAt을 purchaseDate로 매핑 + const formattedFeatures = activeFeatures.map(feature => ({ + id: feature.id, + featureId: feature.featureId, + purchaseDate: feature.createdAt, + expiryDate: feature.expiryDate, + Feature: feature.Feature + })); + + res.json(formattedFeatures); } catch (error) { console.error('활성화된 기능 목록 조회 오류:', error); res.status(500).json({ message: '활성화된 기능 목록을 가져오는 중 오류가 발생했습니다.' }); @@ -93,7 +132,7 @@ exports.getActiveFeatures = async (req, res) => { // 기능 구매 내역 조회 exports.getFeaturePayments = async (req, res) => { try { - const { clubId } = req.query; + const { clubId } = req.body; if (!clubId) { return res.status(400).json({ message: '클럽 ID가 필요합니다.' }); @@ -105,29 +144,41 @@ exports.getFeaturePayments = async (req, res) => { }, include: [{ model: Feature, - attributes: ['name'] + attributes: ['name', 'price'] }], attributes: [ 'id', 'featureId', - 'purchaseDate', + 'createdAt', 'expiryDate', - 'amount', - 'duration', - 'status' + 'enabled' ], - order: [['purchaseDate', 'DESC']] + order: [['createdAt', 'DESC']] }); - const formattedPayments = payments.map(payment => ({ - id: payment.id, - featureName: payment.feature.name, - purchaseDate: payment.purchaseDate, - expiryDate: payment.expiryDate, - amount: payment.amount, - duration: payment.duration, - status: payment.status - })); + // 기간 계산 (월 단위) + const calculateDuration = (startDate, endDate) => { + const start = new Date(startDate); + const end = new Date(endDate); + const diffTime = Math.abs(end - start); + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); + return Math.ceil(diffDays / 30); // 대략적인 월 수 계산 + }; + + const formattedPayments = payments.map(payment => { + const duration = calculateDuration(payment.createdAt, payment.expiryDate); + const amount = payment.Feature.price * duration; + + return { + id: payment.id, + featureName: payment.Feature.name, + purchaseDate: payment.createdAt, + expiryDate: payment.expiryDate, + amount: amount, + duration: duration, + status: payment.enabled ? 'active' : 'inactive' + }; + }); res.json(formattedPayments); } catch (error) { @@ -172,27 +223,32 @@ exports.purchaseFeature = async (req, res) => { return res.status(400).json({ message: '이미 활성화된 기능입니다.' }); } - // 구매 금액 계산 (duration에 따른 할인 적용 가능) - const amount = feature.price * duration; - - // 기능 구매 기록 생성 - const purchaseDate = new Date(); + // 만료일 계산 const expiryDate = new Date(); - expiryDate.setMonth(expiryDate.getMonth() + duration); + expiryDate.setMonth(expiryDate.getMonth() + parseInt(duration)); const clubFeature = await ClubFeature.create({ clubId, featureId, - purchaseDate, expiryDate, - amount, - duration, - status: 'active' + enabled: true }); + // 응답용 객체 생성 + const purchaseInfo = { + id: clubFeature.id, + clubId: clubFeature.clubId, + featureId: clubFeature.featureId, + purchaseDate: clubFeature.createdAt, + expiryDate: clubFeature.expiryDate, + amount: feature.price * parseInt(duration), + duration: parseInt(duration), + status: 'active' + }; + res.json({ message: '기능 구매가 완료되었습니다.', - purchaseInfo: clubFeature + purchaseInfo }); } catch (error) { console.error('기능 구매 오류:', error); diff --git a/backend/controllers/publicController.js b/backend/controllers/publicController.js index 3b69880..2193083 100644 --- a/backend/controllers/publicController.js +++ b/backend/controllers/publicController.js @@ -21,6 +21,17 @@ exports.publicEventEntry = async (req, res) => { if (event.status === '취소') { return res.status(403).json({ message: '취소된 이벤트입니다.' }); } + + // 클럽의 구독 활성 여부 확인 + const clubSubscription = await sequelize.models.ClubSubscription.findOne({ + where: { clubId: event.clubId, status: 'active' }, + include: [{ model: sequelize.models.SubscriptionPlan }] + }); + + // 구독이 비활성화된 경우 접근 제한 + if (!clubSubscription) { + return res.status(403).json({ message: '클럽의 구독이 비활성화되어 이벤트에 접근할 수 없습니다. 클럽 관리자에게 문의하세요.' }); + } let token = null; let isAuthenticated = false; @@ -248,6 +259,26 @@ exports.addPublicGuest = async (req, res) => { const event = await Event.findOne({ where: { publicHash } }); if (!event) return res.status(404).json({ message: '이벤트를 찾을 수 없습니다.' }); if (event.status === '취소') return res.status(403).json({ message: '취소된 이벤트입니다.' }); + + // 클럽의 회원 수와 구독 플랜의 최대 회원 수 확인 + const memberCount = await Member.count({ where: { clubId: event.clubId } }); + + // 클럽의 구독 정보 가져오기 + const clubSubscription = await sequelize.models.ClubSubscription.findOne({ + where: { clubId: event.clubId, status: 'active' }, + include: [{ model: sequelize.models.SubscriptionPlan }] + }); + + // 구독 플랜이 있고 회원 수가 최대치를 초과하는 경우 + if (clubSubscription && clubSubscription.SubscriptionPlan) { + const maxMembers = clubSubscription.SubscriptionPlan.maxMembers; + if (memberCount >= maxMembers) { + return res.status(403).json({ + message: `회원 수가 구독 플랜의 최대 허용 인원(${maxMembers}명)을 초과했습니다. 구독 업그레이드가 필요합니다.` + }); + } + } + // publicAuth 미들웨어로 인증됨 const member = await Member.create({ name, diff --git a/backend/controllers/subscriptionController.js b/backend/controllers/subscriptionController.js index 277c62f..b46173b 100644 --- a/backend/controllers/subscriptionController.js +++ b/backend/controllers/subscriptionController.js @@ -1,4 +1,4 @@ -const { SubscriptionPlan, SubscriptionPlanFeature, ClubSubscription, Feature, Club, User } = require('../models'); +const { SubscriptionPlan, SubscriptionPlanFeature, ClubSubscription, Feature, Club, User, PaymentRequest } = require('../models'); // 전체 구독 목록 조회 exports.getClubSubscriptions = async (req, res) => { @@ -45,6 +45,29 @@ exports.getSubscriptionPlans = async (req, res) => { } }; +// 플랜 상세 정보 조회 (포함된 기능 포함) +exports.getPlanDetail = async (req, res) => { + try { + const { planId } = req.params; + + const plan = await SubscriptionPlan.findByPk(planId, { + include: [{ + model: Feature, + through: { attributes: ['status'] } + }] + }); + + if (!plan) { + return res.status(404).json({ message: '해당 플랜을 찾을 수 없습니다.' }); + } + + res.json(plan); + } catch (error) { + console.error('Error in getPlanDetail:', error); + res.status(500).json({ message: '플랜 상세 정보 조회 중 오류가 발생했습니다.' }); + } +}; + // 구독 플랜 생성 exports.createSubscriptionPlan = async (req, res) => { try { @@ -192,7 +215,7 @@ exports.subscribeClub = async (req, res) => { // 클럽의 현재 구독 정보 조회 exports.getClubSubscription = async (req, res) => { try { - const { clubId } = req.params; + const { clubId } = req.body; const subscription = await ClubSubscription.findOne({ where: { clubId, status: 'active' }, @@ -238,7 +261,9 @@ exports.getCurrentSubscription = async (req, res) => { planId: subscription.SubscriptionPlan.id, status: subscription.status, startDate: subscription.startDate, - endDate: subscription.expiryDate + endDate: subscription.endDate, + planPrice: subscription.SubscriptionPlan.price, + price: subscription.price }); } catch (error) { console.error('Error in getCurrentSubscription:', error); @@ -267,7 +292,368 @@ exports.getSubscriptionPayments = async (req, res) => { }))); } catch (error) { console.error('Error in getSubscriptionPayments:', error); - res.status(500).json({ message: '결제 내역 조회 중 오류가 발생했습니다.' }); + res.status(500).json({ message: '구독 결제 내역 조회 중 오류가 발생했습니다.' }); + } +}; + +// 플랜 변경과 추가 기능을 함께 처리 +exports.changeWithFeatures = async (req, res) => { + try { + const { + clubId, + currentPlanId, + newPlanId, + isUpgrade, + refundAmount, + proRatedNewPlanPrice, + planPriceDifference, + features, + totalFeaturesPrice, + finalAmount, + remainingDays + } = req.body; + + // 클럽과 구독 정보 확인 + const club = await Club.findByPk(clubId); + if (!club) { + return res.status(404).json({ message: '클럽을 찾을 수 없습니다.' }); + } + + const currentSubscription = await ClubSubscription.findOne({ + where: { + clubId, + planId: currentPlanId, + status: 'active' + } + }); + + if (!currentSubscription) { + return res.status(404).json({ message: '현재 활성화된 구독을 찾을 수 없습니다.' }); + } + + const newPlan = await SubscriptionPlan.findByPk(newPlanId); + if (!newPlan) { + return res.status(404).json({ message: '선택한 플랜을 찾을 수 없습니다.' }); + } + + // 트랜잭션 시작 + const transaction = await sequelize.transaction(); + + try { + if (isUpgrade) { + // 업그레이드: 현재 구독 업데이트 + currentSubscription.planId = newPlanId; + currentSubscription.price = newPlan.price; + await currentSubscription.save({ transaction }); + + // 결제 기록 생성 + await Payment.create({ + clubId, + amount: planPriceDifference, + description: `플랜 업그레이드: ${currentPlanId} -> ${newPlanId}`, + status: 'completed', + paymentDate: new Date() + }, { transaction }); + } else { + // 다운그레이드: 다음 결제일에 적용될 구독 생성 + await ClubSubscription.create({ + clubId, + planId: newPlanId, + status: 'pending', + startDate: currentSubscription.endDate, + endDate: new Date(new Date(currentSubscription.endDate).setMonth( + new Date(currentSubscription.endDate).getMonth() + 1 + )), + price: newPlan.price + }, { transaction }); + } + + // 추가 기능 처리 + if (features && features.length > 0) { + for (const feature of features) { + // 현재 구독 만료일 계산 + const expiryDate = new Date(currentSubscription.endDate); + + // 1개월 초과 구독인 경우 해당 기간만큼 추가 + if (feature.duration > 1) { + expiryDate.setMonth(expiryDate.getMonth() + feature.duration - 1); + } + + // 기능 구매 기록 생성 + await ClubFeature.create({ + clubId, + featureId: feature.featureId, + expiryDate, + enabled: true + }, { transaction }); + + // 기능 결제 기록 생성 + await Payment.create({ + clubId, + amount: feature.price, + description: `추가 기능 구매: ${feature.featureId} (${feature.duration}개월)`, + status: 'completed', + paymentDate: new Date() + }, { transaction }); + } + } + + // 트랜잭션 커밋 + await transaction.commit(); + + res.json({ + message: '플랜 변경 및 추가 기능 구매가 완료되었습니다.', + isUpgrade, + planChanged: true, + featuresAdded: features.length > 0, + totalAmount: finalAmount + }); + + } catch (error) { + // 트랜잭션 롤백 + await transaction.rollback(); + throw error; + } + + } catch (error) { + console.error('플랜 변경 및 추가 기능 구매 오류:', error); + res.status(500).json({ message: '처리 중 오류가 발생했습니다.' }); + } +}; + +// 토스페이먼츠 결제 요청 생성 +exports.requestPayment = async (req, res) => { + try { + const { + clubId, + planId, + features, + totalAmount, + planDuration, + planChange, + currentPlanId, + isUpgrade, + successUrl, + failUrl + } = req.body; + + if (!clubId || !totalAmount) { + return res.status(400).json({ message: '필수 데이터가 누락되었습니다.' }); + } + + // 클럽 정보 확인 + const club = await Club.findByPk(clubId); + if (!club) { + return res.status(404).json({ message: '클럽을 찾을 수 없습니다.' }); + } + + // 결제 정보 저장 + const paymentInfo = await PaymentRequest.create({ + clubId, + planId: planId || null, + currentPlanId: currentPlanId || null, + isUpgrade: isUpgrade || false, + planDuration: planDuration || 1, + features: features ? JSON.stringify(features) : null, + totalAmount, + status: 'pending', + orderId: `order_${Date.now()}_${clubId}`, + planChange: planChange || false + }); + + // 토스페이먼츠에 전송할 데이터 구성 + const paymentData = { + orderId: paymentInfo.orderId, + amount: totalAmount, + orderName: planId ? `구독 플랜: ${planDuration}개월` : '추가 기능 구매', + customerName: club.name, + successUrl: successUrl || `${process.env.FRONTEND_URL}/payment/success`, + failUrl: failUrl || `${process.env.FRONTEND_URL}/payment/fail`, + customerEmail: req.user.email + }; + + res.json({ + paymentData, + clientKey: process.env.TOSS_PAYMENTS_CLIENT_KEY + }); + + } catch (error) { + console.error('결제 요청 생성 오류:', error); + res.status(500).json({ message: '결제 요청 생성 중 오류가 발생했습니다.' }); + } +}; + +// 결제 성공 처리 +exports.processPaymentSuccess = async (req, res) => { + try { + const { orderId, paymentKey, amount } = req.query; + + // 결제 요청 정보 조회 + const paymentRequest = await PaymentRequest.findOne({ where: { orderId } }); + if (!paymentRequest) { + return res.status(404).json({ message: '결제 정보를 찾을 수 없습니다.' }); + } + + // 금액 확인 + if (paymentRequest.totalAmount !== parseInt(amount)) { + return res.status(400).json({ message: '결제 금액이 일치하지 않습니다.' }); + } + + // 토스페이먼츠 API로 결제 승인 요청 + const tossPaymentsSecretKey = process.env.TOSS_PAYMENTS_SECRET_KEY; + const encryptedSecretKey = Buffer.from(`${tossPaymentsSecretKey}:`).toString('base64'); + + const response = await axios({ + method: 'post', + url: 'https://api.tosspayments.com/v1/payments/confirm', + headers: { + Authorization: `Basic ${encryptedSecretKey}`, + 'Content-Type': 'application/json' + }, + data: { + orderId, + amount, + paymentKey + } + }); + + // 결제 요청 상태 업데이트 + paymentRequest.status = 'completed'; + paymentRequest.paymentKey = paymentKey; + paymentRequest.paymentData = JSON.stringify(response.data); + await paymentRequest.save(); + + // 트랜잭션 시작 + const transaction = await sequelize.transaction(); + + try { + // 플랜 변경 처리 + if (paymentRequest.planId) { + if (paymentRequest.planChange) { + if (paymentRequest.isUpgrade) { + // 업그레이드: 현재 구독 업데이트 + const currentSubscription = await ClubSubscription.findOne({ + where: { + clubId: paymentRequest.clubId, + planId: paymentRequest.currentPlanId, + status: 'active' + } + }); + + if (currentSubscription) { + currentSubscription.planId = paymentRequest.planId; + await currentSubscription.save({ transaction }); + } + } else { + // 다운그레이드: 다음 결제일에 적용될 구독 생성 + const currentSubscription = await ClubSubscription.findOne({ + where: { + clubId: paymentRequest.clubId, + planId: paymentRequest.currentPlanId, + status: 'active' + } + }); + + if (currentSubscription) { + await ClubSubscription.create({ + clubId: paymentRequest.clubId, + planId: paymentRequest.planId, + status: 'pending', + startDate: currentSubscription.endDate, + endDate: new Date(new Date(currentSubscription.endDate).setMonth( + new Date(currentSubscription.endDate).getMonth() + paymentRequest.planDuration + )), + price: (await SubscriptionPlan.findByPk(paymentRequest.planId))?.price || 0 + }, { transaction }); + } + } + } else { + // 새 구독 생성 + const startDate = new Date(); + const endDate = new Date(); + endDate.setMonth(endDate.getMonth() + paymentRequest.planDuration); + + await ClubSubscription.create({ + clubId: paymentRequest.clubId, + planId: paymentRequest.planId, + status: 'active', + startDate, + endDate, + price: (await SubscriptionPlan.findByPk(paymentRequest.planId))?.price || 0 + }, { transaction }); + } + } + + // 추가 기능 처리 + if (paymentRequest.features) { + const features = JSON.parse(paymentRequest.features); + + for (const feature of features) { + const expiryDate = new Date(); + expiryDate.setMonth(expiryDate.getMonth() + (feature.duration || paymentRequest.planDuration)); + + await ClubFeature.create({ + clubId: paymentRequest.clubId, + featureId: feature.featureId, + expiryDate, + enabled: true + }, { transaction }); + } + } + + // 결제 기록 생성 + await Payment.create({ + clubId: paymentRequest.clubId, + amount: paymentRequest.totalAmount, + description: paymentRequest.planId + ? `구독 플랜 ${paymentRequest.planDuration}개월` + : '추가 기능 구매', + status: 'completed', + paymentDate: new Date(), + paymentMethod: response.data.method || 'card', + paymentKey + }, { transaction }); + + // 트랜잭션 커밋 + await transaction.commit(); + + // 성공 페이지로 리디렉션 + res.redirect(`${process.env.FRONTEND_URL}/payment/success?orderId=${orderId}`); + + } catch (error) { + // 트랜잭션 롤백 + await transaction.rollback(); + console.error('결제 처리 오류:', error); + res.redirect(`${process.env.FRONTEND_URL}/payment/fail?message=결제는 성공했지만 처리 중 오류가 발생했습니다.`); + } + + } catch (error) { + console.error('결제 성공 처리 오류:', error); + res.redirect(`${process.env.FRONTEND_URL}/payment/fail?message=결제 처리 중 오류가 발생했습니다.`); + } +}; + +// 결제 실패 처리 +exports.processPaymentFail = async (req, res) => { + try { + const { orderId, message } = req.query; + + // 결제 요청 정보 조회 및 업데이트 + if (orderId) { + const paymentRequest = await PaymentRequest.findOne({ where: { orderId } }); + if (paymentRequest) { + paymentRequest.status = 'failed'; + paymentRequest.failReason = message || '사용자가 결제를 취소했습니다.'; + await paymentRequest.save(); + } + } + + // 실패 페이지로 리디렉션 + res.redirect(`${process.env.FRONTEND_URL}/payment/fail?message=${encodeURIComponent(message || '결제가 취소되었습니다.')}`); + + } catch (error) { + console.error('결제 실패 처리 오류:', error); + res.redirect(`${process.env.FRONTEND_URL}/payment/fail?message=결제 취소 처리 중 오류가 발생했습니다.`); } }; diff --git a/backend/models/PaymentRequest.js b/backend/models/PaymentRequest.js new file mode 100644 index 0000000..6fc49e7 --- /dev/null +++ b/backend/models/PaymentRequest.js @@ -0,0 +1,82 @@ +const { DataTypes } = require('sequelize'); +const { sequelize } = require('../config/database'); + +const PaymentRequest = sequelize.define('PaymentRequest', { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + comment: '결제 요청 고유 ID' + }, + clubId: { + type: DataTypes.INTEGER, + allowNull: false, + comment: '클럽 ID' + }, + planId: { + type: DataTypes.INTEGER, + allowNull: true, + comment: '선택한 구독 플랜 ID' + }, + currentPlanId: { + type: DataTypes.INTEGER, + allowNull: true, + comment: '현재 구독 중인 플랜 ID' + }, + isUpgrade: { + type: DataTypes.BOOLEAN, + defaultValue: false, + comment: '업그레이드 여부 (true: 업그레이드, false: 다운그레이드 또는 신규)' + }, + planDuration: { + type: DataTypes.INTEGER, + defaultValue: 1, + comment: '구독 기간 (개월 단위)' + }, + features: { + type: DataTypes.TEXT, + allowNull: true, + comment: '추가 기능 목록 (JSON 문자열)' + }, + totalAmount: { + type: DataTypes.INTEGER, + allowNull: false, + comment: '총 결제 금액 (원)' + }, + status: { + type: DataTypes.STRING, + allowNull: false, + defaultValue: 'pending', + comment: '결제 상태 (pending: 대기, success: 성공, failed: 실패, cancelled: 취소)' + }, + orderId: { + type: DataTypes.STRING, + allowNull: false, + unique: true, + comment: '주문 고유 ID' + }, + paymentKey: { + type: DataTypes.STRING, + allowNull: true, + comment: '결제 서비스 제공자의 결제 키' + }, + paymentData: { + type: DataTypes.TEXT, + allowNull: true, + comment: '결제 관련 추가 데이터 (JSON 문자열)' + }, + planChange: { + type: DataTypes.BOOLEAN, + defaultValue: false, + comment: '플랜 변경 여부 (true: 플랜 변경, false: 신규 구독 또는 기능만 추가)' + }, + failReason: { + type: DataTypes.STRING, + allowNull: true, + comment: '결제 실패 사유' + } +}, { + timestamps: true +}); + +module.exports = PaymentRequest; diff --git a/backend/models/index.js b/backend/models/index.js index 717116f..60dfe82 100644 --- a/backend/models/index.js +++ b/backend/models/index.js @@ -16,6 +16,7 @@ const SubscriptionPlanFeature = require('./SubscriptionPlanFeature'); const ClubSubscription = require('./ClubSubscription'); const ActivityLog = require('./activityLog'); const EventTeamMember = require('./EventTeamMember'); +const PaymentRequest = require('./PaymentRequest'); // 모델 동기화 함수 const syncModels = async () => { @@ -52,6 +53,7 @@ const db = { ClubSubscription, ActivityLog, EventTeamMember, + PaymentRequest, }; Object.values(db).forEach(model => { if (model.associate) model.associate(db); @@ -75,5 +77,6 @@ module.exports = { ClubSubscription, ActivityLog, EventTeamMember, + PaymentRequest, syncModels }; diff --git a/backend/routes/subscriptionRoutes.js b/backend/routes/subscriptionRoutes.js index 8fe9e5f..13fc40a 100644 --- a/backend/routes/subscriptionRoutes.js +++ b/backend/routes/subscriptionRoutes.js @@ -1,23 +1,32 @@ const express = require('express'); const router = express.Router(); const subscriptionController = require('../controllers/subscriptionController'); -const authMiddleware = require('../middleware/auth'); +const { authenticateJWT } = require('../middleware/authMiddleware'); // 구독 목록 조회 -router.get('/', authMiddleware, subscriptionController.getClubSubscriptions); +router.get('/', authenticateJWT, subscriptionController.getClubSubscriptions); // 구독 플랜 관리 (관리자 전용) -router.get('/plans', authMiddleware, subscriptionController.getSubscriptionPlans); -router.post('/plans', authMiddleware, subscriptionController.createSubscriptionPlan); -router.put('/plans/:id', authMiddleware, subscriptionController.updateSubscriptionPlan); -router.delete('/plans/:id', authMiddleware, subscriptionController.deleteSubscriptionPlan); -router.get('/plans/:planId/subscriptions', authMiddleware, subscriptionController.getPlanSubscriptions); +router.get('/plans', authenticateJWT, subscriptionController.getSubscriptionPlans); +router.post('/plans', authenticateJWT, subscriptionController.createSubscriptionPlan); +router.put('/plans/:id', authenticateJWT, subscriptionController.updateSubscriptionPlan); +router.delete('/plans/:id', authenticateJWT, subscriptionController.deleteSubscriptionPlan); +router.get('/plans/:planId/subscriptions', authenticateJWT, subscriptionController.getPlanSubscriptions); + +// 플랜 상세 정보 조회 (포함된 기능 포함) +router.get('/plans/:planId', authenticateJWT, subscriptionController.getPlanDetail); // 클럽 구독 관리 -router.post('/subscribe', authMiddleware, subscriptionController.subscribeClub); -router.get('/clubs/:clubId', authMiddleware, subscriptionController.getClubSubscription); +router.post('/subscribe', authenticateJWT, subscriptionController.subscribeClub); +router.post('/clubs', authenticateJWT, subscriptionController.getClubSubscription); // 클럽 구독 정보 및 결제 내역 (POST, 클럽 기준) -router.post('/current', authMiddleware, subscriptionController.getCurrentSubscription); -router.post('/payments', authMiddleware, subscriptionController.getSubscriptionPayments); +router.post('/current', authenticateJWT, subscriptionController.getCurrentSubscription); +router.post('/payments', authenticateJWT, subscriptionController.getSubscriptionPayments); +router.post('/change-with-features', authenticateJWT, subscriptionController.changeWithFeatures); + +// 토스페이먼츠 결제 관련 라우트 +router.post('/payment/request', authenticateJWT, subscriptionController.requestPayment); +router.post('/payment/success', subscriptionController.processPaymentSuccess); +router.post('/payment/fail', subscriptionController.processPaymentFail); module.exports = router; \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6ddd0e2..4ddb06a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -15,6 +15,7 @@ "@fullcalendar/vue3": "^6.1.15", "@primeuix/themes": "^1.0.3", "@primevue/themes": "^4.3.3", + "@tosspayments/payment-sdk": "^1.9.1", "@vuelidate/core": "^2.0.3", "@vuelidate/validators": "^2.0.4", "axios": "^1.8.2", @@ -1459,6 +1460,45 @@ "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", "license": "MIT" }, + "node_modules/@tosspayments/payment__types": { + "version": "1.69.0", + "resolved": "https://registry.npmjs.org/@tosspayments/payment__types/-/payment__types-1.69.0.tgz", + "integrity": "sha512-EbU36vLEWfhRpX/g4V0DyRiUmTkNeeVDYjJamD9aJiLmRbLl9PVdXi5QOQTMYma2RGRgA8drHY4yDMsjvKFwCg==", + "license": "MIT", + "dependencies": { + "@tosspayments/sdk-constants": "^0.2.2" + } + }, + "node_modules/@tosspayments/payment-sdk": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@tosspayments/payment-sdk/-/payment-sdk-1.9.1.tgz", + "integrity": "sha512-OzDG2kOGOrb5JRvprR8R/mQzXFNnLW/6X09uLY1KF4+O7ASu6LrMoFCERd+WeAJMtRAARkclGDcJ/GSu+OHzzg==", + "license": "MIT", + "dependencies": { + "@tosspayments/payment__types": "1.69.0" + } + }, + "node_modules/@tosspayments/sdk-constants": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@tosspayments/sdk-constants/-/sdk-constants-0.2.2.tgz", + "integrity": "sha512-qTIcD9JnVtN65/yiW5sPAlvK5fpQtDluv4IEyslubncbQLQKE+ePSnGZU1fLNPxcchv6QTnwmgj9Ndm6EN/Ymg==", + "license": "MIT", + "dependencies": { + "type-fest": "^2.11.2" + } + }, + "node_modules/@tosspayments/sdk-constants/node_modules/type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@types/estree": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", diff --git a/frontend/package.json b/frontend/package.json index 1b17d80..da89795 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,6 +17,7 @@ "@fullcalendar/vue3": "^6.1.15", "@primeuix/themes": "^1.0.3", "@primevue/themes": "^4.3.3", + "@tosspayments/payment-sdk": "^1.9.1", "@vuelidate/core": "^2.0.3", "@vuelidate/validators": "^2.0.4", "axios": "^1.8.2", diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 73e57b9..4e21c96 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -86,11 +86,13 @@ + + diff --git a/frontend/src/views/payment/PaymentSuccess.vue b/frontend/src/views/payment/PaymentSuccess.vue new file mode 100644 index 0000000..d339cc4 --- /dev/null +++ b/frontend/src/views/payment/PaymentSuccess.vue @@ -0,0 +1,133 @@ + + + + +