feat(sase): plan change + subscription cancel/resume — Phase D

Completes the billing surface; pairs with sase.tr#29.

Admin SDK
- changePlan(input) + cancelSubscriptionById(input) + resumeSubscription(input).
- PlanChangeResult + SubscriptionStateChange types exported.
- SASE_ADMIN_ENDPOINTS lists the three new spoke routes.

Route
- /api/sase/subscriptions/[subId] now accepts change-plan/cancel/resume
  in addition to trial-extend/activate. newPlanId required when
  action=change-plan. Reason ≥ 5 chars enforced. Audit captures the
  action + days + newPlanId + reasonLen on both paths.

Repo
- getUser() also returns currentPlanId for the plan picker.

UI
- BillingActions extended:
    active/trial → [Plan değiştir][İptal et]
    cancelled    → [Devam ettir]
- Change-plan modal lists active plans (current excluded) with brand
  count hint. The spoke flags brand-reassignment via response field;
  v1 surfaces only the confirmation.
- Cancel uses destructive variant + clear messaging.

Phase D ships the user-management mutation surface — A/B/C/D all live.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Semih
2026-05-18 09:47:05 +03:00
parent 01bb820c57
commit 3621c0801f
5 changed files with 203 additions and 14 deletions

View File

@@ -6,7 +6,7 @@ import { writeAudit } from "@/lib/audit";
export const dynamic = "force-dynamic";
const ALLOWED = new Set(["trial-extend", "activate"]);
const ALLOWED = new Set(["trial-extend", "activate", "change-plan", "cancel", "resume"]);
export async function POST(
req: Request,
@@ -25,6 +25,7 @@ export async function POST(
action?: string;
days?: number;
reason?: string;
newPlanId?: string;
};
if (!body.action || !ALLOWED.has(body.action)) {
return NextResponse.json({ ok: false, error: "invalid_action" }, { status: 400 });
@@ -38,6 +39,9 @@ export async function POST(
return NextResponse.json({ ok: false, error: "invalid_days" }, { status: 400 });
}
}
if (body.action === "change-plan" && !body.newPlanId) {
return NextResponse.json({ ok: false, error: "newPlanId_required" }, { status: 400 });
}
const endpoint = `/api/sase/subscriptions/${subId}/${body.action}`;
try {
@@ -50,17 +54,41 @@ export async function POST(
reason,
founderId: session.user.id,
})
: await sdk.activateSubscription({
subscriptionId: subId,
reason,
founderId: session.user.id,
});
: body.action === "activate"
? await sdk.activateSubscription({
subscriptionId: subId,
reason,
founderId: session.user.id,
})
: body.action === "change-plan"
? await sdk.changePlan({
subscriptionId: subId,
newPlanId: body.newPlanId!,
reason,
founderId: session.user.id,
})
: body.action === "cancel"
? await sdk.cancelSubscriptionById({
subscriptionId: subId,
reason,
founderId: session.user.id,
})
: await sdk.resumeSubscription({
subscriptionId: subId,
reason,
founderId: session.user.id,
});
await writeAudit({
projectKey: "sase",
endpoint,
method: "POST",
requestPayload: { action: body.action, days: body.days, reasonLen: reason.length },
requestPayload: {
action: body.action,
days: body.days,
newPlanId: body.newPlanId,
reasonLen: reason.length,
},
responseStatus: 200,
});
@@ -72,7 +100,12 @@ export async function POST(
projectKey: "sase",
endpoint,
method: "POST",
requestPayload: { action: body.action, days: body.days, reasonLen: reason.length },
requestPayload: {
action: body.action,
days: body.days,
newPlanId: body.newPlanId,
reasonLen: reason.length,
},
responseStatus: status,
});
return NextResponse.json({ ok: false, error: message }, { status });

View File

