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:
@@ -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 });
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user