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