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

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:
Fusion
2026-05-15 17:24:06 +03:00
committed by Semih Yesilyurt
parent 1278ef679d
commit 65daf99b2e
20 changed files with 555 additions and 2135 deletions

View File

@@ -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,

View File

@@ -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" }];

View File

@@ -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;

View File

@@ -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();
}
}

View File

@@ -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 {}

View File

@@ -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);
}
}

View File

@@ -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();
}
}

View File

@@ -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],

View File

@@ -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);
}
}