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

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