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:
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user