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