feat(billing-actions): expired/cancelled kullanıcıya "Yeni abonelik başlat" butonu

Expired abonelikte panel "bu durum için aksiyon yok" diyordu; manuel paket
tanımları DB'den yapılıyordu. Artık expired/cancelled/aboneliksiz kullanıcıda
plan + dönem (aylık/yıllık) + süre (dönem default'u / 30g / 1 yıl / 3 yıl /
özel gün) + marka-limitli planda marka seçimi ile yeni active abonelik
açılıyor.

- admin-sdk: hiç implement edilmemiş setSubscription (PATCH) yerine
  startSubscription → POST /internal/admin/users/:id/subscriptions
  (Sase PR #266 ile gelir; merge edilene kadar spoke 404 döner).
- POST /api/sase/users/[id]/subscriptions route + audit kaydı.
- BillingActions artık subscription'sız kullanıcıda da render ediliyor.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ww3tfpuxBcettz81dDvyYt
This commit is contained in:
Semih
2026-09-19 00:47:00 +03:00
parent 5555a628b7
commit b180980e49
4 changed files with 347 additions and 16 deletions

View File

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

View File

@@ -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<Action>(null);
const [days, setDays] = useState<number>(7);
const [reason, setReason] = useState("");
const [newPlanId, setNewPlanId] = useState<string>("");
// "Yeni abonelik" formu
const [startPlanId, setStartPlanId] = useState<string>("");
const [startPeriod, setStartPeriod] = useState<"monthly" | "yearly">("yearly");
const [startDays, setStartDays] = useState<number>(0);
const [startBrandIds, setStartBrandIds] = useState<string[]>([]);
const [error, setError] = useState<string | null>(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
</Button>
)}
{!canExtendTrial && !canBonusExtend && !canActivate && !canChangePlan && !canCancel && !canResume && (
<span className="text-xs text-muted-foreground">
Bu durum için billing aksiyonu yok ({subscriptionStatus}).
</span>
{canStart && plans.length > 0 && (
<Button variant="default" size="sm" onClick={openStart}>
Yeni abonelik başlat
</Button>
)}
{!canExtendTrial &&
!canBonusExtend &&
!canActivate &&
!canChangePlan &&
!canCancel &&
!canResume &&
!canStart && (
<span className="text-xs text-muted-foreground">
Bu durum için billing aksiyonu yok ({subscriptionStatus}).
</span>
)}
</div>
<Dialog open={open !== null} onOpenChange={(v) => !v && close()}>
@@ -286,6 +397,114 @@ export function BillingActions({
</DialogDescription>
</DialogHeader>
)}
{open === "start" && (
<>
<DialogHeader>
<DialogTitle>Yeni abonelik başlat</DialogTitle>
<DialogDescription>
{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ı).
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-1">
<label className="text-sm">Plan</label>
<select
value={startPlanId}
onChange={(e) => {
setStartPlanId(e.target.value);
setStartBrandIds([]);
}}
className="w-full rounded-md border bg-background p-2 text-sm"
>
{plans.map((p) => (
<option key={p.id} value={p.id}>
{p.name} ({p.brandCount === 0 ? "all brands" : `${p.brandCount} brand`})
</option>
))}
</select>
</div>
<div className="space-y-1">
<label className="text-sm">Dönem</label>
<div className="flex gap-1">
{(["monthly", "yearly"] as const).map((p) => (
<Button
key={p}
type="button"
variant={startPeriod === p ? "default" : "outline"}
size="sm"
onClick={() => setStartPeriod(p)}
>
{p === "monthly" ? "Aylık" : "Yıllık"}
</Button>
))}
</div>
</div>
<div className="space-y-1">
<label className="text-sm">Süre</label>
<div className="flex flex-wrap items-center gap-1">
{START_QUICK_DAYS.map((q) => (
<Button
key={q.days}
type="button"
variant={startDays === q.days ? "default" : "outline"}
size="sm"
onClick={() => setStartDays(q.days)}
>
{q.label}
</Button>
))}
<label className="flex items-center gap-1 text-sm">
Özel gün:
<Input
type="number"
min={0}
max={3650}
value={startDays || ""}
onChange={(e) => setStartDays(Number(e.target.value) || 0)}
className="w-24"
/>
</label>
</div>
<p className="text-xs text-muted-foreground">
Bitiş: {startEndsAt}
{startDays > 0 ? ` (+${startDays} gün)` : ` (${startPeriod === "yearly" ? "+1 yıl" : "+1 ay"})`}
</p>
</div>
{startPlan && startPlan.brandCount > 0 && (
<div className="space-y-1">
<label className="text-sm">
Markalar ({startBrandIds.length}/{startPlan.brandCount})
</label>
<div className="grid max-h-48 grid-cols-2 gap-1 overflow-y-auto rounded-md border p-2 text-sm md:grid-cols-3">
{brands.map((b) => {
const checked = startBrandIds.includes(b.id);
const full = !checked && startBrandIds.length >= startPlan.brandCount;
return (
<label
key={b.id}
className={`flex items-center gap-2 ${full ? "opacity-50" : ""}`}
>
<input
type="checkbox"
checked={checked}
disabled={full}
onChange={() => toggleStartBrand(b.id)}
/>
{b.name}
</label>
);
})}
</div>
</div>
)}
</div>
</>
)}
<div className="space-y-2">
<Textarea
@@ -319,7 +538,9 @@ export function BillingActions({
? "Plan değiştir"
: open === "cancel"
? "İptal et"
: "Devam ettir"}
: open === "start"
? "Aboneliği başlat"
: "Devam ettir"}
</Button>
</DialogFooter>
</DialogContent>

View File

@@ -178,13 +178,15 @@ export default async function SaseUserDetailPage({
}
/>
</div>
{saseAdminWired() && user.subscriptionId && user.subscriptionStatus && (
{saseAdminWired() && (
<BillingActions
userId={user.id}
subscriptionId={user.subscriptionId}
subscriptionStatus={user.subscriptionStatus}
subscriptionEndsAt={user.subscriptionEndsAt}
currentPlanId={user.currentPlanId}
plans={plans}
brands={brands}
/>
)}
</CardContent>

View File

@@ -5,14 +5,21 @@ export type SaseSubscriptionStatus = "pending" | "active" | "cancelled" | "expir
export type SaseAdmin = {
/**
* Update a user's subscription. Spoke endpoint: PATCH /internal/admin/users/:id/subscription
* Not yet implemented spoke-side.
* Start a brand-new ACTIVE subscription for a user whose previous one is
* expired/cancelled (or who has none). Full plan → all brands; brand-limited
* plan needs exactly `brandCount` brandIds. `days` overrides the period
* default (monthly +1 ay / yearly +1 yıl). No revenue events fire spoke-side.
* Spoke endpoint: POST /internal/admin/users/:id/subscriptions
*/
setSubscription(input: {
startSubscription(input: {
userId: string;
planId: string;
billingPeriod: "monthly" | "yearly";
}): Promise<{ id: string; status: SaseSubscriptionStatus }>;
days?: number;
brandIds?: string[];
reason: string;
founderId: string;
}): Promise<SubscriptionStartResult>;
/**
* Force-cancel a subscription. Spoke endpoint: DELETE /internal/admin/subscriptions/:id
@@ -259,6 +266,19 @@ export type TrialExtendResult = {
daysAdded: number;
};
export type SubscriptionStartResult = {
success: boolean;
subscriptionId: string;
userId: string;
planId: string;
planName: string;
billingPeriod: "monthly" | "yearly";
status: string;
startDate: string | null;
endDate: string | null;
brandCount: number;
};
export type SubscriptionActivateResult = {
success: boolean;
subscriptionId: string;
@@ -286,8 +306,8 @@ export function createSaseAdmin(): SaseAdmin {
}
const client = new AdminClient({ projectKey: "sase", baseUrl: base, token });
return {
setSubscription: (input) =>
client.call("PATCH", `/internal/admin/users/${input.userId}/subscription`, input),
startSubscription: ({ userId, ...rest }) =>
client.call("POST", `/internal/admin/users/${userId}/subscriptions`, rest),
cancelSubscription: (id) =>
client.call("DELETE", `/internal/admin/subscriptions/${id}`),
resendVerification: (id) =>
@@ -412,7 +432,7 @@ function notWiredSdk(projectKey: string): SaseAdmin {
);
};
return {
setSubscription: () => Promise.reject(reject()),
startSubscription: () => Promise.reject(reject()),
cancelSubscription: () => Promise.reject(reject()),
resendVerification: () => Promise.reject(reject()),
impersonateReadonly: () => Promise.reject(reject()),
@@ -440,7 +460,7 @@ export function saseAdminWired(): boolean {
}
export const SASE_ADMIN_ENDPOINTS = [
"PATCH /internal/admin/users/:id/subscription",
"POST /internal/admin/users/:id/subscriptions",
"DELETE /internal/admin/subscriptions/:id",
"POST /internal/admin/users/:id/resend-verification",
"POST /internal/admin/users/:id/impersonate-readonly",