Merge pull request 'dev' (#56) from dev into main

Reviewed-on: #56
This commit was merged in pull request #56.
This commit is contained in:
2026-05-26 21:49:30 +00:00
6 changed files with 420 additions and 131 deletions

View File

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

View File

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

View File

@@ -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,

View File

@@ -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": {

View File

@@ -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": {

View File

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