feat(payments): Stripe receipt link on billing rows

Add GET /payments/:id/receipt — resolves the Stripe-hosted receipt URL
from the payment intent's latest charge (ownership-scoped; returns EFT
receipt directly when present, null otherwise). Wire a "View receipt"
action on completed Stripe rows that fetches the URL on demand and opens
it, with a toast when none is available.

Closes the last billing-audit item (#8).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-27 00:14:28 +03:00
parent 0e61a2fcc7
commit 7a8fe5e98e
6 changed files with 101 additions and 13 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,13 +1,17 @@
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 { 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,
) {}
async getMyPayments(userId: string) {
return this.db
@@ -16,4 +20,31 @@ export class PaymentsService {
.where(eq(payments.userId, userId))
.orderBy(desc(payments.createdAt));
}
/**
* 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;

View File

@@ -375,6 +375,8 @@
"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": {

View File

@@ -375,6 +375,8 @@
"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": {

View File

@@ -1,5 +1,6 @@
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";
@@ -7,7 +8,7 @@ import { Button } from "@sase/ui";
import { Skeleton } from "@sase/ui";
import { useQuery } from "@tanstack/react-query";
import { Link, createFileRoute } from "@tanstack/react-router";
import { ArrowRight, Download, Filter } from "lucide-react";
import { ArrowRight, Download, Filter, Receipt } from "lucide-react";
import { useState } from "react";
export const Route = createFileRoute("/dashboard/billing")({
@@ -70,6 +71,7 @@ function BillingPage() {
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() {
@@ -77,6 +79,22 @@ function BillingPage() {
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"),
@@ -275,7 +293,7 @@ function BillingPage() {
</Badge>
</td>
<td className="py-4 text-right">
{payment.eftReceiptUrl && (
{payment.eftReceiptUrl ? (
<Button variant="ghost" size="sm" asChild>
<a
href={payment.eftReceiptUrl}
@@ -287,7 +305,17 @@ function BillingPage() {
{t("billing.downloadReceipt")}
</a>
</Button>
)}
) : payment.method === "stripe" && payment.status === "completed" ? (
<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>
))}