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,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;