diff --git a/apps/web/src/app/api/sase/users/[id]/subscriptions/route.ts b/apps/web/src/app/api/sase/users/[id]/subscriptions/route.ts new file mode 100644 index 0000000..d08df73 --- /dev/null +++ b/apps/web/src/app/api/sase/users/[id]/subscriptions/route.ts @@ -0,0 +1,88 @@ +import { NextResponse } from "next/server"; +import { headers } from "next/headers"; +import { auth } from "@/lib/auth"; +import { createSaseAdmin, saseAdminWired } from "@/lib/admin-sdk/sase"; +import { writeAudit } from "@/lib/audit"; + +export const dynamic = "force-dynamic"; + +/** + * Yeni abonelik başlat (expired / cancelled / aboneliği olmayan kullanıcı). + * Spoke: POST /internal/admin/users/:id/subscriptions — canlı (active/trial) + * abonelik varsa spoke 409 döner; onlar için /api/sase/subscriptions/[subId]. + */ +export async function POST(req: Request, ctx: { params: Promise<{ id: string }> }) { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session) { + return NextResponse.json({ ok: false, error: "unauthenticated" }, { status: 401 }); + } + if (!saseAdminWired()) { + return NextResponse.json({ ok: false, error: "spoke_not_wired" }, { status: 503 }); + } + + const { id } = await ctx.params; + const body = (await req.json().catch(() => ({}))) as { + planId?: string; + billingPeriod?: string; + days?: number | null; + brandIds?: string[]; + reason?: string; + }; + const reason = (body.reason ?? "").trim(); + if (reason.length < 5) { + return NextResponse.json({ ok: false, error: "reason_required" }, { status: 400 }); + } + if (!body.planId) { + return NextResponse.json({ ok: false, error: "planId_required" }, { status: 400 }); + } + if (body.billingPeriod !== "monthly" && body.billingPeriod !== "yearly") { + return NextResponse.json({ ok: false, error: "invalid_billing_period" }, { status: 400 }); + } + const days = body.days == null ? undefined : body.days; + if (days !== undefined && (typeof days !== "number" || days <= 0 || days > 3650)) { + return NextResponse.json({ ok: false, error: "invalid_days" }, { status: 400 }); + } + if (body.brandIds !== undefined && !Array.isArray(body.brandIds)) { + return NextResponse.json({ ok: false, error: "invalid_brand_ids" }, { status: 400 }); + } + + const endpoint = `/api/sase/users/${id}/subscriptions`; + const requestPayload = { + action: "start-subscription", + planId: body.planId, + billingPeriod: body.billingPeriod, + days, + brandCount: body.brandIds?.length ?? 0, + reasonLen: reason.length, + }; + try { + const result = await createSaseAdmin().startSubscription({ + userId: id, + planId: body.planId, + billingPeriod: body.billingPeriod, + days, + brandIds: body.brandIds, + reason, + founderId: session.user.id, + }); + await writeAudit({ + projectKey: "sase", + endpoint, + method: "POST", + requestPayload, + responseStatus: 200, + }); + return NextResponse.json({ ok: true, result }); + } catch (err) { + const status = (err as { status?: number } | undefined)?.status ?? 500; + const message = err instanceof Error ? err.message : "unknown"; + await writeAudit({ + projectKey: "sase", + endpoint, + method: "POST", + requestPayload, + responseStatus: status, + }); + return NextResponse.json({ ok: false, error: message }, { status }); + } +} diff --git a/apps/web/src/app/projects/sase/users/[id]/_billing-actions.tsx b/apps/web/src/app/projects/sase/users/[id]/_billing-actions.tsx index 6c0972d..f308c5c 100644 --- a/apps/web/src/app/projects/sase/users/[id]/_billing-actions.tsx +++ b/apps/web/src/app/projects/sase/users/[id]/_billing-actions.tsx @@ -21,28 +21,46 @@ type Action = | "change-plan" | "cancel" | "resume" + | "start" | null; const QUICK_DAYS = [7, 14, 30]; +/** "Yeni abonelik" için hızlı süre seçenekleri (gün). 0 = plan dönemi default'u. */ +const START_QUICK_DAYS: Array<{ label: string; days: number }> = [ + { label: "Dönem default'u", days: 0 }, + { label: "30g", days: 30 }, + { label: "1 yıl", days: 365 }, + { label: "3 yıl", days: 1095 }, +]; export function BillingActions({ + userId, subscriptionId, subscriptionStatus, subscriptionEndsAt, currentPlanId, plans, + brands, }: { - subscriptionId: string; - subscriptionStatus: string; + userId: string; + /** null = kullanıcının hiç subscription'ı yok (sadece "Yeni abonelik" gösterilir). */ + subscriptionId: string | null; + subscriptionStatus: string | null; subscriptionEndsAt: Date | null; currentPlanId: string | null; plans: Array<{ id: string; name: string; brandCount: number }>; + brands: Array<{ id: string; name: string }>; }) { const router = useRouter(); const [open, setOpen] = useState(null); const [days, setDays] = useState(7); const [reason, setReason] = useState(""); const [newPlanId, setNewPlanId] = useState(""); + // "Yeni abonelik" formu + const [startPlanId, setStartPlanId] = useState(""); + const [startPeriod, setStartPeriod] = useState<"monthly" | "yearly">("yearly"); + const [startDays, setStartDays] = useState(0); + const [startBrandIds, setStartBrandIds] = useState([]); const [error, setError] = useState(null); const [pending, startTransition] = useTransition(); @@ -51,11 +69,80 @@ export function BillingActions({ setReason(""); setDays(7); setNewPlanId(""); + setStartPlanId(""); + setStartPeriod("yearly"); + setStartDays(0); + setStartBrandIds([]); setError(null); } + function openStart() { + // Default: kullanıcının son planı (varsa), yoksa ilk plan. + setStartPlanId(currentPlanId && plans.some((p) => p.id === currentPlanId) ? currentPlanId : (plans[0]?.id ?? "")); + setStartPeriod("yearly"); + setStartDays(0); + setStartBrandIds([]); + setOpen("start"); + } + + const startPlan = plans.find((p) => p.id === startPlanId) ?? null; + + function toggleStartBrand(id: string) { + setStartBrandIds((prev) => { + if (prev.includes(id)) return prev.filter((b) => b !== id); + const cap = startPlan?.brandCount ?? 0; + if (cap > 0 && prev.length >= cap) return prev; + return [...prev, id]; + }); + } + + function submitStart() { + setError(null); + if (reason.trim().length < 5) { + setError("Sebep en az 5 karakter olmalı."); + return; + } + if (!startPlan) { + setError("Plan seç."); + return; + } + if (startDays < 0 || startDays > 3650) { + setError("Gün sayısı 0-3650 arası olmalı (0 = dönem default'u)."); + return; + } + if (startPlan.brandCount > 0 && startBrandIds.length !== startPlan.brandCount) { + setError(`Bu plan tam olarak ${startPlan.brandCount} marka gerektirir (${startBrandIds.length} seçili).`); + return; + } + startTransition(async () => { + const res = await fetch(`/api/sase/users/${userId}/subscriptions`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + planId: startPlan.id, + billingPeriod: startPeriod, + days: startDays > 0 ? startDays : null, + brandIds: startPlan.brandCount > 0 ? startBrandIds : undefined, + reason: reason.trim(), + }), + }); + if (!res.ok) { + const data = (await res.json().catch(() => ({}))) as { error?: string }; + setError(data.error ?? `HTTP ${res.status}`); + return; + } + close(); + router.refresh(); + }); + } + function submit() { if (!open) return; + if (open === "start") { + submitStart(); + return; + } + if (!subscriptionId) return; setError(null); if (reason.trim().length < 5) { setError("Sebep en az 5 karakter olmalı."); @@ -100,7 +187,20 @@ export function BillingActions({ const canChangePlan = subscriptionStatus === "active" || subscriptionStatus === "trial"; const canCancel = subscriptionStatus === "active" || subscriptionStatus === "trial"; const canResume = subscriptionStatus === "cancelled"; + // Canlı (active/trial/pending) abonelik yokken yeni bir tane başlatılabilir; + // spoke active/trial varsa zaten 409 döner. + const canStart = + subscriptionStatus === null || + subscriptionStatus === "expired" || + subscriptionStatus === "cancelled"; const otherPlans = plans.filter((p) => p.id !== currentPlanId); + const startEndsAt = (() => { + const d = new Date(); + if (startDays > 0) d.setDate(d.getDate() + startDays); + else if (startPeriod === "yearly") d.setFullYear(d.getFullYear() + 1); + else d.setMonth(d.getMonth() + 1); + return d.toISOString().slice(0, 10); + })(); return ( <> @@ -179,11 +279,22 @@ export function BillingActions({ Subscription'ı devam ettir )} - {!canExtendTrial && !canBonusExtend && !canActivate && !canChangePlan && !canCancel && !canResume && ( - - Bu durum için billing aksiyonu yok ({subscriptionStatus}). - + {canStart && plans.length > 0 && ( + )} + {!canExtendTrial && + !canBonusExtend && + !canActivate && + !canChangePlan && + !canCancel && + !canResume && + !canStart && ( + + Bu durum için billing aksiyonu yok ({subscriptionStatus}). + + )} !v && close()}> @@ -286,6 +397,114 @@ export function BillingActions({ )} + {open === "start" && ( + <> + + Yeni abonelik başlat + + {subscriptionStatus + ? `Mevcut ${subscriptionStatus} kayıt olduğu gibi kalır; ` + : ""} + yeni bir active subscription açılır (başlangıç = şimdi). Full + Paket'te tüm markalar otomatik atanır. Ödeme / gelir eventi + oluşmaz (manuel founder tanımı). + + +
+
+ + +
+
+ +
+ {(["monthly", "yearly"] as const).map((p) => ( + + ))} +
+
+
+ +
+ {START_QUICK_DAYS.map((q) => ( + + ))} + +
+

+ Bitiş: {startEndsAt} + {startDays > 0 ? ` (+${startDays} gün)` : ` (${startPeriod === "yearly" ? "+1 yıl" : "+1 ay"})`} +

+
+ {startPlan && startPlan.brandCount > 0 && ( +
+ +
+ {brands.map((b) => { + const checked = startBrandIds.includes(b.id); + const full = !checked && startBrandIds.length >= startPlan.brandCount; + return ( + + ); + })} +
+
+ )} +
+ + )}