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 });
}
}