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:
2026-03-16 03:32:31 +09:00
parent cee7e74793
commit b931438e51
6 changed files with 428 additions and 72 deletions

View 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,
});
}

View 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 });
}

View 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 ?? [] });
}

View File

@@ -15,7 +15,7 @@ function buildSajuResultUrl(rec: SajuRecord) {
return url; return url;
} }
type Tab = 'profile' | 'saju' | 'lotto' | 'payments' | 'orders'; type Tab = 'profile' | 'subscription' | 'lotto' | 'saju' | 'payments' | 'orders';
type TelegramLinkState = 'idle' | 'generating' | 'waiting' | 'disconnecting'; type TelegramLinkState = 'idle' | 'generating' | 'waiting' | 'disconnecting';
interface SajuRecord { interface SajuRecord {
@@ -57,9 +57,13 @@ interface LottoHistoryItem {
} }
interface ActiveSubscription { interface ActiveSubscription {
id: string;
product_id: string; product_id: string;
created_at: string; status: string;
auto_renew: boolean;
started_at: string;
expires_at: string; expires_at: string;
cancelled_at: string | null;
} }
const PLAN_LABELS: Record<string, { label: string; emoji: string; color: string }> = { const PLAN_LABELS: Record<string, { label: string; emoji: string; color: string }> = {
@@ -131,24 +135,11 @@ export default function MyPage() {
.maybeSingle(); .maybeSingle();
setTelegramChatId(profile?.telegram_chat_id ?? null); setTelegramChatId(profile?.telegram_chat_id ?? null);
// 활성 구독 조회 (paid 상태의 lotto 플랜) // 구독 목록 조회 (subscriptions 테이블)
const LOTTO_PLANS = ['lotto_gold', 'lotto_platinum', 'lotto_diamond']; const subRes = await fetch('/api/subscription');
const { data: subs } = await supabase if (subRes.ok) {
.from('orders') const subData = await subRes.json();
.select('product_id, created_at') setActiveSubscriptions(subData.subscriptions ?? []);
.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);
} }
// 로또 히스토리 조회 // 로또 히스토리 조회
@@ -171,6 +162,38 @@ export default function MyPage() {
router.refresh(); 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 () => { const handleTelegramConnect = async () => {
setTelegramLinkState('generating'); setTelegramLinkState('generating');
@@ -226,12 +249,15 @@ export default function MyPage() {
if (!user) return null; if (!user) return null;
const activeSubs = activeSubscriptions.filter((s) => s.status === 'active' || s.status === 'cancelled');
const tabs: { key: Tab; label: string; count?: number }[] = [ const tabs: { key: Tab; label: string; count?: number }[] = [
{ key: 'profile', label: '내 정보' }, { key: 'profile', label: '내 정보' },
{ key: 'saju', label: '사주 기록', count: sajuRecords.length }, { key: 'subscription', label: '구독 관리', count: activeSubs.length || undefined },
{ key: 'lotto', label: '🎰 로또 기록', count: lottoHistory.length }, { key: 'lotto', label: '로또 기록', count: lottoHistory.length || undefined },
{ key: 'payments', label: '결제 내역', count: payments.length }, { key: 'saju', label: '사주 기록', count: sajuRecords.length || undefined },
{ key: 'orders', label: '의뢰 내역', count: orders.length }, { key: 'payments', label: '결제 내역', count: payments.length || undefined },
{ key: 'orders', label: '의뢰 내역', count: orders.length || undefined },
]; ];
return ( return (
@@ -321,55 +347,25 @@ export default function MyPage() {
</div> </div>
</div> </div>
{/* 구독 중인 서비스 */} {/* 구독 중인 서비스 - 요약 (탭으로 유도) */}
{activeSubscriptions.length > 0 && ( {activeSubs.length > 0 && (
<div className="bg-white rounded-2xl border border-[#dbe8ff] p-6"> <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">
<h2 className="font-bold text-[#04102b] mb-4 flex items-center gap-2"> <div className="flex items-center gap-3">
<div className="w-1 h-5 bg-gradient-to-b from-amber-400 to-orange-500 rounded-full" /> <span className="text-2xl">{PLAN_LABELS[activeSubs[0].product_id]?.emoji ?? '🎟'}</span>
<div>
</h2> <div className="text-sm font-bold text-[#04102b]">
<div className="space-y-3"> {PLAN_LABELS[activeSubs[0].product_id]?.label}
{activeSubscriptions.map((sub) => { </div>
const info = PLAN_LABELS[sub.product_id]; <div className="text-xs text-amber-600 mt-0.5">
const expiresDate = new Date(sub.expires_at); {Math.max(0, Math.ceil((new Date(activeSubs[0].expires_at).getTime() - Date.now()) / 86400000))}
const daysLeft = Math.max(0, Math.ceil((expiresDate.getTime() - Date.now()) / (1000 * 60 * 60 * 24))); {activeSubs[0].status === 'cancelled' && ' · 해지 예정'}
const isExpired = daysLeft === 0; </div>
return ( </div>
<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>
</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> </div>
)} )}
@@ -520,6 +516,122 @@ export default function MyPage() {
</div> </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' && ( {tab === 'lotto' && (
<div> <div>

View 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
View File

@@ -0,0 +1,8 @@
{
"crons": [
{
"path": "/api/cron/subscription-expiry",
"schedule": "0 16 * * *"
}
]
}