@@ -14,7 +14,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
type Action = "trial-extend" | "activate" | null;
type Action = "trial-extend" | "activate" | "change-plan" | "cancel" | "resume" | null;
const QUICK_DAYS = [7, 14, 30];
@@ -22,15 +22,20 @@ export function BillingActions({
subscriptionId,
subscriptionStatus,
subscriptionEndsAt,
currentPlanId,
plans,
}: {
subscriptionId: string;
subscriptionStatus: string;
subscriptionEndsAt: Date | null;
currentPlanId: string | null;
plans: Array<{ id: string; name: string; brandCount: number }>;
}) {
const router = useRouter();
const [open, setOpen] = useState<Action>(null);
const [days, setDays] = useState<number>(7);
const [reason, setReason] = useState("");
const [newPlanId, setNewPlanId] = useState<string>("");
const [error, setError] = useState<string | null>(null);
const [pending, startTransition] = useTransition();
@@ -38,6 +43,7 @@ export function BillingActions({
setOpen(null);
setReason("");
setDays(7);
setNewPlanId("");
setError(null);
}
@@ -52,6 +58,10 @@ export function BillingActions({
setError("Gün sayısı 1-90 arası olmalı.");
return;
}
if (open === "change-plan" && !newPlanId) {
setError("Yeni plan seç.");
return;
}
startTransition(async () => {
const res = await fetch(`/api/sase/subscriptions/${subscriptionId}`, {
method: "POST",
@@ -60,6 +70,7 @@ export function BillingActions({
action: open,
reason: reason.trim(),
days: open === "trial-extend" ? days : undefined,
newPlanId: open === "change-plan" ? newPlanId : undefined,
}),
});
if (!res.ok) {
@@ -75,6 +86,10 @@ export function BillingActions({
const canExtendTrial = subscriptionStatus === "trial";
const canActivate =
subscriptionStatus === "trial" || subscriptionStatus === "pending";
const canChangePlan = subscriptionStatus === "active" || subscriptionStatus === "trial";
const canCancel = subscriptionStatus === "active" || subscriptionStatus === "trial";
const canResume = subscriptionStatus === "cancelled";
const otherPlans = plans.filter((p) => p.id !== currentPlanId);
return (
<>
@@ -111,7 +126,22 @@ export function BillingActions({
Subscription'ı aktive et
</Button>
)}
{!canExtendTrial && !canActivate && (
{canChangePlan && otherPlans.length > 0 && (
<Button variant="outline" size="sm" onClick={() => setOpen("change-plan")}>
Plan değiştir
</Button>
)}
{canCancel && (
<Button variant="destructive" size="sm" onClick={() => setOpen("cancel")}>
Subscription'ı iptal et
</Button>
)}
{canResume && (
<Button variant="default" size="sm" onClick={() => setOpen("resume")}>
Subscription'ı devam ettir
</Button>
)}
{!canExtendTrial && !canActivate && !canChangePlan && !canCancel && !canResume && (
<span className="text-xs text-muted-foreground">
Bu durum için billing aksiyonu yok ({subscriptionStatus}).
</span>
@@ -169,6 +199,50 @@ export function BillingActions({
</DialogDescription>
</DialogHeader>
)}
{open === "change-plan" && (
<>
<DialogHeader>
<DialogTitle>Plan değiştir</DialogTitle>
<DialogDescription>
Yeni planın brand sayısı eskisinden farklıysa kullanıcıya
markaları yeniden seçtirmen gerekebilir (panel uyarır).
</DialogDescription>
</DialogHeader>
<div className="space-y-1">
<label className="text-sm">Yeni plan</label>
<select
value={newPlanId}
onChange={(e) => setNewPlanId(e.target.value)}
className="w-full rounded-md border bg-background p-2 text-sm"
>
<option value="">— seç —</option>
{otherPlans.map((p) => (
<option key={p.id} value={p.id}>
{p.name} ({p.brandCount === 0 ? "all brands" : `${p.brandCount} brand`})
</option>
))}
</select>
</div>
</>
)}
{open === "cancel" && (
<DialogHeader>
<DialogTitle>Subscription'ı iptal et</DialogTitle>
<DialogDescription>
Subscription cancelled olarak işaretlenir, cancelledAt = şimdi.
İstersen sonradan Resume ile geri açabilirsin.
</DialogDescription>
</DialogHeader>
)}
{open === "resume" && (
<DialogHeader>
<DialogTitle>Subscription'ı devam ettir</DialogTitle>
<DialogDescription>
Cancelled subscription tekrar active olur. Bitiş tarihi
değişmez — sadece status flip'i.
</DialogDescription>
</DialogHeader>
)}
<div className="space-y-2">
<Textarea
@@ -185,12 +259,22 @@ export function BillingActions({
<Button variant="ghost" onClick={close} disabled={pending}>
İptal
</Button>
<Button onClick={submit} disabled={pending}>
<Button
variant={open === "cancel" ? "destructive" : "default"}
onClick={submit}
disabled={pending}
>
{pending
? "..."
: open === "trial-extend"
? `+${days}g uzat`
: "Aktive et"}
: open === "activate"
? "Aktive et"
: open === "change-plan"
? "Plan değiştir"
: open === "cancel"
? "İptal et"
: "Devam ettir"}
</Button>
</DialogFooter>
</DialogContent>

View File

@@ -23,7 +23,7 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { getUser, maskEmail } from "@/lib/sase/users";
import { getUser, listPlans, maskEmail } from "@/lib/sase/users";
import {
getUserUsageStats,
getUserTimeline,
@@ -47,10 +47,11 @@ export default async function SaseUserDetailPage({
const user = await getUser(id);
if (!user) notFound();
const [usage, timeline, audit] = await Promise.all([
const [usage, timeline, audit, plans] = await Promise.all([
getUserUsageStats(id),
getUserTimeline(id, 80),
getUserAuditTrail(id, 50),
listPlans(),
]);
return (
@@ -180,6 +181,8 @@ export default async function SaseUserDetailPage({
subscriptionId={user.subscriptionId}
subscriptionStatus={user.subscriptionStatus}
subscriptionEndsAt={user.subscriptionEndsAt}
currentPlanId={user.currentPlanId}
plans={plans}
/>
)}
</CardContent>

View File

@@ -71,6 +71,51 @@ export type SaseAdmin = {
reason: string;
founderId: string;
}): Promise<SubscriptionActivateResult>;
/** Move an active/trial subscription to a different plan. */
changePlan(input: {
subscriptionId: string;
newPlanId: string;
reason: string;
founderId: string;
}): Promise<PlanChangeResult>;
/** Cancel an active/trial subscription. */
cancelSubscriptionById(input: {
subscriptionId: string;
reason: string;
founderId: string;
}): Promise<SubscriptionStateChange>;
/** Resume a previously cancelled subscription. */
resumeSubscription(input: {
subscriptionId: string;
reason: string;
founderId: string;
}): Promise<SubscriptionStateChange>;
};
export type PlanChangeResult = {
success: boolean;
subscriptionId: string;
userId: string;
previousPlanId: string;
newPlanId: string;
newPlanName: string;
newPlanBrandCount: number;
currentBrandCount: number;
brandReassignmentNeeded: boolean;
changedAt: string | null;
};
export type SubscriptionStateChange = {
success: boolean;
subscriptionId: string;
userId: string;
previousStatus: string;
newStatus: string;
cancelledAt?: string | null;
resumedAt?: string | null;
};
export type TrialExtendResult = {
@@ -147,6 +192,22 @@ export function createSaseAdmin(): SaseAdmin {
reason: input.reason,
founderId: input.founderId,
}),
changePlan: (input) =>
client.call("POST", `/internal/admin/subscriptions/${input.subscriptionId}/change-plan`, {
newPlanId: input.newPlanId,
reason: input.reason,
founderId: input.founderId,
}),
cancelSubscriptionById: (input) =>
client.call("POST", `/internal/admin/subscriptions/${input.subscriptionId}/cancel`, {
reason: input.reason,
founderId: input.founderId,
}),
resumeSubscription: (input) =>
client.call("POST", `/internal/admin/subscriptions/${input.subscriptionId}/resume`, {
reason: input.reason,
founderId: input.founderId,
}),
};
}
@@ -166,6 +227,9 @@ function notWiredSdk(projectKey: string): SaseAdmin {
banUser: () => Promise.reject(reject()),
extendTrial: () => Promise.reject(reject()),
activateSubscription: () => Promise.reject(reject()),
changePlan: () => Promise.reject(reject()),
cancelSubscriptionById: () => Promise.reject(reject()),
resumeSubscription: () => Promise.reject(reject()),
};
}
@@ -183,4 +247,7 @@ export const SASE_ADMIN_ENDPOINTS = [
"POST /internal/admin/users/:id/ban",
"POST /internal/admin/subscriptions/:id/trial/extend",
"POST /internal/admin/subscriptions/:id/activate",
"POST /internal/admin/subscriptions/:id/change-plan",
"POST /internal/admin/subscriptions/:id/cancel",
"POST /internal/admin/subscriptions/:id/resume",
];

View File

@@ -188,6 +188,7 @@ export type UserDetail = UserRow & {
statusReason: string | null;
statusChangedAt: Date | null;
subscriptionId: string | null;
currentPlanId: string | null;
payments: Array<{
id: string;
amount: number;
@@ -242,6 +243,7 @@ export async function getUser(id: string): Promise<UserDetail | null> {
statusReason: u.statusReason,
statusChangedAt: u.statusChangedAt,
subscriptionId: sub?.id ?? null,
currentPlanId: sub?.planId ?? null,
planName: sub?.plan.name ?? null,
planTier: tierFromPlan(sub?.plan.name ?? null, sub?.plan.brandCount ?? null),
subscriptionStatus: sub?.status ?? null,