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