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 @@
+
+
+
+
+
+
+
결제가 완료되었습니다
+
주문번호: {{ orderId }}
+
+
+
+
+
+
+
+
+
+
+