feat(billing): indirim kodu sistemi (admin panel + Stripe promo + ödeme)
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled

Admin paneli (admin@sase.tr) kod üretir/takip eder, ödeme sayfası kodu
uygular. Kodlar Stripe Coupon + Promotion Code olarak yaşar (limit/süre/
kullanım Stripe redemption'da enforce edilir); yerel discount_codes aynası
panel listeleme + checkout-anı preview içindir.

- migration 0022_discount_codes + schema (discount_codes)
- DiscountCodesModule: admin CRUD (@Roles admin) + /discount-codes/validate (login)
- StripeService: createPromotion / deactivatePromotion / getPromotionRedemptions
  / previewDiscount + resolvePlanAmount; createCheckoutSession opsiyonel
  discountCode -> discounts[{promotion_code}] + metadata.discount_code; webhook
  gerçek tahsilatı (session.amount_total) yazar + ayna times_redeemed++
- web: /dashboard/admin/discount-codes paneli + ödeme adımı indirim alanı
  (validate preview -> indirimli özet -> kod checkout'a); tr/en çeviriler
- 7 birim test (previewDiscount: % / sabit / süre / geçersiz/pasif/dolu/limit)

Not: 2026-04-22 Stripe API promotionCodes.create `promotion:{type:'coupon'}`
kullanıyor (üst-seviye `coupon` değil). dev'de Stripe key yok -> CREATE dev'de
503; E2E için dev'e Stripe TEST key gerekir.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-29 03:40:18 +03:00
parent 3de7fa4989
commit 346ebaa0e6
17 changed files with 1309 additions and 19 deletions

View File

@@ -51,6 +51,7 @@ import {
Shield,
Sparkles,
Sun,
Ticket,
Trophy,
Users,
} from "lucide-react";
@@ -93,6 +94,7 @@ const adminItems: readonly NavItem[] = [
{ to: "/dashboard/admin/analytics", labelKey: "nav.analytics", icon: BarChart3 },
{ to: "/dashboard/admin/copy-logs", labelKey: "nav.copyLogs", icon: Copy },
{ to: "/dashboard/admin/referrals", labelKey: "nav.referrals", icon: Share2 },
{ to: "/dashboard/admin/discount-codes", labelKey: "nav.discountCodes", icon: Ticket },
{ to: "/dashboard/service-test", labelKey: "nav.serviceTest", icon: FlaskConical },
];

View File

@@ -0,0 +1,432 @@
import { useAuth } from "@/hooks/use-auth";
import { ApiError, api } from "@/lib/api-client";
import { toast } from "@/lib/toast";
import { formatTRY } from "@sase/shared";
import {
Badge,
Button,
Card,
CardContent,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
Input,
Label,
Skeleton,
} from "@sase/ui";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { Copy, Plus, RefreshCw, Ticket } from "lucide-react";
import { useEffect, useState } from "react";
export const Route = createFileRoute("/dashboard/admin/discount-codes")({
component: AdminDiscountCodesPage,
});
type DiscountStatus = "active" | "inactive" | "expired" | "exhausted";
interface DiscountCode {
id: string;
code: string;
discountType: "percentage" | "fixed";
percentOff: number | null;
amountOff: number | null;
duration: "once" | "repeating" | "forever";
durationInMonths: number | null;
maxRedemptions: number | null;
timesRedeemed: number;
expiresAt: string | null;
isActive: boolean;
note: string | null;
createdAt: string;
status: DiscountStatus;
}
const STATUS_VARIANT: Record<DiscountStatus, "default" | "secondary" | "destructive" | "outline"> =
{
active: "default",
inactive: "secondary",
expired: "outline",
exhausted: "destructive",
};
const STATUS_LABEL: Record<DiscountStatus, string> = {
active: "Aktif",
inactive: "Pasif",
expired: "Süresi doldu",
exhausted: "Limit doldu",
};
const SKELETON_KEYS = ["s1", "s2", "s3", "s4", "s5"];
const emptyForm = {
code: "",
discountType: "percentage" as "percentage" | "fixed",
percentOff: "20",
amountOffTL: "",
duration: "once" as "once" | "repeating" | "forever",
durationInMonths: "3",
maxRedemptions: "",
expiresAt: "",
note: "",
};
function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleDateString("tr-TR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
}
function discountLabel(c: DiscountCode) {
return c.discountType === "percentage" ? `%${c.percentOff}` : formatTRY(c.amountOff ?? 0);
}
function durationLabel(c: DiscountCode) {
if (c.duration === "forever") return "Her ödemede";
if (c.duration === "repeating") return `İlk ${c.durationInMonths} ay`;
return "İlk ödeme";
}
function AdminDiscountCodesPage() {
const { user, isLoading: authLoading } = useAuth();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [dialogOpen, setDialogOpen] = useState(false);
const [form, setForm] = useState(emptyForm);
useEffect(() => {
if (!authLoading && user?.role !== "admin") {
navigate({ to: "/dashboard/search" });
}
}, [authLoading, user, navigate]);
const { data: codes, isLoading } = useQuery({
queryKey: ["admin", "discount-codes"],
queryFn: () => api.get<DiscountCode[]>("/discount-codes"),
enabled: user?.role === "admin",
});
const createMutation = useMutation({
mutationFn: (payload: Record<string, unknown>) => api.post("/discount-codes", payload),
onSuccess: () => {
toast.success("İndirim kodu oluşturuldu");
setDialogOpen(false);
setForm(emptyForm);
queryClient.invalidateQueries({ queryKey: ["admin", "discount-codes"] });
},
onError: (err) => {
toast.error(err instanceof ApiError ? err.message : "Kod oluşturulamadı");
},
});
const deactivateMutation = useMutation({
mutationFn: (id: string) => api.patch(`/discount-codes/${id}/deactivate`),
onSuccess: () => {
toast.success("Kod devre dışı bırakıldı");
queryClient.invalidateQueries({ queryKey: ["admin", "discount-codes"] });
},
onError: (err) => {
toast.error(err instanceof ApiError ? err.message : "İşlem başarısız");
},
});
const syncMutation = useMutation({
mutationFn: () => api.post<{ synced: number; updated: number }>("/discount-codes/sync"),
onSuccess: (res) => {
toast.success(`Stripe ile eşitlendi (${res.updated} güncellendi)`);
queryClient.invalidateQueries({ queryKey: ["admin", "discount-codes"] });
},
onError: (err) => {
toast.error(err instanceof ApiError ? err.message : "Eşitleme başarısız");
},
});
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const payload: Record<string, unknown> = {
discountType: form.discountType,
duration: form.duration,
};
if (form.code.trim()) payload.code = form.code.trim();
if (form.discountType === "percentage") payload.percentOff = Number(form.percentOff);
else payload.amountOff = Math.round(Number(form.amountOffTL) * 100);
if (form.duration === "repeating") payload.durationInMonths = Number(form.durationInMonths);
if (form.maxRedemptions.trim()) payload.maxRedemptions = Number(form.maxRedemptions);
if (form.expiresAt) payload.expiresAt = new Date(`${form.expiresAt}T23:59:59`).toISOString();
if (form.note.trim()) payload.note = form.note.trim();
createMutation.mutate(payload);
}
function copyCode(code: string) {
navigator.clipboard?.writeText(code).then(
() => toast.success(`${code} kopyalandı`),
() => toast.error("Kopyalanamadı"),
);
}
if (authLoading) {
return (
<div className="space-y-6">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-96 w-full" />
</div>
);
}
if (user?.role !== "admin") return null;
return (
<div className="mx-auto max-w-6xl space-y-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Ticket className="h-6 w-6 text-brand" />
<h2 className="text-2xl font-bold">İndirim Kodları</h2>
<Badge variant="outline">{codes?.length ?? 0} kod</Badge>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => syncMutation.mutate()}
disabled={syncMutation.isPending}
>
<RefreshCw className={`mr-1 h-4 w-4 ${syncMutation.isPending ? "animate-spin" : ""}`} />
Stripe ile eşitle
</Button>
<Button size="sm" onClick={() => setDialogOpen(true)}>
<Plus className="mr-1 h-4 w-4" />
Yeni Kod
</Button>
</div>
</div>
<p className="text-sm text-muted-foreground">
Kodlar Stripe'da kupon + promosyon kodu olarak oluşturulur; süre ve kullanım limiti ödeme
sırasında Stripe tarafından uygulanır. Kullanım sayıları ödeme tamamlandıkça güncellenir
(anlık değilse "Stripe ile eşitle").
</p>
{isLoading ? (
<div className="space-y-3">
{SKELETON_KEYS.map((k) => (
<Skeleton key={k} className="h-14 w-full" />
))}
</div>
) : !codes || codes.length === 0 ? (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
Henüz indirim kodu yok. "Yeni Kod" ile oluşturun.
</CardContent>
</Card>
) : (
<Card>
<CardContent className="p-0">
<div className="hidden items-center gap-4 border-b px-6 py-3 text-sm font-medium text-muted-foreground lg:grid lg:grid-cols-7">
<span className="col-span-2">Kod</span>
<span>İndirim</span>
<span>Süre</span>
<span>Kullanım</span>
<span>Bitiş</span>
<span className="text-right">İşlem</span>
</div>
<div className="divide-y">
{codes.map((c) => (
<div key={c.id} className="grid items-center gap-4 px-6 py-4 lg:grid-cols-7">
<div className="col-span-2 flex items-center gap-2">
<button
type="button"
onClick={() => copyCode(c.code)}
className="inline-flex items-center gap-1.5 rounded-md bg-muted px-2 py-1 font-mono text-sm font-semibold transition-colors hover:bg-muted/70"
title="Kopyala"
>
{c.code}
<Copy className="h-3 w-3 text-muted-foreground" />
</button>
<Badge variant={STATUS_VARIANT[c.status]}>{STATUS_LABEL[c.status]}</Badge>
</div>
<div className="font-medium">{discountLabel(c)}</div>
<div className="text-sm text-muted-foreground">{durationLabel(c)}</div>
<div className="text-sm">
{c.timesRedeemed} / {c.maxRedemptions ?? "∞"}
</div>
<div className="text-sm text-muted-foreground">
{c.expiresAt ? formatDate(c.expiresAt) : "—"}
</div>
<div className="text-right">
{c.isActive && (
<Button
variant="ghost"
size="sm"
onClick={() => {
if (window.confirm(`${c.code} kodunu devre dışı bırak?`)) {
deactivateMutation.mutate(c.id);
}
}}
disabled={deactivateMutation.isPending}
>
Devre dışı bırak
</Button>
)}
</div>
{c.note && (
<p className="col-span-full text-xs text-muted-foreground lg:col-span-7">
{c.note}
</p>
)}
</div>
))}
</div>
</CardContent>
</Card>
)}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Yeni İndirim Kodu</DialogTitle>
<DialogDescription>
Boş bırakırsanız rastgele bir kod üretilir. Tutarı TL olarak girin.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="code">Kod (opsiyonel)</Label>
<Input
id="code"
placeholder="Örn. YILBASI25 — boş = otomatik"
value={form.code}
onChange={(e) => setForm((p) => ({ ...p, code: e.target.value.toUpperCase() }))}
maxLength={50}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="discountType">İndirim türü</Label>
<select
id="discountType"
value={form.discountType}
onChange={(e) =>
setForm((p) => ({
...p,
discountType: e.target.value as "percentage" | "fixed",
}))
}
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
<option value="percentage">Yüzde (%)</option>
<option value="fixed">Sabit tutar ()</option>
</select>
</div>
{form.discountType === "percentage" ? (
<div className="space-y-2">
<Label htmlFor="percentOff">Yüzde (1-100)</Label>
<Input
id="percentOff"
type="number"
min={1}
max={100}
value={form.percentOff}
onChange={(e) => setForm((p) => ({ ...p, percentOff: e.target.value }))}
required
/>
</div>
) : (
<div className="space-y-2">
<Label htmlFor="amountOffTL">Tutar ()</Label>
<Input
id="amountOffTL"
type="number"
min={1}
step="0.01"
placeholder="100"
value={form.amountOffTL}
onChange={(e) => setForm((p) => ({ ...p, amountOffTL: e.target.value }))}
required
/>
</div>
)}
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="duration">Abonelikte süre</Label>
<select
id="duration"
value={form.duration}
onChange={(e) =>
setForm((p) => ({
...p,
duration: e.target.value as "once" | "repeating" | "forever",
}))
}
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
<option value="once">Sadece ilk ödeme</option>
<option value="repeating">Belirli ay sayısı</option>
<option value="forever">Her yenilemede</option>
</select>
</div>
{form.duration === "repeating" && (
<div className="space-y-2">
<Label htmlFor="durationInMonths">Ay sayısı (1-36)</Label>
<Input
id="durationInMonths"
type="number"
min={1}
max={36}
value={form.durationInMonths}
onChange={(e) => setForm((p) => ({ ...p, durationInMonths: e.target.value }))}
required
/>
</div>
)}
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="maxRedemptions">Kullanım limiti</Label>
<Input
id="maxRedemptions"
type="number"
min={1}
placeholder="Boş = sınırsız"
value={form.maxRedemptions}
onChange={(e) => setForm((p) => ({ ...p, maxRedemptions: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="expiresAt">Son kullanma</Label>
<Input
id="expiresAt"
type="date"
value={form.expiresAt}
onChange={(e) => setForm((p) => ({ ...p, expiresAt: e.target.value }))}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="note">Not (opsiyonel)</Label>
<Input
id="note"
placeholder="Kampanya adı / kime verildiği"
value={form.note}
onChange={(e) => setForm((p) => ({ ...p, note: e.target.value }))}
maxLength={200}
/>
</div>
<DialogFooter>
<Button type="submit" disabled={createMutation.isPending}>
{createMutation.isPending ? "Oluşturuluyor..." : "Oluştur"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -24,6 +24,7 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
Input,
Separator,
Skeleton,
} from "@sase/ui";
@@ -1245,6 +1246,20 @@ export function OrderSummary({
);
}
type ValidDiscount = {
valid: true;
code: string;
discountType: "percentage" | "fixed";
percentOff: number | null;
amountOff: number | null;
duration: string;
durationInMonths: number | null;
originalAmount: number;
discountAmount: number;
finalAmount: number;
};
type DiscountPreview = ValidDiscount | { valid: false; reason: string; message: string };
function PaymentMethodSection({
planKey,
period,
@@ -1292,20 +1307,133 @@ function PaymentMethodSection({
? selectedBrandNames.join(", ")
: `${brandIds.length} ${t("subscription.brandsSelected")}`;
// İndirim kodu: kullanıcı girer → /discount-codes/validate fiyatı doğrular.
// Uygulanınca özet + ödeme butonu indirimli tutarı gösterir; kod checkout'a
// geçirilir ve Stripe redemption'da yeniden doğrular.
const [discountInput, setDiscountInput] = useState("");
const [appliedDiscount, setAppliedDiscount] = useState<ValidDiscount | null>(null);
// Plan/dönem değişince fiyat değişir → uygulanmış indirimi sıfırla (yeniden
// doğrulanması gerekir).
// biome-ignore lint/correctness/useExhaustiveDependencies: planKey/period are intentional reset triggers
useEffect(() => {
setAppliedDiscount(null);
setDiscountInput("");
}, [planKey, period]);
const validateDiscount = useMutation({
mutationFn: (code: string) =>
api.post<DiscountPreview>("/discount-codes/validate", {
code,
planKey,
billingPeriod: period,
}),
onSuccess: (res) => {
if (res.valid) {
setAppliedDiscount(res);
toast.success(t("subscription.discount.applied"));
} else {
setAppliedDiscount(null);
toast.error(res.message || t("subscription.discount.invalid"));
}
},
onError: () => {
setAppliedDiscount(null);
toast.error(t("subscription.discount.invalid"));
},
});
const effectiveTotal = appliedDiscount ? appliedDiscount.finalAmount : totalAmount;
const durationNote = !appliedDiscount
? ""
: appliedDiscount.duration === "forever"
? t("subscription.discount.durationForever")
: appliedDiscount.duration === "repeating" && appliedDiscount.durationInMonths
? t("subscription.discount.durationRepeating", { months: appliedDiscount.durationInMonths })
: t("subscription.discount.durationOnce");
return (
<div className="space-y-4">
<OrderSummary
planKey={planKey}
period={period}
totalAmount={totalAmount}
totalAmount={effectiveTotal}
brandsValue={brandsValue}
/>
{/* İndirim kodu */}
<div className="rounded-2xl border border-border bg-background p-4">
<p className="mb-2 text-sm font-medium">{t("subscription.discount.label")}</p>
{appliedDiscount ? (
<div className="space-y-2">
<div className="flex items-center justify-between gap-3 rounded-xl border border-emerald-300/40 bg-emerald-50/40 px-3 py-2 dark:border-emerald-900/40 dark:bg-emerald-950/10">
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-0.5 text-sm">
<Check className="h-4 w-4 shrink-0 text-emerald-500" />
<span className="font-semibold">{appliedDiscount.code}</span>
<span className="text-muted-foreground">
{appliedDiscount.discountType === "percentage"
? `%${appliedDiscount.percentOff} ${t("subscription.discount.off")}`
: `${formatTRY(appliedDiscount.amountOff ?? 0)}`}
{" · "}
{durationNote}
</span>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
setAppliedDiscount(null);
setDiscountInput("");
}}
>
{t("subscription.discount.remove")}
</Button>
</div>
<p className="text-sm text-muted-foreground">
{t("subscription.discount.newTotal")}{" "}
<span className="font-semibold text-foreground">{formatTRY(effectiveTotal)}</span>{" "}
<span className="text-muted-foreground line-through">{formatTRY(totalAmount)}</span>
</p>
</div>
) : (
<div className="flex gap-2">
<Input
id="discount-code"
value={discountInput}
onChange={(e) => setDiscountInput(e.target.value.toUpperCase())}
onKeyDown={(e) => {
if (e.key === "Enter" && discountInput.trim()) {
e.preventDefault();
validateDiscount.mutate(discountInput.trim());
}
}}
placeholder={t("subscription.discount.placeholder")}
maxLength={50}
className="flex-1"
/>
<Button
type="button"
variant="outline"
onClick={() => validateDiscount.mutate(discountInput.trim())}
disabled={!discountInput.trim() || validateDiscount.isPending}
>
{validateDiscount.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
t("subscription.discount.apply")
)}
</Button>
</div>
)}
</div>
<StripeCheckoutButton
planKey={planKey}
period={period}
brandIds={brandIds}
totalAmount={totalAmount}
totalAmount={effectiveTotal}
discountCode={appliedDiscount?.code}
/>
<div className="flex flex-wrap items-center justify-center gap-x-3 gap-y-1 text-xs text-foreground/70">