chore(payments): remove EFT/bank-transfer method, keep Stripe only
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Removes the EFT (havale) payment surface across API, web, i18n, and docs.
Card payment via Stripe is now the only checkout path.
API (apps/api/src):
- Delete payments/bank-accounts/ module (controller + service + module)
- payments.service: drop EFT methods (createEftPayment, uploadEftReceipt,
approveEft, rejectEft, getActiveBankInfo, getPendingEftPayments) and the
BankAccountsService dependency
- payments.controller: drop /payments/eft, /payments/eft/:id/{receipt,approve,reject},
/payments/bank-info, /payments/pending; keep /payments/me
- payments.module: drop BankAccountsModule import
- admin.service: drop pending EFT counter from getDashboardStats; drop
getPendingPayments (admin EFT approval list)
- admin.controller: drop /admin/payments/pending endpoint
- admin.service.spec: drop the getPendingPayments + pendingPayments assertions
Web (apps/web/src):
- Delete components/payment/bank-transfer-card.tsx
- Delete routes/dashboard/admin/payments.tsx (EFT approval page); regenerate
routeTree.gen.ts
- subscription/index.tsx: drop EFT tab from PaymentMethodSection, drop the
paymentMethod state + handleEftCompleted, simplify ConfirmationCard to the
Stripe-only path, drop unused imports (Tabs, BankTransferCard, Building2)
- billing.tsx: drop "eft" from method filter chips; keep methodLabels.eft and
the legacy receipt download so historical EFT records still display
- dashboard.tsx + admin/index.tsx: drop /dashboard/admin/payments from the
sidebar + admin quick-link grid; trim unused icon imports; KEYS_6 → KEYS_5
for the now-5-card admin stats skeleton
- messages/{tr,en}.json: strip every EFT-only key under payment.* (eftTransfer,
eftConfirmationDescription, eftPaid, eftStatus, bank.*, uploadReceipt* etc.)
Schema (apps/api/src/database/schema):
- bankAccounts table + payments.bankAccountId column are kept as-is: legacy
EFT payments remain visible on the billing page and the schema preserves
historical records.
Docs:
- README.md: "Stripe (kart) + EFT" → "Stripe (kart)"
- CLAUDE.md: stack table + PaymentsModule row + payments table note + route
list updated; admin EFT approval route removed
Verification:
- pnpm typecheck: green (api + web + shared + config + ui)
- pnpm lint: green
- pnpm test: 20 web tests pass, 173 api tests pass
This commit is contained in:
@@ -35,11 +35,6 @@ export class AdminController {
|
||||
return this.adminService.getUserDetail(id);
|
||||
}
|
||||
|
||||
@Get("payments/pending")
|
||||
async getPendingPayments() {
|
||||
return this.adminService.getPendingPayments();
|
||||
}
|
||||
|
||||
@Get("query-logs")
|
||||
async getQueryLogs(
|
||||
@Query("page") page?: string,
|
||||
|
||||
@@ -33,7 +33,6 @@ describe("AdminService", () => {
|
||||
expect(result).toHaveProperty("totalRevenue");
|
||||
expect(result).toHaveProperty("totalQueries");
|
||||
expect(result).toHaveProperty("newUsersThisMonth");
|
||||
expect(result).toHaveProperty("pendingPayments");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -172,25 +171,6 @@ describe("AdminService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPendingPayments", () => {
|
||||
it("should return pending EFT payments with user info", async () => {
|
||||
const pending = [
|
||||
{ id: "pay-1", userId: "u1", userName: "Ali", method: "eft", status: "pending" },
|
||||
];
|
||||
// select().from().innerJoin().where().orderBy() — orderBy terminal
|
||||
const c: Record<string, any> = {};
|
||||
c.from = vi.fn().mockReturnValue(c);
|
||||
c.innerJoin = vi.fn().mockReturnValue(c);
|
||||
c.where = vi.fn().mockReturnValue(c);
|
||||
c.orderBy = vi.fn().mockReturnValue(pending);
|
||||
const db = { select: vi.fn().mockReturnValue(c) };
|
||||
const service = new AdminService(db as any, {} as any);
|
||||
|
||||
const result = await service.getPendingPayments();
|
||||
expect(result).toEqual(pending);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getQueryLogs", () => {
|
||||
it("should return paginated query logs without filter", async () => {
|
||||
const logs = [{ id: "log-1", vin: "VIN1" }];
|
||||
|
||||
@@ -83,7 +83,6 @@ export class AdminService {
|
||||
totalRevenueResult,
|
||||
totalQueriesResult,
|
||||
newUsersThisMonthResult,
|
||||
pendingPaymentsResult,
|
||||
] = await Promise.all([
|
||||
// Total users
|
||||
this.db
|
||||
@@ -111,11 +110,6 @@ export class AdminService {
|
||||
.select({ count: count() })
|
||||
.from(users)
|
||||
.where(gte(users.createdAt, startOfMonth)),
|
||||
// Pending payments
|
||||
this.db
|
||||
.select({ count: count() })
|
||||
.from(payments)
|
||||
.where(and(eq(payments.method, "eft"), eq(payments.status, "pending"))),
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -124,7 +118,6 @@ export class AdminService {
|
||||
totalRevenue: totalRevenueResult[0].total,
|
||||
totalQueries: totalQueriesResult[0].count,
|
||||
newUsersThisMonth: newUsersThisMonthResult[0].count,
|
||||
pendingPayments: pendingPaymentsResult[0].count,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -214,29 +207,6 @@ export class AdminService {
|
||||
};
|
||||
}
|
||||
|
||||
async getPendingPayments() {
|
||||
const result = await this.db
|
||||
.select({
|
||||
id: payments.id,
|
||||
userId: payments.userId,
|
||||
userName: users.name,
|
||||
userEmail: users.email,
|
||||
subscriptionId: payments.subscriptionId,
|
||||
amount: payments.amount,
|
||||
currency: payments.currency,
|
||||
method: payments.method,
|
||||
status: payments.status,
|
||||
eftReceiptUrl: payments.eftReceiptUrl,
|
||||
createdAt: payments.createdAt,
|
||||
})
|
||||
.from(payments)
|
||||
.innerJoin(users, eq(payments.userId, users.id))
|
||||
.where(and(eq(payments.method, "eft"), eq(payments.status, "pending")))
|
||||
.orderBy(payments.createdAt);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async getQueryLogs(page = 1, limit = 50, userId?: string) {
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from "@nestjs/common";
|
||||
import { FileInterceptor } from "@nestjs/platform-express";
|
||||
import { Roles } from "../../common/decorators/roles.decorator";
|
||||
import { RolesGuard } from "../../common/guards/roles.guard";
|
||||
import {
|
||||
BankAccountsService,
|
||||
type CreateBankAccountInput,
|
||||
type UpdateBankAccountInput,
|
||||
} from "./bank-accounts.service";
|
||||
|
||||
function parseBool(v: unknown): boolean | undefined {
|
||||
if (v === undefined || v === null) return undefined;
|
||||
if (typeof v === "boolean") return v;
|
||||
const s = String(v).toLowerCase().trim();
|
||||
if (s === "true" || s === "1") return true;
|
||||
if (s === "false" || s === "0") return false;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parseInt32(v: unknown): number | undefined {
|
||||
if (v === undefined || v === null || v === "") return undefined;
|
||||
const n = Number(v);
|
||||
if (!Number.isFinite(n)) return undefined;
|
||||
return Math.trunc(n);
|
||||
}
|
||||
|
||||
@Controller("admin/bank-accounts")
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles("admin")
|
||||
export class BankAccountsAdminController {
|
||||
constructor(private bankAccounts: BankAccountsService) {}
|
||||
|
||||
@Get()
|
||||
async list() {
|
||||
return this.bankAccounts.findAll();
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
async get(@Param("id") id: string) {
|
||||
return this.bankAccounts.findOne(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@UseInterceptors(FileInterceptor("qr"))
|
||||
async create(@Body() body: Record<string, unknown>, @UploadedFile() qr?: Express.Multer.File) {
|
||||
if (!body.bankName || !body.accountHolder || !body.iban) {
|
||||
throw new BadRequestException("bankName, accountHolder ve iban zorunlu");
|
||||
}
|
||||
|
||||
const input: CreateBankAccountInput = {
|
||||
bankName: String(body.bankName),
|
||||
accountHolder: String(body.accountHolder),
|
||||
iban: String(body.iban),
|
||||
kolayAdres: body.kolayAdres ? String(body.kolayAdres) : null,
|
||||
kolayAdresType:
|
||||
body.kolayAdresType === "email" ||
|
||||
body.kolayAdresType === "phone" ||
|
||||
body.kolayAdresType === "tckn"
|
||||
? body.kolayAdresType
|
||||
: null,
|
||||
descriptionTemplate: body.descriptionTemplate ? String(body.descriptionTemplate) : undefined,
|
||||
displayOrder: parseInt32(body.displayOrder),
|
||||
isActive: parseBool(body.isActive),
|
||||
};
|
||||
|
||||
const qrFile = qr ? { buffer: qr.buffer, mimetype: qr.mimetype } : undefined;
|
||||
return this.bankAccounts.create(input, qrFile);
|
||||
}
|
||||
|
||||
@Patch(":id")
|
||||
@UseInterceptors(FileInterceptor("qr"))
|
||||
async update(
|
||||
@Param("id") id: string,
|
||||
@Body() body: Record<string, unknown>,
|
||||
@UploadedFile() qr?: Express.Multer.File,
|
||||
) {
|
||||
const input: UpdateBankAccountInput = {};
|
||||
if (body.bankName !== undefined) input.bankName = String(body.bankName);
|
||||
if (body.accountHolder !== undefined) input.accountHolder = String(body.accountHolder);
|
||||
if (body.iban !== undefined) input.iban = String(body.iban);
|
||||
if (body.kolayAdres !== undefined) {
|
||||
input.kolayAdres =
|
||||
body.kolayAdres === null || body.kolayAdres === "" ? null : String(body.kolayAdres);
|
||||
}
|
||||
if (body.kolayAdresType !== undefined) {
|
||||
const t = body.kolayAdresType;
|
||||
input.kolayAdresType = t === "email" || t === "phone" || t === "tckn" ? t : null;
|
||||
}
|
||||
if (body.descriptionTemplate !== undefined) {
|
||||
input.descriptionTemplate = String(body.descriptionTemplate);
|
||||
}
|
||||
if (body.displayOrder !== undefined) {
|
||||
const n = parseInt32(body.displayOrder);
|
||||
if (n !== undefined) input.displayOrder = n;
|
||||
}
|
||||
|
||||
const qrFile = qr ? { buffer: qr.buffer, mimetype: qr.mimetype } : undefined;
|
||||
return this.bankAccounts.update(id, input, qrFile);
|
||||
}
|
||||
|
||||
@Delete(":id")
|
||||
async remove(@Param("id") id: string) {
|
||||
return this.bankAccounts.remove(id);
|
||||
}
|
||||
|
||||
@Post(":id/activate")
|
||||
async activate(@Param("id") id: string) {
|
||||
return this.bankAccounts.activate(id);
|
||||
}
|
||||
|
||||
@Post("deactivate-all")
|
||||
async deactivateAll() {
|
||||
return this.bankAccounts.deactivateAll();
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { BankAccountsAdminController } from "./bank-accounts.controller";
|
||||
import { BankAccountsService } from "./bank-accounts.service";
|
||||
|
||||
@Module({
|
||||
controllers: [BankAccountsAdminController],
|
||||
providers: [BankAccountsService],
|
||||
exports: [BankAccountsService],
|
||||
})
|
||||
export class BankAccountsModule {}
|
||||
@@ -1,218 +0,0 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { asc, desc, eq, ne } from "drizzle-orm";
|
||||
import { DATABASE, type Database } from "../../database/database.provider";
|
||||
import { bankAccounts } from "../../database/schema/core";
|
||||
import { StorageService } from "../../storage/storage.service";
|
||||
|
||||
export interface CreateBankAccountInput {
|
||||
bankName: string;
|
||||
accountHolder: string;
|
||||
iban: string;
|
||||
kolayAdres?: string | null;
|
||||
kolayAdresType?: "email" | "phone" | "tckn" | null;
|
||||
descriptionTemplate?: string;
|
||||
displayOrder?: number;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateBankAccountInput {
|
||||
bankName?: string;
|
||||
accountHolder?: string;
|
||||
iban?: string;
|
||||
kolayAdres?: string | null;
|
||||
kolayAdresType?: "email" | "phone" | "tckn" | null;
|
||||
descriptionTemplate?: string;
|
||||
displayOrder?: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class BankAccountsService {
|
||||
private readonly logger = new Logger(BankAccountsService.name);
|
||||
|
||||
constructor(
|
||||
@Inject(DATABASE) private db: Database,
|
||||
private storageService: StorageService,
|
||||
) {}
|
||||
|
||||
async findActive() {
|
||||
const [row] = await this.db
|
||||
.select()
|
||||
.from(bankAccounts)
|
||||
.where(eq(bankAccounts.isActive, true))
|
||||
.limit(1);
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
async findAll() {
|
||||
return this.db
|
||||
.select()
|
||||
.from(bankAccounts)
|
||||
.orderBy(
|
||||
desc(bankAccounts.isActive),
|
||||
asc(bankAccounts.displayOrder),
|
||||
asc(bankAccounts.bankName),
|
||||
);
|
||||
}
|
||||
|
||||
async findOne(id: string) {
|
||||
const [row] = await this.db.select().from(bankAccounts).where(eq(bankAccounts.id, id)).limit(1);
|
||||
if (!row) throw new NotFoundException("Banka hesabı bulunamadı");
|
||||
return row;
|
||||
}
|
||||
|
||||
async create(input: CreateBankAccountInput, qrFile?: { buffer: Buffer; mimetype: string }) {
|
||||
this.validateIban(input.iban);
|
||||
|
||||
const normalizedIban = this.normalizeIban(input.iban);
|
||||
|
||||
// If this is going to be active, deactivate any existing active row first.
|
||||
if (input.isActive) {
|
||||
await this.db
|
||||
.update(bankAccounts)
|
||||
.set({ isActive: false, updatedAt: new Date() })
|
||||
.where(eq(bankAccounts.isActive, true));
|
||||
}
|
||||
|
||||
const [row] = await this.db
|
||||
.insert(bankAccounts)
|
||||
.values({
|
||||
bankName: input.bankName,
|
||||
accountHolder: input.accountHolder,
|
||||
iban: normalizedIban,
|
||||
kolayAdres: input.kolayAdres ?? null,
|
||||
kolayAdresType: input.kolayAdresType ?? null,
|
||||
descriptionTemplate: input.descriptionTemplate ?? "SASE-{{paymentId}}",
|
||||
displayOrder: input.displayOrder ?? 0,
|
||||
isActive: input.isActive ?? false,
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (qrFile) {
|
||||
const url = await this.uploadQr(row.id, qrFile);
|
||||
const [updated] = await this.db
|
||||
.update(bankAccounts)
|
||||
.set({ qrImageUrl: url, updatedAt: new Date() })
|
||||
.where(eq(bankAccounts.id, row.id))
|
||||
.returning();
|
||||
return updated;
|
||||
}
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
input: UpdateBankAccountInput,
|
||||
qrFile?: { buffer: Buffer; mimetype: string },
|
||||
) {
|
||||
await this.findOne(id); // 404 if missing
|
||||
|
||||
if (input.iban !== undefined) {
|
||||
this.validateIban(input.iban);
|
||||
}
|
||||
|
||||
const patch: Partial<typeof bankAccounts.$inferInsert> = {
|
||||
...input,
|
||||
iban: input.iban !== undefined ? this.normalizeIban(input.iban) : undefined,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
// Strip undefined keys so we don't overwrite columns with null by accident.
|
||||
for (const key of Object.keys(patch) as (keyof typeof patch)[]) {
|
||||
if (patch[key] === undefined) delete patch[key];
|
||||
}
|
||||
|
||||
if (qrFile) {
|
||||
patch.qrImageUrl = await this.uploadQr(id, qrFile);
|
||||
}
|
||||
|
||||
const [updated] = await this.db
|
||||
.update(bankAccounts)
|
||||
.set(patch)
|
||||
.where(eq(bankAccounts.id, id))
|
||||
.returning();
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
async remove(id: string) {
|
||||
const row = await this.findOne(id);
|
||||
if (row.isActive) {
|
||||
throw new ConflictException("Aktif hesap silinemez. Önce başka bir hesabı aktive edin.");
|
||||
}
|
||||
await this.db.delete(bankAccounts).where(eq(bankAccounts.id, id));
|
||||
return { deleted: true };
|
||||
}
|
||||
|
||||
async activate(id: string) {
|
||||
await this.findOne(id); // 404 if missing
|
||||
|
||||
return this.db.transaction(async (tx) => {
|
||||
// Deactivate any other currently-active row(s)
|
||||
await tx
|
||||
.update(bankAccounts)
|
||||
.set({ isActive: false, updatedAt: new Date() })
|
||||
.where(eq(bankAccounts.isActive, true));
|
||||
|
||||
// Activate the requested row
|
||||
const [activated] = await tx
|
||||
.update(bankAccounts)
|
||||
.set({ isActive: true, updatedAt: new Date() })
|
||||
.where(eq(bankAccounts.id, id))
|
||||
.returning();
|
||||
|
||||
this.logger.log(`Bank account ${id} (${activated.bankName}) activated`);
|
||||
return activated;
|
||||
});
|
||||
}
|
||||
|
||||
async deactivateAll() {
|
||||
await this.db
|
||||
.update(bankAccounts)
|
||||
.set({ isActive: false, updatedAt: new Date() })
|
||||
.where(eq(bankAccounts.isActive, true));
|
||||
return { deactivated: true };
|
||||
}
|
||||
|
||||
resolveDescription(template: string, paymentId: string): string {
|
||||
const short = paymentId.substring(0, 8).toUpperCase();
|
||||
return template.replace(/\{\{\s*paymentId\s*\}\}/gi, short);
|
||||
}
|
||||
|
||||
private validateIban(iban: string) {
|
||||
const normalized = this.normalizeIban(iban);
|
||||
if (!/^TR\d{24}$/.test(normalized)) {
|
||||
throw new BadRequestException(
|
||||
"Geçersiz IBAN. TR ile başlamalı ve toplam 26 karakter olmalı.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeIban(iban: string): string {
|
||||
return iban.replace(/\s+/g, "").toUpperCase();
|
||||
}
|
||||
|
||||
private async uploadQr(
|
||||
bankAccountId: string,
|
||||
file: { buffer: Buffer; mimetype: string },
|
||||
): Promise<string> {
|
||||
const allowed = ["image/png", "image/jpeg", "image/webp"];
|
||||
if (!allowed.includes(file.mimetype)) {
|
||||
throw new BadRequestException("QR sadece PNG/JPG/WEBP olabilir");
|
||||
}
|
||||
if (file.buffer.length > 2 * 1024 * 1024) {
|
||||
throw new BadRequestException("QR dosyası 2MB'dan büyük olamaz");
|
||||
}
|
||||
const ext =
|
||||
file.mimetype === "image/png" ? "png" : file.mimetype === "image/webp" ? "webp" : "jpg";
|
||||
const key = `bank-qr/${bankAccountId}-${Date.now()}.${ext}`;
|
||||
return this.storageService.upload(key, file.buffer, file.mimetype);
|
||||
}
|
||||
}
|
||||
@@ -1,86 +1,13 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from "@nestjs/common";
|
||||
import { FileInterceptor } from "@nestjs/platform-express";
|
||||
import { Controller, Get } from "@nestjs/common";
|
||||
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
||||
import { Roles } from "../common/decorators/roles.decorator";
|
||||
import { RolesGuard } from "../common/guards/roles.guard";
|
||||
import { PaymentsService } from "./payments.service";
|
||||
|
||||
@Controller("payments")
|
||||
export class PaymentsController {
|
||||
constructor(private paymentsService: PaymentsService) {}
|
||||
|
||||
@Get("bank-info")
|
||||
async getBankInfo() {
|
||||
return this.paymentsService.getActiveBankInfo();
|
||||
}
|
||||
|
||||
@Post("eft")
|
||||
async createEft(
|
||||
@CurrentUser("id") userId: string,
|
||||
@Body() body: { planKey: string; billingPeriod: "monthly" | "yearly"; brandIds: string[] },
|
||||
) {
|
||||
return this.paymentsService.createEftPayment(
|
||||
userId,
|
||||
body.planKey,
|
||||
body.billingPeriod,
|
||||
body.brandIds,
|
||||
);
|
||||
}
|
||||
|
||||
@Post("eft/:id/receipt")
|
||||
@UseInterceptors(FileInterceptor("file"))
|
||||
async uploadReceipt(
|
||||
@Param("id") paymentId: string,
|
||||
@CurrentUser("id") userId: string,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
) {
|
||||
if (!file) throw new BadRequestException("File is required");
|
||||
|
||||
const allowedTypes = ["image/png", "image/jpeg", "application/pdf"];
|
||||
if (!allowedTypes.includes(file.mimetype)) {
|
||||
throw new BadRequestException("Only PNG, JPG, and PDF files are allowed");
|
||||
}
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
throw new BadRequestException("File size must be less than 5MB");
|
||||
}
|
||||
|
||||
return this.paymentsService.uploadEftReceipt(paymentId, userId, file.buffer, file.originalname);
|
||||
}
|
||||
|
||||
@Patch("eft/:id/approve")
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles("admin")
|
||||
async approveEft(@Param("id") id: string, @Body() body: { adminNote?: string }) {
|
||||
return this.paymentsService.approveEft(id, body.adminNote);
|
||||
}
|
||||
|
||||
@Patch("eft/:id/reject")
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles("admin")
|
||||
async rejectEft(@Param("id") id: string, @Body() body: { adminNote?: string }) {
|
||||
return this.paymentsService.rejectEft(id, body.adminNote);
|
||||
}
|
||||
|
||||
@Get("me")
|
||||
async getMyPayments(@CurrentUser("id") userId: string) {
|
||||
return this.paymentsService.getMyPayments(userId);
|
||||
}
|
||||
|
||||
@Get("pending")
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles("admin")
|
||||
async getPendingEft() {
|
||||
return this.paymentsService.getPendingEftPayments();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { SubscriptionsModule } from "../subscriptions/subscriptions.module";
|
||||
import { BankAccountsModule } from "./bank-accounts/bank-accounts.module";
|
||||
import { PaymentsController } from "./payments.controller";
|
||||
import { PaymentsService } from "./payments.service";
|
||||
import { StripeModule } from "./stripe/stripe.module";
|
||||
|
||||
@Module({
|
||||
imports: [SubscriptionsModule, BankAccountsModule, StripeModule],
|
||||
imports: [SubscriptionsModule, StripeModule],
|
||||
controllers: [PaymentsController],
|
||||
providers: [PaymentsService],
|
||||
exports: [PaymentsService],
|
||||
|
||||
@@ -1,232 +1,13 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
ServiceUnavailableException,
|
||||
} from "@nestjs/common";
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
import { Inject, Injectable, Logger } from "@nestjs/common";
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import { DATABASE, type Database } from "../database/database.provider";
|
||||
import { payments, plans, userSubscriptions } from "../database/schema/core";
|
||||
import { PostHogService } from "../posthog/posthog.service";
|
||||
import { StorageService } from "../storage/storage.service";
|
||||
import { SubscriptionsService } from "../subscriptions/subscriptions.service";
|
||||
import { BankAccountsService } from "./bank-accounts/bank-accounts.service";
|
||||
|
||||
const PLAN_KEY_TO_BRAND_COUNT: Record<string, number> = {
|
||||
brand1: 1,
|
||||
brand2: 2,
|
||||
brand3: 3,
|
||||
full: 0,
|
||||
};
|
||||
import { payments } from "../database/schema/core";
|
||||
|
||||
@Injectable()
|
||||
export class PaymentsService {
|
||||
private readonly logger = new Logger(PaymentsService.name);
|
||||
|
||||
constructor(
|
||||
@Inject(DATABASE) private db: Database,
|
||||
private subscriptionsService: SubscriptionsService,
|
||||
private storageService: StorageService,
|
||||
private posthog: PostHogService,
|
||||
private bankAccounts: BankAccountsService,
|
||||
) {}
|
||||
|
||||
private async resolvePlanId(planKey: string): Promise<string> {
|
||||
const brandCount = PLAN_KEY_TO_BRAND_COUNT[planKey];
|
||||
if (brandCount === undefined)
|
||||
throw new BadRequestException(`Geçersiz plan anahtarı: ${planKey}`);
|
||||
|
||||
const [plan] = await this.db
|
||||
.select()
|
||||
.from(plans)
|
||||
.where(and(eq(plans.brandCount, brandCount), eq(plans.isActive, true)))
|
||||
.limit(1);
|
||||
|
||||
if (!plan) throw new NotFoundException("Plan bulunamadı");
|
||||
return plan.id;
|
||||
}
|
||||
|
||||
private async createSubscriptionForPayment(
|
||||
userId: string,
|
||||
planKey: string,
|
||||
billingPeriod: "monthly" | "yearly",
|
||||
brandIds: string[],
|
||||
) {
|
||||
const planId = await this.resolvePlanId(planKey);
|
||||
const subscription = await this.subscriptionsService.create(userId, {
|
||||
planId,
|
||||
brandIds,
|
||||
billingPeriod,
|
||||
});
|
||||
|
||||
if (brandIds.length > 0 && planKey !== "full") {
|
||||
await this.subscriptionsService.addBrandsToSubscription(subscription.id, userId, brandIds);
|
||||
}
|
||||
|
||||
return subscription;
|
||||
}
|
||||
|
||||
async getActiveBankInfo() {
|
||||
const account = await this.bankAccounts.findActive();
|
||||
if (!account) return null;
|
||||
return {
|
||||
id: account.id,
|
||||
bankName: account.bankName,
|
||||
accountHolder: account.accountHolder,
|
||||
iban: account.iban,
|
||||
kolayAdres: account.kolayAdres,
|
||||
kolayAdresType: account.kolayAdresType,
|
||||
qrImageUrl: account.qrImageUrl,
|
||||
};
|
||||
}
|
||||
|
||||
async createEftPayment(
|
||||
userId: string,
|
||||
planKey: string,
|
||||
billingPeriod: "monthly" | "yearly",
|
||||
brandIds: string[],
|
||||
) {
|
||||
const activeAccount = await this.bankAccounts.findActive();
|
||||
if (!activeAccount) {
|
||||
throw new ServiceUnavailableException(
|
||||
"Havale şu an kullanılamıyor. Lütfen kart ile ödemeyi deneyin.",
|
||||
);
|
||||
}
|
||||
|
||||
const sub = await this.createSubscriptionForPayment(userId, planKey, billingPeriod, brandIds);
|
||||
|
||||
const [plan] = await this.db.select().from(plans).where(eq(plans.id, sub.planId)).limit(1);
|
||||
const amount = billingPeriod === "yearly" ? plan.priceYearly : plan.priceMonthly;
|
||||
|
||||
const [payment] = await this.db
|
||||
.insert(payments)
|
||||
.values({
|
||||
userId,
|
||||
subscriptionId: sub.id,
|
||||
amount,
|
||||
currency: "TRY",
|
||||
method: "eft",
|
||||
status: "pending",
|
||||
bankAccountId: activeAccount.id,
|
||||
})
|
||||
.returning();
|
||||
|
||||
this.posthog.captureForUser(userId, "payment_initiated", {
|
||||
method: "eft",
|
||||
payment_id: payment.id,
|
||||
plan: planKey,
|
||||
period: billingPeriod,
|
||||
amount,
|
||||
bank_account_id: activeAccount.id,
|
||||
});
|
||||
|
||||
return {
|
||||
paymentId: payment.id,
|
||||
bankInfo: {
|
||||
id: activeAccount.id,
|
||||
bankName: activeAccount.bankName,
|
||||
accountHolder: activeAccount.accountHolder,
|
||||
iban: activeAccount.iban,
|
||||
kolayAdres: activeAccount.kolayAdres,
|
||||
kolayAdresType: activeAccount.kolayAdresType,
|
||||
qrImageUrl: activeAccount.qrImageUrl,
|
||||
description: this.bankAccounts.resolveDescription(
|
||||
activeAccount.descriptionTemplate,
|
||||
payment.id,
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async uploadEftReceipt(paymentId: string, userId: string, file: Buffer, filename: string) {
|
||||
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("Ödeme bulunamadı");
|
||||
if (payment.method !== "eft") throw new BadRequestException("Bu bir EFT ödemesi değil");
|
||||
|
||||
const key = `receipts/${paymentId}/${filename}`;
|
||||
const url = await this.storageService.upload(key, file, "application/pdf");
|
||||
|
||||
await this.db
|
||||
.update(payments)
|
||||
.set({ eftReceiptUrl: url, updatedAt: new Date() })
|
||||
.where(eq(payments.id, paymentId));
|
||||
|
||||
this.posthog.captureForUser(userId, "receipt_uploaded", {
|
||||
payment_id: paymentId,
|
||||
});
|
||||
|
||||
return { receiptUrl: url };
|
||||
}
|
||||
|
||||
async approveEft(paymentId: string, adminNote?: string) {
|
||||
const [payment] = await this.db
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq(payments.id, paymentId))
|
||||
.limit(1);
|
||||
|
||||
if (!payment) throw new NotFoundException("Ödeme bulunamadı");
|
||||
if (payment.method !== "eft") throw new BadRequestException("Bu bir EFT ödemesi değil");
|
||||
|
||||
await this.db
|
||||
.update(payments)
|
||||
.set({ status: "completed", adminNote, updatedAt: new Date() })
|
||||
.where(eq(payments.id, paymentId));
|
||||
|
||||
await this.subscriptionsService.activateSubscription(payment.subscriptionId);
|
||||
|
||||
this.posthog.captureForUser(payment.userId, "payment_success", {
|
||||
method: "eft",
|
||||
payment_id: paymentId,
|
||||
subscription_id: payment.subscriptionId,
|
||||
amount: Number(payment.amount),
|
||||
});
|
||||
|
||||
return { status: "completed" };
|
||||
}
|
||||
|
||||
async rejectEft(paymentId: string, adminNote?: string) {
|
||||
const [payment] = await this.db
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq(payments.id, paymentId))
|
||||
.limit(1);
|
||||
|
||||
await this.db
|
||||
.update(payments)
|
||||
.set({ status: "failed", adminNote, updatedAt: new Date() })
|
||||
.where(eq(payments.id, paymentId));
|
||||
|
||||
if (payment) {
|
||||
// Also expire the pending subscription so the user can retry without "zaten aktif" conflict.
|
||||
await this.db
|
||||
.update(userSubscriptions)
|
||||
.set({ status: "expired", updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(userSubscriptions.id, payment.subscriptionId),
|
||||
eq(userSubscriptions.status, "pending"),
|
||||
),
|
||||
);
|
||||
|
||||
this.posthog.captureForUser(payment.userId, "payment_failed", {
|
||||
method: "eft",
|
||||
payment_id: paymentId,
|
||||
subscription_id: payment.subscriptionId,
|
||||
amount: Number(payment.amount),
|
||||
reason: adminNote ?? "rejected_by_admin",
|
||||
});
|
||||
}
|
||||
|
||||
return { status: "failed" };
|
||||
}
|
||||
constructor(@Inject(DATABASE) private db: Database) {}
|
||||
|
||||
async getMyPayments(userId: string) {
|
||||
return this.db
|
||||
@@ -235,12 +16,4 @@ export class PaymentsService {
|
||||
.where(eq(payments.userId, userId))
|
||||
.orderBy(desc(payments.createdAt));
|
||||
}
|
||||
|
||||
async getPendingEftPayments() {
|
||||
return this.db
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(and(eq(payments.method, "eft"), eq(payments.status, "pending")))
|
||||
.orderBy(payments.createdAt);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,460 +0,0 @@
|
||||
import { api } from "@/lib/api-client";
|
||||
import { startAction } from "@/lib/faro";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import { capture } from "@/lib/posthog";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { formatTRY } from "@sase/shared";
|
||||
import { Badge, Button, Skeleton } from "@sase/ui";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Building2,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Copy,
|
||||
FileText,
|
||||
Hash,
|
||||
Landmark,
|
||||
QrCode,
|
||||
Upload,
|
||||
UploadCloud,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
|
||||
interface BankInfo {
|
||||
id: string;
|
||||
bankName: string;
|
||||
accountHolder: string;
|
||||
iban: string;
|
||||
kolayAdres: string | null;
|
||||
kolayAdresType: "email" | "phone" | "tckn" | null;
|
||||
qrImageUrl: string | null;
|
||||
}
|
||||
|
||||
interface EftCreateResponse {
|
||||
paymentId: string;
|
||||
bankInfo: BankInfo & { description: string };
|
||||
}
|
||||
|
||||
interface BankTransferCardProps {
|
||||
planKey: string;
|
||||
period: "monthly" | "yearly";
|
||||
brandIds: string[];
|
||||
totalAmount: number;
|
||||
onCompleted: () => void;
|
||||
}
|
||||
|
||||
function formatIban(iban: string): string {
|
||||
// Group into chunks of 4 for readability: TR33 0001 0000 ...
|
||||
const compact = iban.replace(/\s+/g, "").toUpperCase();
|
||||
return compact.match(/.{1,4}/g)?.join(" ") ?? compact;
|
||||
}
|
||||
|
||||
function maskIban(iban: string): string {
|
||||
const compact = iban.replace(/\s+/g, "");
|
||||
if (compact.length < 10) return iban;
|
||||
return `${compact.slice(0, 6)} •••• •••• •••• ${compact.slice(-4)}`;
|
||||
}
|
||||
|
||||
export function BankTransferCard({
|
||||
planKey,
|
||||
period,
|
||||
brandIds,
|
||||
totalAmount,
|
||||
onCompleted,
|
||||
}: BankTransferCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [uploadedFile, setUploadedFile] = useState<File | null>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [eftPayment, setEftPayment] = useState<EftCreateResponse | null>(null);
|
||||
const [revealIban, setRevealIban] = useState(false);
|
||||
|
||||
const { data: bankInfo, isLoading } = useQuery({
|
||||
queryKey: ["payments", "bank-info"],
|
||||
queryFn: () => api.get<BankInfo | null>("/payments/bank-info"),
|
||||
});
|
||||
|
||||
const eftMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
api.post<EftCreateResponse>("/payments/eft", {
|
||||
planKey,
|
||||
billingPeriod: period,
|
||||
brandIds,
|
||||
}),
|
||||
onSuccess: (data) => {
|
||||
setEftPayment(data);
|
||||
capture("eft_initiated", {
|
||||
plan: planKey,
|
||||
period,
|
||||
amount: totalAmount,
|
||||
bank_account_id: data.bankInfo.id,
|
||||
});
|
||||
},
|
||||
onError: () => toast.error(t("errors.generic")),
|
||||
});
|
||||
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: (file: File) => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
return api.upload<{ receiptUrl: string }>(
|
||||
`/payments/eft/${eftPayment?.paymentId}/receipt`,
|
||||
formData,
|
||||
);
|
||||
},
|
||||
onSuccess: () => {
|
||||
capture("payment_success", {
|
||||
method: "eft",
|
||||
plan: planKey,
|
||||
period,
|
||||
amount: totalAmount,
|
||||
payment_id: eftPayment?.paymentId,
|
||||
});
|
||||
toast.success(t("payment.receiptUploaded"));
|
||||
onCompleted();
|
||||
},
|
||||
onError: () => toast.error(t("payment.uploadFailed")),
|
||||
});
|
||||
|
||||
const validateAndSetFile = useCallback(
|
||||
(file: File) => {
|
||||
const valid = ["image/png", "image/jpeg", "application/pdf"];
|
||||
if (!valid.includes(file.type)) {
|
||||
toast.error(t("errors.invalidFileType"));
|
||||
return;
|
||||
}
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
toast.error(t("errors.fileTooBig"));
|
||||
return;
|
||||
}
|
||||
setUploadedFile(file);
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
}, []);
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
}, []);
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file) validateAndSetFile(file);
|
||||
},
|
||||
[validateAndSetFile],
|
||||
);
|
||||
const handleFileSelect = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) validateAndSetFile(file);
|
||||
},
|
||||
[validateAndSetFile],
|
||||
);
|
||||
|
||||
function copy(text: string, eventName: string) {
|
||||
navigator.clipboard.writeText(text);
|
||||
toast.success(t("common.copied"));
|
||||
capture(eventName, { bank_account_id: bankInfo?.id });
|
||||
}
|
||||
|
||||
function handleEftProceed() {
|
||||
startAction("payment-eft", { plan: planKey, period, amount: String(totalAmount) });
|
||||
capture("payment_initiated", { method: "eft", plan: planKey, period, amount: totalAmount });
|
||||
eftMutation.mutate();
|
||||
}
|
||||
|
||||
function handleUploadReceipt() {
|
||||
if (!uploadedFile) return;
|
||||
startAction("receipt-upload", { paymentId: eftPayment?.paymentId ?? "" });
|
||||
capture("receipt_uploaded", { payment_id: eftPayment?.paymentId });
|
||||
uploadMutation.mutate(uploadedFile);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-7 w-56" />
|
||||
<Skeleton className="h-44 w-full rounded-2xl" />
|
||||
<Skeleton className="h-12 w-full rounded-lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!bankInfo) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-dashed border-amber-300/60 bg-amber-50/60 p-6 text-sm text-amber-900 dark:border-amber-700/60 dark:bg-amber-950/30 dark:text-amber-200">
|
||||
<p className="mb-1 font-semibold">{t("payment.bank.unavailableTitle")}</p>
|
||||
<p>{t("payment.bank.unavailableDescription")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const description = eftPayment?.bankInfo.description ?? t("payment.bank.previewDescription");
|
||||
const displayIban = revealIban ? formatIban(bankInfo.iban) : maskIban(bankInfo.iban);
|
||||
const compactIban = bankInfo.iban.replace(/\s+/g, "");
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Bank info hero card */}
|
||||
<div className="relative overflow-hidden rounded-2xl border border-border bg-gradient-to-br from-background via-background to-primary/[0.04] p-6 shadow-sm">
|
||||
<div className="pointer-events-none absolute -right-12 -top-12 h-44 w-44 rounded-full bg-primary/[0.08] blur-2xl" />
|
||||
<div className="relative grid gap-6 sm:grid-cols-[1fr_auto]">
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wider text-muted-foreground">
|
||||
{t("payment.bank.bankLabel")}
|
||||
</p>
|
||||
<p className="mt-1 flex items-center gap-2 text-lg font-semibold">
|
||||
<Landmark className="h-5 w-5 text-primary" aria-hidden="true" />
|
||||
{bankInfo.bankName}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wider text-muted-foreground">
|
||||
{t("payment.bank.accountHolderLabel")}
|
||||
</p>
|
||||
<p className="mt-1 flex items-center gap-2 font-medium">
|
||||
<User className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
|
||||
{bankInfo.accountHolder}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-xs uppercase tracking-wider text-muted-foreground">
|
||||
{t("payment.bank.ibanLabel")}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs font-medium text-primary hover:underline"
|
||||
onClick={() => setRevealIban((v) => !v)}
|
||||
>
|
||||
{revealIban ? t("payment.bank.hide") : t("payment.bank.reveal")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<code className="flex-1 truncate font-mono text-sm tracking-wide">
|
||||
{displayIban}
|
||||
</code>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 px-2"
|
||||
onClick={() => copy(compactIban, "iban_copied")}
|
||||
aria-label={t("payment.bank.copyIban")}
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{bankInfo.kolayAdres && (
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wider text-muted-foreground">
|
||||
{t("payment.bank.kolayAdresLabel")}
|
||||
{bankInfo.kolayAdresType && (
|
||||
<Badge variant="outline" className="ml-2 text-[10px] uppercase">
|
||||
{t(`payment.bank.kolayAdresType.${bankInfo.kolayAdresType}`)}
|
||||
</Badge>
|
||||
)}
|
||||
</p>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<code className="flex-1 truncate font-mono text-sm">{bankInfo.kolayAdres}</code>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 px-2"
|
||||
onClick={() => copy(bankInfo.kolayAdres ?? "", "kolay_adres_copied")}
|
||||
aria-label={t("payment.bank.copyKolayAdres")}
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 border-t border-border/60 pt-4">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wider text-muted-foreground">
|
||||
{t("payment.bank.descriptionLabel")}
|
||||
</p>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<code className="flex-1 truncate font-mono text-sm">{description}</code>
|
||||
{eftPayment && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 px-2"
|
||||
onClick={() => copy(description, "description_copied")}
|
||||
aria-label={t("payment.bank.copyDescription")}
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wider text-muted-foreground">
|
||||
{t("payment.bank.amountLabel")}
|
||||
</p>
|
||||
<p className="mt-1 text-lg font-bold text-foreground">{formatTRY(totalAmount)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* QR image */}
|
||||
{bankInfo.qrImageUrl ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => capture("qr_viewed", { bank_account_id: bankInfo.id })}
|
||||
className="group relative flex h-44 w-44 shrink-0 items-center justify-center overflow-hidden rounded-xl border border-border bg-background p-2 transition-transform hover:scale-[1.02]"
|
||||
aria-label={t("payment.bank.qrAlt")}
|
||||
>
|
||||
<img
|
||||
src={bankInfo.qrImageUrl}
|
||||
alt={t("payment.bank.qrAlt")}
|
||||
className="h-full w-full object-contain"
|
||||
loading="lazy"
|
||||
/>
|
||||
<span className="pointer-events-none absolute bottom-1.5 left-1.5 right-1.5 rounded-md bg-background/85 px-1.5 py-0.5 text-center text-[10px] uppercase tracking-wider text-muted-foreground backdrop-blur">
|
||||
{t("payment.bank.qrHint")}
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<div className="hidden h-44 w-44 shrink-0 flex-col items-center justify-center rounded-xl border border-dashed border-border bg-muted/30 p-3 text-center text-xs text-muted-foreground sm:flex">
|
||||
<QrCode className="mb-2 h-7 w-7 opacity-40" />
|
||||
{t("payment.bank.qrUnavailable")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!eftPayment ? (
|
||||
<Button
|
||||
className="w-full"
|
||||
size="lg"
|
||||
onClick={handleEftProceed}
|
||||
disabled={eftMutation.isPending}
|
||||
>
|
||||
{eftMutation.isPending ? (
|
||||
<>
|
||||
<Clock className="mr-2 h-4 w-4 animate-spin" />
|
||||
{t("payment.processingPayment")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Building2 className="mr-2 h-4 w-4" />
|
||||
{t("payment.bank.proceedToUpload")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<div className="space-y-4 rounded-2xl border border-border bg-background p-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||
<UploadCloud className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold">{t("payment.uploadReceipt")}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("payment.uploadReceiptDescription")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`flex w-full cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-dashed p-6 transition-colors ${
|
||||
isDragging
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-muted-foreground/25 hover:border-primary/50"
|
||||
}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
accept="image/png,image/jpeg,application/pdf"
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
{uploadedFile ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<FileText className="h-8 w-8 text-primary" />
|
||||
<div className="text-left">
|
||||
<p className="text-sm font-medium">{uploadedFile.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{(uploadedFile.size / 1024 / 1024).toFixed(2)} MB
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="mb-2 h-8 w-8 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">{t("payment.dragDrop")}</p>
|
||||
<p className="text-xs text-muted-foreground">{t("payment.supportedFormats")}</p>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{uploadedFile && (
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={handleUploadReceipt}
|
||||
disabled={uploadMutation.isPending}
|
||||
>
|
||||
{uploadMutation.isPending ? (
|
||||
<>
|
||||
<Clock className="mr-2 h-4 w-4 animate-spin" />
|
||||
{t("payment.uploading")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
{t("payment.uploadReceipt")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div className="rounded-lg border border-border/60 bg-muted/30 p-4">
|
||||
<p className="mb-2 flex items-center gap-1.5 text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
<Hash className="h-3 w-3" />
|
||||
{t("payment.paymentStatus")}
|
||||
</p>
|
||||
<ol className="space-y-2 text-sm">
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
|
||||
{t("payment.eftStatus.created")}
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
{uploadedFile ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
|
||||
) : (
|
||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
{t("payment.eftStatus.receiptUploaded")}
|
||||
</li>
|
||||
<li className="flex items-center gap-2 text-muted-foreground">
|
||||
<Clock className="h-4 w-4" />
|
||||
{t("payment.waitingApproval")}
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -273,7 +273,6 @@
|
||||
"totalAmount": "Total Amount",
|
||||
"paymentMethod": "Payment Method",
|
||||
"creditCard": "Credit Card",
|
||||
"eftTransfer": "EFT/Wire Transfer",
|
||||
"payWithCard": "Pay with Card",
|
||||
"paying": "Processing payment...",
|
||||
"bankDetails": "Bank Details",
|
||||
@@ -282,30 +281,15 @@
|
||||
"iban": "IBAN",
|
||||
"description": "Description",
|
||||
"paymentDescription": "Sase.tr Subscription Payment",
|
||||
"uploadReceipt": "Upload Receipt",
|
||||
"uploadReceiptDescription": "Upload your receipt after EFT/Wire transfer.",
|
||||
"dragDrop": "Drag and drop a file or click to browse",
|
||||
"supportedFormats": "PNG, JPG, or PDF (max 5MB)",
|
||||
"uploading": "Uploading...",
|
||||
"receiptUploaded": "Receipt uploaded successfully. Waiting for approval.",
|
||||
"uploadFailed": "Receipt upload failed.",
|
||||
"paymentStatus": "Payment Status",
|
||||
"waitingApproval": "Waiting for Approval",
|
||||
"approved": "Approved",
|
||||
"step1": "Plan & Brands",
|
||||
"step2": "Payment",
|
||||
"step3": "Confirmation",
|
||||
"confirmation": "Payment received!",
|
||||
"confirmationDescription": "Your subscription has been activated successfully.",
|
||||
"eftConfirmationDescription": "Your EFT/Wire receipt has been received. Your subscription will be activated after approval.",
|
||||
"goToDashboard": "Go to Dashboard",
|
||||
"initializeFailed": "Payment initialization failed. Please try again.",
|
||||
"processingPayment": "Processing payment...",
|
||||
"eftPaid": "I Made EFT/Wire Transfer",
|
||||
"eftStatus": {
|
||||
"created": "EFT/Wire transfer record created",
|
||||
"receiptUploaded": "Receipt uploaded"
|
||||
},
|
||||
"paymentFailed": "Payment failed. Please try again.",
|
||||
"stripe": {
|
||||
"secureCheckoutTitle": "Secure payment with 3D Secure",
|
||||
@@ -315,31 +299,6 @@
|
||||
"verifyingTitle": "Verifying your payment",
|
||||
"verifyingDescription": "Your subscription will activate as soon as Stripe confirms. Stay on this page for a few seconds.",
|
||||
"trustProvider": "Stripe payment infrastructure"
|
||||
},
|
||||
"bank": {
|
||||
"bankLabel": "Bank",
|
||||
"accountHolderLabel": "Account holder",
|
||||
"ibanLabel": "IBAN",
|
||||
"kolayAdresLabel": "Easy Address",
|
||||
"kolayAdresType": {
|
||||
"email": "Email",
|
||||
"phone": "Phone",
|
||||
"tckn": "TR ID"
|
||||
},
|
||||
"descriptionLabel": "Reference",
|
||||
"amountLabel": "Amount",
|
||||
"copyIban": "Copy IBAN",
|
||||
"copyKolayAdres": "Copy easy address",
|
||||
"copyDescription": "Copy reference",
|
||||
"qrAlt": "Mobile banking QR code",
|
||||
"qrHint": "Scan in your bank app",
|
||||
"qrUnavailable": "QR coming soon",
|
||||
"reveal": "Show",
|
||||
"hide": "Hide",
|
||||
"previewDescription": "Fills in once your payment is created",
|
||||
"proceedToUpload": "I made the transfer — upload receipt",
|
||||
"unavailableTitle": "Bank transfer unavailable",
|
||||
"unavailableDescription": "Please use card payment or try again shortly."
|
||||
}
|
||||
},
|
||||
"billing": {
|
||||
|
||||
@@ -273,7 +273,6 @@
|
||||
"totalAmount": "Toplam Tutar",
|
||||
"paymentMethod": "Ödeme Yöntemi",
|
||||
"creditCard": "Kredi Kartı",
|
||||
"eftTransfer": "EFT/Havale",
|
||||
"payWithCard": "Kartla Öde",
|
||||
"paying": "Ödeme yapılıyor...",
|
||||
"bankDetails": "Banka Bilgileri",
|
||||
@@ -282,30 +281,15 @@
|
||||
"iban": "IBAN",
|
||||
"description": "Açıklama",
|
||||
"paymentDescription": "Sase.tr Abonelik Ödemesi",
|
||||
"uploadReceipt": "Dekont Yükle",
|
||||
"uploadReceiptDescription": "EFT/Havale sonrası dekontunuzu yükleyin.",
|
||||
"dragDrop": "Dosyayı sürükleyip bırakın veya tıklayın",
|
||||
"supportedFormats": "PNG, JPG veya PDF (maks. 5MB)",
|
||||
"uploading": "Yükleniyor...",
|
||||
"receiptUploaded": "Dekont başarıyla yüklendi. Onay bekleniyor.",
|
||||
"uploadFailed": "Dekont yükleme başarısız.",
|
||||
"paymentStatus": "Ödeme Durumu",
|
||||
"waitingApproval": "Onay Bekleniyor",
|
||||
"approved": "Onaylandı",
|
||||
"step1": "Plan ve Markalar",
|
||||
"step2": "Ödeme",
|
||||
"step3": "Onay",
|
||||
"confirmation": "Ödemeniz alındı!",
|
||||
"confirmationDescription": "Aboneliğiniz başarıyla aktifleştirildi.",
|
||||
"eftConfirmationDescription": "EFT/Havale dekontunuz alındı. Onay sonrası aboneliğiniz aktifleştirilecektir.",
|
||||
"goToDashboard": "Panele Git",
|
||||
"initializeFailed": "Ödeme başlatılamadı. Lütfen tekrar deneyin.",
|
||||
"processingPayment": "Ödeme işleniyor...",
|
||||
"eftPaid": "EFT/Havale Yaptım",
|
||||
"eftStatus": {
|
||||
"created": "EFT/Havale kaydı oluşturuldu",
|
||||
"receiptUploaded": "Dekont yüklendi"
|
||||
},
|
||||
"paymentFailed": "Ödeme başarısız oldu. Lütfen tekrar deneyin.",
|
||||
"stripe": {
|
||||
"secureCheckoutTitle": "3D Secure ile güvenli ödeme",
|
||||
@@ -315,31 +299,6 @@
|
||||
"verifyingTitle": "Ödemen doğrulanıyor",
|
||||
"verifyingDescription": "Stripe onayı geldiğinde aboneliğin otomatik aktifleşecek. Bu sayfayı kapatmadan birkaç saniye bekle.",
|
||||
"trustProvider": "Stripe ödeme altyapısı"
|
||||
},
|
||||
"bank": {
|
||||
"bankLabel": "Banka",
|
||||
"accountHolderLabel": "Hesap Sahibi",
|
||||
"ibanLabel": "IBAN",
|
||||
"kolayAdresLabel": "Kolay Adres",
|
||||
"kolayAdresType": {
|
||||
"email": "E-posta",
|
||||
"phone": "Telefon",
|
||||
"tckn": "TCKN"
|
||||
},
|
||||
"descriptionLabel": "Açıklama",
|
||||
"amountLabel": "Tutar",
|
||||
"copyIban": "IBAN'ı kopyala",
|
||||
"copyKolayAdres": "Kolay Adres'i kopyala",
|
||||
"copyDescription": "Açıklamayı kopyala",
|
||||
"qrAlt": "Mobil bankacılık QR kodu",
|
||||
"qrHint": "Bankan ile tara",
|
||||
"qrUnavailable": "QR yakında",
|
||||
"reveal": "Göster",
|
||||
"hide": "Gizle",
|
||||
"previewDescription": "Ödeme oluştuğunda burası dolar",
|
||||
"proceedToUpload": "Havaleyi yaptım, dekont yükle",
|
||||
"unavailableTitle": "Havale şu an aktif değil",
|
||||
"unavailableDescription": "Kart ile ödemeyi tercih edebilir veya birazdan tekrar denersin."
|
||||
}
|
||||
},
|
||||
"billing": {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,7 +13,6 @@ import {
|
||||
BookOpen,
|
||||
Copy,
|
||||
CreditCard,
|
||||
DollarSign,
|
||||
FlaskConical,
|
||||
History,
|
||||
LayoutDashboard,
|
||||
@@ -67,7 +66,6 @@ const supportItems = [
|
||||
const adminItems = [
|
||||
{ to: "/dashboard/admin", label: "Yönetim Paneli", icon: Shield },
|
||||
{ to: "/dashboard/admin/users", label: "Kullanıcılar", icon: Users },
|
||||
{ to: "/dashboard/admin/payments", label: "Ödemeler", icon: DollarSign },
|
||||
{ to: "/dashboard/admin/analytics", label: "Analitik", icon: BarChart3 },
|
||||
{ to: "/dashboard/admin/copy-logs", label: "OEM Kopyalama", icon: Copy },
|
||||
{ to: "/dashboard/admin/referrals", label: "Referanslar", icon: Share2 },
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import { KEYS_6 } from "@/lib/keys";
|
||||
import { KEYS_5 } from "@/lib/keys";
|
||||
import { formatTRY } from "@sase/shared";
|
||||
import { Card, CardContent } from "@sase/ui";
|
||||
import { Skeleton } from "@sase/ui";
|
||||
@@ -10,10 +10,8 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import {
|
||||
Activity,
|
||||
Clock,
|
||||
Copy,
|
||||
CreditCard,
|
||||
Receipt,
|
||||
Search,
|
||||
TrendingUp,
|
||||
UserCog,
|
||||
@@ -39,7 +37,6 @@ interface DashboardStats {
|
||||
totalRevenue: number;
|
||||
totalQueries: number;
|
||||
newUsersThisMonth: number;
|
||||
pendingPayments: number;
|
||||
}
|
||||
|
||||
interface DailyStat {
|
||||
@@ -77,7 +74,7 @@ function AdminDashboardPage() {
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{KEYS_6.map((__k) => (
|
||||
{KEYS_5.map((__k) => (
|
||||
<Skeleton key={__k} className="h-32" />
|
||||
))}
|
||||
</div>
|
||||
@@ -123,13 +120,6 @@ function AdminDashboardPage() {
|
||||
color: "text-orange-600",
|
||||
bg: "bg-orange-50",
|
||||
},
|
||||
{
|
||||
label: "Bekleyen Odeme",
|
||||
value: stats?.pendingPayments ?? 0,
|
||||
icon: Clock,
|
||||
color: "text-red-600",
|
||||
bg: "bg-red-50",
|
||||
},
|
||||
];
|
||||
|
||||
const quickLinks = [
|
||||
@@ -138,11 +128,6 @@ function AdminDashboardPage() {
|
||||
label: "Kullanici Yonetimi",
|
||||
icon: UserCog,
|
||||
},
|
||||
{
|
||||
to: "/dashboard/admin/payments",
|
||||
label: "Odeme Onaylari",
|
||||
icon: Receipt,
|
||||
},
|
||||
{
|
||||
to: "/dashboard/admin/analytics",
|
||||
label: "Sorgu Analizi",
|
||||
@@ -182,7 +167,7 @@ function AdminDashboardPage() {
|
||||
{/* Stat Cards */}
|
||||
{statsLoading ? (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{KEYS_6.map((__k) => (
|
||||
{KEYS_5.map((__k) => (
|
||||
<Skeleton key={__k} className="h-32" />
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,234 +0,0 @@
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import { formatTRY } from "@sase/shared";
|
||||
import { Card, CardContent } from "@sase/ui";
|
||||
import { Button } from "@sase/ui";
|
||||
import { Badge } from "@sase/ui";
|
||||
import { Skeleton } from "@sase/ui";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { AlertTriangle, CheckCircle, ExternalLink, Receipt, XCircle } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { KEYS_5 } from "@/lib/keys";
|
||||
export const Route = createFileRoute("/dashboard/admin/payments")({
|
||||
component: AdminPaymentsPage,
|
||||
});
|
||||
|
||||
interface PendingPayment {
|
||||
id: string;
|
||||
userId: string;
|
||||
userName: string;
|
||||
userEmail: string;
|
||||
subscriptionId: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
method: string;
|
||||
status: string;
|
||||
eftReceiptUrl: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
function AdminPaymentsPage() {
|
||||
const { t } = useTranslation();
|
||||
const { user, isLoading: authLoading } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [confirmAction, setConfirmAction] = useState<{
|
||||
id: string;
|
||||
type: "approve" | "reject";
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && user?.role !== "admin") {
|
||||
navigate({ to: "/dashboard/search" });
|
||||
}
|
||||
}, [authLoading, user, navigate]);
|
||||
|
||||
const { data: payments, isLoading } = useQuery({
|
||||
queryKey: ["admin", "payments", "pending"],
|
||||
queryFn: () => api.get<PendingPayment[]>("/admin/payments/pending"),
|
||||
enabled: user?.role === "admin",
|
||||
});
|
||||
|
||||
const approveMutation = useMutation({
|
||||
mutationFn: (paymentId: string) => api.patch(`/payments/eft/${paymentId}/approve`, {}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["admin", "payments"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["admin", "dashboard"] });
|
||||
setConfirmAction(null);
|
||||
},
|
||||
});
|
||||
|
||||
const rejectMutation = useMutation({
|
||||
mutationFn: (paymentId: string) => api.patch(`/payments/eft/${paymentId}/reject`, {}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["admin", "payments"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["admin", "dashboard"] });
|
||||
setConfirmAction(null);
|
||||
},
|
||||
});
|
||||
|
||||
if (authLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-96 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (user?.role !== "admin") return null;
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleDateString("tr-TR", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold">EFT Odeme Onaylari</h2>
|
||||
<Badge variant="secondary">{payments?.length ?? 0} bekleyen</Badge>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-4">
|
||||
{KEYS_5.map((__k) => (
|
||||
<Skeleton key={__k} className="h-32 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : !payments || payments.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center gap-3 py-16 text-center">
|
||||
<CheckCircle className="h-12 w-12 text-green-500" />
|
||||
<p className="text-lg font-medium">Bekleyen odeme bulunmuyor</p>
|
||||
<p className="text-sm text-muted-foreground">Tum EFT odemeleri islenmis durumda</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{payments.map((payment) => (
|
||||
<Card key={payment.id}>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
{/* User Info */}
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium">{payment.userName}</p>
|
||||
<p className="text-sm text-muted-foreground">{payment.userEmail}</p>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>{formatDate(payment.createdAt)}</span>
|
||||
<span>|</span>
|
||||
<span>ID: {payment.id.substring(0, 8)}...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Amount & Receipt */}
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<p className="text-xl font-bold">{formatTRY(payment.amount)}</p>
|
||||
{payment.eftReceiptUrl ? (
|
||||
<a
|
||||
href={payment.eftReceiptUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-sm text-primary hover:underline"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
Dekont Goruntule
|
||||
</a>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<Receipt className="h-3 w-3" />
|
||||
Dekont yuklenmemis
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Confirm Dialog */}
|
||||
{confirmAction && confirmAction.id === payment.id ? (
|
||||
<div className="mt-4 rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-800 dark:bg-amber-950">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="mt-0.5 h-5 w-5 text-amber-600" />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">
|
||||
{confirmAction.type === "approve"
|
||||
? "Odemeyi onaylamak istediginize emin misiniz?"
|
||||
: "Odemeyi reddetmek istediginize emin misiniz?"}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{confirmAction.type === "approve"
|
||||
? "Bu islem aboneligi aktif hale getirecektir."
|
||||
: "Bu islem odemeyi basarisiz olarak isaretleyecektir."}
|
||||
</p>
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={confirmAction.type === "approve" ? "default" : "destructive"}
|
||||
disabled={approveMutation.isPending || rejectMutation.isPending}
|
||||
onClick={() => {
|
||||
if (confirmAction.type === "approve") {
|
||||
approveMutation.mutate(payment.id);
|
||||
} else {
|
||||
rejectMutation.mutate(payment.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{approveMutation.isPending || rejectMutation.isPending
|
||||
? "Isleniyor..."
|
||||
: "Evet, onayla"}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setConfirmAction(null)}
|
||||
>
|
||||
Iptal
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 flex items-center gap-2 border-t pt-4">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setConfirmAction({
|
||||
id: payment.id,
|
||||
type: "approve",
|
||||
})
|
||||
}
|
||||
>
|
||||
<CheckCircle className="mr-1 h-4 w-4" />
|
||||
Onayla
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() =>
|
||||
setConfirmAction({
|
||||
id: payment.id,
|
||||
type: "reject",
|
||||
})
|
||||
}
|
||||
>
|
||||
<XCircle className="mr-1 h-4 w-4" />
|
||||
Reddet
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -77,7 +77,7 @@ function BillingPage() {
|
||||
|
||||
{/* Method Filter */}
|
||||
<div className="flex gap-1">
|
||||
{["all", "stripe", "eft"].map((method) => (
|
||||
{["all", "stripe"].map((method) => (
|
||||
<Button
|
||||
key={method}
|
||||
variant={methodFilter === method ? "default" : "outline"}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { BankTransferCard } from "@/components/payment/bank-transfer-card";
|
||||
import { StripeCheckoutButton } from "@/components/payment/stripe-checkout-button";
|
||||
import { CarBrandLogo } from "@/components/ui/car-brand-logo";
|
||||
import { api } from "@/lib/api-client";
|
||||
@@ -25,17 +24,12 @@ import {
|
||||
DialogTitle,
|
||||
Separator,
|
||||
Skeleton,
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@sase/ui";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowRight,
|
||||
Building2,
|
||||
Check,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
@@ -189,7 +183,6 @@ export function SubscriptionPage() {
|
||||
const [selectedPlanKey, setSelectedPlanKey] = useState<string | null>(null);
|
||||
const [selectedBrandIds, setSelectedBrandIds] = useState<string[]>([]);
|
||||
const [billingPeriod, setBillingPeriod] = useState<"monthly" | "yearly">("monthly");
|
||||
const [paymentMethod, setPaymentMethod] = useState<"stripe" | "eft">("stripe");
|
||||
|
||||
// ─── Page-level state ──────────────────────────────────────────────────────
|
||||
const [cancelDialogOpen, setCancelDialogOpen] = useState(false);
|
||||
@@ -343,7 +336,6 @@ export function SubscriptionPage() {
|
||||
if (search.stripe === "success" && !stripeResultRef.current) {
|
||||
stripeResultRef.current = true;
|
||||
setStep("confirmation");
|
||||
setPaymentMethod("stripe");
|
||||
capture("stripe_redirect_returned", { result: "success", session_id: search.session_id });
|
||||
} else if (search.stripe === "cancelled" && !stripeResultRef.current) {
|
||||
stripeResultRef.current = true;
|
||||
@@ -459,11 +451,6 @@ export function SubscriptionPage() {
|
||||
}
|
||||
}
|
||||
|
||||
function handleEftCompleted() {
|
||||
setStep("confirmation");
|
||||
queryClient.invalidateQueries({ queryKey: ["subscription"] });
|
||||
}
|
||||
|
||||
function handleCancelClick() {
|
||||
if (downgradePlan) {
|
||||
const currentBrandIds = (subscription?.brands ?? []).map((b) => b.brandId);
|
||||
@@ -675,9 +662,7 @@ export function SubscriptionPage() {
|
||||
title={t("subscription.steps.payment.title")}
|
||||
isActive={step === "payment"}
|
||||
isComplete={step === "confirmation"}
|
||||
summary={
|
||||
paymentMethod === "stripe" ? t("payment.creditCard") : t("payment.eftTransfer")
|
||||
}
|
||||
summary={t("payment.creditCard")}
|
||||
onEdit={() => setStep("payment")}
|
||||
>
|
||||
<PaymentMethodSection
|
||||
@@ -685,18 +670,14 @@ export function SubscriptionPage() {
|
||||
period={billingPeriod}
|
||||
brandIds={selectedBrandIds}
|
||||
totalAmount={totalAmount}
|
||||
method={paymentMethod}
|
||||
onMethodChange={setPaymentMethod}
|
||||
onEftCompleted={handleEftCompleted}
|
||||
/>
|
||||
</StepCard>
|
||||
)}
|
||||
|
||||
{step === "confirmation" && (
|
||||
<ConfirmationCard
|
||||
method={paymentMethod}
|
||||
subscription={subscription ?? null}
|
||||
isStripeVerifying={paymentMethod === "stripe" && subscription?.status !== "active"}
|
||||
isStripeVerifying={subscription?.status !== "active"}
|
||||
navigate={navigate}
|
||||
/>
|
||||
)}
|
||||
@@ -1100,50 +1081,21 @@ function PaymentMethodSection({
|
||||
period,
|
||||
brandIds,
|
||||
totalAmount,
|
||||
method,
|
||||
onMethodChange,
|
||||
onEftCompleted,
|
||||
}: {
|
||||
planKey: string;
|
||||
period: "monthly" | "yearly";
|
||||
brandIds: string[];
|
||||
totalAmount: number;
|
||||
method: "stripe" | "eft";
|
||||
onMethodChange: (m: "stripe" | "eft") => void;
|
||||
onEftCompleted: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Tabs value={method} onValueChange={(v) => onMethodChange(v as "stripe" | "eft")}>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="stripe" data-faro-user-action-name="payment-tab-card">
|
||||
<CreditCard className="mr-2 h-4 w-4" />
|
||||
{t("payment.creditCard")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="eft" data-faro-user-action-name="payment-tab-eft">
|
||||
<Building2 className="mr-2 h-4 w-4" />
|
||||
{t("payment.eftTransfer")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="stripe" className="pt-4">
|
||||
<StripeCheckoutButton
|
||||
planKey={planKey}
|
||||
period={period}
|
||||
brandIds={brandIds}
|
||||
totalAmount={totalAmount}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="eft" className="pt-4">
|
||||
<BankTransferCard
|
||||
planKey={planKey}
|
||||
period={period}
|
||||
brandIds={brandIds}
|
||||
totalAmount={totalAmount}
|
||||
onCompleted={onEftCompleted}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<StripeCheckoutButton
|
||||
planKey={planKey}
|
||||
period={period}
|
||||
brandIds={brandIds}
|
||||
totalAmount={totalAmount}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-center gap-x-3 gap-y-1 text-xs text-foreground/70">
|
||||
<span aria-hidden="true">🔒</span>
|
||||
@@ -1160,18 +1112,16 @@ function PaymentMethodSection({
|
||||
}
|
||||
|
||||
function ConfirmationCard({
|
||||
method,
|
||||
subscription,
|
||||
isStripeVerifying,
|
||||
navigate,
|
||||
}: {
|
||||
method: "stripe" | "eft";
|
||||
subscription: Subscription | null;
|
||||
isStripeVerifying: boolean;
|
||||
navigate: ReturnType<typeof useNavigate>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (method === "stripe" && isStripeVerifying) {
|
||||
if (isStripeVerifying) {
|
||||
return (
|
||||
<Card className="border-primary/30 ring-1 ring-primary/10">
|
||||
<CardContent className="flex flex-col items-center gap-4 py-12 text-center">
|
||||
@@ -1192,13 +1142,9 @@ function ConfirmationCard({
|
||||
<CheckCircle2 className="h-16 w-16 text-emerald-500" />
|
||||
<div>
|
||||
<h3 className="text-2xl font-bold">{t("payment.confirmation")}</h3>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
{method === "stripe"
|
||||
? t("payment.confirmationDescription")
|
||||
: t("payment.eftConfirmationDescription")}
|
||||
</p>
|
||||
<p className="mt-2 text-muted-foreground">{t("payment.confirmationDescription")}</p>
|
||||
</div>
|
||||
{subscription?.endDate && method === "stripe" && (
|
||||
{subscription?.endDate && (
|
||||
<div className="rounded-lg border border-border bg-background/60 px-4 py-2 text-sm">
|
||||
<span className="text-muted-foreground">{t("subscription.endDate")}: </span>
|
||||
<span className="font-medium">
|
||||
|
||||
Reference in New Issue
Block a user