feat(sase): trial extend + manual activate — Phase C

Billing tab on user detail page now has writable actions wired to spoke
endpoints landed in sase.tr#28.

Admin SDK
- extendTrial({ subscriptionId, days, reason, founderId }) + result type
- activateSubscription({ subscriptionId, reason, founderId }) + result type
- SASE_ADMIN_ENDPOINTS list updated

Repo
- getUser() now returns subscriptionId so the billing UI can act on it.

Route
- POST /api/sase/subscriptions/[subId] — single multiplexed endpoint:
  body { action: 'trial-extend' | 'activate', reason, days? }.
  Auth + spoke-wired + reason ≥ 5 chars + days 1..90 (trial-extend).
  Audit on success and failure paths.

UI
- BillingActions client component on the Subscription & Billing tab.
- Trial state: [+7g][+14g][+30g] quick buttons + custom days input +
  reason modal.
- Trial or pending state: [Subscription'ı aktive et] button.
- Other states show "no billing action available" hint.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Semih
2026-05-18 09:41:45 +03:00
parent b1ec7a5277
commit 01bb820c57
5 changed files with 340 additions and 1 deletions

View File

@@ -0,0 +1,80 @@
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";
const ALLOWED = new Set(["trial-extend", "activate"]);
export async function POST(
req: Request,
ctx: { params: Promise<{ subId: 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 { subId } = await ctx.params;
const body = (await req.json().catch(() => ({}))) as {
action?: string;
days?: number;
reason?: string;
};
if (!body.action || !ALLOWED.has(body.action)) {
return NextResponse.json({ ok: false, error: "invalid_action" }, { status: 400 });
}
const reason = (body.reason ?? "").trim();
if (reason.length < 5) {
return NextResponse.json({ ok: false, error: "reason_required" }, { status: 400 });
}
if (body.action === "trial-extend") {
if (typeof body.days !== "number" || body.days <= 0 || body.days > 90) {
return NextResponse.json({ ok: false, error: "invalid_days" }, { status: 400 });
}
}
const endpoint = `/api/sase/subscriptions/${subId}/${body.action}`;
try {
const sdk = createSaseAdmin();
const result =
body.action === "trial-extend"
? await sdk.extendTrial({
subscriptionId: subId,
days: body.days!,
reason,
founderId: session.user.id,
})
: await sdk.activateSubscription({
subscriptionId: subId,
reason,
founderId: session.user.id,
});
await writeAudit({
projectKey: "sase",
endpoint,
method: "POST",
requestPayload: { action: body.action, days: body.days, reasonLen: reason.length },
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: { action: body.action, days: body.days, reasonLen: reason.length },
responseStatus: status,
});
return NextResponse.json({ ok: false, error: message }, { status });
}
}

View File

@@ -0,0 +1,200 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
type Action = "trial-extend" | "activate" | null;
const QUICK_DAYS = [7, 14, 30];
export function BillingActions({
subscriptionId,
subscriptionStatus,
subscriptionEndsAt,
}: {
subscriptionId: string;
subscriptionStatus: string;
subscriptionEndsAt: Date | null;
}) {
const router = useRouter();
const [open, setOpen] = useState<Action>(null);
const [days, setDays] = useState<number>(7);
const [reason, setReason] = useState("");
const [error, setError] = useState<string | null>(null);
const [pending, startTransition] = useTransition();
function close() {
setOpen(null);
setReason("");
setDays(7);
setError(null);
}
function submit() {
if (!open) return;
setError(null);
if (reason.trim().length < 5) {
setError("Sebep en az 5 karakter olmalı.");
return;
}
if (open === "trial-extend" && (days <= 0 || days > 90)) {
setError("Gün sayısı 1-90 arası olmalı.");
return;
}
startTransition(async () => {
const res = await fetch(`/api/sase/subscriptions/${subscriptionId}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: open,
reason: reason.trim(),
days: open === "trial-extend" ? days : undefined,
}),
});
if (!res.ok) {
const data = (await res.json().catch(() => ({}))) as { error?: string };
setError(data.error ?? `HTTP ${res.status}`);
return;
}
close();
router.refresh();
});
}
const canExtendTrial = subscriptionStatus === "trial";
const canActivate =
subscriptionStatus === "trial" || subscriptionStatus === "pending";
return (
<>
<div className="flex flex-wrap gap-2">
{canExtendTrial && (
<>
{QUICK_DAYS.map((d) => (
<Button
key={d}
variant="outline"
size="sm"
onClick={() => {
setDays(d);
setOpen("trial-extend");
}}
>
Trial +{d}g
</Button>
))}
<Button
variant="outline"
size="sm"
onClick={() => {
setDays(0);
setOpen("trial-extend");
}}
>
Trial özel süre
</Button>
</>
)}
{canActivate && (
<Button variant="default" size="sm" onClick={() => setOpen("activate")}>
Subscription'ı aktive et
</Button>
)}
{!canExtendTrial && !canActivate && (
<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()}>
<DialogContent>
{open === "trial-extend" && (
<>
<DialogHeader>
<DialogTitle>Trial uzat</DialogTitle>
<DialogDescription>
Mevcut bitiş:{" "}
{subscriptionEndsAt
? subscriptionEndsAt.toISOString().slice(0, 10)
: "—"}
. Yeni bitiş = max(şimdi, mevcut bitiş) + N gün.
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm">
Gün:
<Input
type="number"
min={1}
max={90}
value={days || ""}
onChange={(e) => setDays(Number(e.target.value))}
className="w-24"
/>
</label>
<div className="flex gap-1">
{QUICK_DAYS.map((d) => (
<Button
key={d}
type="button"
variant={days === d ? "default" : "outline"}
size="sm"
onClick={() => setDays(d)}
>
+{d}g
</Button>
))}
</div>
</div>
</>
)}
{open === "activate" && (
<DialogHeader>
<DialogTitle>Subscription'ı manuel aktive et</DialogTitle>
<DialogDescription>
Trial veya pending durumdan active'e geçirir, plan default
süresi kadar (aylık/yıllık) çalışır.
</DialogDescription>
</DialogHeader>
)}
<div className="space-y-2">
<Textarea
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="Sebep (zorunlu, audit'e geçer)"
rows={3}
maxLength={500}
/>
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
<DialogFooter>
<Button variant="ghost" onClick={close} disabled={pending}>
İptal
</Button>
<Button onClick={submit} disabled={pending}>
{pending
? "..."
: open === "trial-extend"
? `+${days}g uzat`
: "Aktive et"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -33,6 +33,7 @@ import { EmailReveal } from "./_email-reveal";
import { ImpersonateButton } from "./_impersonate-button"; import { ImpersonateButton } from "./_impersonate-button";
import { NotesTab } from "./_notes"; import { NotesTab } from "./_notes";
import { LifecycleButtons, LifecycleStatusBadge } from "./_lifecycle-buttons"; import { LifecycleButtons, LifecycleStatusBadge } from "./_lifecycle-buttons";
import { BillingActions } from "./_billing-actions";
import { saseAdminWired } from "@/lib/admin-sdk/sase"; import { saseAdminWired } from "@/lib/admin-sdk/sase";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -160,7 +161,7 @@ export default async function SaseUserDetailPage({
<CardHeader> <CardHeader>
<CardDescription>Aktif subscription</CardDescription> <CardDescription>Aktif subscription</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="text-sm"> <CardContent className="space-y-3 text-sm">
<div className="grid grid-cols-2 gap-3 md:grid-cols-4"> <div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<Stat label="Plan" value={user.planName ?? "—"} /> <Stat label="Plan" value={user.planName ?? "—"} />
<Stat label="Tier" value={user.planTier ?? "—"} /> <Stat label="Tier" value={user.planTier ?? "—"} />
@@ -174,6 +175,13 @@ export default async function SaseUserDetailPage({
} }
/> />
</div> </div>
{saseAdminWired() && user.subscriptionId && user.subscriptionStatus && (
<BillingActions
subscriptionId={user.subscriptionId}
subscriptionStatus={user.subscriptionStatus}
subscriptionEndsAt={user.subscriptionEndsAt}
/>
)}
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -56,6 +56,40 @@ export type SaseAdmin = {
founderId: string; founderId: string;
reason: string; reason: string;
}): Promise<LifecycleResult>; }): Promise<LifecycleResult>;
/** Extend an active trial subscription by N days. */
extendTrial(input: {
subscriptionId: string;
days: number;
reason: string;
founderId: string;
}): Promise<TrialExtendResult>;
/** Manually activate a pending/trial subscription (e.g. customer paid offline). */
activateSubscription(input: {
subscriptionId: string;
reason: string;
founderId: string;
}): Promise<SubscriptionActivateResult>;
};
export type TrialExtendResult = {
success: boolean;
subscriptionId: string;
userId: string;
previousEndDate: string | null;
newEndDate: string | null;
daysAdded: number;
};
export type SubscriptionActivateResult = {
success: boolean;
subscriptionId: string;
userId: string;
previousStatus: string;
newStatus: string;
startDate: string | null;
endDate: string | null;
}; };
export type LifecycleResult = { export type LifecycleResult = {
@@ -102,6 +136,17 @@ export function createSaseAdmin(): SaseAdmin {
reason: input.reason, reason: input.reason,
founderId: input.founderId, founderId: input.founderId,
}), }),
extendTrial: (input) =>
client.call("POST", `/internal/admin/subscriptions/${input.subscriptionId}/trial/extend`, {
days: input.days,
reason: input.reason,
founderId: input.founderId,
}),
activateSubscription: (input) =>
client.call("POST", `/internal/admin/subscriptions/${input.subscriptionId}/activate`, {
reason: input.reason,
founderId: input.founderId,
}),
}; };
} }
@@ -119,6 +164,8 @@ function notWiredSdk(projectKey: string): SaseAdmin {
suspendUser: () => Promise.reject(reject()), suspendUser: () => Promise.reject(reject()),
reactivateUser: () => Promise.reject(reject()), reactivateUser: () => Promise.reject(reject()),
banUser: () => Promise.reject(reject()), banUser: () => Promise.reject(reject()),
extendTrial: () => Promise.reject(reject()),
activateSubscription: () => Promise.reject(reject()),
}; };
} }
@@ -134,4 +181,6 @@ export const SASE_ADMIN_ENDPOINTS = [
"POST /internal/admin/users/:id/suspend", "POST /internal/admin/users/:id/suspend",
"POST /internal/admin/users/:id/reactivate", "POST /internal/admin/users/:id/reactivate",
"POST /internal/admin/users/:id/ban", "POST /internal/admin/users/:id/ban",
"POST /internal/admin/subscriptions/:id/trial/extend",
"POST /internal/admin/subscriptions/:id/activate",
]; ];

View File

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