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;