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:
88
apps/web/src/app/api/sase/users/[id]/subscriptions/route.ts
Normal file
88
apps/web/src/app/api/sase/users/[id]/subscriptions/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user