fix(payments): curate /payments/me response — stop leaking internal columns, join plan name

getMyPayments returned the raw payment row, exposing internal fields
(adminNote, iyzicoPaymentId, bankAccountId, session/intent ids) to the
end user. Replace with an explicit projection that returns only what the
billing UI needs, joins planName from the subscription's plan (was always
"-"), and surfaces Stripe receipt availability as a hasStripeReceipt
boolean instead of the raw payment intent id. Frontend reads the boolean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-27 00:31:33 +03:00
parent d77face831
commit c633e846e2
2 changed files with 37 additions and 9 deletions

View File

@@ -1,7 +1,7 @@
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 { payments, plans, userSubscriptions } from "../database/schema/core";
import { StripeService } from "./stripe/stripe.service";
@Injectable()
@@ -13,12 +13,42 @@ export class PaymentsService {
private stripeService: StripeService,
) {}
/**
* Payment history for the billing page. Returns a curated projection — never
* the raw row — so internal columns (adminNote, iyzicoPaymentId,
* bankAccountId, session/intent ids) are not leaked to the client. planName
* is joined from the subscription's plan; Stripe receipt availability is
* surfaced as a boolean rather than exposing the payment intent id.
*/
async getMyPayments(userId: string) {
return this.db
.select()
const rows = await this.db
.select({
id: payments.id,
amount: payments.amount,
method: payments.method,
status: payments.status,
createdAt: payments.createdAt,
planName: plans.name,
eftReceiptUrl: payments.eftReceiptUrl,
stripePaymentIntentId: payments.stripePaymentIntentId,
})
.from(payments)
.leftJoin(userSubscriptions, eq(payments.subscriptionId, userSubscriptions.id))
.leftJoin(plans, eq(userSubscriptions.planId, plans.id))
.where(eq(payments.userId, userId))
.orderBy(desc(payments.createdAt));
return rows.map((row) => ({
id: row.id,
amount: row.amount,
method: row.method,
status: row.status,
createdAt: row.createdAt,
planName: row.planName ?? null,
eftReceiptUrl: row.eftReceiptUrl ?? null,
hasStripeReceipt:
row.method === "stripe" && row.status === "completed" && !!row.stripePaymentIntentId,
}));
}
/**

View File

@@ -28,9 +28,9 @@ interface Payment {
amount: number;
method: "stripe" | "eft";
status: "completed" | "pending" | "failed" | "refunded";
planName?: string;
eftReceiptUrl?: string;
stripePaymentIntentId?: string | null;
planName?: string | null;
eftReceiptUrl?: string | null;
hasStripeReceipt?: boolean;
createdAt: string;
}
@@ -307,9 +307,7 @@ function BillingPage() {
{t("billing.downloadReceipt")}
</a>
</Button>
) : payment.method === "stripe" &&
payment.status === "completed" &&
payment.stripePaymentIntentId ? (
) : payment.hasStripeReceipt ? (
<Button
variant="ghost"
size="sm"