dev #56
@@ -1,4 +1,4 @@
|
||||
import { Controller, Get } from "@nestjs/common";
|
||||
import { Controller, Get, Param } from "@nestjs/common";
|
||||
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
||||
import { PaymentsService } from "./payments.service";
|
||||
|
||||
@@ -10,4 +10,9 @@ export class PaymentsController {
|
||||
async getMyPayments(@CurrentUser("id") userId: string) {
|
||||
return this.paymentsService.getMyPayments(userId);
|
||||
}
|
||||
|
||||
@Get(":id/receipt")
|
||||
async getReceipt(@CurrentUser("id") userId: string, @Param("id") id: string) {
|
||||
return this.paymentsService.getReceiptUrl(userId, id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,80 @@
|
||||
import { Inject, Injectable, Logger } from "@nestjs/common";
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import { Inject, Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
import { DATABASE, type Database } from "../database/database.provider";
|
||||
import { payments } from "../database/schema/core";
|
||||
import { payments, plans, userSubscriptions } from "../database/schema/core";
|
||||
import { StripeService } from "./stripe/stripe.service";
|
||||
|
||||
@Injectable()
|
||||
export class PaymentsService {
|
||||
private readonly logger = new Logger(PaymentsService.name);
|
||||
|
||||
constructor(@Inject(DATABASE) private db: Database) {}
|
||||
constructor(
|
||||
@Inject(DATABASE) private db: Database,
|
||||
private stripeService: StripeService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Payment history for the billing page. Returns a curated projection — never
|
||||
* the raw row — so internal columns (adminNote, iyzicoPaymentId,
|
||||
* bankAccountId, session/intent ids) are not leaked to the client. planName
|
||||
* is joined from the subscription's plan; Stripe receipt availability is
|
||||
* surfaced as a boolean rather than exposing the payment intent id.
|
||||
*/
|
||||
async getMyPayments(userId: string) {
|
||||
return this.db
|
||||
.select()
|
||||
const rows = await this.db
|
||||
.select({
|
||||
id: payments.id,
|
||||
amount: payments.amount,
|
||||
method: payments.method,
|
||||
status: payments.status,
|
||||
createdAt: payments.createdAt,
|
||||
planName: plans.name,
|
||||
eftReceiptUrl: payments.eftReceiptUrl,
|
||||
stripePaymentIntentId: payments.stripePaymentIntentId,
|
||||
})
|
||||
.from(payments)
|
||||
.leftJoin(userSubscriptions, eq(payments.subscriptionId, userSubscriptions.id))
|
||||
.leftJoin(plans, eq(userSubscriptions.planId, plans.id))
|
||||
.where(eq(payments.userId, userId))
|
||||
.orderBy(desc(payments.createdAt));
|
||||
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
amount: row.amount,
|
||||
method: row.method,
|
||||
status: row.status,
|
||||
createdAt: row.createdAt,
|
||||
planName: row.planName ?? null,
|
||||
eftReceiptUrl: row.eftReceiptUrl ?? null,
|
||||
hasStripeReceipt:
|
||||
row.method === "stripe" && row.status === "completed" && !!row.stripePaymentIntentId,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a receipt URL for one of the current user's payments.
|
||||
* EFT receipts are stored locally; Stripe receipts are fetched live from the
|
||||
* payment intent's latest charge. Returns { url: null } when none exists.
|
||||
*/
|
||||
async getReceiptUrl(userId: string, paymentId: string): Promise<{ url: string | null }> {
|
||||
const [payment] = await this.db
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(and(eq(payments.id, paymentId), eq(payments.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
if (!payment) {
|
||||
throw new NotFoundException("Payment not found");
|
||||
}
|
||||
|
||||
if (payment.eftReceiptUrl) {
|
||||
return { url: payment.eftReceiptUrl };
|
||||
}
|
||||
|
||||
if (payment.method === "stripe" && payment.stripePaymentIntentId) {
|
||||
return { url: await this.stripeService.getReceiptUrl(payment.stripePaymentIntentId) };
|
||||
}
|
||||
|
||||
return { url: null };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +66,30 @@ export class StripeService {
|
||||
return this.stripe !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the Stripe-hosted receipt URL for a payment intent by expanding its
|
||||
* latest charge. Returns null when no receipt is available (e.g. not yet
|
||||
* charged). Throws if Stripe is disabled.
|
||||
*/
|
||||
async getReceiptUrl(paymentIntentId: string): Promise<string | null> {
|
||||
if (!this.stripe) {
|
||||
throw new ServiceUnavailableException("Stripe is not configured");
|
||||
}
|
||||
try {
|
||||
const intent = await this.stripe.paymentIntents.retrieve(paymentIntentId, {
|
||||
expand: ["latest_charge"],
|
||||
});
|
||||
const charge = intent.latest_charge;
|
||||
if (charge && typeof charge !== "string") {
|
||||
return charge.receipt_url ?? null;
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
this.logger.warn(`Failed to fetch receipt for ${paymentIntentId}: ${String(error)}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async createCheckoutSession(
|
||||
userId: string,
|
||||
userEmail: string,
|
||||
@@ -314,9 +338,7 @@ export class StripeService {
|
||||
.limit(1);
|
||||
if (!payment) throw new NotFoundException("Payment bulunamadı");
|
||||
if (!payment.stripePaymentIntentId) {
|
||||
throw new BadRequestException(
|
||||
"Bu payment Stripe üzerinden alınmadı (legacy/Iyzico)",
|
||||
);
|
||||
throw new BadRequestException("Bu payment Stripe üzerinden alınmadı (legacy/Iyzico)");
|
||||
}
|
||||
if (payment.status !== "completed" && payment.status !== "partially_refunded") {
|
||||
throw new BadRequestException(
|
||||
@@ -327,9 +349,7 @@ export class StripeService {
|
||||
input.amount !== undefined &&
|
||||
(input.amount <= 0 || input.amount > Number(payment.amount))
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
`Refund tutarı 1..${payment.amount} aralığında olmalı`,
|
||||
);
|
||||
throw new BadRequestException(`Refund tutarı 1..${payment.amount} aralığında olmalı`);
|
||||
}
|
||||
|
||||
const isFullRefund = input.amount === undefined;
|
||||
@@ -374,7 +394,7 @@ export class StripeService {
|
||||
paymentId: payment.id,
|
||||
userId: payment.userId,
|
||||
stripeRefundId: refund.id,
|
||||
amount: isFullRefund ? Number(payment.amount) : input.amount!,
|
||||
amount: input.amount ?? Number(payment.amount),
|
||||
isFullRefund,
|
||||
newStatus,
|
||||
currency: payment.currency,
|
||||
|
||||
@@ -366,12 +366,17 @@
|
||||
"title": "Payment History",
|
||||
"payments": "Payments",
|
||||
"noPayments": "No payment records yet.",
|
||||
"noResults": "No payments match this filter.",
|
||||
"clearFilters": "Clear filters",
|
||||
"showMore": "Show more",
|
||||
"date": "Date",
|
||||
"plan": "Plan",
|
||||
"amount": "Amount",
|
||||
"method": "Method",
|
||||
"status": "Status",
|
||||
"downloadReceipt": "Download Receipt",
|
||||
"viewReceipt": "View receipt",
|
||||
"receiptUnavailable": "No receipt available for this payment.",
|
||||
"filterByStatus": "Filter by Status",
|
||||
"filterByMethod": "Filter by Method",
|
||||
"statusLabels": {
|
||||
@@ -383,6 +388,22 @@
|
||||
"methodLabels": {
|
||||
"stripe": "Credit Card",
|
||||
"eft": "EFT/Wire"
|
||||
},
|
||||
"summary": {
|
||||
"title": "Current Plan",
|
||||
"noPlan": "You have no active subscription.",
|
||||
"managePlan": "Manage subscription",
|
||||
"choosePlan": "Choose a plan",
|
||||
"nextRenewal": "Next renewal",
|
||||
"trialEnds": "Trial ends",
|
||||
"ends": "Ends on"
|
||||
},
|
||||
"subStatus": {
|
||||
"trial": "Trial",
|
||||
"active": "Active",
|
||||
"cancelled": "Cancelled",
|
||||
"expired": "Expired",
|
||||
"pending": "Pending"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
|
||||
@@ -366,12 +366,17 @@
|
||||
"title": "Ödeme Geçmişi",
|
||||
"payments": "Ödemeler",
|
||||
"noPayments": "Henüz ödeme kaydı yok.",
|
||||
"noResults": "Bu filtreyle eşleşen ödeme yok.",
|
||||
"clearFilters": "Filtreleri temizle",
|
||||
"showMore": "Daha fazla göster",
|
||||
"date": "Tarih",
|
||||
"plan": "Plan",
|
||||
"amount": "Tutar",
|
||||
"method": "Yöntem",
|
||||
"status": "Durum",
|
||||
"downloadReceipt": "Dekontu İndir",
|
||||
"viewReceipt": "Faturayı görüntüle",
|
||||
"receiptUnavailable": "Bu ödeme için makbuz bulunamadı.",
|
||||
"filterByStatus": "Duruma Göre Filtrele",
|
||||
"filterByMethod": "Yönteme Göre Filtrele",
|
||||
"statusLabels": {
|
||||
@@ -383,6 +388,22 @@
|
||||
"methodLabels": {
|
||||
"stripe": "Kredi Kartı",
|
||||
"eft": "EFT/Havale"
|
||||
},
|
||||
"summary": {
|
||||
"title": "Mevcut Plan",
|
||||
"noPlan": "Aktif aboneliğiniz yok.",
|
||||
"managePlan": "Aboneliği Yönet",
|
||||
"choosePlan": "Plan Seç",
|
||||
"nextRenewal": "Sonraki yenileme",
|
||||
"trialEnds": "Deneme bitişi",
|
||||
"ends": "Bitiş tarihi"
|
||||
},
|
||||
"subStatus": {
|
||||
"trial": "Deneme",
|
||||
"active": "Aktif",
|
||||
"cancelled": "İptal edildi",
|
||||
"expired": "Süresi doldu",
|
||||
"pending": "Beklemede"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
|
||||
@@ -1,40 +1,137 @@
|
||||
import { api } from "@/lib/api-client";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { formatTRY } from "@sase/shared";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@sase/ui";
|
||||
import { Badge } from "@sase/ui";
|
||||
import { Button } from "@sase/ui";
|
||||
import { Skeleton } from "@sase/ui";
|
||||
import { Separator } from "@sase/ui";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { Download, Filter } from "lucide-react";
|
||||
import { Link, createFileRoute } from "@tanstack/react-router";
|
||||
import { ArrowRight, Download, Filter, Receipt } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
export const Route = createFileRoute("/dashboard/billing")({
|
||||
component: BillingPage,
|
||||
});
|
||||
|
||||
interface Subscription {
|
||||
status: "trial" | "active" | "canceled" | "expired" | string;
|
||||
plan?: { name: string; key: string };
|
||||
billingPeriod?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
}
|
||||
|
||||
interface Payment {
|
||||
id: string;
|
||||
amount: number;
|
||||
method: "stripe" | "eft";
|
||||
status: "completed" | "pending" | "failed" | "refunded";
|
||||
planName?: string;
|
||||
receiptUrl?: string;
|
||||
planName?: string | null;
|
||||
eftReceiptUrl?: string | null;
|
||||
hasStripeReceipt?: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
function SegmentedFilter({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
options: { value: string; label: string }[];
|
||||
}) {
|
||||
return (
|
||||
<fieldset className="inline-flex max-w-full flex-wrap items-center gap-1 rounded-lg bg-muted p-1">
|
||||
<legend className="sr-only">{label}</legend>
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => onChange(option.value)}
|
||||
aria-pressed={value === option.value}
|
||||
className={`rounded-md px-3 py-1 text-sm font-medium transition-colors ${
|
||||
value === option.value
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
// Responsive cell: on mobile a labelled flex row (label via ::before from
|
||||
// data-label); on sm+ a normal table cell.
|
||||
const TD =
|
||||
"flex items-center justify-between gap-4 py-1 before:font-medium before:text-muted-foreground before:content-[attr(data-label)] sm:table-cell sm:py-4 sm:before:content-none";
|
||||
|
||||
function BillingPage() {
|
||||
const { t } = useTranslation();
|
||||
const { t, locale } = useTranslation();
|
||||
const [statusFilter, setStatusFilter] = useState<string>("all");
|
||||
const [methodFilter, setMethodFilter] = useState<string>("all");
|
||||
const [visibleCount, setVisibleCount] = useState(10);
|
||||
const [receiptLoadingId, setReceiptLoadingId] = useState<string | null>(null);
|
||||
|
||||
const hasActiveFilter = statusFilter !== "all" || methodFilter !== "all";
|
||||
function clearFilters() {
|
||||
setStatusFilter("all");
|
||||
setMethodFilter("all");
|
||||
}
|
||||
|
||||
async function openReceipt(paymentId: string) {
|
||||
setReceiptLoadingId(paymentId);
|
||||
try {
|
||||
const { url } = await api.get<{ url: string | null }>(`/payments/${paymentId}/receipt`);
|
||||
if (url) {
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
} else {
|
||||
toast.error(t("billing.receiptUnavailable"));
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("billing.receiptUnavailable"));
|
||||
} finally {
|
||||
setReceiptLoadingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
const { data: payments, isLoading } = useQuery({
|
||||
queryKey: ["payments", "me"],
|
||||
queryFn: () => api.get<Payment[]>("/payments/me"),
|
||||
});
|
||||
|
||||
const { data: subData, isLoading: subLoading } = useQuery({
|
||||
queryKey: ["subscription", "me"],
|
||||
queryFn: () =>
|
||||
api.get<{ subscription: Subscription | null; eligibleForTrial: boolean }>(
|
||||
"/subscriptions/me",
|
||||
),
|
||||
});
|
||||
const subscription = subData?.subscription;
|
||||
|
||||
const formatDate = (iso: string) =>
|
||||
new Date(iso).toLocaleDateString(locale === "tr" ? "tr-TR" : "en-US", {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
const subStatusVariant: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
|
||||
active: "default",
|
||||
trial: "secondary",
|
||||
cancelled: "outline",
|
||||
expired: "destructive",
|
||||
pending: "secondary",
|
||||
};
|
||||
const endDateLabel =
|
||||
subscription?.status === "trial" ? t("billing.summary.trialEnds") : t("billing.summary.ends");
|
||||
|
||||
const statusVariants: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
|
||||
completed: "default",
|
||||
pending: "secondary",
|
||||
@@ -52,55 +149,101 @@ function BillingPage() {
|
||||
<div className="mx-auto max-w-4xl space-y-6">
|
||||
<h2 className="text-2xl font-bold">{t("billing.title")}</h2>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">{t("common.filter")}:</span>
|
||||
</div>
|
||||
|
||||
{/* Status Filter */}
|
||||
<div className="flex gap-1">
|
||||
{["all", "completed", "pending", "failed", "refunded"].map((status) => (
|
||||
<Button
|
||||
key={status}
|
||||
variant={statusFilter === status ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setStatusFilter(status)}
|
||||
>
|
||||
{status === "all" ? t("common.all") : t(`billing.statusLabels.${status}`)}
|
||||
{/* Subscription summary */}
|
||||
{subLoading ? (
|
||||
<Skeleton className="h-24 w-full rounded-xl" />
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col gap-4 py-5 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{t("billing.summary.title")}
|
||||
</p>
|
||||
{subscription ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg font-semibold">{subscription.plan?.name ?? "—"}</span>
|
||||
<Badge variant={subStatusVariant[subscription.status] ?? "secondary"}>
|
||||
{t(`billing.subStatus.${subscription.status}`)}
|
||||
</Badge>
|
||||
</div>
|
||||
{subscription.endDate && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{endDateLabel}:{" "}
|
||||
<span className="tabular-nums">{formatDate(subscription.endDate)}</span>
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">{t("billing.summary.noPlan")}</p>
|
||||
)}
|
||||
</div>
|
||||
<Button asChild variant={subscription ? "outline" : "default"} size="sm">
|
||||
<Link to="/dashboard/subscription">
|
||||
{subscription ? t("billing.summary.managePlan") : t("billing.summary.choosePlan")}
|
||||
<ArrowRight className="ml-1.5 size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Separator orientation="vertical" className="h-6" />
|
||||
|
||||
{/* Method Filter */}
|
||||
<div className="flex gap-1">
|
||||
{["all", "stripe"].map((method) => (
|
||||
<Button
|
||||
key={method}
|
||||
variant={methodFilter === method ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setMethodFilter(method)}
|
||||
>
|
||||
{method === "all" ? t("common.all") : t(`billing.methodLabels.${method}`)}
|
||||
</Button>
|
||||
))}
|
||||
{/* Filters — only meaningful once there are payments */}
|
||||
{(payments?.length ?? 0) > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Filter className="h-4 w-4" />
|
||||
{t("common.filter")}:
|
||||
</span>
|
||||
<SegmentedFilter
|
||||
label={t("billing.filterByStatus")}
|
||||
value={statusFilter}
|
||||
onChange={setStatusFilter}
|
||||
options={[
|
||||
{ value: "all", label: t("common.all") },
|
||||
...["completed", "pending", "failed", "refunded"].map((s) => ({
|
||||
value: s,
|
||||
label: t(`billing.statusLabels.${s}`),
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
<SegmentedFilter
|
||||
label={t("billing.filterByMethod")}
|
||||
value={methodFilter}
|
||||
onChange={setMethodFilter}
|
||||
options={[
|
||||
{ value: "all", label: t("common.all") },
|
||||
...["stripe", "eft"].map((m) => ({
|
||||
value: m,
|
||||
label: t(`billing.methodLabels.${m}`),
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading State */}
|
||||
{isLoading ? (
|
||||
<div className="space-y-4">
|
||||
{["s1", "s2", "s3", "s4", "s5"].map((id) => (
|
||||
<Skeleton key={id} className="h-16 w-full" />
|
||||
))}
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-32" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
{["s1", "s2", "s3", "s4", "s5"].map((id) => (
|
||||
<Skeleton key={id} className="h-10 w-full" />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : !filteredPayments || filteredPayments.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center text-muted-foreground">
|
||||
{t("billing.noPayments")}
|
||||
<CardContent className="flex flex-col items-center gap-3 py-12 text-center text-muted-foreground">
|
||||
<p>{hasActiveFilter ? t("billing.noResults") : t("billing.noPayments")}</p>
|
||||
{hasActiveFilter && (
|
||||
<Button variant="outline" size="sm" onClick={clearFilters}>
|
||||
{t("billing.clearFilters")}
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
@@ -109,77 +252,95 @@ function BillingPage() {
|
||||
<CardTitle>{t("billing.payments")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* Table Header */}
|
||||
<div className="hidden items-center gap-4 border-b pb-3 text-sm font-medium text-muted-foreground sm:grid sm:grid-cols-6">
|
||||
<span>{t("billing.date")}</span>
|
||||
<span>{t("billing.plan")}</span>
|
||||
<span className="text-right">{t("billing.amount")}</span>
|
||||
<span className="text-center">{t("billing.method")}</span>
|
||||
<span className="text-center">{t("billing.status")}</span>
|
||||
<span className="text-right" />
|
||||
</div>
|
||||
|
||||
{/* Table Rows */}
|
||||
<div className="divide-y">
|
||||
{filteredPayments.map((payment) => (
|
||||
<div key={payment.id} className="grid items-center gap-4 py-4 sm:grid-cols-6">
|
||||
{/* Date */}
|
||||
<div>
|
||||
<p className="text-sm font-medium sm:font-normal">
|
||||
{new Date(payment.createdAt).toLocaleDateString("tr-TR", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
})}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground sm:hidden">
|
||||
{payment.planName || "-"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Plan */}
|
||||
<div className="hidden sm:block">
|
||||
<p className="text-sm">{payment.planName || "-"}</p>
|
||||
</div>
|
||||
|
||||
{/* Amount */}
|
||||
<div className="text-right">
|
||||
<span className="font-medium">{formatTRY(payment.amount)}</span>
|
||||
</div>
|
||||
|
||||
{/* Method */}
|
||||
<div className="text-center">
|
||||
<Badge variant={payment.method === "stripe" ? "secondary" : "outline"}>
|
||||
{t(`billing.methodLabels.${payment.method}`)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div className="text-center">
|
||||
<Badge variant={statusVariants[payment.status] || "secondary"}>
|
||||
{t(`billing.statusLabels.${payment.status}`)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="text-right">
|
||||
{payment.method === "eft" && payment.receiptUrl && (
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<a
|
||||
href={payment.receiptUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
download
|
||||
>
|
||||
<Download className="mr-1 h-3 w-3" />
|
||||
{t("billing.downloadReceipt")}
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="hidden sm:table-header-group">
|
||||
<tr className="border-b text-left align-bottom text-muted-foreground">
|
||||
<th scope="col" className="pb-3 font-medium">
|
||||
{t("billing.date")}
|
||||
</th>
|
||||
<th scope="col" className="pb-3 font-medium">
|
||||
{t("billing.plan")}
|
||||
</th>
|
||||
<th scope="col" className="pb-3 text-right font-medium">
|
||||
{t("billing.amount")}
|
||||
</th>
|
||||
<th scope="col" className="pb-3 text-center font-medium">
|
||||
{t("billing.method")}
|
||||
</th>
|
||||
<th scope="col" className="pb-3 text-center font-medium">
|
||||
{t("billing.status")}
|
||||
</th>
|
||||
<th scope="col" className="pb-3 text-right font-medium">
|
||||
<span className="sr-only">{t("billing.downloadReceipt")}</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{filteredPayments.slice(0, visibleCount).map((payment) => (
|
||||
<tr key={payment.id} className="block py-3 sm:table-row sm:py-0">
|
||||
<td data-label={t("billing.date")} className={`${TD} tabular-nums`}>
|
||||
{new Date(payment.createdAt).toLocaleDateString(
|
||||
locale === "tr" ? "tr-TR" : "en-US",
|
||||
{ day: "2-digit", month: "2-digit", year: "numeric" },
|
||||
)}
|
||||
</td>
|
||||
<td data-label={t("billing.plan")} className={TD}>
|
||||
{payment.planName || "-"}
|
||||
</td>
|
||||
<td
|
||||
data-label={t("billing.amount")}
|
||||
className={`${TD} font-medium tabular-nums sm:text-right`}
|
||||
>
|
||||
{formatTRY(payment.amount)}
|
||||
</td>
|
||||
<td data-label={t("billing.method")} className={`${TD} sm:text-center`}>
|
||||
<Badge variant={payment.method === "stripe" ? "secondary" : "outline"}>
|
||||
{t(`billing.methodLabels.${payment.method}`)}
|
||||
</Badge>
|
||||
</td>
|
||||
<td data-label={t("billing.status")} className={`${TD} sm:text-center`}>
|
||||
<Badge variant={statusVariants[payment.status] || "secondary"}>
|
||||
{t(`billing.statusLabels.${payment.status}`)}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="block pt-3 text-right sm:table-cell sm:py-4 sm:pt-4">
|
||||
{payment.eftReceiptUrl ? (
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<a
|
||||
href={payment.eftReceiptUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
download
|
||||
>
|
||||
<Download className="mr-1 h-3 w-3" />
|
||||
{t("billing.downloadReceipt")}
|
||||
</a>
|
||||
</Button>
|
||||
) : payment.hasStripeReceipt ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={receiptLoadingId === payment.id}
|
||||
onClick={() => openReceipt(payment.id)}
|
||||
>
|
||||
<Receipt className="mr-1 h-3 w-3" />
|
||||
{t("billing.viewReceipt")}
|
||||
</Button>
|
||||
) : null}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{filteredPayments.length > visibleCount && (
|
||||
<div className="mt-4 text-center">
|
||||
<Button variant="outline" size="sm" onClick={() => setVisibleCount((c) => c + 10)}>
|
||||
{t("billing.showMore")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user