feat: 구독 관리 시스템 (해지, 자동갱신 토글, 만료 Cron)
- subscriptions 테이블 마이그레이션 (기존 paid orders에서 자동 생성) - GET/PATCH /api/subscription: 구독 조회, 해지, 자동갱신 토글 - 마이페이지 구독 관리 탭: D-day, 해지 버튼, 자동갱신 토글 - 해지 시 만료일까지 서비스 계속 이용 가능 - Vercel Cron: 매일 01:00 KST 만료 구독 자동 처리 + 텔레그램 알림 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
78
app/api/cron/subscription-expiry/route.ts
Normal file
78
app/api/cron/subscription-expiry/route.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createAdminClient } from '@/lib/supabase/admin';
|
||||
import { sendMessage } from '@/lib/telegram';
|
||||
|
||||
/**
|
||||
* GET /api/cron/subscription-expiry
|
||||
* Vercel Cron: 매일 01:00 KST (UTC 16:00) 실행
|
||||
* - 만료된 구독 → status='expired'
|
||||
* - 3일 후 만료 예정 구독 → 텔레그램 알림 발송
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
// Vercel Cron 인증
|
||||
const authHeader = req.headers.get('authorization');
|
||||
if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
|
||||
return NextResponse.json({ error: 'UNAUTHORIZED' }, { status: 401 });
|
||||
}
|
||||
|
||||
const supabase = createAdminClient();
|
||||
const now = new Date().toISOString();
|
||||
const in3days = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString();
|
||||
|
||||
// 1. 만료된 구독 처리
|
||||
const { data: expired, error: expireError } = await supabase
|
||||
.from('subscriptions')
|
||||
.update({ status: 'expired' })
|
||||
.eq('status', 'active')
|
||||
.lt('expires_at', now)
|
||||
.select('id, user_id, product_id');
|
||||
|
||||
if (expireError) {
|
||||
console.error('subscription expiry error:', expireError);
|
||||
}
|
||||
|
||||
// 2. 3일 후 만료 예정 → 텔레그램 알림
|
||||
const { data: expiringSoon } = await supabase
|
||||
.from('subscriptions')
|
||||
.select('id, user_id, product_id, expires_at, profiles!inner(telegram_chat_id)')
|
||||
.eq('status', 'active')
|
||||
.eq('auto_renew', false)
|
||||
.lt('expires_at', in3days)
|
||||
.gt('expires_at', now);
|
||||
|
||||
const PLAN_NAMES: Record<string, string> = {
|
||||
lotto_gold: '🥇 골드',
|
||||
lotto_platinum: '💎 플래티넘',
|
||||
lotto_diamond: '👑 다이아',
|
||||
};
|
||||
|
||||
let notified = 0;
|
||||
if (expiringSoon) {
|
||||
for (const sub of expiringSoon) {
|
||||
const profile = sub.profiles as unknown as { telegram_chat_id: string | null };
|
||||
const chatId = profile?.telegram_chat_id;
|
||||
if (!chatId) continue;
|
||||
|
||||
const expiresAt = new Date(sub.expires_at).toLocaleDateString('ko-KR');
|
||||
const planName = PLAN_NAMES[sub.product_id] ?? sub.product_id;
|
||||
|
||||
await sendMessage(
|
||||
chatId,
|
||||
`⏰ *구독 만료 안내*\n\n` +
|
||||
`로또 번호 추천 *${planName}* 플랜이\n` +
|
||||
`*${expiresAt}*에 만료됩니다.\n\n` +
|
||||
`지속적인 번호 추천을 받으시려면\n` +
|
||||
`마이페이지에서 구독을 갱신해 주세요.\n\n` +
|
||||
`👉 https://jaengseung-made.com/mypage`
|
||||
);
|
||||
notified++;
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
expired_count: expired?.length ?? 0,
|
||||
notified_count: notified,
|
||||
processed_at: now,
|
||||
});
|
||||
}
|
||||
87
app/api/subscription/[id]/route.ts
Normal file
87
app/api/subscription/[id]/route.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
|
||||
/**
|
||||
* PATCH /api/subscription/[id]
|
||||
* action: 'cancel' | 'toggle_autorenew'
|
||||
*
|
||||
* cancel — 구독 즉시 해지 (status='cancelled', auto_renew=false)
|
||||
* toggle_autorenew — 자동갱신 on/off 전환
|
||||
*/
|
||||
export async function PATCH(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient();
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser();
|
||||
if (authError || !user) {
|
||||
return NextResponse.json({ error: 'UNAUTHORIZED' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
let body: { action?: string };
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'INVALID_JSON' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { action } = body;
|
||||
|
||||
// 본인 구독인지 확인
|
||||
const { data: sub, error: fetchError } = await supabase
|
||||
.from('subscriptions')
|
||||
.select('id, status, auto_renew, expires_at')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.maybeSingle();
|
||||
|
||||
if (fetchError || !sub) {
|
||||
return NextResponse.json({ error: 'NOT_FOUND' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (action === 'cancel') {
|
||||
if (sub.status === 'cancelled') {
|
||||
return NextResponse.json({ error: 'ALREADY_CANCELLED' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from('subscriptions')
|
||||
.update({
|
||||
status: 'cancelled',
|
||||
auto_renew: false,
|
||||
cancelled_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('id', id);
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: 'DB_ERROR' }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
message: '구독이 해지되었습니다. 만료일까지는 서비스를 계속 이용할 수 있습니다.',
|
||||
expires_at: sub.expires_at,
|
||||
});
|
||||
}
|
||||
|
||||
if (action === 'toggle_autorenew') {
|
||||
if (sub.status === 'cancelled' || sub.status === 'expired') {
|
||||
return NextResponse.json({ error: 'SUBSCRIPTION_NOT_ACTIVE' }, { status: 400 });
|
||||
}
|
||||
|
||||
const newValue = !sub.auto_renew;
|
||||
const { error } = await supabase
|
||||
.from('subscriptions')
|
||||
.update({ auto_renew: newValue })
|
||||
.eq('id', id);
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: 'DB_ERROR' }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, auto_renew: newValue });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'INVALID_ACTION' }, { status: 400 });
|
||||
}
|
||||
27
app/api/subscription/route.ts
Normal file
27
app/api/subscription/route.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
|
||||
/**
|
||||
* GET /api/subscription
|
||||
* 내 활성/만료 구독 목록 조회
|
||||
*/
|
||||
export async function GET() {
|
||||
const supabase = await createClient();
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser();
|
||||
if (authError || !user) {
|
||||
return NextResponse.json({ error: 'UNAUTHORIZED' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('subscriptions')
|
||||
.select('id, product_id, status, auto_renew, started_at, expires_at, cancelled_at')
|
||||
.eq('user_id', user.id)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(20);
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: 'DB_ERROR' }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, subscriptions: data ?? [] });
|
||||
}
|
||||
@@ -15,7 +15,7 @@ function buildSajuResultUrl(rec: SajuRecord) {
|
||||
return url;
|
||||
}
|
||||
|
||||
type Tab = 'profile' | 'saju' | 'lotto' | 'payments' | 'orders';
|
||||
type Tab = 'profile' | 'subscription' | 'lotto' | 'saju' | 'payments' | 'orders';
|
||||
type TelegramLinkState = 'idle' | 'generating' | 'waiting' | 'disconnecting';
|
||||
|
||||
interface SajuRecord {
|
||||
@@ -57,9 +57,13 @@ interface LottoHistoryItem {
|
||||
}
|
||||
|
||||
interface ActiveSubscription {
|
||||
id: string;
|
||||
product_id: string;
|
||||
created_at: string;
|
||||
status: string;
|
||||
auto_renew: boolean;
|
||||
started_at: string;
|
||||
expires_at: string;
|
||||
cancelled_at: string | null;
|
||||
}
|
||||
|
||||
const PLAN_LABELS: Record<string, { label: string; emoji: string; color: string }> = {
|
||||
@@ -131,24 +135,11 @@ export default function MyPage() {
|
||||
.maybeSingle();
|
||||
setTelegramChatId(profile?.telegram_chat_id ?? null);
|
||||
|
||||
// 활성 구독 조회 (paid 상태의 lotto 플랜)
|
||||
const LOTTO_PLANS = ['lotto_gold', 'lotto_platinum', 'lotto_diamond'];
|
||||
const { data: subs } = await supabase
|
||||
.from('orders')
|
||||
.select('product_id, created_at')
|
||||
.eq('user_id', user.id)
|
||||
.eq('status', 'paid')
|
||||
.in('product_id', LOTTO_PLANS)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (subs && subs.length > 0) {
|
||||
const activeSubs: ActiveSubscription[] = subs.map((s) => {
|
||||
const createdAt = new Date(s.created_at);
|
||||
const expiresAt = new Date(createdAt);
|
||||
expiresAt.setDate(expiresAt.getDate() + 31);
|
||||
return { product_id: s.product_id, created_at: s.created_at, expires_at: expiresAt.toISOString() };
|
||||
});
|
||||
setActiveSubscriptions(activeSubs);
|
||||
// 구독 목록 조회 (subscriptions 테이블)
|
||||
const subRes = await fetch('/api/subscription');
|
||||
if (subRes.ok) {
|
||||
const subData = await subRes.json();
|
||||
setActiveSubscriptions(subData.subscriptions ?? []);
|
||||
}
|
||||
|
||||
// 로또 히스토리 조회
|
||||
@@ -171,6 +162,38 @@ export default function MyPage() {
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
// ── 구독 해지 ──
|
||||
const handleCancelSubscription = async (subId: string) => {
|
||||
if (!confirm('구독을 해지하시겠습니까?\n만료일까지는 서비스를 계속 이용할 수 있습니다.')) return;
|
||||
const res = await fetch(`/api/subscription/${subId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'cancel' }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setActiveSubscriptions((prev) =>
|
||||
prev.map((s) => s.id === subId ? { ...s, status: 'cancelled', auto_renew: false, cancelled_at: new Date().toISOString() } : s)
|
||||
);
|
||||
} else {
|
||||
alert('해지 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.');
|
||||
}
|
||||
};
|
||||
|
||||
// ── 자동갱신 토글 ──
|
||||
const handleToggleAutoRenew = async (subId: string) => {
|
||||
const res = await fetch(`/api/subscription/${subId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'toggle_autorenew' }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setActiveSubscriptions((prev) =>
|
||||
prev.map((s) => s.id === subId ? { ...s, auto_renew: data.auto_renew } : s)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// ── 텔레그램 연결 ──
|
||||
const handleTelegramConnect = async () => {
|
||||
setTelegramLinkState('generating');
|
||||
@@ -226,12 +249,15 @@ export default function MyPage() {
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
const activeSubs = activeSubscriptions.filter((s) => s.status === 'active' || s.status === 'cancelled');
|
||||
|
||||
const tabs: { key: Tab; label: string; count?: number }[] = [
|
||||
{ key: 'profile', label: '내 정보' },
|
||||
{ key: 'saju', label: '사주 기록', count: sajuRecords.length },
|
||||
{ key: 'lotto', label: '🎰 로또 기록', count: lottoHistory.length },
|
||||
{ key: 'payments', label: '결제 내역', count: payments.length },
|
||||
{ key: 'orders', label: '의뢰 내역', count: orders.length },
|
||||
{ key: 'subscription', label: '구독 관리', count: activeSubs.length || undefined },
|
||||
{ key: 'lotto', label: '로또 기록', count: lottoHistory.length || undefined },
|
||||
{ key: 'saju', label: '사주 기록', count: sajuRecords.length || undefined },
|
||||
{ key: 'payments', label: '결제 내역', count: payments.length || undefined },
|
||||
{ key: 'orders', label: '의뢰 내역', count: orders.length || undefined },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -321,55 +347,25 @@ export default function MyPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 구독 중인 서비스 */}
|
||||
{activeSubscriptions.length > 0 && (
|
||||
<div className="bg-white rounded-2xl border border-[#dbe8ff] p-6">
|
||||
<h2 className="font-bold text-[#04102b] mb-4 flex items-center gap-2">
|
||||
<div className="w-1 h-5 bg-gradient-to-b from-amber-400 to-orange-500 rounded-full" />
|
||||
구독 중인 서비스
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
{activeSubscriptions.map((sub) => {
|
||||
const info = PLAN_LABELS[sub.product_id];
|
||||
const expiresDate = new Date(sub.expires_at);
|
||||
const daysLeft = Math.max(0, Math.ceil((expiresDate.getTime() - Date.now()) / (1000 * 60 * 60 * 24)));
|
||||
const isExpired = daysLeft === 0;
|
||||
return (
|
||||
<div key={sub.product_id + sub.created_at}
|
||||
className={`flex items-center justify-between p-4 rounded-xl border ${isExpired ? 'border-slate-200 bg-slate-50' : 'border-amber-200 bg-amber-50/50'}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">{info?.emoji ?? '🎟'}</span>
|
||||
<div>
|
||||
<div className="text-sm font-bold text-[#04102b]">
|
||||
로또 번호 추천 {info?.label ?? sub.product_id}
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 mt-0.5">
|
||||
{new Date(sub.created_at).toLocaleDateString('ko-KR')} 구독 시작
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
{isExpired ? (
|
||||
<span className="text-xs font-bold text-slate-400 bg-slate-100 px-2 py-1 rounded-lg">만료됨</span>
|
||||
) : (
|
||||
<>
|
||||
<div className={`text-sm font-bold ${daysLeft <= 5 ? 'text-red-500' : 'text-amber-600'}`}>
|
||||
D-{daysLeft}
|
||||
</div>
|
||||
<div className="text-xs text-slate-400">{expiresDate.toLocaleDateString('ko-KR')} 만료</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<a href="/services/lotto/recommend"
|
||||
className="inline-flex items-center gap-1.5 text-xs font-semibold text-amber-600 hover:text-amber-700 transition">
|
||||
번호 추천받기 →
|
||||
</a>
|
||||
{/* 구독 중인 서비스 - 요약 (탭으로 유도) */}
|
||||
{activeSubs.length > 0 && (
|
||||
<div className="bg-gradient-to-br from-amber-50 to-orange-50 rounded-2xl border border-amber-200 p-5 flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">{PLAN_LABELS[activeSubs[0].product_id]?.emoji ?? '🎟'}</span>
|
||||
<div>
|
||||
<div className="text-sm font-bold text-[#04102b]">
|
||||
로또 {PLAN_LABELS[activeSubs[0].product_id]?.label} 구독 중
|
||||
</div>
|
||||
<div className="text-xs text-amber-600 mt-0.5">
|
||||
{Math.max(0, Math.ceil((new Date(activeSubs[0].expires_at).getTime() - Date.now()) / 86400000))}일 후 만료
|
||||
{activeSubs[0].status === 'cancelled' && ' · 해지 예정'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => setTab('subscription')}
|
||||
className="text-xs font-bold text-amber-700 bg-amber-100 hover:bg-amber-200 px-3 py-1.5 rounded-lg transition">
|
||||
구독 관리 →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -520,6 +516,122 @@ export default function MyPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 구독 관리 */}
|
||||
{tab === 'subscription' && (
|
||||
<div className="space-y-4">
|
||||
{activeSubscriptions.length === 0 ? (
|
||||
<EmptyState
|
||||
icon="📦"
|
||||
title="활성 구독이 없습니다"
|
||||
desc="로또 번호 추천 서비스를 구독하면 여기서 관리할 수 있습니다"
|
||||
linkHref="/services/lotto"
|
||||
linkLabel="구독 플랜 보기"
|
||||
/>
|
||||
) : (
|
||||
activeSubscriptions.map((sub) => {
|
||||
const info = PLAN_LABELS[sub.product_id];
|
||||
const expiresDate = new Date(sub.expires_at);
|
||||
const daysLeft = Math.max(0, Math.ceil((expiresDate.getTime() - Date.now()) / 86400000));
|
||||
const isExpired = sub.status === 'expired';
|
||||
const isCancelled = sub.status === 'cancelled';
|
||||
const isActive = sub.status === 'active';
|
||||
|
||||
return (
|
||||
<div key={sub.id} className={`bg-white rounded-2xl border p-6 ${isExpired ? 'border-slate-200 opacity-60' : isCancelled ? 'border-orange-200' : 'border-[#dbe8ff]'}`}>
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-start justify-between mb-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-3xl">{info?.emoji ?? '🎟'}</span>
|
||||
<div>
|
||||
<div className="font-bold text-[#04102b] text-base">
|
||||
로또 번호 추천 {info?.label ?? sub.product_id}
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 mt-0.5">
|
||||
{new Date(sub.started_at).toLocaleDateString('ko-KR')} 시작
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`text-xs font-bold px-2.5 py-1 rounded-full ${
|
||||
isActive ? 'bg-emerald-50 text-emerald-600 border border-emerald-200' :
|
||||
isCancelled ? 'bg-orange-50 text-orange-600 border border-orange-200' :
|
||||
'bg-slate-100 text-slate-500'
|
||||
}`}>
|
||||
{isActive ? '이용 중' : isCancelled ? '해지 예정' : '만료됨'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 만료 정보 */}
|
||||
<div className="grid grid-cols-2 gap-3 mb-5">
|
||||
<div className="bg-slate-50 rounded-xl p-3">
|
||||
<div className="text-xs text-slate-400 mb-1">만료일</div>
|
||||
<div className="text-sm font-bold text-[#04102b]">
|
||||
{expiresDate.toLocaleDateString('ko-KR', { year: 'numeric', month: 'long', day: 'numeric' })}
|
||||
</div>
|
||||
</div>
|
||||
<div className={`rounded-xl p-3 ${daysLeft <= 5 && !isExpired ? 'bg-red-50' : 'bg-slate-50'}`}>
|
||||
<div className="text-xs text-slate-400 mb-1">남은 기간</div>
|
||||
<div className={`text-sm font-bold ${isExpired ? 'text-slate-400' : daysLeft <= 5 ? 'text-red-500' : 'text-emerald-600'}`}>
|
||||
{isExpired ? '만료됨' : `D-${daysLeft}`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 자동갱신 토글 */}
|
||||
{!isExpired && (
|
||||
<div className="flex items-center justify-between py-3 border-t border-slate-100 mb-4">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-[#04102b]">자동 갱신</div>
|
||||
<div className="text-xs text-slate-400 mt-0.5">
|
||||
{sub.auto_renew ? '만료 시 자동으로 갱신됩니다' : '만료 시 자동 갱신되지 않습니다'}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleToggleAutoRenew(sub.id)}
|
||||
disabled={isCancelled}
|
||||
className={`relative w-11 h-6 rounded-full transition-colors duration-200 focus:outline-none disabled:opacity-40 ${sub.auto_renew ? 'bg-emerald-500' : 'bg-slate-200'}`}
|
||||
>
|
||||
<span className={`absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform duration-200 ${sub.auto_renew ? 'translate-x-5' : 'translate-x-0'}`} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 해지 취소 버튼 */}
|
||||
{isCancelled && (
|
||||
<div className="bg-orange-50 border border-orange-200 rounded-xl p-3 mb-4 text-xs text-orange-700">
|
||||
해지 신청됨 · {expiresDate.toLocaleDateString('ko-KR')}까지 서비스를 이용할 수 있습니다.
|
||||
{sub.cancelled_at && ` (해지일: ${new Date(sub.cancelled_at).toLocaleDateString('ko-KR')})`}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 액션 버튼 */}
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<a href="/services/lotto/recommend"
|
||||
className="flex-1 text-center py-2 text-sm font-bold text-white bg-gradient-to-r from-amber-500 to-orange-500 hover:from-amber-400 hover:to-orange-400 rounded-xl transition shadow-sm">
|
||||
번호 추천받기
|
||||
</a>
|
||||
{isActive && (
|
||||
<button
|
||||
onClick={() => handleCancelSubscription(sub.id)}
|
||||
className="px-4 py-2 text-sm font-semibold text-red-500 border border-red-200 rounded-xl hover:bg-red-50 transition"
|
||||
>
|
||||
구독 해지
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
{/* 구독 플랜 이동 */}
|
||||
<div className="text-center py-2">
|
||||
<a href="/services/lotto" className="text-sm text-slate-400 hover:text-slate-600 transition">
|
||||
다른 플랜 보기 →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 로또 번호 기록 */}
|
||||
{tab === 'lotto' && (
|
||||
<div>
|
||||
|
||||
44
supabase/migrations/004_subscriptions.sql
Normal file
44
supabase/migrations/004_subscriptions.sql
Normal file
@@ -0,0 +1,44 @@
|
||||
-- ─── 구독 관리 테이블 ──────────────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS public.subscriptions (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id uuid NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
|
||||
product_id text NOT NULL REFERENCES public.products(id),
|
||||
order_id uuid REFERENCES public.orders(id),
|
||||
status text NOT NULL DEFAULT 'active', -- 'active' | 'cancelled' | 'expired'
|
||||
auto_renew boolean NOT NULL DEFAULT false, -- Toss 빌링키 연동 전까지 false
|
||||
started_at timestamptz NOT NULL DEFAULT now(),
|
||||
expires_at timestamptz NOT NULL,
|
||||
cancelled_at timestamptz,
|
||||
billing_key text, -- Toss 자동결제 빌링키 (향후)
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- RLS
|
||||
ALTER TABLE public.subscriptions ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY "subscriptions_select_own" ON public.subscriptions
|
||||
FOR SELECT USING (auth.uid() = user_id);
|
||||
|
||||
CREATE POLICY "subscriptions_update_own" ON public.subscriptions
|
||||
FOR UPDATE USING (auth.uid() = user_id);
|
||||
|
||||
-- 인덱스
|
||||
CREATE INDEX IF NOT EXISTS subscriptions_user_status ON public.subscriptions (user_id, status);
|
||||
CREATE INDEX IF NOT EXISTS subscriptions_expires_at ON public.subscriptions (expires_at) WHERE status = 'active';
|
||||
|
||||
-- ─── 기존 paid lotto orders → subscriptions 마이그레이션 ───────────────────────
|
||||
INSERT INTO public.subscriptions (user_id, product_id, order_id, status, started_at, expires_at)
|
||||
SELECT
|
||||
o.user_id,
|
||||
o.product_id,
|
||||
o.id,
|
||||
CASE
|
||||
WHEN (o.created_at + INTERVAL '31 days') < now() THEN 'expired'
|
||||
ELSE 'active'
|
||||
END,
|
||||
o.created_at,
|
||||
o.created_at + INTERVAL '31 days'
|
||||
FROM public.orders o
|
||||
WHERE o.status = 'paid'
|
||||
AND o.product_id IN ('lotto_gold', 'lotto_platinum', 'lotto_diamond')
|
||||
ON CONFLICT DO NOTHING;
|
||||
8
vercel.json
Normal file
8
vercel.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"crons": [
|
||||
{
|
||||
"path": "/api/cron/subscription-expiry",
|
||||
"schedule": "0 16 * * *"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user