feat(sase): refund + bonus extension — Phase E

Pairs with sase.tr#30.

Refund
- admin-sdk: refundPayment({ paymentId, amount?, reason, founderId }) +
  RefundResult type; SASE_ADMIN_ENDPOINTS lists the new route.
- POST /api/sase/payments/[paymentId]/refund — auth + spoke-wired,
  reason ≥ 5 chars, amount > 0 when provided (full refund if omitted).
  Audit on success and failure.
- _refund-button.tsx: per-row [Refund] button in the Billing tab
  payments table. Modal has [✓] full vs partial input, reason, and a
  destructive submit. Hidden for payments that aren't refundable
  (status !== completed/partially_refunded).
- Payment row now exposes an "İşlem" column (only when spoke is wired).

Bonus extension (goodwill)
- BillingActions: new "Bonus +7g/+14g/+30g + özel" buttons surfaced
  for active subscriptions. Hits the same /trial/extend wire under the
  hood — the spoke generalized that endpoint to active too — but the
  panel uses "Bonus süre ekle (goodwill)" copy instead of "Trial uzat".

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Semih
2026-05-18 10:06:16 +03:00
parent 631ca6b4c4
commit 0eacc32286
5 changed files with 316 additions and 15 deletions

View File

@@ -0,0 +1,69 @@
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";
export async function POST(
req: Request,
ctx: { params: Promise<{ paymentId: 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 { paymentId } = await ctx.params;
const body = (await req.json().catch(() => ({}))) as {
amount?: number | null;
reason?: string;
};
const reason = (body.reason ?? "").trim();
if (reason.length < 5) {
return NextResponse.json({ ok: false, error: "reason_required" }, { status: 400 });
}
let amount: number | undefined;
if (body.amount !== undefined && body.amount !== null) {
if (typeof body.amount !== "number" || body.amount <= 0) {
return NextResponse.json({ ok: false, error: "invalid_amount" }, { status: 400 });
}
amount = Math.round(body.amount);
}
const endpoint = `/api/sase/payments/${paymentId}/refund`;
try {
const sdk = createSaseAdmin();
const result = await sdk.refundPayment({
paymentId,
amount,
reason,
founderId: session.user.id,
});
await writeAudit({
projectKey: "sase",
endpoint,
method: "POST",
requestPayload: { paymentId, amount, 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: { paymentId, amount, reasonLen: reason.length },
responseStatus: status,
});
return NextResponse.json({ ok: false, error: message }, { status });
}
}

View File

@@ -14,7 +14,14 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
type Action = "trial-extend" | "activate" | "change-plan" | "cancel" | "resume" | null;
type Action =
| "trial-extend"
| "bonus-extend"
| "activate"
| "change-plan"
| "cancel"
| "resume"
| null;
const QUICK_DAYS = [7, 14, 30];
@@ -54,7 +61,7 @@ export function BillingActions({
setError("Sebep en az 5 karakter olmalı.");
return;
}
if (open === "trial-extend" && (days <= 0 || days > 90)) {
if ((open === "trial-extend" || open === "bonus-extend") && (days <= 0 || days > 90)) {
setError("Gün sayısı 1-90 arası olmalı.");
return;
}
@@ -62,14 +69,17 @@ export function BillingActions({
setError("Yeni plan seç.");
return;
}
// Both trial-extend and bonus-extend hit the same spoke endpoint (the
// generalized extend), so we send action='trial-extend' on the wire.
const wireAction = open === "bonus-extend" ? "trial-extend" : open;
startTransition(async () => {
const res = await fetch(`/api/sase/subscriptions/${subscriptionId}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: open,
action: wireAction,
reason: reason.trim(),
days: open === "trial-extend" ? days : undefined,
days: open === "trial-extend" || open === "bonus-extend" ? days : undefined,
newPlanId: open === "change-plan" ? newPlanId : undefined,
}),
});
@@ -84,6 +94,7 @@ export function BillingActions({
}
const canExtendTrial = subscriptionStatus === "trial";
const canBonusExtend = subscriptionStatus === "active";
const canActivate =
subscriptionStatus === "trial" || subscriptionStatus === "pending";
const canChangePlan = subscriptionStatus === "active" || subscriptionStatus === "trial";
@@ -121,6 +132,33 @@ export function BillingActions({
</Button>
</>
)}
{canBonusExtend && (
<>
{QUICK_DAYS.map((d) => (
<Button
key={`bonus-${d}`}
variant="outline"
size="sm"
onClick={() => {
setDays(d);
setOpen("bonus-extend");
}}
>
Bonus +{d}g
</Button>
))}
<Button
variant="outline"
size="sm"
onClick={() => {
setDays(0);
setOpen("bonus-extend");
}}
>
Bonus özel süre
</Button>
</>
)}
{canActivate && (
<Button variant="default" size="sm" onClick={() => setOpen("activate")}>
Subscription'ı aktive et
@@ -141,7 +179,7 @@ export function BillingActions({
Subscription'ı devam ettir
</Button>
)}
{!canExtendTrial && !canActivate && !canChangePlan && !canCancel && !canResume && (
{!canExtendTrial && !canBonusExtend && !canActivate && !canChangePlan && !canCancel && !canResume && (
<span className="text-xs text-muted-foreground">
Bu durum için billing aksiyonu yok ({subscriptionStatus}).
</span>
@@ -150,11 +188,16 @@ export function BillingActions({
<Dialog open={open !== null} onOpenChange={(v) => !v && close()}>
<DialogContent>
{open === "trial-extend" && (
{(open === "trial-extend" || open === "bonus-extend") && (
<>
<DialogHeader>
<DialogTitle>Trial uzat</DialogTitle>
<DialogTitle>
{open === "trial-extend" ? "Trial uzat" : "Bonus süre ekle (goodwill)"}
</DialogTitle>
<DialogDescription>
{open === "bonus-extend"
? "Aktif subscription'ın bitiş tarihini ücretsiz olarak ileri al. "
: ""}
Mevcut bitiş:{" "}
{subscriptionEndsAt
? subscriptionEndsAt.toISOString().slice(0, 10)
@@ -268,13 +311,15 @@ export function BillingActions({
? "..."
: open === "trial-extend"
? `+${days}g uzat`
: open === "activate"
? "Aktive et"
: open === "change-plan"
? "Plan değiştir"
: open === "cancel"
? "İptal et"
: "Devam ettir"}
: open === "bonus-extend"
? `+${days}g bonus`
: open === "activate"
? "Aktive et"
: open === "change-plan"
? "Plan değiştir"
: open === "cancel"
? "İptal et"
: "Devam ettir"}
</Button>
</DialogFooter>
</DialogContent>

View File

@@ -0,0 +1,145 @@
"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 { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
export function RefundButton({
paymentId,
paymentAmount,
currency,
status,
}: {
paymentId: string;
paymentAmount: number;
currency: string;
status: string;
}) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [fullRefund, setFullRefund] = useState(true);
const [partial, setPartial] = useState<string>("");
const [reason, setReason] = useState("");
const [error, setError] = useState<string | null>(null);
const [pending, startTransition] = useTransition();
const refundable = status === "completed" || status === "partially_refunded";
function close() {
setOpen(false);
setFullRefund(true);
setPartial("");
setReason("");
setError(null);
}
function submit() {
setError(null);
if (reason.trim().length < 5) {
setError("Sebep en az 5 karakter olmalı.");
return;
}
let amount: number | undefined;
if (!fullRefund) {
const n = Number(partial);
if (!Number.isFinite(n) || n <= 0 || n > paymentAmount) {
setError(`Tutar 1-${paymentAmount} arası olmalı.`);
return;
}
amount = Math.round(n);
}
startTransition(async () => {
const res = await fetch(`/api/sase/payments/${paymentId}/refund`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ amount: amount ?? null, reason: reason.trim() }),
});
if (!res.ok) {
const data = (await res.json().catch(() => ({}))) as { error?: string };
setError(data.error ?? `HTTP ${res.status}`);
return;
}
close();
router.refresh();
});
}
if (!refundable) {
return (
<span className="text-xs text-muted-foreground"></span>
);
}
return (
<>
<Button variant="outline" size="xs" onClick={() => setOpen(true)}>
Refund
</Button>
<Dialog open={open} onOpenChange={(v) => !v && close()}>
<DialogContent>
<DialogHeader>
<DialogTitle>Refund</DialogTitle>
<DialogDescription>
Stripe üzerinden iade tetiklenir.{" "}
{paymentAmount.toLocaleString("tr-TR")} {currency} maksimum.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<label className="flex items-center gap-2 text-sm">
<Checkbox
checked={fullRefund}
onCheckedChange={(v) => setFullRefund(v === true)}
/>
Tam iade ({paymentAmount.toLocaleString("tr-TR")} {currency})
</label>
{!fullRefund && (
<div className="space-y-1">
<label className="text-sm">Kısmi tutar ({currency})</label>
<Input
type="number"
min={1}
max={paymentAmount}
value={partial}
onChange={(e) => setPartial(e.target.value)}
className="w-40"
/>
</div>
)}
<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 variant="destructive" onClick={submit} disabled={pending}>
{pending ? "..." : "İade et"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -33,6 +33,7 @@ import { ImpersonateButton } from "./_impersonate-button";
import { NotesTab } from "./_notes";
import { LifecycleButtons, LifecycleStatusBadge } from "./_lifecycle-buttons";
import { BillingActions } from "./_billing-actions";
import { RefundButton } from "./_refund-button";
import { saseAdminWired } from "@/lib/admin-sdk/sase";
export const dynamic = "force-dynamic";
@@ -195,12 +196,16 @@ export default async function SaseUserDetailPage({
<TableHead>Yöntem</TableHead>
<TableHead className="text-right">Tutar</TableHead>
<TableHead>Durum</TableHead>
{saseAdminWired() && <TableHead className="text-right">İşlem</TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{user.payments.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="text-center text-muted-foreground">
<TableCell
colSpan={saseAdminWired() ? 5 : 4}
className="text-center text-muted-foreground"
>
Ödeme kaydı yok.
</TableCell>
</TableRow>
@@ -219,6 +224,16 @@ export default async function SaseUserDetailPage({
<TableCell>
<PaymentStatusBadge status={p.status} />
</TableCell>
{saseAdminWired() && (
<TableCell className="text-right">
<RefundButton
paymentId={p.id}
paymentAmount={p.amount}
currency={p.currency}
status={p.status}
/>
</TableCell>
)}
</TableRow>
))
)}

View File

@@ -93,6 +93,25 @@ export type SaseAdmin = {
reason: string;
founderId: string;
}): Promise<SubscriptionStateChange>;
/** Refund a Stripe payment, full or partial (amount in kuruş). */
refundPayment(input: {
paymentId: string;
amount?: number;
reason: string;
founderId: string;
}): Promise<RefundResult>;
};
export type RefundResult = {
success: boolean;
paymentId: string;
userId: string;
stripeRefundId: string;
amount: number;
isFullRefund: boolean;
newStatus: string;
currency: string;
};
export type PlanChangeResult = {
@@ -208,6 +227,12 @@ export function createSaseAdmin(): SaseAdmin {
reason: input.reason,
founderId: input.founderId,
}),
refundPayment: (input) =>
client.call("POST", `/internal/admin/payments/${input.paymentId}/refund`, {
amount: input.amount,
reason: input.reason,
founderId: input.founderId,
}),
};
}
@@ -230,6 +255,7 @@ function notWiredSdk(projectKey: string): SaseAdmin {
changePlan: () => Promise.reject(reject()),
cancelSubscriptionById: () => Promise.reject(reject()),
resumeSubscription: () => Promise.reject(reject()),
refundPayment: () => Promise.reject(reject()),
};
}
@@ -250,4 +276,5 @@ export const SASE_ADMIN_ENDPOINTS = [
"POST /internal/admin/subscriptions/:id/change-plan",
"POST /internal/admin/subscriptions/:id/cancel",
"POST /internal/admin/subscriptions/:id/resume",
"POST /internal/admin/payments/:id/refund",
];