From 8e1cf9b4e18fe9da92c1e75618c35f61fdc143f2 Mon Sep 17 00:00:00 2001 From: gahusb Date: Thu, 2 Jul 2026 14:07:56 +0900 Subject: [PATCH] =?UTF-8?q?chore(phase0):=20packages=C2=B7subscription=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0=20=E2=80=94=20=ED=8E=98=EC=9D=B4=EC=A7=80/AP?= =?UTF-8?q?I/cron/vercel.json=20+=20=ED=8C=8C=EA=B8=89(stats=C2=B7members?= =?UTF-8?q?=C2=B7saju)=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- app/admin/dashboard/page.tsx | 12 -- app/admin/members/page.tsx | 29 ---- app/api/admin/members/route.ts | 6 +- app/api/admin/services/route.ts | 1 - app/api/admin/stats/route.ts | 6 +- app/api/cron/subscription-expiry/route.ts | 78 ---------- app/api/subscription/[id]/route.ts | 87 ----------- app/api/subscription/route.ts | 31 ---- app/packages/layout.tsx | 21 --- app/packages/page.tsx | 173 ---------------------- app/work/saju/result/page.tsx | 29 +--- lib/saas-catalog.ts | 64 -------- lib/service-visibility.ts | 2 +- vercel.json | 8 - 14 files changed, 13 insertions(+), 534 deletions(-) delete mode 100644 app/api/cron/subscription-expiry/route.ts delete mode 100644 app/api/subscription/[id]/route.ts delete mode 100644 app/api/subscription/route.ts delete mode 100644 app/packages/layout.tsx delete mode 100644 app/packages/page.tsx delete mode 100644 lib/saas-catalog.ts delete mode 100644 vercel.json diff --git a/app/admin/dashboard/page.tsx b/app/admin/dashboard/page.tsx index ef1a0a0..8499b29 100644 --- a/app/admin/dashboard/page.tsx +++ b/app/admin/dashboard/page.tsx @@ -7,7 +7,6 @@ interface Stats { totalOrders: number; totalRevenue: number; pendingContacts: number; - activeSubscribers: number; monthlyChart: Array<{ month: string; revenue: number }>; } @@ -157,17 +156,6 @@ export default function AdminDashboard() { } /> - - - - } - /> = { - lotto_gold: '🥇 골드', - lotto_platinum: '💎 플래티넘', - lotto_diamond: '👑 다이아', -}; - export default function AdminMembersPage() { const [members, setMembers] = useState([]); const [loading, setLoading] = useState(true); @@ -77,7 +70,6 @@ export default function AdminMembersPage() { 이메일 이름 가입일 - 구독 결제 건수 총 결제액 @@ -90,16 +82,6 @@ export default function AdminMembersPage() { {new Date(m.created_at).toLocaleDateString('ko-KR')} - - {m.activeSub ? ( -
- {PLAN_LABELS[m.activeSub.product_id] ?? m.activeSub.product_id} -
{new Date(m.activeSub.expires_at).toLocaleDateString('ko-KR')} 만료
-
- ) : ( - - - )} - 0 ? 'bg-green-900/40 text-green-400' : 'bg-slate-700 text-slate-500'}`}> {m.orderCount}건 @@ -124,11 +106,6 @@ export default function AdminMembersPage() {

{m.email ?? '-'}

{m.full_name ?? '이름 없음'}

- {m.activeSub && ( - - {PLAN_LABELS[m.activeSub.product_id] ?? m.activeSub.product_id} - - )} {/* 상세 정보 그리드 */} @@ -150,12 +127,6 @@ export default function AdminMembersPage() {

- - {m.activeSub && ( -

- 구독 만료: {new Date(m.activeSub.expires_at).toLocaleDateString('ko-KR')} -

- )} ))} diff --git a/app/api/admin/members/route.ts b/app/api/admin/members/route.ts index 860abd3..863164f 100644 --- a/app/api/admin/members/route.ts +++ b/app/api/admin/members/route.ts @@ -27,14 +27,12 @@ export async function GET() { // 각 회원의 주문 수 + 결제 금액 집계 const enriched = await Promise.all( (profiles ?? []).map(async (p: { id: string; email: string; full_name: string; created_at: string }) => { - const [ordersRes, paymentsRes, subsRes] = await Promise.all([ + const [ordersRes, paymentsRes] = await Promise.all([ supabase.from('orders').select('id', { count: 'exact', head: true }).eq('user_id', p.id).eq('status', 'paid'), supabase.from('payments').select('amount').eq('user_id', p.id).eq('status', 'paid'), - supabase.from('subscriptions').select('product_id, status, expires_at').eq('user_id', p.id).eq('status', 'active').order('created_at', { ascending: false }).limit(1), ]); const totalPaid = (paymentsRes.data ?? []).reduce((s: number, x: { amount: number }) => s + x.amount, 0); - const activeSub = subsRes.data?.[0] ?? null; - return { ...p, orderCount: ordersRes.count ?? 0, totalPaid, activeSub }; + return { ...p, orderCount: ordersRes.count ?? 0, totalPaid }; }) ); diff --git a/app/api/admin/services/route.ts b/app/api/admin/services/route.ts index 6cf8b16..b81f124 100644 --- a/app/api/admin/services/route.ts +++ b/app/api/admin/services/route.ts @@ -54,6 +54,5 @@ const DEFAULT_SERVICES = [ { id: 'saju', name: 'AI 사주 분석', description: '사주 입력 및 AI 해석 (레거시)', is_active: false, order_index: 101 }, { id: 'music', name: 'AI 음악 팩', description: '음악 가이드 패키지·샘플·스튜디오', is_active: false, order_index: 102 }, { id: 'gyeol', name: 'CONTOUR 설문', description: '/gyeol PMF 설문', is_active: false, order_index: 103 }, - { id: 'packages', name: 'SaaS 제품 허브(구)', description: '구 /packages 페이지', is_active: false, order_index: 104 }, { id: 'lotto', name: '로또 추천', description: '로또 번호 추천 노출', is_active: false, order_index: 105 }, ]; diff --git a/app/api/admin/stats/route.ts b/app/api/admin/stats/route.ts index 7b19106..8a5da4a 100644 --- a/app/api/admin/stats/route.ts +++ b/app/api/admin/stats/route.ts @@ -15,20 +15,18 @@ export async function GET() { const supabase = createAdminClient(); // 병렬 쿼리 - const [profilesRes, ordersRes, paymentsRes, contactsRes, monthlyRes, subsRes] = await Promise.all([ + const [profilesRes, ordersRes, paymentsRes, contactsRes, monthlyRes] = await Promise.all([ supabase.from('profiles').select('id', { count: 'exact', head: true }), supabase.from('orders').select('id', { count: 'exact', head: true }).eq('status', 'paid'), supabase.from('payments').select('amount').eq('status', 'paid'), supabase.from('contact_requests').select('id', { count: 'exact', head: true }).eq('status', 'pending'), supabase.from('payments').select('amount, created_at').eq('status', 'paid').order('created_at', { ascending: true }), - supabase.from('subscriptions').select('id', { count: 'exact', head: true }).eq('status', 'active'), ]); const totalMembers = profilesRes.count ?? 0; const totalOrders = ordersRes.count ?? 0; const totalRevenue = (paymentsRes.data ?? []).reduce((sum: number, p: { amount: number }) => sum + p.amount, 0); const pendingContacts = contactsRes.count ?? 0; - const activeSubscribers = subsRes.count ?? 0; // 최근 6개월 월별 수익 집계 const monthly: Record = {}; @@ -49,5 +47,5 @@ export async function GET() { const monthlyChart = Object.entries(monthly).map(([month, revenue]) => ({ month, revenue })); - return NextResponse.json({ totalMembers, totalOrders, totalRevenue, pendingContacts, activeSubscribers, monthlyChart }); + return NextResponse.json({ totalMembers, totalOrders, totalRevenue, pendingContacts, monthlyChart }); } diff --git a/app/api/cron/subscription-expiry/route.ts b/app/api/cron/subscription-expiry/route.ts deleted file mode 100644 index 9d54347..0000000 --- a/app/api/cron/subscription-expiry/route.ts +++ /dev/null @@ -1,78 +0,0 @@ -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 = { - 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, - }); -} diff --git a/app/api/subscription/[id]/route.ts b/app/api/subscription/[id]/route.ts deleted file mode 100644 index 7962417..0000000 --- a/app/api/subscription/[id]/route.ts +++ /dev/null @@ -1,87 +0,0 @@ -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 }); -} diff --git a/app/api/subscription/route.ts b/app/api/subscription/route.ts deleted file mode 100644 index c95a89f..0000000 --- a/app/api/subscription/route.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { NextResponse } from 'next/server'; -import { createClient } from '@/lib/supabase/server'; -import { createAdminClient } from '@/lib/supabase/admin'; - -/** - * GET /api/subscription - * 내 활성/만료 구독 목록 조회 - * - auth 검증은 anon client, DB 조회는 admin client (RLS 우회) - */ -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 }); - } - - // admin client로 RLS 우회 (subscriptions 테이블 SELECT 정책 없을 때도 동작) - const admin = createAdminClient(); - const { data, error } = await admin - .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', detail: error.message }, { status: 500 }); - } - - return NextResponse.json({ ok: true, subscriptions: data ?? [] }); -} diff --git a/app/packages/layout.tsx b/app/packages/layout.tsx deleted file mode 100644 index 22b86b5..0000000 --- a/app/packages/layout.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { notFound } from 'next/navigation'; -import type { Metadata } from 'next'; -import { isServiceVisible } from '@/lib/service-visibility'; - -export const metadata: Metadata = { - title: 'SaaS 제품 · 월 구독 패키지', - description: - '현직 엔지니어가 실제 운영하며 검증한 자동화를 월 구독 SaaS 제품으로 제공합니다. 첫 제품 준비 중 — 출시 알림을 신청하세요.', - keywords: ['SaaS', '자동화 구독', '월 구독 자동화', 'AI 자동화 제품', '쟁승메이드'], - openGraph: { - title: 'SaaS 제품 · 월 구독 패키지 | 쟁승메이드', - description: - '검증된 자동화를 SaaS로. 현직 엔지니어가 직접 운영·검증한 자동화 제품 카탈로그.', - url: 'https://jaengseung-made.com/packages', - }, -}; - -export default async function PackagesLayout({ children }: { children: React.ReactNode }) { - if (!(await isServiceVisible('packages'))) notFound(); - return <>{children}; -} diff --git a/app/packages/page.tsx b/app/packages/page.tsx deleted file mode 100644 index f8a465f..0000000 --- a/app/packages/page.tsx +++ /dev/null @@ -1,173 +0,0 @@ -'use client'; - -import { useState } from 'react'; -import Link from 'next/link'; -import ContactModal from '@/app/components/ContactModal'; -import { trackCTAClick } from '@/lib/gtag'; -import { - getAvailablePackages, - getComingSoonPackages, - type SaasCatalogItem, -} from '@/lib/saas-catalog'; - -const WAITLIST_SERVICE = 'SaaS 출시 알림 신청'; - -function PackageCard({ pkg, dimmed }: { pkg: SaasCatalogItem; dimmed?: boolean }) { - const inner = ( - <> -
-

- {pkg.category} -

- {pkg.badge && ( - - {pkg.badge} - - )} - {dimmed && !pkg.badge && ( - - Coming Soon - - )} -
-

{pkg.name}

-

{pkg.tagline}

-

{pkg.description}

-
    - {pkg.features.map((f) => ( -
  • - · - {f} -
  • - ))} -
-
- {pkg.priceLabel ? ( - {pkg.priceLabel} - ) : ( - 가격 준비 중 - )} - {!dimmed && } -
- - ); - - const base = - 'group rounded-2xl border p-6 flex flex-col transition'; - if (dimmed) { - return ( -
{inner}
- ); - } - return ( - trackCTAClick(`packages_card_${pkg.slug}`)} - className={`${base} border-white/15 bg-white/[0.02] hover:border-white/40 hover:bg-white/[0.05]`} - style={{ textDecoration: 'none' }} - > - {inner} - - ); -} - -export default function PackagesPage() { - const [modalOpen, setModalOpen] = useState(false); - const available = getAvailablePackages(); - const comingSoon = getComingSoonPackages(); - const isEmpty = available.length === 0; - - return ( -
- setModalOpen(false)} - service={WAITLIST_SERVICE} - checklist={['관심 있는 업무·자동화 분야', '연락받을 이메일', '현재 겪는 반복 업무(선택)']} - /> - - {/* Hero */} -
-
-
-

- SaaS Products -

-

- 검증된 자동화를 -
SaaS로 만듭니다. -

-

- 현직 엔지니어가 실제로 운영하며 검증한 자동화를 월 구독 제품으로. - {isEmpty ? ' 첫 제품을 준비하고 있습니다.' : ''} -

- {isEmpty && ( - - )} -
-
- - {/* Available 카탈로그 */} - {available.length > 0 && ( -
-
- {available.map((pkg) => ( - - ))} -
-
- )} - - {/* Coming Soon 예고 */} - {comingSoon.length > 0 && ( -
-
-

- Coming Soon -

-

- 곧 만나볼 제품 -

-
- {comingSoon.map((pkg) => ( - - ))} -
-
-
- )} - - {/* 출시 알림 CTA — 항상 노출(빈 상태 아닐 때도 대기자 수집) */} -
-
-

- 새 제품이 나오면 가장 먼저 알려드릴까요? -

-

- 관심 분야를 남겨주시면 출시 시 이메일로 안내드립니다. 원하는 자동화 제안도 환영합니다. -

- -
-
-
- ); -} diff --git a/app/work/saju/result/page.tsx b/app/work/saju/result/page.tsx index f0f485a..3040bfd 100644 --- a/app/work/saju/result/page.tsx +++ b/app/work/saju/result/page.tsx @@ -110,30 +110,17 @@ export default async function SajuResultPage({ searchParams }: PageProps) { } } - // 로또 구독 확인 — subscriptions 테이블 (세션 클라이언트로 RLS select_own 통과) - const { data: lottoSub } = await supabase - .from('subscriptions') - .select('id') + // 로또 이용권 확인 — orders 테이블 (최근 31일 paid 주문) + const thirtyOneDaysAgo = new Date(Date.now() - 31 * 24 * 60 * 60 * 1000).toISOString(); + const { data: lottoOrder } = await supabase + .from('orders') + .select('id, created_at') .eq('user_id', user.id) - .eq('status', 'active') + .eq('status', 'paid') .in('product_id', ['lotto_gold', 'lotto_platinum', 'lotto_diamond', 'lotto_annual']) + .gte('created_at', thirtyOneDaysAgo) .maybeSingle(); - hasLottoSubscription = !!lottoSub; - - // subscriptions에서 못 찾으면 orders 테이블로 폴백 (구독 마이그레이션 전 데이터) - if (!hasLottoSubscription) { - const now = new Date().toISOString(); - const thirtyOneDaysAgo = new Date(Date.now() - 31 * 24 * 60 * 60 * 1000).toISOString(); - const { data: lottoOrder } = await supabase - .from('orders') - .select('id, created_at') - .eq('user_id', user.id) - .eq('status', 'paid') - .in('product_id', ['lotto_gold', 'lotto_platinum', 'lotto_diamond', 'lotto_annual']) - .gte('created_at', thirtyOneDaysAgo) - .maybeSingle(); - hasLottoSubscription = !!lottoOrder; - } + hasLottoSubscription = !!lottoOrder; } } catch { // 미로그인 시 무시 diff --git a/lib/saas-catalog.ts b/lib/saas-catalog.ts deleted file mode 100644 index 77f484f..0000000 --- a/lib/saas-catalog.ts +++ /dev/null @@ -1,64 +0,0 @@ -// SaaS 제품 카탈로그 (/packages) -// -// 확장 규칙: 새 SaaS 제품을 출시하면 SAAS_CATALOG 배열에 객체 하나만 추가하면 -// /packages 페이지에 카드가 자동으로 노출된다. 결제는 productId로 lib/products.ts의 -// PRODUCTS 정의와 subscriptions 인프라에 연결한다. -// -// 음악(AI 음악 생성 개발 가이드 패키지)은 단품 라인이므로 여기에 넣지 않는다(/music 유지). - -export type SaasStatus = 'available' | 'coming_soon'; - -export interface SaasCatalogItem { - /** /packages 내 식별자 (향후 /packages/[slug] 상세에 사용) */ - slug: string; - /** 카드 제목 */ - name: string; - /** 한 줄 요약 (카드 상단) */ - tagline: string; - /** 카드 본문 설명 */ - description: string; - /** 가격 표시용 라벨 (예: "월 ₩29,000"). 미정이면 생략 */ - priceLabel?: string; - /** 과금 형태 */ - billing: 'monthly' | 'one_time'; - /** 노출 상태 — available: 구매 가능 / coming_soon: 예고 */ - status: SaasStatus; - /** 핵심 기능 목록 */ - features: string[]; - /** 분류 라벨 (예: '자동화') */ - category: string; - /** lib/products.ts PRODUCTS 키 참조 (결제 연결, available일 때) */ - productId?: string; - /** available일 때 상세/결제 경로 */ - href?: string; - /** 카드 강조 뱃지 (예: 'NEW') */ - badge?: string; -} - -/** - * 등록된 SaaS 제품 목록. - * - * 2026-05-31 현재 비어 있다. 메이킹 스페이스에서 검증된 자동화가 1개 확정되면 - * 아래 형태로 항목을 추가한다: - * - * { - * slug: 'making-verify', - * name: '메이킹 검증 자동화', - * tagline: '...', - * description: '...', - * priceLabel: '월 ₩29,000', - * billing: 'monthly', - * status: 'available', - * features: ['...'], - * category: '자동화', - * productId: 'making_verify_monthly', // lib/products.ts에 함께 추가 - * href: '/packages/making-verify', - * } - */ -export const SAAS_CATALOG: SaasCatalogItem[] = []; - -export const getAvailablePackages = () => - SAAS_CATALOG.filter((p) => p.status === 'available'); - -export const getComingSoonPackages = () => - SAAS_CATALOG.filter((p) => p.status === 'coming_soon'); diff --git a/lib/service-visibility.ts b/lib/service-visibility.ts index fc0e687..d1ff675 100644 --- a/lib/service-visibility.ts +++ b/lib/service-visibility.ts @@ -3,7 +3,7 @@ import { createAdminClient } from '@/lib/supabase/admin'; import { verifyAdminTokenNode } from '@/lib/admin-auth'; /** 숨김 가능 서비스 id (service_settings.id와 일치) */ -export type HideableService = 'saju' | 'music' | 'gyeol' | 'packages' | 'lotto'; +export type HideableService = 'saju' | 'music' | 'gyeol' | 'lotto'; /** * 서비스 노출 여부. admin_token 세션이면 항상 true. diff --git a/vercel.json b/vercel.json deleted file mode 100644 index eec2d3c..0000000 --- a/vercel.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "crons": [ - { - "path": "/api/cron/subscription-expiry", - "schedule": "0 16 * * *" - } - ] -}