feat: replace Iyzico with Stripe + single-page subscription stepper
Subscription/checkout flow rewritten end-to-end. The plan-card "Devam Et" button silently wiped brand selection on re-click and the actual proceed button lived offscreen — fixed by collapsing plan/brands/payment/confirm into a single vertical stepper with one sticky CTA. Backend - Stripe Hosted Checkout (`/payments/stripe/checkout`) + webhook (`/payments/stripe/webhook`, raw body) replacing the stubbed Iyzico module. Webhook activates subscription on `checkout.session.completed`, expires the pending subscription on cancel/expire so users can retry. - New `bank_accounts` table — multiple rows, single `is_active` enforced by a partial unique index. Admin CRUD under `/admin/bank-accounts` with multipart QR upload to MinIO; atomic `/activate` swap in a transaction; `GET /payments/bank-info` returns the active row. - `payments` gains `stripe_session_id`, `stripe_payment_intent_id`, `bank_account_id`. EFT flow now reads the active bank account at payment time and stores the FK for reconciliation. - Env: `IYZICO_*` removed, `STRIPE_*` added (validated by zod schema). - `main.ts` `rawBody: true` for Stripe signature verification. - Drizzle 0003 snapshot id collision fixed (VIEW-only migration shared prevId with 0002, blocking new generates). Frontend - `/dashboard/subscription` rewritten as a 4-step vertical stepper with step-aware sticky bottom CTA; plan re-selection is idempotent and preserves brand state. `/dashboard/subscription/pay` deleted; Stripe returns to the same page via `?stripe=success|cancelled` and the UI polls `/subscriptions/me` until the webhook activates the row. - New components: `bank-transfer-card.tsx` (DB-driven IBAN + Kolay Adres + uploaded QR image + receipt upload) and `stripe-checkout-button.tsx`. - Active subscription view, trial onboarding/urgency banner, downgrade and cancel dialogs preserved. - TR/EN i18n: new `subscription.steps.*`, `subscription.stickyCta.*`, `payment.stripe.*`, `payment.bank.*`; provider label updated. PostHog: `method: "iyzico"` → `"stripe"`; new events `iban_copied`, `kolay_adres_copied`, `qr_viewed`, `eft_initiated`, `stripe_redirect_returned`. Deploy runs `db:migrate` which applies 0004_hot_quicksilver (additive: new table + nullable columns; safe to apply on prod). Operator must add `STRIPE_SECRET_KEY`, `STRIPE_PUBLISHABLE_KEY`, `STRIPE_WEBHOOK_SECRET` to env and create the first `bank_accounts` row via the admin endpoint before the bank tab works. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
22
apps/api/drizzle/0004_hot_quicksilver.sql
Normal file
22
apps/api/drizzle/0004_hot_quicksilver.sql
Normal file
@@ -0,0 +1,22 @@
|
||||
CREATE TABLE "bank_accounts" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"bank_name" varchar(100) NOT NULL,
|
||||
"account_holder" varchar(200) NOT NULL,
|
||||
"iban" varchar(34) NOT NULL,
|
||||
"kolay_adres" varchar(100),
|
||||
"kolay_adres_type" varchar(20),
|
||||
"qr_image_url" text,
|
||||
"description_template" varchar(200) DEFAULT 'SASE-{{paymentId}}' NOT NULL,
|
||||
"is_active" boolean DEFAULT false NOT NULL,
|
||||
"display_order" integer DEFAULT 0 NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "payments" ADD COLUMN "stripe_session_id" text;--> statement-breakpoint
|
||||
ALTER TABLE "payments" ADD COLUMN "stripe_payment_intent_id" text;--> statement-breakpoint
|
||||
ALTER TABLE "payments" ADD COLUMN "bank_account_id" uuid;--> statement-breakpoint
|
||||
CREATE INDEX "bank_accounts_active_idx" ON "bank_accounts" USING btree ("is_active");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "bank_accounts_one_active_idx" ON "bank_accounts" USING btree ("is_active") WHERE "is_active" = true;--> statement-breakpoint
|
||||
ALTER TABLE "payments" ADD CONSTRAINT "payments_bank_account_id_bank_accounts_id_fk" FOREIGN KEY ("bank_account_id") REFERENCES "public"."bank_accounts"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "payments_stripe_session_id_idx" ON "payments" USING btree ("stripe_session_id");
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"id": "b797d2fd-854c-43bd-9924-cd1ca469a239",
|
||||
"prevId": "03894be0-b7bc-4a6a-940d-37b9f4a71b27",
|
||||
"id": "d8a51462-659a-4425-8a7f-090dd5a70926",
|
||||
"prevId": "b797d2fd-854c-43bd-9924-cd1ca469a239",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
|
||||
5326
apps/api/drizzle/meta/0004_snapshot.json
Normal file
5326
apps/api/drizzle/meta/0004_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -63,6 +63,7 @@
|
||||
"posthog-node": "^5.34.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.0",
|
||||
"stripe": "^22.1.1",
|
||||
"undici": "^7.22.0",
|
||||
"zod": "^3.24.0"
|
||||
},
|
||||
|
||||
@@ -25,10 +25,16 @@ export default () => ({
|
||||
cors: {
|
||||
origin: (process.env.CORS_ORIGIN || "http://localhost:3000").split(","),
|
||||
},
|
||||
iyzico: {
|
||||
apiKey: process.env.IYZICO_API_KEY,
|
||||
secretKey: process.env.IYZICO_SECRET_KEY,
|
||||
baseUrl: process.env.IYZICO_BASE_URL,
|
||||
stripe: {
|
||||
secretKey: process.env.STRIPE_SECRET_KEY,
|
||||
publishableKey: process.env.STRIPE_PUBLISHABLE_KEY,
|
||||
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET,
|
||||
successUrl:
|
||||
process.env.STRIPE_SUCCESS_URL ||
|
||||
"http://localhost:3000/dashboard/subscription?stripe=success",
|
||||
cancelUrl:
|
||||
process.env.STRIPE_CANCEL_URL ||
|
||||
"http://localhost:3000/dashboard/subscription?stripe=cancelled",
|
||||
},
|
||||
pl24: {
|
||||
baseUrl: process.env.PL24_BASE_URL || "https://www.partslink24.com",
|
||||
|
||||
@@ -162,6 +162,28 @@ export const userBrands = pgTable(
|
||||
],
|
||||
);
|
||||
|
||||
// ─── Bank Accounts (EFT/Havale destination accounts, one active at a time) ────
|
||||
export const bankAccounts = pgTable(
|
||||
"bank_accounts",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
bankName: varchar("bank_name", { length: 100 }).notNull(),
|
||||
accountHolder: varchar("account_holder", { length: 200 }).notNull(),
|
||||
iban: varchar("iban", { length: 34 }).notNull(),
|
||||
kolayAdres: varchar("kolay_adres", { length: 100 }),
|
||||
kolayAdresType: varchar("kolay_adres_type", { length: 20 }),
|
||||
qrImageUrl: text("qr_image_url"),
|
||||
descriptionTemplate: varchar("description_template", { length: 200 })
|
||||
.notNull()
|
||||
.default("SASE-{{paymentId}}"),
|
||||
isActive: boolean("is_active").default(false).notNull(),
|
||||
displayOrder: integer("display_order").default(0).notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => [index("bank_accounts_active_idx").on(table.isActive)],
|
||||
);
|
||||
|
||||
// ─── Payments ───────────────────────────────────────
|
||||
export const payments = pgTable(
|
||||
"payments",
|
||||
@@ -178,6 +200,9 @@ export const payments = pgTable(
|
||||
method: varchar("method", { length: 20 }).notNull(),
|
||||
status: varchar("status", { length: 20 }).default("pending").notNull(),
|
||||
iyzicoPaymentId: text("iyzico_payment_id"),
|
||||
stripeSessionId: text("stripe_session_id"),
|
||||
stripePaymentIntentId: text("stripe_payment_intent_id"),
|
||||
bankAccountId: uuid("bank_account_id").references(() => bankAccounts.id),
|
||||
eftReceiptUrl: text("eft_receipt_url"),
|
||||
adminNote: text("admin_note"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
@@ -186,6 +211,7 @@ export const payments = pgTable(
|
||||
(table) => [
|
||||
index("payments_user_id_idx").on(table.userId),
|
||||
index("payments_status_idx").on(table.status),
|
||||
index("payments_stripe_session_id_idx").on(table.stripeSessionId),
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { relations } from "drizzle-orm";
|
||||
import {
|
||||
accounts,
|
||||
bankAccounts,
|
||||
brands,
|
||||
catalogVehicles,
|
||||
categories,
|
||||
@@ -75,6 +76,14 @@ export const paymentsRelations = relations(payments, ({ one }) => ({
|
||||
fields: [payments.subscriptionId],
|
||||
references: [userSubscriptions.id],
|
||||
}),
|
||||
bankAccount: one(bankAccounts, {
|
||||
fields: [payments.bankAccountId],
|
||||
references: [bankAccounts.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const bankAccountsRelations = relations(bankAccounts, ({ many }) => ({
|
||||
payments: many(payments),
|
||||
}));
|
||||
|
||||
export const queryLogsRelations = relations(queryLogs, ({ one }) => ({
|
||||
|
||||
@@ -10,7 +10,7 @@ import { AppModule } from "./app.module";
|
||||
import { fileUploadValidation } from "./common/middleware/file-upload-validation.middleware";
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
const app = await NestFactory.create(AppModule, { rawBody: true });
|
||||
|
||||
const configService = app.get(ConfigService);
|
||||
const port = configService.get<number>("port", 4000);
|
||||
|
||||
127
apps/api/src/payments/bank-accounts/bank-accounts.controller.ts
Normal file
127
apps/api/src/payments/bank-accounts/bank-accounts.controller.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
10
apps/api/src/payments/bank-accounts/bank-accounts.module.ts
Normal file
10
apps/api/src/payments/bank-accounts/bank-accounts.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
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 {}
|
||||
218
apps/api/src/payments/bank-accounts/bank-accounts.service.ts
Normal file
218
apps/api/src/payments/bank-accounts/bank-accounts.service.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -20,28 +20,9 @@ import { PaymentsService } from "./payments.service";
|
||||
export class PaymentsController {
|
||||
constructor(private paymentsService: PaymentsService) {}
|
||||
|
||||
@Post("iyzico/initialize")
|
||||
async initializeIyzico(
|
||||
@CurrentUser("id") userId: string,
|
||||
@Body() body: { planKey: string; billingPeriod: "monthly" | "yearly"; brandIds: string[] },
|
||||
) {
|
||||
return this.paymentsService.initializeIyzico(
|
||||
userId,
|
||||
body.planKey,
|
||||
body.billingPeriod,
|
||||
body.brandIds,
|
||||
);
|
||||
}
|
||||
|
||||
@Post("iyzico/callback")
|
||||
async iyzicoCallback(
|
||||
@Body() body: { paymentId: string; iyzicoPaymentId: string; status: string },
|
||||
) {
|
||||
return this.paymentsService.handleIyzicoCallback(
|
||||
body.paymentId,
|
||||
body.iyzicoPaymentId,
|
||||
body.status,
|
||||
);
|
||||
@Get("bank-info")
|
||||
async getBankInfo() {
|
||||
return this.paymentsService.getActiveBankInfo();
|
||||
}
|
||||
|
||||
@Post("eft")
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
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],
|
||||
imports: [SubscriptionsModule, BankAccountsModule, StripeModule],
|
||||
controllers: [PaymentsController],
|
||||
providers: [PaymentsService],
|
||||
exports: [PaymentsService],
|
||||
|
||||
@@ -1,416 +0,0 @@
|
||||
import { BadRequestException, NotFoundException } from "@nestjs/common";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { PaymentsService } from "./payments.service";
|
||||
|
||||
function createMockDb(overrides: Record<string, unknown> = {}) {
|
||||
function chainable(terminalValue: unknown) {
|
||||
const chain: Record<string, unknown> = {};
|
||||
const methods = [
|
||||
"select",
|
||||
"from",
|
||||
"where",
|
||||
"orderBy",
|
||||
"limit",
|
||||
"offset",
|
||||
"innerJoin",
|
||||
"leftJoin",
|
||||
"insert",
|
||||
"values",
|
||||
"update",
|
||||
"set",
|
||||
"delete",
|
||||
"returning",
|
||||
"onConflictDoNothing",
|
||||
"groupBy",
|
||||
];
|
||||
for (const m of methods) chain[m] = vi.fn().mockReturnValue(chain);
|
||||
chain.limit = vi.fn().mockReturnValue(terminalValue);
|
||||
chain.returning = vi.fn().mockReturnValue(terminalValue);
|
||||
chain.orderBy = vi.fn().mockReturnValue(terminalValue);
|
||||
return chain;
|
||||
}
|
||||
|
||||
return {
|
||||
select: vi.fn().mockImplementation(() => chainable(overrides._selectRows ?? [])),
|
||||
insert: vi.fn().mockImplementation(() => chainable(overrides._insertRows ?? [])),
|
||||
update: vi.fn().mockImplementation(() => chainable(overrides._updateRows ?? [])),
|
||||
delete: vi.fn().mockImplementation(() => chainable(overrides._deleteRows ?? [])),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createService(
|
||||
dbOverrides: Record<string, unknown> = {},
|
||||
subServiceOverrides: Record<string, any> = {},
|
||||
) {
|
||||
const db = typeof dbOverrides.select === "function" ? dbOverrides : createMockDb(dbOverrides);
|
||||
const configService = { get: vi.fn().mockReturnValue("test-value") };
|
||||
const subscriptionsService = {
|
||||
activateSubscription: vi.fn().mockResolvedValue(undefined),
|
||||
create: vi.fn().mockResolvedValue({ id: "sub-1", planId: "plan-1", status: "pending" }),
|
||||
addBrandsToSubscription: vi.fn().mockResolvedValue(undefined),
|
||||
...subServiceOverrides,
|
||||
};
|
||||
const storageService = {
|
||||
upload: vi.fn().mockResolvedValue("https://storage.test/receipt.pdf"),
|
||||
};
|
||||
const posthogService = {
|
||||
capture: vi.fn(),
|
||||
captureForUser: vi.fn(),
|
||||
};
|
||||
|
||||
const service = new PaymentsService(
|
||||
db as any,
|
||||
configService as any,
|
||||
subscriptionsService as any,
|
||||
storageService as any,
|
||||
posthogService as any,
|
||||
);
|
||||
|
||||
return { service, db, configService, subscriptionsService, storageService, posthogService };
|
||||
}
|
||||
|
||||
describe("PaymentsService", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("initializeIyzico", () => {
|
||||
it("should create subscription, payment and return paymentId", async () => {
|
||||
const db = {
|
||||
select: vi.fn().mockReturnValue({
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
limit: vi.fn().mockReturnValue([
|
||||
{
|
||||
id: "plan-1",
|
||||
brandCount: 1,
|
||||
priceMonthly: 20000,
|
||||
priceYearly: 200000,
|
||||
isActive: true,
|
||||
},
|
||||
]),
|
||||
}),
|
||||
insert: vi.fn().mockReturnValue({
|
||||
values: vi.fn().mockReturnThis(),
|
||||
returning: vi.fn().mockReturnValue([{ id: "pay-1", status: "pending" }]),
|
||||
}),
|
||||
};
|
||||
const { service, subscriptionsService } = createService(db);
|
||||
|
||||
const result = await service.initializeIyzico("u1", "brand1", "monthly", ["brand-id-1"]);
|
||||
expect(result.paymentId).toBe("pay-1");
|
||||
expect(result.status).toBe("pending");
|
||||
expect(subscriptionsService.create).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should throw BadRequestException for invalid plan key", async () => {
|
||||
const { service } = createService();
|
||||
|
||||
await expect(service.initializeIyzico("u1", "invalid", "monthly", [])).rejects.toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleIyzicoCallback", () => {
|
||||
it("should activate subscription on success and capture payment_success", async () => {
|
||||
const db = {
|
||||
select: vi.fn().mockReturnValue({
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
limit: vi.fn().mockReturnValue([
|
||||
{
|
||||
id: "pay-1",
|
||||
subscriptionId: "sub-1",
|
||||
status: "pending",
|
||||
userId: "u1",
|
||||
amount: 20000,
|
||||
},
|
||||
]),
|
||||
}),
|
||||
update: vi.fn().mockReturnValue({
|
||||
set: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
};
|
||||
const { service, subscriptionsService, posthogService } = createService(db);
|
||||
|
||||
const result = await service.handleIyzicoCallback("pay-1", "iyz-123", "success");
|
||||
expect(result.status).toBe("completed");
|
||||
expect(subscriptionsService.activateSubscription).toHaveBeenCalledWith("sub-1");
|
||||
expect(posthogService.captureForUser).toHaveBeenCalledWith(
|
||||
"u1",
|
||||
"payment_success",
|
||||
expect.objectContaining({
|
||||
method: "iyzico",
|
||||
payment_id: "pay-1",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should set failed status on failure callback and capture payment_failed", async () => {
|
||||
const db = {
|
||||
select: vi.fn().mockReturnValue({
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
limit: vi.fn().mockReturnValue([
|
||||
{
|
||||
id: "pay-1",
|
||||
subscriptionId: "sub-1",
|
||||
status: "pending",
|
||||
userId: "u1",
|
||||
amount: 20000,
|
||||
},
|
||||
]),
|
||||
}),
|
||||
update: vi.fn().mockReturnValue({
|
||||
set: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
};
|
||||
const { service, subscriptionsService, posthogService } = createService(db);
|
||||
|
||||
const result = await service.handleIyzicoCallback("pay-1", "iyz-123", "failure");
|
||||
expect(result.status).toBe("failed");
|
||||
expect(subscriptionsService.activateSubscription).not.toHaveBeenCalled();
|
||||
expect(posthogService.captureForUser).toHaveBeenCalledWith(
|
||||
"u1",
|
||||
"payment_failed",
|
||||
expect.objectContaining({
|
||||
method: "iyzico",
|
||||
payment_id: "pay-1",
|
||||
reason: "failure",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw NotFoundException when payment not found", async () => {
|
||||
const db = {
|
||||
select: vi.fn().mockReturnValue({
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
limit: vi.fn().mockReturnValue([]),
|
||||
}),
|
||||
};
|
||||
const { service } = createService(db);
|
||||
|
||||
await expect(service.handleIyzicoCallback("nonexistent", "iyz-1", "success")).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createEftPayment", () => {
|
||||
it("should create subscription, EFT payment and return bank info", async () => {
|
||||
const db = {
|
||||
select: vi.fn().mockReturnValue({
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
limit: vi.fn().mockReturnValue([
|
||||
{
|
||||
id: "plan-1",
|
||||
brandCount: 0,
|
||||
priceMonthly: 99900,
|
||||
priceYearly: 999000,
|
||||
isActive: true,
|
||||
},
|
||||
]),
|
||||
}),
|
||||
insert: vi.fn().mockReturnValue({
|
||||
values: vi.fn().mockReturnThis(),
|
||||
returning: vi
|
||||
.fn()
|
||||
.mockReturnValue([{ id: "pay-eft-1", method: "eft", status: "pending" }]),
|
||||
}),
|
||||
};
|
||||
const { service, subscriptionsService } = createService(db);
|
||||
|
||||
const result = await service.createEftPayment("u1", "full", "yearly", []);
|
||||
expect(result.paymentId).toBe("pay-eft-1");
|
||||
expect(result.bankInfo).toBeDefined();
|
||||
expect(result.bankInfo.bankName).toBe("Ziraat Bankası");
|
||||
expect(subscriptionsService.create).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should throw BadRequestException for invalid plan key", async () => {
|
||||
const { service } = createService();
|
||||
|
||||
await expect(service.createEftPayment("u1", "nonexistent", "monthly", [])).rejects.toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("uploadEftReceipt", () => {
|
||||
it("should upload receipt and return url", async () => {
|
||||
const db = {
|
||||
select: vi.fn().mockReturnValue({
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
limit: vi.fn().mockReturnValue([{ id: "pay-1", userId: "u1", method: "eft" }]),
|
||||
}),
|
||||
update: vi.fn().mockReturnValue({
|
||||
set: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
};
|
||||
const { service } = createService(db);
|
||||
|
||||
const result = await service.uploadEftReceipt(
|
||||
"pay-1",
|
||||
"u1",
|
||||
Buffer.from("pdf"),
|
||||
"receipt.pdf",
|
||||
);
|
||||
expect(result.receiptUrl).toBe("https://storage.test/receipt.pdf");
|
||||
});
|
||||
|
||||
it("should throw NotFoundException when payment not found", async () => {
|
||||
const db = {
|
||||
select: vi.fn().mockReturnValue({
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
limit: vi.fn().mockReturnValue([]),
|
||||
}),
|
||||
};
|
||||
const { service } = createService(db);
|
||||
|
||||
await expect(
|
||||
service.uploadEftReceipt("pay-x", "u1", Buffer.from("pdf"), "r.pdf"),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it("should throw BadRequestException when not EFT method", async () => {
|
||||
const db = {
|
||||
select: vi.fn().mockReturnValue({
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
limit: vi.fn().mockReturnValue([{ id: "pay-1", userId: "u1", method: "iyzico" }]),
|
||||
}),
|
||||
};
|
||||
const { service } = createService(db);
|
||||
|
||||
await expect(
|
||||
service.uploadEftReceipt("pay-1", "u1", Buffer.from("pdf"), "r.pdf"),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
describe("approveEft", () => {
|
||||
it("should approve, activate subscription, and capture payment_success", async () => {
|
||||
const db = {
|
||||
select: vi.fn().mockReturnValue({
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
limit: vi
|
||||
.fn()
|
||||
.mockReturnValue([
|
||||
{ id: "pay-1", subscriptionId: "sub-1", method: "eft", userId: "u1", amount: 20000 },
|
||||
]),
|
||||
}),
|
||||
update: vi.fn().mockReturnValue({
|
||||
set: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
};
|
||||
const { service, subscriptionsService, posthogService } = createService(db);
|
||||
|
||||
const result = await service.approveEft("pay-1", "Looks good");
|
||||
expect(result.status).toBe("completed");
|
||||
expect(subscriptionsService.activateSubscription).toHaveBeenCalledWith("sub-1");
|
||||
expect(posthogService.captureForUser).toHaveBeenCalledWith(
|
||||
"u1",
|
||||
"payment_success",
|
||||
expect.objectContaining({
|
||||
method: "eft",
|
||||
payment_id: "pay-1",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw NotFoundException when payment not found", async () => {
|
||||
const db = {
|
||||
select: vi.fn().mockReturnValue({
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
limit: vi.fn().mockReturnValue([]),
|
||||
}),
|
||||
};
|
||||
const { service } = createService(db);
|
||||
|
||||
await expect(service.approveEft("nonexistent")).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it("should throw BadRequestException when not EFT method", async () => {
|
||||
const db = {
|
||||
select: vi.fn().mockReturnValue({
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
limit: vi.fn().mockReturnValue([{ id: "pay-1", method: "iyzico" }]),
|
||||
}),
|
||||
};
|
||||
const { service } = createService(db);
|
||||
|
||||
await expect(service.approveEft("pay-1")).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rejectEft", () => {
|
||||
it("should reject and capture payment_failed", async () => {
|
||||
const payment = {
|
||||
id: "pay-1",
|
||||
subscriptionId: "sub-1",
|
||||
userId: "u1",
|
||||
amount: 20000,
|
||||
method: "eft",
|
||||
};
|
||||
const db = {
|
||||
select: vi.fn().mockReturnValue({
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
limit: vi.fn().mockReturnValue([payment]),
|
||||
}),
|
||||
update: vi.fn().mockReturnValue({
|
||||
set: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
};
|
||||
const { service, posthogService } = createService(db);
|
||||
|
||||
const result = await service.rejectEft("pay-1", "Bad receipt");
|
||||
expect(result.status).toBe("failed");
|
||||
expect(posthogService.captureForUser).toHaveBeenCalledWith(
|
||||
"u1",
|
||||
"payment_failed",
|
||||
expect.objectContaining({
|
||||
method: "eft",
|
||||
payment_id: "pay-1",
|
||||
reason: "Bad receipt",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getMyPayments", () => {
|
||||
it("should return list of payments", async () => {
|
||||
const paymentsList = [{ id: "pay-1" }, { id: "pay-2" }];
|
||||
const db = createMockDb({ _selectRows: paymentsList });
|
||||
const { service } = createService(db);
|
||||
|
||||
const result = await service.getMyPayments("u1");
|
||||
expect(result).toEqual(paymentsList);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPendingEftPayments", () => {
|
||||
it("should return pending EFT payments", async () => {
|
||||
const pending = [{ id: "pay-1", method: "eft", status: "pending" }];
|
||||
const db = createMockDb({ _selectRows: pending });
|
||||
const { service } = createService(db);
|
||||
|
||||
const result = await service.getPendingEftPayments();
|
||||
expect(result).toEqual(pending);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,18 @@
|
||||
import { BadRequestException, Inject, Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import {
|
||||
BadRequestException,
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
ServiceUnavailableException,
|
||||
} from "@nestjs/common";
|
||||
import { and, 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,
|
||||
@@ -20,10 +27,10 @@ export class PaymentsService {
|
||||
|
||||
constructor(
|
||||
@Inject(DATABASE) private db: Database,
|
||||
private configService: ConfigService,
|
||||
private subscriptionsService: SubscriptionsService,
|
||||
private storageService: StorageService,
|
||||
private posthog: PostHogService,
|
||||
private bankAccounts: BankAccountsService,
|
||||
) {}
|
||||
|
||||
private async resolvePlanId(planKey: string): Promise<string> {
|
||||
@@ -61,90 +68,33 @@ export class PaymentsService {
|
||||
return subscription;
|
||||
}
|
||||
|
||||
async initializeIyzico(
|
||||
userId: string,
|
||||
planKey: string,
|
||||
billingPeriod: "monthly" | "yearly",
|
||||
brandIds: string[],
|
||||
) {
|
||||
const sub = await this.createSubscriptionForPayment(userId, planKey, billingPeriod, brandIds);
|
||||
|
||||
const amount =
|
||||
billingPeriod === "yearly"
|
||||
? (await this.db.select().from(plans).where(eq(plans.id, sub.planId)).limit(1))[0]
|
||||
.priceYearly
|
||||
: (await this.db.select().from(plans).where(eq(plans.id, sub.planId)).limit(1))[0]
|
||||
.priceMonthly;
|
||||
|
||||
const [payment] = await this.db
|
||||
.insert(payments)
|
||||
.values({
|
||||
userId,
|
||||
subscriptionId: sub.id,
|
||||
amount,
|
||||
currency: "TRY",
|
||||
method: "iyzico",
|
||||
status: "pending",
|
||||
})
|
||||
.returning();
|
||||
|
||||
// TODO: Integrate with actual iyzico API
|
||||
this.logger.log(`iyzico payment initialized for subscription ${sub.id}`);
|
||||
|
||||
async getActiveBankInfo() {
|
||||
const account = await this.bankAccounts.findActive();
|
||||
if (!account) return null;
|
||||
return {
|
||||
paymentId: payment.id,
|
||||
status: "pending",
|
||||
id: account.id,
|
||||
bankName: account.bankName,
|
||||
accountHolder: account.accountHolder,
|
||||
iban: account.iban,
|
||||
kolayAdres: account.kolayAdres,
|
||||
kolayAdresType: account.kolayAdresType,
|
||||
qrImageUrl: account.qrImageUrl,
|
||||
};
|
||||
}
|
||||
|
||||
async handleIyzicoCallback(paymentId: string, iyzicoPaymentId: string, status: string) {
|
||||
const [payment] = await this.db
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq(payments.id, paymentId))
|
||||
.limit(1);
|
||||
|
||||
if (!payment) throw new NotFoundException("Ödeme bulunamadı");
|
||||
|
||||
const newStatus = status === "success" ? "completed" : "failed";
|
||||
|
||||
await this.db
|
||||
.update(payments)
|
||||
.set({
|
||||
status: newStatus,
|
||||
iyzicoPaymentId,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(payments.id, paymentId));
|
||||
|
||||
if (newStatus === "completed") {
|
||||
await this.subscriptionsService.activateSubscription(payment.subscriptionId);
|
||||
this.posthog.captureForUser(payment.userId, "payment_success", {
|
||||
method: "iyzico",
|
||||
payment_id: paymentId,
|
||||
iyzico_payment_id: iyzicoPaymentId,
|
||||
subscription_id: payment.subscriptionId,
|
||||
amount: Number(payment.amount),
|
||||
});
|
||||
} else {
|
||||
this.posthog.captureForUser(payment.userId, "payment_failed", {
|
||||
method: "iyzico",
|
||||
payment_id: paymentId,
|
||||
iyzico_payment_id: iyzicoPaymentId,
|
||||
subscription_id: payment.subscriptionId,
|
||||
reason: status,
|
||||
});
|
||||
}
|
||||
|
||||
return { status: newStatus };
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -159,16 +109,33 @@ export class PaymentsService {
|
||||
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: {
|
||||
bankName: "Ziraat Bankası",
|
||||
iban: "TR33 0001 0000 1234 5678 9012 34",
|
||||
accountHolder: "Sase Teknoloji A.Ş.",
|
||||
description: `SASE-${payment.id.substring(0, 8).toUpperCase()}`,
|
||||
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,
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -191,6 +158,10 @@ export class PaymentsService {
|
||||
.set({ eftReceiptUrl: url, updatedAt: new Date() })
|
||||
.where(eq(payments.id, paymentId));
|
||||
|
||||
this.posthog.captureForUser(userId, "receipt_uploaded", {
|
||||
payment_id: paymentId,
|
||||
});
|
||||
|
||||
return { receiptUrl: url };
|
||||
}
|
||||
|
||||
@@ -234,6 +205,17 @@ export class PaymentsService {
|
||||
.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,
|
||||
|
||||
55
apps/api/src/payments/stripe/stripe.controller.ts
Normal file
55
apps/api/src/payments/stripe/stripe.controller.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Headers,
|
||||
Post,
|
||||
RawBodyRequest,
|
||||
Req,
|
||||
} from "@nestjs/common";
|
||||
import type { Request } from "express";
|
||||
import { CurrentUser } from "../../common/decorators/current-user.decorator";
|
||||
import { Public } from "../../common/decorators/public.decorator";
|
||||
import { StripeService } from "./stripe.service";
|
||||
|
||||
interface AuthedUser {
|
||||
id: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
@Controller("payments/stripe")
|
||||
export class StripeController {
|
||||
constructor(private stripeService: StripeService) {}
|
||||
|
||||
@Post("checkout")
|
||||
async createCheckout(
|
||||
@CurrentUser() user: AuthedUser,
|
||||
@Body() body: { planKey: string; billingPeriod: "monthly" | "yearly"; brandIds: string[] },
|
||||
) {
|
||||
if (!body?.planKey || !body?.billingPeriod) {
|
||||
throw new BadRequestException("planKey ve billingPeriod zorunlu");
|
||||
}
|
||||
return this.stripeService.createCheckoutSession(
|
||||
user.id,
|
||||
user.email,
|
||||
body.planKey,
|
||||
body.billingPeriod,
|
||||
body.brandIds ?? [],
|
||||
);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post("webhook")
|
||||
async webhook(
|
||||
@Req() req: RawBodyRequest<Request>,
|
||||
@Headers("stripe-signature") signature: string,
|
||||
) {
|
||||
if (!signature) {
|
||||
throw new BadRequestException("Missing stripe-signature header");
|
||||
}
|
||||
if (!req.rawBody) {
|
||||
throw new BadRequestException("Missing raw body — server not configured with rawBody:true");
|
||||
}
|
||||
return this.stripeService.handleWebhook(req.rawBody, signature);
|
||||
}
|
||||
}
|
||||
12
apps/api/src/payments/stripe/stripe.module.ts
Normal file
12
apps/api/src/payments/stripe/stripe.module.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { SubscriptionsModule } from "../../subscriptions/subscriptions.module";
|
||||
import { StripeController } from "./stripe.controller";
|
||||
import { StripeService } from "./stripe.service";
|
||||
|
||||
@Module({
|
||||
imports: [SubscriptionsModule],
|
||||
controllers: [StripeController],
|
||||
providers: [StripeService],
|
||||
exports: [StripeService],
|
||||
})
|
||||
export class StripeModule {}
|
||||
286
apps/api/src/payments/stripe/stripe.service.ts
Normal file
286
apps/api/src/payments/stripe/stripe.service.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
ServiceUnavailableException,
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import Stripe from "stripe";
|
||||
import { DATABASE, type Database } from "../../database/database.provider";
|
||||
|
||||
// Stripe v22 hides its namespace types behind `export = StripeConstructor`, so we
|
||||
// reach the canonical Stripe namespace through the internal core .d.ts path.
|
||||
// This is purely a type-time import — no runtime effect.
|
||||
type StripeNs = import("stripe/cjs/stripe.core").Stripe;
|
||||
type StripeEvent = import("stripe/cjs/stripe.core").Stripe.Event;
|
||||
type CheckoutSession = import("stripe/cjs/stripe.core").Stripe.Checkout.Session;
|
||||
import { payments, plans, userSubscriptions } from "../../database/schema/core";
|
||||
import { PostHogService } from "../../posthog/posthog.service";
|
||||
import { SubscriptionsService } from "../../subscriptions/subscriptions.service";
|
||||
|
||||
const PLAN_KEY_TO_BRAND_COUNT: Record<string, number> = {
|
||||
brand1: 1,
|
||||
brand2: 2,
|
||||
brand3: 3,
|
||||
full: 0,
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class StripeService {
|
||||
private readonly logger = new Logger(StripeService.name);
|
||||
private readonly stripe: StripeNs | null;
|
||||
private readonly webhookSecret: string | undefined;
|
||||
private readonly successUrl: string;
|
||||
private readonly cancelUrl: string;
|
||||
|
||||
constructor(
|
||||
@Inject(DATABASE) private db: Database,
|
||||
private configService: ConfigService,
|
||||
private subscriptionsService: SubscriptionsService,
|
||||
private posthog: PostHogService,
|
||||
) {
|
||||
const secretKey = this.configService.get<string>("stripe.secretKey");
|
||||
this.webhookSecret = this.configService.get<string>("stripe.webhookSecret");
|
||||
this.successUrl =
|
||||
this.configService.get<string>("stripe.successUrl") ??
|
||||
"http://localhost:3000/dashboard/subscription?stripe=success";
|
||||
this.cancelUrl =
|
||||
this.configService.get<string>("stripe.cancelUrl") ??
|
||||
"http://localhost:3000/dashboard/subscription?stripe=cancelled";
|
||||
|
||||
if (secretKey) {
|
||||
this.stripe = new Stripe(secretKey, {
|
||||
apiVersion: "2026-04-22.dahlia",
|
||||
});
|
||||
this.logger.log("Stripe client initialized");
|
||||
} else {
|
||||
this.stripe = null;
|
||||
this.logger.warn("STRIPE_SECRET_KEY not set — Stripe payments disabled");
|
||||
}
|
||||
}
|
||||
|
||||
isEnabled(): boolean {
|
||||
return this.stripe !== null;
|
||||
}
|
||||
|
||||
async createCheckoutSession(
|
||||
userId: string,
|
||||
userEmail: string,
|
||||
planKey: string,
|
||||
billingPeriod: "monthly" | "yearly",
|
||||
brandIds: string[],
|
||||
) {
|
||||
if (!this.stripe) {
|
||||
throw new ServiceUnavailableException("Stripe ödeme şu an kullanılamıyor");
|
||||
}
|
||||
|
||||
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ı");
|
||||
|
||||
const subscription = await this.subscriptionsService.create(userId, {
|
||||
planId: plan.id,
|
||||
brandIds,
|
||||
billingPeriod,
|
||||
});
|
||||
|
||||
if (brandIds.length > 0 && planKey !== "full") {
|
||||
await this.subscriptionsService.addBrandsToSubscription(subscription.id, userId, brandIds);
|
||||
}
|
||||
|
||||
const amount = billingPeriod === "yearly" ? plan.priceYearly : plan.priceMonthly;
|
||||
const productName = `${plan.name} (${billingPeriod === "yearly" ? "Yıllık" : "Aylık"})`;
|
||||
|
||||
const [payment] = await this.db
|
||||
.insert(payments)
|
||||
.values({
|
||||
userId,
|
||||
subscriptionId: subscription.id,
|
||||
amount,
|
||||
currency: "TRY",
|
||||
method: "stripe",
|
||||
status: "pending",
|
||||
})
|
||||
.returning();
|
||||
|
||||
const session = await this.stripe.checkout.sessions.create({
|
||||
mode: "payment",
|
||||
payment_method_types: ["card"],
|
||||
customer_email: userEmail,
|
||||
line_items: [
|
||||
{
|
||||
price_data: {
|
||||
currency: "try",
|
||||
product_data: { name: productName },
|
||||
unit_amount: amount, // already in kuruş (smallest unit)
|
||||
},
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
success_url: `${this.successUrl}&session_id={CHECKOUT_SESSION_ID}`,
|
||||
cancel_url: this.cancelUrl,
|
||||
client_reference_id: payment.id,
|
||||
metadata: {
|
||||
payment_id: payment.id,
|
||||
subscription_id: subscription.id,
|
||||
user_id: userId,
|
||||
plan_key: planKey,
|
||||
billing_period: billingPeriod,
|
||||
},
|
||||
});
|
||||
|
||||
await this.db
|
||||
.update(payments)
|
||||
.set({ stripeSessionId: session.id, updatedAt: new Date() })
|
||||
.where(eq(payments.id, payment.id));
|
||||
|
||||
this.posthog.captureForUser(userId, "payment_initiated", {
|
||||
method: "stripe",
|
||||
payment_id: payment.id,
|
||||
plan: planKey,
|
||||
period: billingPeriod,
|
||||
amount,
|
||||
});
|
||||
|
||||
return {
|
||||
paymentId: payment.id,
|
||||
sessionId: session.id,
|
||||
redirectUrl: session.url ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
async handleWebhook(rawBody: Buffer, signature: string) {
|
||||
if (!this.stripe || !this.webhookSecret) {
|
||||
throw new ServiceUnavailableException("Stripe webhook not configured");
|
||||
}
|
||||
|
||||
let event: StripeEvent;
|
||||
try {
|
||||
event = this.stripe.webhooks.constructEvent(
|
||||
rawBody,
|
||||
signature,
|
||||
this.webhookSecret,
|
||||
) as StripeEvent;
|
||||
} catch (err) {
|
||||
this.logger.warn(`Stripe webhook signature verification failed: ${(err as Error).message}`);
|
||||
throw new BadRequestException("Invalid signature");
|
||||
}
|
||||
|
||||
this.logger.log(`Stripe webhook received: ${event.type} (${event.id})`);
|
||||
|
||||
switch (event.type) {
|
||||
case "checkout.session.completed": {
|
||||
const session = event.data.object as CheckoutSession;
|
||||
await this.handleCheckoutCompleted(session);
|
||||
break;
|
||||
}
|
||||
case "checkout.session.async_payment_failed":
|
||||
case "checkout.session.expired": {
|
||||
const session = event.data.object as CheckoutSession;
|
||||
await this.handleCheckoutFailed(session, event.type);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
this.logger.debug(`Unhandled Stripe event type: ${event.type}`);
|
||||
}
|
||||
|
||||
return { received: true };
|
||||
}
|
||||
|
||||
private async handleCheckoutCompleted(session: CheckoutSession) {
|
||||
const paymentId = session.client_reference_id ?? session.metadata?.payment_id;
|
||||
if (!paymentId) {
|
||||
this.logger.warn(`checkout.session.completed missing payment_id (session ${session.id})`);
|
||||
return;
|
||||
}
|
||||
|
||||
const [payment] = await this.db
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq(payments.id, paymentId))
|
||||
.limit(1);
|
||||
|
||||
if (!payment) {
|
||||
this.logger.warn(`Payment ${paymentId} not found for completed session ${session.id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (payment.status === "completed") {
|
||||
this.logger.debug(`Payment ${paymentId} already completed — skipping`);
|
||||
return;
|
||||
}
|
||||
|
||||
const paymentIntentId =
|
||||
typeof session.payment_intent === "string" ? session.payment_intent : null;
|
||||
|
||||
await this.db
|
||||
.update(payments)
|
||||
.set({
|
||||
status: "completed",
|
||||
stripePaymentIntentId: paymentIntentId,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(payments.id, paymentId));
|
||||
|
||||
await this.subscriptionsService.activateSubscription(payment.subscriptionId);
|
||||
|
||||
this.posthog.captureForUser(payment.userId, "payment_success", {
|
||||
method: "stripe",
|
||||
payment_id: paymentId,
|
||||
subscription_id: payment.subscriptionId,
|
||||
amount: Number(payment.amount),
|
||||
stripe_session_id: session.id,
|
||||
stripe_payment_intent_id: paymentIntentId,
|
||||
});
|
||||
|
||||
this.logger.log(`Subscription ${payment.subscriptionId} activated via Stripe ${session.id}`);
|
||||
}
|
||||
|
||||
private async handleCheckoutFailed(session: CheckoutSession, reason: string) {
|
||||
const paymentId = session.client_reference_id ?? session.metadata?.payment_id;
|
||||
if (!paymentId) return;
|
||||
|
||||
const [payment] = await this.db
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq(payments.id, paymentId))
|
||||
.limit(1);
|
||||
|
||||
if (!payment || payment.status !== "pending") return;
|
||||
|
||||
await this.db
|
||||
.update(payments)
|
||||
.set({ status: "failed", adminNote: reason, updatedAt: new Date() })
|
||||
.where(eq(payments.id, paymentId));
|
||||
|
||||
// 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: "stripe",
|
||||
payment_id: paymentId,
|
||||
subscription_id: payment.subscriptionId,
|
||||
reason,
|
||||
});
|
||||
}
|
||||
}
|
||||
460
apps/web/src/components/payment/bank-transfer-card.tsx
Normal file
460
apps/web/src/components/payment/bank-transfer-card.tsx
Normal file
@@ -0,0 +1,460 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,560 +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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@sase/ui";
|
||||
import { Badge } from "@sase/ui";
|
||||
import { Button } from "@sase/ui";
|
||||
import { Skeleton } from "@sase/ui";
|
||||
import { Separator } from "@sase/ui";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@sase/ui";
|
||||
import { Label } from "@sase/ui";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Building2,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Copy,
|
||||
CreditCard,
|
||||
FileText,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
interface Brand {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
logoUrl?: string;
|
||||
}
|
||||
|
||||
interface PaymentContentProps {
|
||||
planKey: string;
|
||||
period: "monthly" | "yearly";
|
||||
brandIds: string[];
|
||||
result?: string;
|
||||
}
|
||||
|
||||
const planConfig: Record<string, { priceMonthly: number; priceYearly: number }> = {
|
||||
brand1: { priceMonthly: 20000, priceYearly: 200000 },
|
||||
brand2: { priceMonthly: 35000, priceYearly: 350000 },
|
||||
brand3: { priceMonthly: 50000, priceYearly: 500000 },
|
||||
full: { priceMonthly: 99900, priceYearly: 999000 },
|
||||
};
|
||||
|
||||
const bankDetails = {
|
||||
bankName: "Ziraat Bankası",
|
||||
accountHolder: "Sase Teknoloji A.Ş.",
|
||||
iban: "TR33 0001 0000 1234 5678 9012 34",
|
||||
description: "Sase.tr Abonelik",
|
||||
};
|
||||
|
||||
type Step = "summary" | "payment" | "confirmation";
|
||||
|
||||
export function PaymentContent({ planKey, period, brandIds, result }: PaymentContentProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [step, setStep] = useState<Step>("summary");
|
||||
const [paymentMethod, setPaymentMethod] = useState<"iyzico" | "eft">("iyzico");
|
||||
const [uploadedFile, setUploadedFile] = useState<File | null>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [eftPaymentId, setEftPaymentId] = useState<string | null>(null);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: only re-run when result changes
|
||||
useEffect(() => {
|
||||
if (!result) return;
|
||||
if (result === "success") {
|
||||
capture("payment_success", { method: "iyzico", plan: planKey, period, amount: totalAmount });
|
||||
setStep("confirmation");
|
||||
} else {
|
||||
capture("payment_failed", { method: "iyzico", plan: planKey, period, reason: result });
|
||||
toast.error(t("payment.paymentFailed"));
|
||||
}
|
||||
}, [result]);
|
||||
|
||||
const config = planConfig[planKey];
|
||||
const totalAmount = config
|
||||
? period === "monthly"
|
||||
? config.priceMonthly
|
||||
: config.priceYearly
|
||||
: 0;
|
||||
|
||||
const { data: brands } = useQuery({
|
||||
queryKey: ["brands"],
|
||||
queryFn: () => api.get<Brand[]>("/brands"),
|
||||
});
|
||||
|
||||
const selectedBrands = brands?.filter((b) => brandIds.includes(b.id)) || [];
|
||||
|
||||
const iyzicoMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
api.post<{ redirectUrl: string }>("/payments/iyzico/initialize", {
|
||||
planKey,
|
||||
billingPeriod: period,
|
||||
brandIds,
|
||||
}),
|
||||
onSuccess: (data) => {
|
||||
if (data.redirectUrl) {
|
||||
window.location.href = data.redirectUrl;
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t("payment.initializeFailed"));
|
||||
},
|
||||
});
|
||||
|
||||
const eftMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
api.post<{ paymentId: string }>("/payments/eft", {
|
||||
planKey,
|
||||
billingPeriod: period,
|
||||
brandIds,
|
||||
}),
|
||||
onSuccess: (data) => {
|
||||
setEftPaymentId(data.paymentId);
|
||||
toast.success(t("payment.processingPayment"));
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t("errors.generic"));
|
||||
},
|
||||
});
|
||||
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: (file: File) => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
return api.upload<{ success: boolean }>(`/payments/eft/${eftPaymentId}/receipt`, formData);
|
||||
},
|
||||
onSuccess: () => {
|
||||
capture("payment_success", {
|
||||
method: "eft",
|
||||
plan: planKey,
|
||||
period,
|
||||
amount: totalAmount,
|
||||
payment_id: eftPaymentId,
|
||||
});
|
||||
toast.success(t("payment.receiptUploaded"));
|
||||
setStep("confirmation");
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t("payment.uploadFailed"));
|
||||
},
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
}, []);
|
||||
|
||||
function validateAndSetFile(file: File) {
|
||||
const validTypes = ["image/png", "image/jpeg", "application/pdf"];
|
||||
if (!validTypes.includes(file.type)) {
|
||||
toast.error(t("errors.invalidFileType"));
|
||||
return;
|
||||
}
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
toast.error(t("errors.fileTooBig"));
|
||||
return;
|
||||
}
|
||||
setUploadedFile(file);
|
||||
}
|
||||
|
||||
function handleFileSelect(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
validateAndSetFile(file);
|
||||
}
|
||||
}
|
||||
|
||||
function copyToClipboard(text: string) {
|
||||
navigator.clipboard.writeText(text);
|
||||
toast.success(t("common.copied"));
|
||||
}
|
||||
|
||||
function handlePayWithCard() {
|
||||
startAction("payment-iyzico", { plan: planKey, period, amount: String(totalAmount) });
|
||||
capture("payment_initiated", { method: "iyzico", plan: planKey, period, amount: totalAmount });
|
||||
iyzicoMutation.mutate();
|
||||
}
|
||||
|
||||
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) {
|
||||
startAction("receipt-upload", { paymentId: eftPaymentId || "" });
|
||||
capture("receipt_uploaded", { payment_id: eftPaymentId });
|
||||
uploadMutation.mutate(uploadedFile);
|
||||
}
|
||||
}
|
||||
|
||||
if (!planKey || !config) {
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl py-12 text-center">
|
||||
<p className="text-muted-foreground">{t("common.noData")}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mt-4"
|
||||
onClick={() => navigate({ to: "/dashboard/subscription" })}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
{t("common.back")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Step 3: Confirmation
|
||||
if (step === "confirmation") {
|
||||
return (
|
||||
<div className="mx-auto max-w-lg space-y-6">
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center">
|
||||
<CheckCircle2 className="mx-auto mb-4 h-16 w-16 text-green-500" />
|
||||
<h2 className="mb-2 text-2xl font-bold">{t("payment.confirmation")}</h2>
|
||||
<p className="text-muted-foreground">
|
||||
{paymentMethod === "iyzico"
|
||||
? t("payment.confirmationDescription")
|
||||
: t("payment.eftConfirmationDescription")}
|
||||
</p>
|
||||
<Button className="mt-6" onClick={() => navigate({ to: "/dashboard/search" })}>
|
||||
{t("payment.goToDashboard")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl space-y-6">
|
||||
{/* Back Button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
step === "payment" ? setStep("summary") : navigate({ to: "/dashboard/subscription" })
|
||||
}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
{t("common.back")}
|
||||
</Button>
|
||||
|
||||
{/* Steps Indicator */}
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
{[
|
||||
{ key: "summary", label: t("payment.step1") },
|
||||
{ key: "payment", label: t("payment.step2") },
|
||||
{ key: "confirmation", label: t("payment.step3") },
|
||||
].map((s, i) => (
|
||||
<div key={s.key} className="flex items-center gap-2">
|
||||
<div
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-full text-sm font-medium ${
|
||||
s.key === step || (step === "payment" && i === 0)
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{i + 1}
|
||||
</div>
|
||||
<span className="hidden text-sm sm:inline">{s.label}</span>
|
||||
{i < 2 && <div className="h-px w-8 bg-border" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Step 1: Summary */}
|
||||
{step === "summary" && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("payment.summary")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">{t("payment.selectedPlan")}</span>
|
||||
<span className="font-medium">
|
||||
{t(`subscription.plans.${planKey}.name`)} (
|
||||
{period === "monthly" ? t("common.monthly") : t("common.yearly")})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<span className="mb-2 block text-sm text-muted-foreground">
|
||||
{t("payment.selectedBrands")}
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{planKey === "full" ? (
|
||||
<Badge>{t("subscription.allBrandsSelected")}</Badge>
|
||||
) : selectedBrands.length > 0 ? (
|
||||
selectedBrands.map((brand) => (
|
||||
<Badge key={brand.id} variant="outline">
|
||||
{brand.name}
|
||||
</Badge>
|
||||
))
|
||||
) : (
|
||||
brandIds.map((id) => (
|
||||
<Badge key={id} variant="outline">
|
||||
{id}
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-lg font-semibold">{t("payment.totalAmount")}</span>
|
||||
<span className="text-2xl font-bold text-primary">{formatTRY(totalAmount)}</span>
|
||||
</div>
|
||||
|
||||
<Button className="w-full" size="lg" onClick={() => setStep("payment")}>
|
||||
{t("common.next")} →
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Step 2: Payment */}
|
||||
{step === "payment" && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("payment.paymentMethod")}</CardTitle>
|
||||
<CardDescription>
|
||||
{t("payment.totalAmount")}: {formatTRY(totalAmount)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Tabs
|
||||
defaultValue="iyzico"
|
||||
onValueChange={(v) => setPaymentMethod(v as "iyzico" | "eft")}
|
||||
>
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger
|
||||
value="iyzico"
|
||||
data-faro-user-action-name="payment-tab-card"
|
||||
className="flex-1"
|
||||
>
|
||||
<CreditCard className="mr-2 h-4 w-4" />
|
||||
{t("payment.creditCard")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="eft"
|
||||
data-faro-user-action-name="payment-tab-eft"
|
||||
className="flex-1"
|
||||
>
|
||||
<Building2 className="mr-2 h-4 w-4" />
|
||||
{t("payment.eftTransfer")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* iyzico Credit Card Tab */}
|
||||
<TabsContent value="iyzico" className="space-y-4 pt-4">
|
||||
<div className="rounded-lg border bg-muted/30 p-4">
|
||||
<p className="text-sm text-muted-foreground">{t("payment.iyzicoTrustNotice")}</p>
|
||||
</div>
|
||||
<Button
|
||||
className="w-full"
|
||||
size="lg"
|
||||
onClick={handlePayWithCard}
|
||||
disabled={iyzicoMutation.isPending}
|
||||
>
|
||||
{iyzicoMutation.isPending ? (
|
||||
<>
|
||||
<Clock className="mr-2 h-4 w-4 animate-spin" />
|
||||
{t("payment.paying")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CreditCard className="mr-2 h-4 w-4" />
|
||||
{t("payment.payWithCard")} - {formatTRY(totalAmount)}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</TabsContent>
|
||||
|
||||
{/* EFT/Havale Tab */}
|
||||
<TabsContent value="eft" className="space-y-6 pt-4">
|
||||
{/* Bank Account Details */}
|
||||
<div className="space-y-3 rounded-lg border p-4">
|
||||
<h4 className="font-semibold">{t("payment.bankDetails")}</h4>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-muted-foreground">{t("payment.bankName")}</Label>
|
||||
<p className="font-medium">{bankDetails.bankName}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-muted-foreground">
|
||||
{t("payment.accountHolder")}
|
||||
</Label>
|
||||
<p className="font-medium">{bankDetails.accountHolder}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<Label className="text-muted-foreground">{t("payment.iban")}</Label>
|
||||
<p className="font-mono font-medium">{bankDetails.iban}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => copyToClipboard(bankDetails.iban.replace(/\s/g, ""))}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-muted-foreground">{t("payment.description")}</Label>
|
||||
<p className="font-medium">{bankDetails.description}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-muted-foreground">{t("payment.totalAmount")}</Label>
|
||||
<p className="text-lg font-bold text-primary">{formatTRY(totalAmount)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* EFT Initiate */}
|
||||
{!eftPaymentId && (
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={handleEftProceed}
|
||||
disabled={eftMutation.isPending}
|
||||
>
|
||||
{eftMutation.isPending ? t("payment.processingPayment") : t("payment.eftPaid")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Receipt Upload */}
|
||||
{eftPaymentId && (
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-semibold">{t("payment.uploadReceipt")}</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("payment.uploadReceiptDescription")}
|
||||
</p>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`flex w-full cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed p-8 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-2">
|
||||
<FileText className="h-8 w-8 text-primary" />
|
||||
<div>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Payment Status Tracker */}
|
||||
<div className="rounded-lg border p-4">
|
||||
<h5 className="mb-3 text-sm font-medium">{t("payment.paymentStatus")}</h5>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<CheckCircle2 className="h-5 w-5 text-green-500" />
|
||||
<span className="text-sm">{t("payment.eftStatus.created")}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{uploadedFile ? (
|
||||
<CheckCircle2 className="h-5 w-5 text-green-500" />
|
||||
) : (
|
||||
<Clock className="h-5 w-5 text-muted-foreground" />
|
||||
)}
|
||||
<span className="text-sm">{t("payment.eftStatus.receiptUploaded")}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Clock className="h-5 w-5 text-muted-foreground" />
|
||||
<span className="text-sm">{t("payment.waitingApproval")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
97
apps/web/src/components/payment/stripe-checkout-button.tsx
Normal file
97
apps/web/src/components/payment/stripe-checkout-button.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
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 { Button } from "@sase/ui";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { Clock, CreditCard, Lock } from "lucide-react";
|
||||
|
||||
interface StripeCheckoutButtonProps {
|
||||
planKey: string;
|
||||
period: "monthly" | "yearly";
|
||||
brandIds: string[];
|
||||
totalAmount: number;
|
||||
}
|
||||
|
||||
interface StripeCheckoutResponse {
|
||||
paymentId: string;
|
||||
sessionId: string;
|
||||
redirectUrl: string;
|
||||
}
|
||||
|
||||
export function StripeCheckoutButton({
|
||||
planKey,
|
||||
period,
|
||||
brandIds,
|
||||
totalAmount,
|
||||
}: StripeCheckoutButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () =>
|
||||
api.post<StripeCheckoutResponse>("/payments/stripe/checkout", {
|
||||
planKey,
|
||||
billingPeriod: period,
|
||||
brandIds,
|
||||
}),
|
||||
onSuccess: (data) => {
|
||||
if (data.redirectUrl) {
|
||||
window.location.href = data.redirectUrl;
|
||||
} else {
|
||||
toast.error(t("payment.initializeFailed"));
|
||||
}
|
||||
},
|
||||
onError: () => toast.error(t("payment.initializeFailed")),
|
||||
});
|
||||
|
||||
function handleClick() {
|
||||
startAction("payment-stripe", { plan: planKey, period, amount: String(totalAmount) });
|
||||
capture("payment_initiated", {
|
||||
method: "stripe",
|
||||
plan: planKey,
|
||||
period,
|
||||
amount: totalAmount,
|
||||
});
|
||||
mutation.mutate();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-2xl border border-border bg-gradient-to-br from-background via-background to-primary/[0.04] 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">
|
||||
<Lock className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold">{t("payment.stripe.secureCheckoutTitle")}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("payment.stripe.secureCheckoutDescription")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="lg"
|
||||
className="w-full"
|
||||
onClick={handleClick}
|
||||
disabled={mutation.isPending}
|
||||
data-faro-user-action-name="stripe-checkout"
|
||||
>
|
||||
{mutation.isPending ? (
|
||||
<>
|
||||
<Clock className="mr-2 h-4 w-4 animate-spin" />
|
||||
{t("payment.paying")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CreditCard className="mr-2 h-4 w-4" />
|
||||
{t("payment.stripe.payButton", { amount: formatTRY(totalAmount) })}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -39,10 +39,7 @@ export function TrialUrgencyBanner() {
|
||||
const endDate = subscription?.endDate;
|
||||
const days =
|
||||
subscription?.status === "trial" && endDate
|
||||
? Math.max(
|
||||
0,
|
||||
Math.ceil((new Date(endDate).getTime() - Date.now()) / (1000 * 60 * 60 * 24)),
|
||||
)
|
||||
? Math.max(0, Math.ceil((new Date(endDate).getTime() - Date.now()) / (1000 * 60 * 60 * 24)))
|
||||
: null;
|
||||
const visible = !isLoading && days !== null && days <= 7;
|
||||
const dismissKey = endDate ? getDismissKey(endDate) : null;
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"confirm": "Confirm",
|
||||
"delete": "Delete",
|
||||
"edit": "Edit",
|
||||
"change": "Change",
|
||||
"close": "Close",
|
||||
"back": "Back",
|
||||
"next": "Next",
|
||||
@@ -122,6 +123,35 @@
|
||||
},
|
||||
"subscription": {
|
||||
"title": "Subscription",
|
||||
"subtitle": "Pick a plan, choose your brands, complete payment — all on one page.",
|
||||
"step": "Step",
|
||||
"selected": "Selected",
|
||||
"tapToSelect": "Select",
|
||||
"selectExactBrands": "Please select exactly {count} brands.",
|
||||
"steps": {
|
||||
"plan": {
|
||||
"title": "Choose a plan",
|
||||
"short": "Plan",
|
||||
"description": "Switch between monthly and yearly."
|
||||
},
|
||||
"brands": {
|
||||
"title": "Choose your brands",
|
||||
"short": "Brands",
|
||||
"description": "Select {count} brands to include in your plan."
|
||||
},
|
||||
"payment": {
|
||||
"title": "Payment method",
|
||||
"short": "Payment"
|
||||
},
|
||||
"confirmation": {
|
||||
"short": "Done"
|
||||
}
|
||||
},
|
||||
"stickyCta": {
|
||||
"toBrands": "Choose brands",
|
||||
"toPayment": "Go to payment",
|
||||
"selectBrands": "Select {count} more brand(s)"
|
||||
},
|
||||
"noSubscription": "You don't have an active subscription.",
|
||||
"choosePlan": "Choose Plan",
|
||||
"subscribe": "Subscribe",
|
||||
@@ -219,7 +249,7 @@
|
||||
"trustCancelAnytime": "Cancel anytime",
|
||||
"trustRefund": "7-day refund guarantee",
|
||||
"paymentTrustSSL": "256-bit SSL",
|
||||
"paymentTrustProvider": "Iyzico infrastructure",
|
||||
"paymentTrustProvider": "Stripe infrastructure",
|
||||
"paymentTrustKVKK": "KVKK compliant",
|
||||
"trialDaysLeft": "{days} days left",
|
||||
"trialBanner": {
|
||||
@@ -235,7 +265,7 @@
|
||||
"selectedBrands": "Selected Brands",
|
||||
"totalAmount": "Total Amount",
|
||||
"paymentMethod": "Payment Method",
|
||||
"creditCard": "Credit Card (iyzico)",
|
||||
"creditCard": "Credit Card",
|
||||
"eftTransfer": "EFT/Wire Transfer",
|
||||
"payWithCard": "Pay with Card",
|
||||
"paying": "Processing payment...",
|
||||
@@ -264,11 +294,45 @@
|
||||
"goToDashboard": "Go to Dashboard",
|
||||
"initializeFailed": "Payment initialization failed. Please try again.",
|
||||
"processingPayment": "Processing payment...",
|
||||
"iyzicoTrustNotice": "Secure payment with 3D Secure. Powered by iyzico. You will be redirected to the iyzico payment page when you click the button.",
|
||||
"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",
|
||||
"secureCheckoutDescription": "Card details are processed on Stripe's PCI-DSS Level 1 infrastructure. Sase never sees them.",
|
||||
"payButton": "Pay {amount}",
|
||||
"cancelled": "Payment cancelled. You can try again.",
|
||||
"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": {
|
||||
@@ -291,6 +355,7 @@
|
||||
},
|
||||
"methodLabels": {
|
||||
"iyzico": "Credit Card",
|
||||
"stripe": "Credit Card",
|
||||
"eft": "EFT/Wire"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"confirm": "Onayla",
|
||||
"delete": "Sil",
|
||||
"edit": "Düzenle",
|
||||
"change": "Değiştir",
|
||||
"close": "Kapat",
|
||||
"back": "Geri",
|
||||
"next": "İleri",
|
||||
@@ -122,6 +123,35 @@
|
||||
},
|
||||
"subscription": {
|
||||
"title": "Abonelik",
|
||||
"subtitle": "Planını seç, markalarını belirle, ödemeni yap. Hepsi tek sayfada.",
|
||||
"step": "Adım",
|
||||
"selected": "Seçildi",
|
||||
"tapToSelect": "Seç",
|
||||
"selectExactBrands": "Lütfen tam olarak {count} marka seçin.",
|
||||
"steps": {
|
||||
"plan": {
|
||||
"title": "Plan seç",
|
||||
"short": "Plan",
|
||||
"description": "Aylık veya yıllık dilim arasında geç."
|
||||
},
|
||||
"brands": {
|
||||
"title": "Markaları seç",
|
||||
"short": "Markalar",
|
||||
"description": "Erişim isteyeceğin {count} marka seçmelisin."
|
||||
},
|
||||
"payment": {
|
||||
"title": "Ödeme yöntemi",
|
||||
"short": "Ödeme"
|
||||
},
|
||||
"confirmation": {
|
||||
"short": "Onay"
|
||||
}
|
||||
},
|
||||
"stickyCta": {
|
||||
"toBrands": "Markaları seç",
|
||||
"toPayment": "Ödeme yöntemine geç",
|
||||
"selectBrands": "{count} marka daha seç"
|
||||
},
|
||||
"noSubscription": "Aktif aboneliğiniz yok.",
|
||||
"choosePlan": "Plan Seç",
|
||||
"subscribe": "Abone Ol",
|
||||
@@ -219,7 +249,7 @@
|
||||
"trustCancelAnytime": "İstediğin zaman iptal",
|
||||
"trustRefund": "7 gün iade garantisi",
|
||||
"paymentTrustSSL": "256-bit SSL",
|
||||
"paymentTrustProvider": "Iyzico altyapısı",
|
||||
"paymentTrustProvider": "Stripe altyapısı",
|
||||
"paymentTrustKVKK": "KVKK uyumlu",
|
||||
"trialDaysLeft": "{days} gün kaldı",
|
||||
"trialBanner": {
|
||||
@@ -235,7 +265,7 @@
|
||||
"selectedBrands": "Seçilen Markalar",
|
||||
"totalAmount": "Toplam Tutar",
|
||||
"paymentMethod": "Ödeme Yöntemi",
|
||||
"creditCard": "Kredi Kartı (iyzico)",
|
||||
"creditCard": "Kredi Kartı",
|
||||
"eftTransfer": "EFT/Havale",
|
||||
"payWithCard": "Kartla Öde",
|
||||
"paying": "Ödeme yapılıyor...",
|
||||
@@ -264,11 +294,45 @@
|
||||
"goToDashboard": "Panele Git",
|
||||
"initializeFailed": "Ödeme başlatılamadı. Lütfen tekrar deneyin.",
|
||||
"processingPayment": "Ödeme işleniyor...",
|
||||
"iyzicoTrustNotice": "3D Secure ile güvenli ödeme. iyzico altyapısı kullanılmaktadır. Butona tıkladığınızda iyzico ödeme sayfasına yönlendirileceksiniz.",
|
||||
"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",
|
||||
"secureCheckoutDescription": "Kart bilgilerin Stripe'ın PCI-DSS Seviye 1 altyapısında işlenir. Sase bu bilgileri görmez.",
|
||||
"payButton": "{amount} ile öde",
|
||||
"cancelled": "Ödeme iptal edildi. Tekrar deneyebilirsin.",
|
||||
"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": {
|
||||
@@ -291,6 +355,7 @@
|
||||
},
|
||||
"methodLabels": {
|
||||
"iyzico": "Kredi Kartı",
|
||||
"stripe": "Kredi Kartı",
|
||||
"eft": "EFT/Havale"
|
||||
}
|
||||
},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,982 +0,0 @@
|
||||
/**
|
||||
* Regression tests for /dashboard/subscription page.
|
||||
*
|
||||
* Validates P0-1 through P0-10 CRO fixes and prevents regressions.
|
||||
* Tests cover rendering, interaction, trust/i18n, edge cases, and accessibility.
|
||||
*/
|
||||
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// ─── Pre-declare mock variables via vi.hoisted() ─────────────────────────────
|
||||
const {
|
||||
mockNavigate,
|
||||
mockCapture,
|
||||
mockSetPeopleProperties,
|
||||
mockStartAction,
|
||||
mockToast,
|
||||
mockInvalidateQueries,
|
||||
mockUseQuery,
|
||||
mockUseMutation,
|
||||
mockAuthUser,
|
||||
} = vi.hoisted(() => ({
|
||||
mockNavigate: vi.fn(),
|
||||
mockCapture: vi.fn(),
|
||||
mockSetPeopleProperties: vi.fn(),
|
||||
mockStartAction: vi.fn(),
|
||||
mockToast: { success: vi.fn(), error: vi.fn() },
|
||||
mockInvalidateQueries: vi.fn(),
|
||||
mockUseQuery: vi.fn(),
|
||||
mockUseMutation: vi.fn(),
|
||||
mockAuthUser: {
|
||||
id: "user-1",
|
||||
name: "Test User",
|
||||
email: "test@sase.tr",
|
||||
image: null,
|
||||
role: "user",
|
||||
referralCode: "TEST123",
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
}));
|
||||
|
||||
// ─── Mock TanStack Router ────────────────────────────────────────────────────
|
||||
vi.mock("@tanstack/react-router", async () => {
|
||||
const actual = await vi.importActual<any>("@tanstack/react-router");
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => mockNavigate,
|
||||
createFileRoute: () => (opts: any) => opts,
|
||||
Link: ({ children, to, ...props }: any) => (
|
||||
<a href={to} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
// ─── Mock TanStack Query ────────────────────────────────────────────────────
|
||||
vi.mock("@tanstack/react-query", async () => {
|
||||
const actual = await vi.importActual<any>("@tanstack/react-query");
|
||||
return {
|
||||
...actual,
|
||||
useQuery: mockUseQuery,
|
||||
useMutation: mockUseMutation,
|
||||
useQueryClient: () => ({ invalidateQueries: mockInvalidateQueries }),
|
||||
};
|
||||
});
|
||||
|
||||
// ─── Mock i18n ───────────────────────────────────────────────────────────────
|
||||
vi.mock("@/lib/i18n", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, _params?: Record<string, unknown>) => key,
|
||||
locale: "tr",
|
||||
setLocale: vi.fn(),
|
||||
}),
|
||||
t: (key: string) => key,
|
||||
initLocale: vi.fn(),
|
||||
useI18nStore: {
|
||||
getState: () => ({ locale: "tr" }),
|
||||
subscribe: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// ─── Mock API client ────────────────────────────────────────────────────────
|
||||
vi.mock("@/lib/api-client", () => ({
|
||||
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), upload: vi.fn() },
|
||||
ApiError: class ApiError extends Error {
|
||||
code?: string;
|
||||
status?: number;
|
||||
constructor(message: string, code?: string, status?: number) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.code = code;
|
||||
this.status = status;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
// ─── Mock PostHog ────────────────────────────────────────────────────────────
|
||||
vi.mock("@/lib/posthog", () => ({
|
||||
capture: mockCapture,
|
||||
setPeopleProperties: mockSetPeopleProperties,
|
||||
}));
|
||||
|
||||
// ─── Mock Faro ──────────────────────────────────────────────────────────────
|
||||
vi.mock("@/lib/faro", () => ({
|
||||
startAction: mockStartAction,
|
||||
pushEvent: vi.fn(),
|
||||
}));
|
||||
|
||||
// ─── Mock auth store ────────────────────────────────────────────────────────
|
||||
vi.mock("@/stores/auth.store", () => {
|
||||
const setUser = vi.fn();
|
||||
return {
|
||||
useAuthStore: Object.assign(
|
||||
vi.fn(() => ({ user: mockAuthUser, isLoading: false, setUser })),
|
||||
{
|
||||
getState: vi.fn(() => ({ user: mockAuthUser, isLoading: false })),
|
||||
subscribe: vi.fn(() => vi.fn()),
|
||||
},
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
// ─── Mock user settings ─────────────────────────────────────────────────────
|
||||
vi.mock("@/lib/user-settings", () => ({
|
||||
getUserSettings: () => ({ theme: "dark" }),
|
||||
setUserSetting: vi.fn(),
|
||||
}));
|
||||
|
||||
// ─── Mock toast ─────────────────────────────────────────────────────────────
|
||||
vi.mock("@/lib/toast", () => ({ toast: mockToast }));
|
||||
|
||||
// ─── Mock canvas-confetti ───────────────────────────────────────────────────
|
||||
vi.mock("canvas-confetti", () => ({ default: vi.fn() }));
|
||||
|
||||
// ─── Mock IntersectionObserver (not available in jsdom) ────────────────────
|
||||
vi.stubGlobal(
|
||||
"IntersectionObserver",
|
||||
vi.fn(() => ({
|
||||
observe: vi.fn(),
|
||||
unobserve: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
})),
|
||||
);
|
||||
|
||||
// ─── Mock BrandSelector ─────────────────────────────────────────────────────
|
||||
vi.mock("@/components/subscription/brand-selector", () => ({
|
||||
BrandSelector: ({
|
||||
maxBrands,
|
||||
selectedBrandIds,
|
||||
onSelectionChange,
|
||||
isFullPlan,
|
||||
}: {
|
||||
maxBrands: number;
|
||||
selectedBrandIds: string[];
|
||||
onSelectionChange: (ids: string[]) => void;
|
||||
isFullPlan: boolean;
|
||||
}) => (
|
||||
<div data-testid="brand-selector">
|
||||
<span data-testid="brand-selector-max">{maxBrands}</span>
|
||||
<span data-testid="brand-selector-full">{String(isFullPlan)}</span>
|
||||
<button
|
||||
data-testid="brand-selector-select"
|
||||
onClick={() => onSelectionChange(["brand-vw", "brand-audi"])}
|
||||
>
|
||||
Select Brands
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// ─── Mock CarBrandLogo ──────────────────────────────────────────────────────
|
||||
vi.mock("@/components/ui/car-brand-logo", () => ({
|
||||
CarBrandLogo: ({ brandName }: any) => (
|
||||
<span data-testid={`brand-logo-${brandName}`}>[{brandName}]</span>
|
||||
),
|
||||
}));
|
||||
|
||||
// ─── Mock BRAND_SKELETON_KEYS ───────────────────────────────────────────────
|
||||
vi.mock("@/lib/keys", () => ({ BRAND_SKELETON_KEYS: ["sk1", "sk2", "sk3", "sk4"] }));
|
||||
|
||||
// ─── Mock Remotion ──────────────────────────────────────────────────────────
|
||||
vi.mock("@remotion/player", () => ({
|
||||
default: ({ style, component: Comp, inputProps }: any) => (
|
||||
<div data-testid="remotion-player" style={style}>
|
||||
<Comp {...inputProps} />
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/remotion/OnboardingProgress", () => ({
|
||||
default: ({ stepLabels }: { isDark: boolean; stepLabels: string[] }) => (
|
||||
<div data-testid="onboarding-progress">{stepLabels?.join(" | ")}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// ─── Mock matchMedia ────────────────────────────────────────────────────────
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query: string) => ({
|
||||
matches: query === "(prefers-color-scheme: dark)",
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Test helpers ───────────────────────────────────────────────────────────
|
||||
import { SubscriptionPage } from "@/routes/dashboard/subscription/index";
|
||||
|
||||
interface SubscriptionBrand {
|
||||
brandId: string;
|
||||
brandName: string;
|
||||
}
|
||||
interface Subscription {
|
||||
status: string;
|
||||
plan?: { name: string; key: string };
|
||||
billingPeriod: string;
|
||||
brands?: SubscriptionBrand[];
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
}
|
||||
|
||||
function renderPage(
|
||||
options: {
|
||||
subscription?: Subscription | null;
|
||||
eligibleForTrial?: boolean;
|
||||
isLoading?: boolean;
|
||||
searchParams?: string;
|
||||
/** Custom mutate function for useMutation (for tests that need to inspect calls) */
|
||||
mutationMutate?: ReturnType<typeof vi.fn>;
|
||||
} = {},
|
||||
) {
|
||||
vi.clearAllMocks();
|
||||
|
||||
if (options.searchParams) {
|
||||
window.history.replaceState({}, "", `http://localhost:3000/?${options.searchParams}`);
|
||||
} else {
|
||||
window.history.replaceState({}, "", "http://localhost:3000/");
|
||||
}
|
||||
|
||||
mockUseQuery.mockImplementation(({ queryKey }: any) => {
|
||||
if (Array.isArray(queryKey) && (queryKey[1] === "me" || queryKey[0] === "subscription")) {
|
||||
return {
|
||||
data: options.isLoading
|
||||
? undefined
|
||||
: {
|
||||
subscription: options.subscription ?? null,
|
||||
eligibleForTrial: options.eligibleForTrial ?? false,
|
||||
},
|
||||
isLoading: options.isLoading ?? false,
|
||||
isError: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
};
|
||||
}
|
||||
return { data: undefined, isLoading: false, isError: false, error: null, refetch: vi.fn() };
|
||||
});
|
||||
|
||||
const mutate = options.mutationMutate || vi.fn();
|
||||
mockUseMutation.mockImplementation(() => ({
|
||||
mutate,
|
||||
isPending: false,
|
||||
isSuccess: false,
|
||||
isError: false,
|
||||
}));
|
||||
|
||||
return { ...render(<SubscriptionPage />), mutate };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
window.history.replaceState({}, "", "http://localhost:3000/");
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// 1. RENDERING REGRESSION TESTS
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe("rendering", () => {
|
||||
describe("plan cards", () => {
|
||||
it("renders all 4 plan cards with correct names and monthly prices", () => {
|
||||
renderPage();
|
||||
// Scope to plan comparison grid to avoid FeatureMatrix table duplicates
|
||||
const planGrid = document.querySelector(".grid.gap-4");
|
||||
expect(planGrid).toBeInTheDocument();
|
||||
const gridScope = within(planGrid as HTMLElement);
|
||||
expect(gridScope.getByText("subscription.plans.brand1.name")).toBeInTheDocument();
|
||||
expect(gridScope.getByText("subscription.plans.brand2.name")).toBeInTheDocument();
|
||||
expect(gridScope.getByText("subscription.plans.brand3.name")).toBeInTheDocument();
|
||||
expect(gridScope.getByText("subscription.plans.full.name")).toBeInTheDocument();
|
||||
expect(gridScope.getByText("₺200,00")).toBeInTheDocument();
|
||||
expect(gridScope.getByText("₺350,00")).toBeInTheDocument();
|
||||
expect(gridScope.getByText("₺500,00")).toBeInTheDocument();
|
||||
expect(gridScope.getByText("₺999,00")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders feature lists for each plan", () => {
|
||||
renderPage();
|
||||
expect(screen.getAllByText("subscription.features.vinSearch").length).toBeGreaterThanOrEqual(
|
||||
1,
|
||||
);
|
||||
expect(
|
||||
screen.getAllByText("subscription.features.partsCatalog").length,
|
||||
).toBeGreaterThanOrEqual(1);
|
||||
expect(
|
||||
screen.getAllByText("subscription.features.schemaViewer").length,
|
||||
).toBeGreaterThanOrEqual(1);
|
||||
expect(
|
||||
screen.getAllByText("subscription.features.prioritySupport").length,
|
||||
).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText("subscription.features.oemSearch").length).toBeGreaterThanOrEqual(
|
||||
1,
|
||||
);
|
||||
expect(screen.getAllByText("subscription.features.allBrands").length).toBeGreaterThanOrEqual(
|
||||
1,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("billing period toggle", () => {
|
||||
it("switches between monthly and yearly pricing", () => {
|
||||
renderPage();
|
||||
expect(screen.getByText("₺200,00")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "common.yearly" }));
|
||||
expect(screen.getByText("₺2.000,00")).toBeInTheDocument();
|
||||
expect(screen.getByText("₺3.500,00")).toBeInTheDocument();
|
||||
expect(screen.getByText("₺5.000,00")).toBeInTheDocument();
|
||||
expect(screen.getByText("₺9.990,00")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows yearly discount badge on yearly selection (P0-1)", () => {
|
||||
renderPage();
|
||||
expect(screen.queryByText("subscription.yearlyDiscount")).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "common.yearly" }));
|
||||
const badges = screen.getAllByText("subscription.yearlyDiscount");
|
||||
expect(badges.length).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("popular plan visual dominance (P0-2)", () => {
|
||||
it("brand2 (popular) card has stronger visual styling", () => {
|
||||
renderPage();
|
||||
// Popular badge only appears on the plan card (FeatureMatrix uses aria-label only)
|
||||
const cardBadge = screen.getByText("subscription.popular");
|
||||
expect(cardBadge.className).toContain("text-xs");
|
||||
// The popular badge is inside a <Badge> element; walk up to the Card
|
||||
const popularCard = cardBadge.closest('[class*="rounded-xl"]');
|
||||
expect(popularCard).toBeTruthy();
|
||||
expect(popularCard?.className).toContain("shadow-brand");
|
||||
});
|
||||
|
||||
it("only one plan card has popular badge", () => {
|
||||
renderPage();
|
||||
// Only plan card renders the popular badge (FeatureMatrix uses aria-label only)
|
||||
expect(screen.getAllByText("subscription.popular").length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("current plan badge (P0-5)", () => {
|
||||
it("shows Mevcut Plan badge on current active plan card", () => {
|
||||
renderPage({
|
||||
subscription: {
|
||||
status: "active",
|
||||
plan: { name: "1 Marka", key: "brand1" },
|
||||
billingPeriod: "monthly",
|
||||
brands: [{ brandId: "vw", brandName: "Volkswagen" }],
|
||||
startDate: "2025-01-01",
|
||||
endDate: "2026-01-01",
|
||||
},
|
||||
eligibleForTrial: false,
|
||||
});
|
||||
const badges = screen.getAllByText("subscription.currentPlan");
|
||||
expect(badges.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("current plan CTA is non-interactive", () => {
|
||||
renderPage({
|
||||
subscription: {
|
||||
status: "active",
|
||||
plan: { name: "2 Marka", key: "brand2" },
|
||||
billingPeriod: "monthly",
|
||||
brands: [
|
||||
{ brandId: "vw", brandName: "Volkswagen" },
|
||||
{ brandId: "audi", brandName: "Audi" },
|
||||
],
|
||||
},
|
||||
eligibleForTrial: false,
|
||||
});
|
||||
// Current plan shows Mevcut Plan div instead of a Button
|
||||
const currentPlanInstances = screen.getAllByText("subscription.currentPlan");
|
||||
expect(currentPlanInstances.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("loading state", () => {
|
||||
it("renders skeleton placeholders while loading", () => {
|
||||
renderPage({ isLoading: true });
|
||||
const skeletons = document.querySelectorAll(".animate-pulse");
|
||||
expect(skeletons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("does not render plan cards while loading", () => {
|
||||
renderPage({ isLoading: true });
|
||||
expect(screen.queryByText("subscription.plans.brand1.name")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("FN-278: renders 4 skeleton cards in a 4-column grid matching plan comparison layout", () => {
|
||||
renderPage({ isLoading: true });
|
||||
// The skeleton grid should have lg:grid-cols-4 class
|
||||
const grid = document.querySelector(".grid.gap-4");
|
||||
expect(grid).toBeInTheDocument();
|
||||
expect(grid?.className).toContain("lg:grid-cols-4");
|
||||
// Should contain exactly 4 skeleton cards
|
||||
const skeletonCards = grid?.querySelectorAll(".animate-pulse");
|
||||
expect(skeletonCards?.length).toBe(4);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// 2. INTERACTION REGRESSION TESTS
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe("interaction", () => {
|
||||
describe("selected plan CTA (P0-3)", () => {
|
||||
it("CTA says Devam Et (proceed) when plan selected", () => {
|
||||
renderPage();
|
||||
expect(screen.queryByText("subscription.proceed")).not.toBeInTheDocument();
|
||||
// Scope to plan comparison grid (not FeatureMatrix table)
|
||||
const planGrid = document.querySelector(".grid.gap-4");
|
||||
const gridScope = within(planGrid as HTMLElement);
|
||||
// Click the Button inside the brand1 plan card
|
||||
const brand1Card = gridScope
|
||||
.getByText("subscription.plans.brand1.name")
|
||||
.closest('[class*="rounded-xl"]')!;
|
||||
fireEvent.click(
|
||||
within(brand1Card as HTMLElement).getByRole("button", { name: "subscription.choosePlan" }),
|
||||
);
|
||||
const ctaElements = screen.getAllByText(/subscription\.proceed/);
|
||||
expect(ctaElements.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("CTA includes price info with ile Devam Et format", () => {
|
||||
renderPage();
|
||||
const planGrid = document.querySelector(".grid.gap-4");
|
||||
const gridScope = within(planGrid as HTMLElement);
|
||||
const brand2Card = gridScope
|
||||
.getByText("subscription.plans.brand2.name")
|
||||
.closest('[class*="rounded-xl"]')!;
|
||||
fireEvent.click(
|
||||
within(brand2Card as HTMLElement).getByRole("button", { name: "subscription.choosePlan" }),
|
||||
);
|
||||
const ctaButtons = screen.getAllByRole("button", { name: /subscription\.proceed/ });
|
||||
const bigCta = ctaButtons.find((b) => b.textContent?.includes("₺350,00"));
|
||||
expect(bigCta).toBeTruthy();
|
||||
expect(bigCta?.textContent).toContain("subscription.proceed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("order summary card (P0-4)", () => {
|
||||
it("order summary is not visible when no plan is selected", () => {
|
||||
renderPage();
|
||||
expect(screen.queryByText("subscription.orderSummary")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("order summary renders with plan, period, and price when plan selected", () => {
|
||||
renderPage();
|
||||
const planGrid = document.querySelector(".grid.gap-4");
|
||||
const gridScope = within(planGrid as HTMLElement);
|
||||
const brand3Card = gridScope
|
||||
.getByText("subscription.plans.brand3.name")
|
||||
.closest('[class*="rounded-xl"]')!;
|
||||
fireEvent.click(
|
||||
within(brand3Card as HTMLElement).getByRole("button", { name: "subscription.choosePlan" }),
|
||||
);
|
||||
expect(screen.getByText("subscription.orderSummary")).toBeInTheDocument();
|
||||
expect(screen.getByText("subscription.orderSummaryPlan")).toBeInTheDocument();
|
||||
expect(screen.getByText("subscription.orderSummaryPeriod")).toBeInTheDocument();
|
||||
expect(screen.getByText("subscription.orderSummaryPrice")).toBeInTheDocument();
|
||||
// Plan name appears in card, summary, and FeatureMatrix — use getAllByText
|
||||
expect(screen.getAllByText("subscription.plans.brand3.name").length).toBeGreaterThanOrEqual(
|
||||
1,
|
||||
);
|
||||
// common.monthly appears in both toggle button and order summary
|
||||
expect(screen.getAllByText("common.monthly").length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("order summary updates when billing period changes", () => {
|
||||
renderPage();
|
||||
const planGrid1 = document.querySelector(".grid.gap-4");
|
||||
const gridScope1 = within(planGrid1 as HTMLElement);
|
||||
const brand1Card = gridScope1
|
||||
.getByText("subscription.plans.brand1.name")
|
||||
.closest('[class*="rounded-xl"]')!;
|
||||
fireEvent.click(
|
||||
within(brand1Card as HTMLElement).getByRole("button", { name: "subscription.choosePlan" }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "common.yearly" }));
|
||||
// Yearly price appears in both card and summary
|
||||
const summaryPrices = screen.getAllByText("₺2.000,00");
|
||||
expect(summaryPrices.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("trial card hidden when status is trial (P0-6)", () => {
|
||||
it("does not show trial CTA when subscription status is trial", () => {
|
||||
renderPage({
|
||||
subscription: {
|
||||
status: "trial",
|
||||
plan: { name: "Full Paket", key: "full" },
|
||||
billingPeriod: "monthly",
|
||||
brands: [],
|
||||
endDate: new Date(Date.now() + 15 * 86400000).toISOString(),
|
||||
},
|
||||
eligibleForTrial: true,
|
||||
});
|
||||
expect(screen.queryByText("subscription.trialTitle")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("subscription.startTrial")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows trial CTA when no subscription and eligible", () => {
|
||||
renderPage({ subscription: null, eligibleForTrial: true });
|
||||
expect(screen.getByText("subscription.trialTitle")).toBeInTheDocument();
|
||||
expect(screen.getByText("subscription.startTrial")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("proceed to payment navigation", () => {
|
||||
it("navigates to /dashboard/subscription/pay with correct search params", () => {
|
||||
renderPage();
|
||||
const planGridNav = document.querySelector(".grid.gap-4");
|
||||
const gridScopeNav = within(planGridNav as HTMLElement);
|
||||
const brand2Card = gridScopeNav
|
||||
.getByText("subscription.plans.brand2.name")
|
||||
.closest('[class*="rounded-xl"]')!;
|
||||
fireEvent.click(
|
||||
within(brand2Card as HTMLElement).getByRole("button", { name: "subscription.choosePlan" }),
|
||||
);
|
||||
fireEvent.click(screen.getByTestId("brand-selector-select"));
|
||||
// Click the big CTA (not the small card button)
|
||||
const ctaButtons = screen.getAllByRole("button", { name: /subscription\.proceed/ });
|
||||
const bigCta = ctaButtons.find((b) => b.textContent?.includes("₺"))!;
|
||||
fireEvent.click(bigCta);
|
||||
expect(mockNavigate).toHaveBeenCalledWith({
|
||||
to: "/dashboard/subscription/pay",
|
||||
search: expect.objectContaining({ plan: "brand2", period: "monthly" }),
|
||||
});
|
||||
expect(mockCapture).toHaveBeenCalledWith(
|
||||
"checkout_started",
|
||||
expect.objectContaining({ plan: "brand2", period: "monthly" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows error toast when proceeding without brand selection", () => {
|
||||
renderPage();
|
||||
const planGridErr = document.querySelector(".grid.gap-4");
|
||||
const gridScopeErr = within(planGridErr as HTMLElement);
|
||||
const brand1Card = gridScopeErr
|
||||
.getByText("subscription.plans.brand1.name")
|
||||
.closest('[class*="rounded-xl"]')!;
|
||||
fireEvent.click(
|
||||
within(brand1Card as HTMLElement).getByRole("button", { name: "subscription.choosePlan" }),
|
||||
);
|
||||
const ctaButtons = screen.getAllByRole("button", { name: /subscription\.proceed/ });
|
||||
const bigCta = ctaButtons.find((b) => b.textContent?.includes("₺"))!;
|
||||
fireEvent.click(bigCta);
|
||||
expect(mockToast.error).toHaveBeenCalledWith("subscription.selectBrandsDescription");
|
||||
});
|
||||
});
|
||||
|
||||
describe("current plan non-interactive", () => {
|
||||
it("clicking current plan card does not select it", () => {
|
||||
renderPage({
|
||||
subscription: {
|
||||
status: "active",
|
||||
plan: { name: "2 Marka", key: "brand2" },
|
||||
billingPeriod: "monthly",
|
||||
brands: [
|
||||
{ brandId: "vw", brandName: "Volkswagen" },
|
||||
{ brandId: "audi", brandName: "Audi" },
|
||||
],
|
||||
},
|
||||
eligibleForTrial: false,
|
||||
});
|
||||
const planGridCur = document.querySelector(".grid.gap-4");
|
||||
const gridScopeCur = within(planGridCur as HTMLElement);
|
||||
// brand1 card should have a selectable button (not current plan)
|
||||
const brand1Card = gridScopeCur
|
||||
.getByText("subscription.plans.brand1.name")
|
||||
.closest('[class*="rounded-xl"]')!;
|
||||
const brand1Button = within(brand1Card as HTMLElement).queryByRole("button");
|
||||
expect(brand1Button).toBeTruthy();
|
||||
// Current plan (brand2) should NOT have a button — it shows a non-interactive div instead
|
||||
const currentPlanCards = document.querySelectorAll('[class*="rounded-xl"]');
|
||||
let currentPlanHasNoButton = false;
|
||||
currentPlanCards.forEach((card) => {
|
||||
if (
|
||||
card.textContent?.includes("subscription.plans.brand2.name") &&
|
||||
card.className.includes("border-green")
|
||||
) {
|
||||
// Current plan card should have a div with "Mevcut Plan" text, not a button
|
||||
const btn = card.querySelector("button");
|
||||
if (!btn) currentPlanHasNoButton = true;
|
||||
}
|
||||
});
|
||||
expect(currentPlanHasNoButton).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("plan selection persists across billing toggle", () => {
|
||||
it("selected plan stays selected after switching billing period", () => {
|
||||
renderPage();
|
||||
const planGridPersist = document.querySelector(".grid.gap-4");
|
||||
const gridScopePersist = within(planGridPersist as HTMLElement);
|
||||
const brand3Card = gridScopePersist
|
||||
.getByText("subscription.plans.brand3.name")
|
||||
.closest('[class*="rounded-xl"]')!;
|
||||
fireEvent.click(
|
||||
within(brand3Card as HTMLElement).getByRole("button", { name: "subscription.choosePlan" }),
|
||||
);
|
||||
expect(screen.getByText("subscription.orderSummary")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "common.yearly" }));
|
||||
expect(screen.getByText("subscription.orderSummary")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// 3. TRUST & I18N TESTS (P0-7 through P0-10)
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe("trust and i18n", () => {
|
||||
describe("PostHog events (P0-10)", () => {
|
||||
it("fires checkout_started event when proceeding to payment", () => {
|
||||
renderPage();
|
||||
const planGridPH1 = document.querySelector(".grid.gap-4");
|
||||
const gridScopePH1 = within(planGridPH1 as HTMLElement);
|
||||
const brand2Card = gridScopePH1
|
||||
.getByText("subscription.plans.brand2.name")
|
||||
.closest('[class*="rounded-xl"]')!;
|
||||
fireEvent.click(
|
||||
within(brand2Card as HTMLElement).getByRole("button", { name: "subscription.choosePlan" }),
|
||||
);
|
||||
fireEvent.click(screen.getByTestId("brand-selector-select"));
|
||||
const ctaButtons = screen.getAllByRole("button", { name: /subscription\.proceed/ });
|
||||
fireEvent.click(ctaButtons.find((b) => b.textContent?.includes("₺"))!);
|
||||
expect(mockCapture).toHaveBeenCalledWith(
|
||||
"checkout_started",
|
||||
expect.objectContaining({ plan: "brand2", period: "monthly" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("fires plan_selected event when clicking a plan card button", () => {
|
||||
renderPage();
|
||||
const planGridPH2 = document.querySelector(".grid.gap-4");
|
||||
const gridScopePH2 = within(planGridPH2 as HTMLElement);
|
||||
const brand3Card = gridScopePH2
|
||||
.getByText("subscription.plans.brand3.name")
|
||||
.closest('[class*="rounded-xl"]')!;
|
||||
fireEvent.click(
|
||||
within(brand3Card as HTMLElement).getByRole("button", { name: "subscription.choosePlan" }),
|
||||
);
|
||||
expect(mockCapture).toHaveBeenCalledWith("plan_selected", { plan: "brand3" });
|
||||
});
|
||||
|
||||
it("fires trial_started event when clicking start trial", () => {
|
||||
renderPage({ subscription: null, eligibleForTrial: true });
|
||||
fireEvent.click(screen.getByText("subscription.startTrial"));
|
||||
expect(mockCapture).toHaveBeenCalledWith("trial_started");
|
||||
});
|
||||
|
||||
it("FN-278: fires yearly_toggle_clicked event when switching to yearly billing", () => {
|
||||
renderPage();
|
||||
fireEvent.click(screen.getByRole("button", { name: "common.yearly" }));
|
||||
expect(mockCapture).toHaveBeenCalledWith("yearly_toggle_clicked", { period: "yearly" });
|
||||
});
|
||||
|
||||
it("FN-278: fires yearly_toggle_clicked event when switching to monthly billing", () => {
|
||||
renderPage();
|
||||
// Switch to yearly first so we can test switching back to monthly
|
||||
fireEvent.click(screen.getByRole("button", { name: "common.yearly" }));
|
||||
mockCapture.mockClear();
|
||||
fireEvent.click(screen.getByRole("button", { name: "common.monthly" }));
|
||||
expect(mockCapture).toHaveBeenCalledWith("yearly_toggle_clicked", { period: "monthly" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("i18n key usage (P0-9)", () => {
|
||||
it("plan card button labels use i18n keys", () => {
|
||||
renderPage();
|
||||
const buttons = screen.getAllByText("subscription.choosePlan");
|
||||
expect(buttons.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("page title uses i18n key", () => {
|
||||
renderPage();
|
||||
expect(screen.getByText("subscription.title")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("billing period toggle labels use i18n keys", () => {
|
||||
renderPage();
|
||||
expect(screen.getByRole("button", { name: "common.monthly" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "common.yearly" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("status labels use i18n keys", () => {
|
||||
renderPage({
|
||||
subscription: {
|
||||
status: "active",
|
||||
plan: { name: "1 Marka", key: "brand1" },
|
||||
billingPeriod: "monthly",
|
||||
brands: [{ brandId: "vw", brandName: "Volkswagen" }],
|
||||
},
|
||||
eligibleForTrial: false,
|
||||
});
|
||||
expect(screen.getByText("subscription.statusLabels.active")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("trust i18n keys exist (P0-7, P0-8)", () => {
|
||||
it("trust-related i18n keys are available", async () => {
|
||||
const { t: realT } = await vi.importActual<any>("@/lib/i18n");
|
||||
const keys = [
|
||||
"subscription.trustNoCard",
|
||||
"subscription.trustCancelAnytime",
|
||||
"subscription.trustRefund",
|
||||
"subscription.paymentTrustSSL",
|
||||
"subscription.paymentTrustProvider",
|
||||
"subscription.paymentTrustKVKK",
|
||||
];
|
||||
for (const key of keys) {
|
||||
const result = realT(key);
|
||||
expect(typeof result).toBe("string");
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// 4. EDGE CASE TESTS
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe("edge cases", () => {
|
||||
describe("no subscription + trial eligible", () => {
|
||||
it("shows trial CTA card with features", () => {
|
||||
renderPage({ subscription: null, eligibleForTrial: true });
|
||||
expect(screen.getByText("subscription.trialTitle")).toBeInTheDocument();
|
||||
expect(screen.getByText("subscription.trialDescription")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("subscription.features.allBrands").length).toBeGreaterThanOrEqual(
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
it("start trial button triggers trial mutation", () => {
|
||||
const trialMutate = vi.fn();
|
||||
renderPage({ subscription: null, eligibleForTrial: true, mutationMutate: trialMutate });
|
||||
fireEvent.click(screen.getByText("subscription.startTrial"));
|
||||
expect(trialMutate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("expired subscription", () => {
|
||||
it("shows trial CTA when expired and eligible", () => {
|
||||
renderPage({
|
||||
subscription: {
|
||||
status: "expired",
|
||||
plan: { name: "1 Marka", key: "brand1" },
|
||||
billingPeriod: "monthly",
|
||||
brands: [],
|
||||
},
|
||||
eligibleForTrial: true,
|
||||
});
|
||||
expect(screen.getByText("subscription.trialTitle")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show trial CTA when expired but not eligible", () => {
|
||||
renderPage({
|
||||
subscription: {
|
||||
status: "expired",
|
||||
plan: { name: "1 Marka", key: "brand1" },
|
||||
billingPeriod: "monthly",
|
||||
brands: [],
|
||||
},
|
||||
eligibleForTrial: false,
|
||||
});
|
||||
expect(screen.queryByText("subscription.trialTitle")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("empty state", () => {
|
||||
it("shows noSubscription message when no subscription and not eligible", () => {
|
||||
renderPage({ subscription: null, eligibleForTrial: false });
|
||||
expect(screen.getByText("subscription.noSubscription")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("full plan user", () => {
|
||||
it("shows current plan info for full plan users", () => {
|
||||
renderPage({
|
||||
subscription: {
|
||||
status: "active",
|
||||
plan: { name: "Full Paket", key: "full" },
|
||||
billingPeriod: "monthly",
|
||||
brands: [
|
||||
{ brandId: "vw", brandName: "Volkswagen" },
|
||||
{ brandId: "audi", brandName: "Audi" },
|
||||
{ brandId: "bmw", brandName: "BMW" },
|
||||
],
|
||||
startDate: "2025-01-01",
|
||||
endDate: "2026-01-01",
|
||||
},
|
||||
eligibleForTrial: false,
|
||||
});
|
||||
// Accessible brands section uses colon in text: "subscription.accessibleBrands:"
|
||||
expect(screen.getByText(/subscription\.accessibleBrands/)).toBeInTheDocument();
|
||||
expect(screen.getAllByText("subscription.currentPlan").length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cancelled subscription", () => {
|
||||
it("shows resume button for cancelled subscriptions", () => {
|
||||
renderPage({
|
||||
subscription: {
|
||||
status: "cancelled",
|
||||
plan: { name: "2 Marka", key: "brand2" },
|
||||
billingPeriod: "monthly",
|
||||
brands: [],
|
||||
},
|
||||
eligibleForTrial: false,
|
||||
});
|
||||
const resumeBtn = screen.queryByText("subscription.resumeSubscription");
|
||||
if (resumeBtn) expect(resumeBtn).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("active subscription with trial eligibility", () => {
|
||||
it("does not show trial card when user is on trial", () => {
|
||||
renderPage({
|
||||
subscription: {
|
||||
status: "trial",
|
||||
plan: { name: "Full Paket", key: "full" },
|
||||
billingPeriod: "monthly",
|
||||
brands: [],
|
||||
endDate: new Date(Date.now() + 10 * 86400000).toISOString(),
|
||||
},
|
||||
eligibleForTrial: true,
|
||||
});
|
||||
// Trial CTA hidden (P0-6)
|
||||
expect(screen.queryByText("subscription.trialTitle")).not.toBeInTheDocument();
|
||||
// Status card also hidden when trial + eligible (trial card condition in component)
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// 5. ACCESSIBILITY BASELINE TESTS
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe("accessibility", () => {
|
||||
it("CTA buttons have accessible labels", () => {
|
||||
renderPage();
|
||||
const buttons = screen.getAllByRole("button", { name: /subscription\.(choosePlan|proceed)/ });
|
||||
buttons.forEach((b) => expect(b.textContent).toBeTruthy());
|
||||
});
|
||||
|
||||
it("current plan card is not clickable", () => {
|
||||
renderPage({
|
||||
subscription: {
|
||||
status: "active",
|
||||
plan: { name: "2 Marka", key: "brand2" },
|
||||
billingPeriod: "monthly",
|
||||
brands: [{ brandId: "vw", brandName: "Volkswagen" }],
|
||||
},
|
||||
eligibleForTrial: false,
|
||||
});
|
||||
const cards = document.querySelectorAll('[class*="rounded-xl"]');
|
||||
let found = false;
|
||||
cards.forEach((card) => {
|
||||
if (
|
||||
card.textContent?.includes("subscription.plans.brand2.name") &&
|
||||
card.className.includes("border-green")
|
||||
) {
|
||||
// Current plan card should not have a button element
|
||||
if (!card.querySelector("button")) found = true;
|
||||
}
|
||||
});
|
||||
expect(found).toBe(true);
|
||||
});
|
||||
|
||||
it("plan cards have visible names", () => {
|
||||
renderPage();
|
||||
const planGridAcc = document.querySelector(".grid.gap-4");
|
||||
const gridScopeAcc = within(planGridAcc as HTMLElement);
|
||||
["brand1", "brand2", "brand3", "full"].forEach((key) => {
|
||||
expect(gridScopeAcc.getByText(`subscription.plans.${key}.name`)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("popular plan is distinguishable beyond color", () => {
|
||||
renderPage();
|
||||
// Popular badge only appears on the plan card (FeatureMatrix uses aria-label only)
|
||||
const cardBadge = screen.getByText("subscription.popular");
|
||||
expect(cardBadge.className).toContain("text-xs");
|
||||
const popularCard = cardBadge.closest('[class*="ring-2"]');
|
||||
expect(popularCard).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// 6. SUBSCRIPTION STATUS DISPLAY TESTS
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe("subscription status display", () => {
|
||||
it("shows subscription info card with dates and brands", () => {
|
||||
renderPage({
|
||||
subscription: {
|
||||
status: "active",
|
||||
plan: { name: "1 Marka", key: "brand1" },
|
||||
billingPeriod: "yearly",
|
||||
brands: [{ brandId: "vw", brandName: "Volkswagen" }],
|
||||
startDate: "2025-06-01",
|
||||
endDate: "2026-06-01",
|
||||
},
|
||||
eligibleForTrial: false,
|
||||
});
|
||||
expect(screen.getAllByText("subscription.currentPlan").length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText("subscription.statusLabels.active")).toBeInTheDocument();
|
||||
// startDate/endDate labels are rendered as "startDate: " with colon + space
|
||||
expect(screen.getByText(/subscription\.startDate/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/subscription\.endDate/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/subscription\.accessibleBrands/)).toBeInTheDocument();
|
||||
expect(screen.getByTestId("brand-logo-Volkswagen")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows billing period in subscription card description", () => {
|
||||
renderPage({
|
||||
subscription: {
|
||||
status: "active",
|
||||
plan: { name: "1 Marka", key: "brand1" },
|
||||
billingPeriod: "yearly",
|
||||
brands: [{ brandId: "vw", brandName: "Volkswagen" }],
|
||||
},
|
||||
eligibleForTrial: false,
|
||||
});
|
||||
// common.yearly appears in both subscription card description and toggle button
|
||||
expect(screen.getAllByText(/common\.yearly/).length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("does not show cancel button for non-active subscriptions", () => {
|
||||
renderPage({
|
||||
subscription: {
|
||||
status: "cancelled",
|
||||
plan: { name: "1 Marka", key: "brand1" },
|
||||
billingPeriod: "monthly",
|
||||
brands: [],
|
||||
},
|
||||
eligibleForTrial: false,
|
||||
});
|
||||
expect(screen.queryByText("subscription.cancelSubscription")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows cancel button only for active subscriptions", () => {
|
||||
renderPage({
|
||||
subscription: {
|
||||
status: "active",
|
||||
plan: { name: "1 Marka", key: "brand1" },
|
||||
billingPeriod: "monthly",
|
||||
brands: [{ brandId: "vw", brandName: "Volkswagen" }],
|
||||
},
|
||||
eligibleForTrial: false,
|
||||
});
|
||||
expect(screen.getByText("subscription.cancelSubscription")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,591 +0,0 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
/**
|
||||
* Tests for the SubscriptionPage including FN-206 Feature Matrix.
|
||||
*/
|
||||
import { act } from "react";
|
||||
import { vi } from "vitest";
|
||||
import { SubscriptionPage } from "./index";
|
||||
|
||||
// ── Mocks ────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Mock @/lib/api-client
|
||||
const apiGet = vi.fn();
|
||||
vi.mock("@/lib/api-client", () => ({
|
||||
api: { get: (...args: unknown[]) => apiGet(...args) },
|
||||
ApiError: class ApiError extends Error {
|
||||
code?: string;
|
||||
status?: number;
|
||||
constructor(message: string, code?: string, status?: number) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.code = code;
|
||||
this.status = status;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock @tanstack/react-router
|
||||
const mockNavigate = vi.fn();
|
||||
vi.mock("@tanstack/react-router", async () => {
|
||||
const actual = await vi.importActual<any>("@tanstack/react-router");
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => mockNavigate,
|
||||
createFileRoute: () => (routeOpts: any) => routeOpts,
|
||||
Link: ({ children, to, ...props }: any) => (
|
||||
<a href={to} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock @/lib/posthog
|
||||
vi.mock("@/lib/posthog", () => ({
|
||||
capture: vi.fn(),
|
||||
setPeopleProperties: vi.fn(),
|
||||
}));
|
||||
import { capture } from "@/lib/posthog";
|
||||
|
||||
// Mock @/lib/faro
|
||||
vi.mock("@/lib/faro", () => ({
|
||||
startAction: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock @/lib/toast
|
||||
vi.mock("@/lib/toast", () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
// Mock @/lib/user-settings
|
||||
vi.mock("@/lib/user-settings", () => ({
|
||||
getUserSettings: () => ({ theme: "dark" }),
|
||||
}));
|
||||
|
||||
// Mock canvas-confetti
|
||||
vi.mock("canvas-confetti", () => ({ default: vi.fn() }));
|
||||
|
||||
// Mock IntersectionObserver (not available in jsdom)
|
||||
const mockIntersectionObserver = vi.fn();
|
||||
mockIntersectionObserver.mockReturnValue({
|
||||
observe: vi.fn(),
|
||||
unobserve: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
});
|
||||
vi.stubGlobal("IntersectionObserver", mockIntersectionObserver);
|
||||
|
||||
// Mock lazy BrandSelector
|
||||
vi.mock("@/components/subscription/brand-selector", () => ({
|
||||
BrandSelector: () => <div data-testid="brand-selector">BrandSelector</div>,
|
||||
}));
|
||||
|
||||
// Mock @remotion/player
|
||||
vi.mock("@remotion/player", () => ({
|
||||
Player: () => null,
|
||||
}));
|
||||
|
||||
// Mock OnboardingProgress
|
||||
vi.mock("@/remotion/OnboardingProgress", () => ({
|
||||
default: () => null,
|
||||
}));
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function renderWithProviders(ui: React.ReactElement) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockNavigate.mockClear();
|
||||
// Reset URL params to avoid cross-test contamination from onboarding flow:
|
||||
window.history.replaceState({}, "", "/dashboard/subscription");
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// FN-206: Feature matrix comparison table
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe("FN-206 — Feature matrix comparison table", () => {
|
||||
test("renders the feature matrix section heading", async () => {
|
||||
apiGet.mockResolvedValue({ subscription: null, eligibleForTrial: false });
|
||||
renderWithProviders(<SubscriptionPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Özellik Karşılaştırması")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test("renders 5 header cells: 1 empty label column + 4 plan columns", async () => {
|
||||
apiGet.mockResolvedValue({ subscription: null, eligibleForTrial: false });
|
||||
renderWithProviders(<SubscriptionPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Özellik Karşılaştırması")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const table = document.querySelector("table");
|
||||
expect(table).toBeInTheDocument();
|
||||
const headerCells = table?.querySelectorAll("thead th");
|
||||
expect(headerCells?.length).toBe(5);
|
||||
});
|
||||
|
||||
test("renders 6 feature rows (one per ALL_FEATURES entry)", async () => {
|
||||
apiGet.mockResolvedValue({ subscription: null, eligibleForTrial: false });
|
||||
renderWithProviders(<SubscriptionPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Özellik Karşılaştırması")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const table = document.querySelector("table");
|
||||
const rows = table?.querySelectorAll("tbody tr");
|
||||
expect(rows?.length).toBe(6);
|
||||
});
|
||||
|
||||
test("full plan column (index 4) has a Check for every row", async () => {
|
||||
apiGet.mockResolvedValue({ subscription: null, eligibleForTrial: false });
|
||||
renderWithProviders(<SubscriptionPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Özellik Karşılaştırması")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const table = document.querySelector("table");
|
||||
const rows = table?.querySelectorAll("tbody tr");
|
||||
for (const row of rows ?? []) {
|
||||
const cells = row.querySelectorAll("td");
|
||||
// td[0]=label, td[1]=brand1, td[2]=brand2, td[3]=brand3, td[4]=full
|
||||
const fullCell = cells[4];
|
||||
expect(fullCell.querySelector('[aria-label="Evet"]')).toBeInTheDocument();
|
||||
expect(fullCell.querySelector('[aria-label="Hayır"]')).not.toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
test("brand1 column (index 1) shows Minus for prioritySupport (row 4) and oemSearch (row 5)", async () => {
|
||||
apiGet.mockResolvedValue({ subscription: null, eligibleForTrial: false });
|
||||
renderWithProviders(<SubscriptionPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Özellik Karşılaştırması")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const table = document.querySelector("table");
|
||||
const rows = table?.querySelectorAll("tbody tr");
|
||||
|
||||
// prioritySupport row (index 4)
|
||||
const brand1PriorityCell = rows[4].querySelectorAll("td")[1];
|
||||
expect(brand1PriorityCell.querySelector('[aria-label="Hayır"]')).toBeInTheDocument();
|
||||
|
||||
// oemSearch row (index 5)
|
||||
const brand1OemCell = rows[5].querySelectorAll("td")[1];
|
||||
expect(brand1OemCell.querySelector('[aria-label="Hayır"]')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Fixtures ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const fixtures = {
|
||||
// No subscription, not trial-eligible — shows plan cards + no-sub banner
|
||||
noSub: { subscription: null, eligibleForTrial: false },
|
||||
|
||||
// No subscription, trial-eligible — shows trial CTA card
|
||||
trialEligible: { subscription: null, eligibleForTrial: true },
|
||||
|
||||
// Active subscriber on brand1 (monthly)
|
||||
activeBrand1: {
|
||||
subscription: {
|
||||
status: "active",
|
||||
plan: { name: "1 Marka", key: "brand1" },
|
||||
billingPeriod: "monthly",
|
||||
brands: [{ brandId: "b1", brandName: "Volkswagen" }],
|
||||
startDate: "2026-01-01",
|
||||
endDate: "2026-12-31",
|
||||
},
|
||||
eligibleForTrial: false,
|
||||
},
|
||||
|
||||
// Trial user (5 days left)
|
||||
trialActive: {
|
||||
subscription: {
|
||||
status: "trial",
|
||||
plan: { name: "Full Paket", key: "full" },
|
||||
billingPeriod: "monthly",
|
||||
brands: [],
|
||||
startDate: new Date().toISOString(),
|
||||
endDate: new Date(Date.now() + 5 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
},
|
||||
eligibleForTrial: false,
|
||||
},
|
||||
|
||||
// Cancelled subscription
|
||||
cancelled: {
|
||||
subscription: {
|
||||
status: "cancelled",
|
||||
plan: { name: "1 Marka", key: "brand1" },
|
||||
billingPeriod: "monthly",
|
||||
brands: [],
|
||||
startDate: "2026-01-01",
|
||||
endDate: "2026-06-01",
|
||||
},
|
||||
eligibleForTrial: false,
|
||||
},
|
||||
|
||||
// Expired subscription, not trial-eligible
|
||||
expired: {
|
||||
subscription: {
|
||||
status: "expired",
|
||||
plan: { name: "1 Marka", key: "brand1" },
|
||||
billingPeriod: "monthly",
|
||||
brands: [],
|
||||
startDate: "2025-01-01",
|
||||
endDate: "2025-12-31",
|
||||
},
|
||||
eligibleForTrial: false,
|
||||
},
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// Group A — Plan Card Rendering (P0-1, P0-2)
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe("P0-1 / P0-2 — Plan card rendering", () => {
|
||||
test("renders all 4 plan card names in the grid", async () => {
|
||||
apiGet.mockResolvedValue(fixtures.noSub);
|
||||
renderWithProviders(<SubscriptionPage />);
|
||||
// Names appear in both plan cards and feature matrix table headers
|
||||
await waitFor(() => expect(screen.getAllByText("1 Marka").length).toBeGreaterThanOrEqual(1));
|
||||
expect(screen.getAllByText("2 Marka").length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText("3 Marka").length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText("Full Paket").length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
test("renders monthly prices for all plans by default", async () => {
|
||||
apiGet.mockResolvedValue(fixtures.noSub);
|
||||
renderWithProviders(<SubscriptionPage />);
|
||||
await waitFor(() => expect(screen.getAllByText("1 Marka").length).toBeGreaterThanOrEqual(1));
|
||||
// brand1 monthly price: 20000 / 100 = 200 → ₺200,00
|
||||
expect(screen.getByText(/₺200,00/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("P0-1: yearly toggle switches prices and shows 17% indirim badge on every card", async () => {
|
||||
apiGet.mockResolvedValue(fixtures.noSub);
|
||||
renderWithProviders(<SubscriptionPage />);
|
||||
await waitFor(() => expect(screen.getAllByText("1 Marka").length).toBeGreaterThanOrEqual(1));
|
||||
|
||||
await act(async () => {
|
||||
screen.getByText("Yıllık").click();
|
||||
});
|
||||
|
||||
// 17% indirim badge appears (one per plan card)
|
||||
const discountBadges = screen.getAllByText("17% indirim");
|
||||
expect(discountBadges.length).toBe(4);
|
||||
|
||||
// brand1 yearly price: 200000 / 100 = 2000 → ₺2.000,00
|
||||
expect(screen.getByText(/₺2\.000,00/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("P0-2: brand2 card has Popüler badge (visual dominance indicator)", async () => {
|
||||
apiGet.mockResolvedValue(fixtures.noSub);
|
||||
renderWithProviders(<SubscriptionPage />);
|
||||
await waitFor(() => expect(screen.getAllByText("Popüler").length).toBeGreaterThanOrEqual(1));
|
||||
});
|
||||
|
||||
test("full plan shows allBrands feature; brand1 does not", async () => {
|
||||
apiGet.mockResolvedValue(fixtures.noSub);
|
||||
renderWithProviders(<SubscriptionPage />);
|
||||
await waitFor(() => expect(screen.getAllByText("1 Marka").length).toBeGreaterThanOrEqual(1));
|
||||
// "Tüm markalar" should appear at least once (full plan)
|
||||
const allBrandsFeature = screen.getAllByText(/tüm marka/i);
|
||||
expect(allBrandsFeature.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
test("FN-278: yearly_toggle_clicked PostHog event fires when switching to yearly", async () => {
|
||||
apiGet.mockResolvedValue(fixtures.noSub);
|
||||
renderWithProviders(<SubscriptionPage />);
|
||||
await waitFor(() => expect(screen.getAllByText("1 Marka").length).toBeGreaterThanOrEqual(1));
|
||||
|
||||
await act(async () => {
|
||||
screen.getByText("Yıllık").click();
|
||||
});
|
||||
|
||||
expect(capture).toHaveBeenCalledWith("yearly_toggle_clicked", { period: "yearly" });
|
||||
});
|
||||
|
||||
test("FN-278: yearly_toggle_clicked PostHog event fires when switching to monthly", async () => {
|
||||
apiGet.mockResolvedValue(fixtures.noSub);
|
||||
renderWithProviders(<SubscriptionPage />);
|
||||
await waitFor(() => expect(screen.getAllByText("1 Marka").length).toBeGreaterThanOrEqual(1));
|
||||
|
||||
// Switch to yearly first
|
||||
await act(async () => {
|
||||
screen.getByText("Yıllık").click();
|
||||
});
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Switch back to monthly
|
||||
await act(async () => {
|
||||
screen.getByText("Aylık").click();
|
||||
});
|
||||
|
||||
expect(capture).toHaveBeenCalledWith("yearly_toggle_clicked", { period: "monthly" });
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// Group B — Current Plan Badge (P0-5)
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe("P0-5 — Current active plan is non-interactive", () => {
|
||||
test("active subscriber sees Mevcut Plan badge and no Plan Seç button for their plan", async () => {
|
||||
apiGet.mockResolvedValue(fixtures.activeBrand1);
|
||||
renderWithProviders(<SubscriptionPage />);
|
||||
await waitFor(() => {
|
||||
const badges = screen.getAllByText("Mevcut Plan");
|
||||
expect(badges.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
// There should be 3 "Plan Seç" buttons (for brand2, brand3, full) but NOT for brand1
|
||||
const choosePlanButtons = screen.getAllByText("Plan Seç");
|
||||
expect(choosePlanButtons).toHaveLength(3);
|
||||
});
|
||||
|
||||
test("P0-5: clicking current plan card does not trigger plan selection", async () => {
|
||||
apiGet.mockResolvedValue(fixtures.activeBrand1);
|
||||
renderWithProviders(<SubscriptionPage />);
|
||||
await waitFor(() => {
|
||||
screen.getAllByText("Mevcut Plan");
|
||||
});
|
||||
|
||||
// The brand-selector section should NOT appear (no plan selected)
|
||||
expect(screen.queryByTestId("brand-selector")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// Group C — Plan Selection Interaction (P0-3, P0-4)
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe("P0-3 / P0-4 — Plan selection interaction", () => {
|
||||
test("P0-3: selecting a plan changes button label from Plan Seç to Devam Et", async () => {
|
||||
apiGet.mockResolvedValue(fixtures.noSub);
|
||||
renderWithProviders(<SubscriptionPage />);
|
||||
await waitFor(() => expect(screen.getAllByText("Plan Seç").length).toBe(4));
|
||||
|
||||
const buttons = screen.getAllByText("Plan Seç");
|
||||
await act(async () => {
|
||||
buttons[0].click(); // select brand1
|
||||
});
|
||||
|
||||
// The clicked card's button becomes "Devam Et"; others stay "Plan Seç"
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Devam Et")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("Plan Seç").length).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
test("P0-4: selecting a plan shows order summary with plan name, period, and price", async () => {
|
||||
apiGet.mockResolvedValue(fixtures.noSub);
|
||||
renderWithProviders(<SubscriptionPage />);
|
||||
await waitFor(() => expect(screen.getAllByText("Plan Seç").length).toBe(4));
|
||||
|
||||
await act(async () => {
|
||||
screen.getAllByText("Plan Seç")[0].click(); // select brand1
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
// Order summary card is visible
|
||||
expect(screen.getByText(/sipariş özeti/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Summary shows selected plan name
|
||||
// brand1 name appears at least twice: once in plan grid, once in summary
|
||||
const brand1Occurrences = screen.getAllByText("1 Marka");
|
||||
expect(brand1Occurrences.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
test("plan selection persists after switching billing period", async () => {
|
||||
apiGet.mockResolvedValue(fixtures.noSub);
|
||||
renderWithProviders(<SubscriptionPage />);
|
||||
await waitFor(() => expect(screen.getAllByText("Plan Seç").length).toBe(4));
|
||||
|
||||
// Select brand2
|
||||
await act(async () => {
|
||||
screen.getAllByText("Plan Seç")[1].click();
|
||||
});
|
||||
await waitFor(() => expect(screen.getByText("Devam Et")).toBeInTheDocument());
|
||||
|
||||
// Switch to yearly
|
||||
await act(async () => {
|
||||
screen.getByText("Yıllık").click();
|
||||
});
|
||||
|
||||
// "Devam Et" is still present (selection was preserved)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Devam Et")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test("plan_selected PostHog event fires when clicking a plan card", async () => {
|
||||
apiGet.mockResolvedValue(fixtures.noSub);
|
||||
renderWithProviders(<SubscriptionPage />);
|
||||
await waitFor(() => expect(screen.getAllByText("Plan Seç").length).toBe(4));
|
||||
|
||||
await act(async () => {
|
||||
screen.getAllByText("Plan Seç")[0].click();
|
||||
});
|
||||
|
||||
expect(capture).toHaveBeenCalledWith("plan_selected", { plan: "brand1" });
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// Group D — CTA Navigation + PostHog (P0-10 completion)
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe("P0-10 (part 2) — checkout_started event and navigation", () => {
|
||||
test("CTA click on full plan navigates to /dashboard/subscription/pay", async () => {
|
||||
apiGet.mockResolvedValue(fixtures.noSub);
|
||||
renderWithProviders(<SubscriptionPage />);
|
||||
await waitFor(() => expect(screen.getAllByText("Plan Seç").length).toBe(4));
|
||||
|
||||
// Select full plan (index 3) — full plan bypasses brand validation
|
||||
await act(async () => {
|
||||
screen.getAllByText("Plan Seç")[3].click();
|
||||
});
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId("brand-selector")).toBeInTheDocument());
|
||||
|
||||
// Click proceed CTA (the bottom-right navigation button)
|
||||
await act(async () => {
|
||||
const proceedButtons = screen
|
||||
.getAllByRole("button")
|
||||
.filter((b) => b.textContent?.includes("Devam Et"));
|
||||
// Use last — card footer button re-triggers selection; bottom button navigates
|
||||
proceedButtons[proceedButtons.length - 1].click();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNavigate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
to: "/dashboard/subscription/pay",
|
||||
search: expect.objectContaining({ plan: "full", period: "monthly" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("checkout_started PostHog event fires with plan and period on CTA click", async () => {
|
||||
apiGet.mockResolvedValue(fixtures.noSub);
|
||||
renderWithProviders(<SubscriptionPage />);
|
||||
await waitFor(() => expect(screen.getAllByText("Plan Seç").length).toBe(4));
|
||||
|
||||
await act(async () => {
|
||||
screen.getAllByText("Plan Seç")[3].click(); // full plan
|
||||
});
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId("brand-selector")).toBeInTheDocument());
|
||||
|
||||
await act(async () => {
|
||||
const proceedButtons = screen
|
||||
.getAllByRole("button")
|
||||
.filter((b) => b.textContent?.includes("Devam Et"));
|
||||
proceedButtons[proceedButtons.length - 1].click();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(capture).toHaveBeenCalledWith("checkout_started", {
|
||||
plan: "full",
|
||||
period: "monthly",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// Group E — Trial Card Visibility (P0-6)
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe("P0-6 — Trial card visibility", () => {
|
||||
test("trial CTA card visible when no subscription and eligible for trial", async () => {
|
||||
apiGet.mockResolvedValue(fixtures.trialEligible);
|
||||
renderWithProviders(<SubscriptionPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Ücretsiz Denemeyi Başlat")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test("P0-6: trial CTA card hidden when subscription.status is trial", async () => {
|
||||
apiGet.mockResolvedValue(fixtures.trialActive);
|
||||
renderWithProviders(<SubscriptionPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
const badges = screen.getAllByText("Mevcut Plan");
|
||||
expect(badges.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
expect(screen.queryByText("Ücretsiz Denemeyi Başlat")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("trial CTA hidden for expired subscription that is not trial-eligible", async () => {
|
||||
apiGet.mockResolvedValue(fixtures.expired);
|
||||
renderWithProviders(<SubscriptionPage />);
|
||||
// "Full Paket" appears in both plan card and feature matrix table header
|
||||
await waitFor(() => expect(screen.getAllByText("Full Paket").length).toBeGreaterThanOrEqual(1));
|
||||
expect(screen.queryByText("Ücretsiz Denemeyi Başlat")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// Group F — Edge Cases
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe("Edge cases — subscription states", () => {
|
||||
test("no-subscription banner visible when not eligible for trial and no subscription", async () => {
|
||||
apiGet.mockResolvedValue(fixtures.noSub);
|
||||
renderWithProviders(<SubscriptionPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Aktif aboneliğiniz yok.")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test("cancelled subscription shows Aboneliği Devam Ettir button", async () => {
|
||||
apiGet.mockResolvedValue(fixtures.cancelled);
|
||||
renderWithProviders(<SubscriptionPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Aboneliği Devam Ettir")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// Group G — Accessibility
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe("Accessibility", () => {
|
||||
test("plan selection CTA buttons have descriptive labels (not icon-only)", async () => {
|
||||
apiGet.mockResolvedValue(fixtures.noSub);
|
||||
renderWithProviders(<SubscriptionPage />);
|
||||
await waitFor(() => expect(screen.getAllByText("Plan Seç").length).toBe(4));
|
||||
|
||||
const buttons = screen.getAllByRole("button", { name: /plan seç/i });
|
||||
expect(buttons.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
test("popular plan is identifiable by text (Popüler badge), not just color", async () => {
|
||||
apiGet.mockResolvedValue(fixtures.noSub);
|
||||
renderWithProviders(<SubscriptionPage />);
|
||||
await waitFor(() => expect(screen.getAllByText("Popüler").length).toBeGreaterThanOrEqual(1));
|
||||
|
||||
// Verify the badge element is present
|
||||
const popularBadges = screen.getAllByText("Popüler");
|
||||
expect(popularBadges.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,53 +0,0 @@
|
||||
import { Skeleton } from "@sase/ui";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { Suspense, lazy } from "react";
|
||||
|
||||
const PaymentContent = lazy(() =>
|
||||
import("@/components/payment/payment-content").then((mod) => ({
|
||||
default: mod.PaymentContent,
|
||||
})),
|
||||
);
|
||||
|
||||
export const Route = createFileRoute("/dashboard/subscription/pay")({
|
||||
validateSearch: (search: Record<string, unknown>) => {
|
||||
const params: {
|
||||
plan: string;
|
||||
period: "monthly" | "yearly";
|
||||
brands: string;
|
||||
result?: string;
|
||||
} = {
|
||||
plan: (search.plan as string) || "",
|
||||
period: ((search.period as string) || "monthly") as "monthly" | "yearly",
|
||||
brands: (search.brands as string) || "",
|
||||
};
|
||||
const result = search.result as string | undefined;
|
||||
if (result) params.result = result;
|
||||
return params;
|
||||
},
|
||||
component: PaymentPage,
|
||||
});
|
||||
|
||||
function PaymentPage() {
|
||||
const { plan, period, brands, result } = Route.useSearch();
|
||||
const brandIds = brands.split(",").filter(Boolean);
|
||||
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="mx-auto max-w-3xl space-y-6">
|
||||
<Skeleton className="h-8 w-24" />
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Skeleton className="h-8 w-8 rounded-full" />
|
||||
<Skeleton className="h-4 w-16" />
|
||||
<Skeleton className="h-8 w-8 rounded-full" />
|
||||
<Skeleton className="h-4 w-16" />
|
||||
<Skeleton className="h-8 w-8 rounded-full" />
|
||||
</div>
|
||||
<Skeleton className="h-96 w-full" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<PaymentContent planKey={plan} period={period} brandIds={brandIds} result={result} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -28,9 +28,17 @@ export const envSchema = z.object({
|
||||
|
||||
CORS_ORIGIN: z.string().default("http://localhost:3000"),
|
||||
|
||||
IYZICO_API_KEY: z.string().optional(),
|
||||
IYZICO_SECRET_KEY: z.string().optional(),
|
||||
IYZICO_BASE_URL: z.string().optional(),
|
||||
STRIPE_SECRET_KEY: z.string().optional(),
|
||||
STRIPE_PUBLISHABLE_KEY: z.string().optional(),
|
||||
STRIPE_WEBHOOK_SECRET: z.string().optional(),
|
||||
STRIPE_SUCCESS_URL: z
|
||||
.string()
|
||||
.url()
|
||||
.default("http://localhost:3000/dashboard/subscription?stripe=success"),
|
||||
STRIPE_CANCEL_URL: z
|
||||
.string()
|
||||
.url()
|
||||
.default("http://localhost:3000/dashboard/subscription?stripe=cancelled"),
|
||||
|
||||
PL24_BASE_URL: z.string().optional(),
|
||||
PL24_COMPANY_CODE: z.string().optional(),
|
||||
|
||||
16
pnpm-lock.yaml
generated
16
pnpm-lock.yaml
generated
@@ -147,6 +147,9 @@ importers:
|
||||
rxjs:
|
||||
specifier: ^7.8.0
|
||||
version: 7.8.2
|
||||
stripe:
|
||||
specifier: ^22.1.1
|
||||
version: 22.1.1(@types/node@22.19.11)
|
||||
undici:
|
||||
specifier: ^7.22.0
|
||||
version: 7.22.0
|
||||
@@ -5529,6 +5532,15 @@ packages:
|
||||
strip-literal@3.1.0:
|
||||
resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==}
|
||||
|
||||
stripe@22.1.1:
|
||||
resolution: {integrity: sha512-cmodIYP27tBkJ8G7DuGgWw0PFuemlFZbuF3Wwr1TrjFjUa3T7NIgCe6TVwX8BO2ynu+xtTuDGfHafNDCPt9lXA==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
'@types/node': '>=18'
|
||||
peerDependenciesMeta:
|
||||
'@types/node':
|
||||
optional: true
|
||||
|
||||
strnum@2.1.2:
|
||||
resolution: {integrity: sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==}
|
||||
|
||||
@@ -11678,6 +11690,10 @@ snapshots:
|
||||
dependencies:
|
||||
js-tokens: 9.0.1
|
||||
|
||||
stripe@22.1.1(@types/node@22.19.11):
|
||||
optionalDependencies:
|
||||
'@types/node': 22.19.11
|
||||
|
||||
strnum@2.1.2: {}
|
||||
|
||||
strtok3@10.3.4:
|
||||
|
||||
Reference in New Issue
Block a user