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",
|
"id": "d8a51462-659a-4425-8a7f-090dd5a70926",
|
||||||
"prevId": "03894be0-b7bc-4a6a-940d-37b9f4a71b27",
|
"prevId": "b797d2fd-854c-43bd-9924-cd1ca469a239",
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"dialect": "postgresql",
|
"dialect": "postgresql",
|
||||||
"tables": {
|
"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",
|
"posthog-node": "^5.34.1",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.0",
|
"rxjs": "^7.8.0",
|
||||||
|
"stripe": "^22.1.1",
|
||||||
"undici": "^7.22.0",
|
"undici": "^7.22.0",
|
||||||
"zod": "^3.24.0"
|
"zod": "^3.24.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -25,10 +25,16 @@ export default () => ({
|
|||||||
cors: {
|
cors: {
|
||||||
origin: (process.env.CORS_ORIGIN || "http://localhost:3000").split(","),
|
origin: (process.env.CORS_ORIGIN || "http://localhost:3000").split(","),
|
||||||
},
|
},
|
||||||
iyzico: {
|
stripe: {
|
||||||
apiKey: process.env.IYZICO_API_KEY,
|
secretKey: process.env.STRIPE_SECRET_KEY,
|
||||||
secretKey: process.env.IYZICO_SECRET_KEY,
|
publishableKey: process.env.STRIPE_PUBLISHABLE_KEY,
|
||||||
baseUrl: process.env.IYZICO_BASE_URL,
|
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: {
|
pl24: {
|
||||||
baseUrl: process.env.PL24_BASE_URL || "https://www.partslink24.com",
|
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 ───────────────────────────────────────
|
// ─── Payments ───────────────────────────────────────
|
||||||
export const payments = pgTable(
|
export const payments = pgTable(
|
||||||
"payments",
|
"payments",
|
||||||
@@ -178,6 +200,9 @@ export const payments = pgTable(
|
|||||||
method: varchar("method", { length: 20 }).notNull(),
|
method: varchar("method", { length: 20 }).notNull(),
|
||||||
status: varchar("status", { length: 20 }).default("pending").notNull(),
|
status: varchar("status", { length: 20 }).default("pending").notNull(),
|
||||||
iyzicoPaymentId: text("iyzico_payment_id"),
|
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"),
|
eftReceiptUrl: text("eft_receipt_url"),
|
||||||
adminNote: text("admin_note"),
|
adminNote: text("admin_note"),
|
||||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
@@ -186,6 +211,7 @@ export const payments = pgTable(
|
|||||||
(table) => [
|
(table) => [
|
||||||
index("payments_user_id_idx").on(table.userId),
|
index("payments_user_id_idx").on(table.userId),
|
||||||
index("payments_status_idx").on(table.status),
|
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 { relations } from "drizzle-orm";
|
||||||
import {
|
import {
|
||||||
accounts,
|
accounts,
|
||||||
|
bankAccounts,
|
||||||
brands,
|
brands,
|
||||||
catalogVehicles,
|
catalogVehicles,
|
||||||
categories,
|
categories,
|
||||||
@@ -75,6 +76,14 @@ export const paymentsRelations = relations(payments, ({ one }) => ({
|
|||||||
fields: [payments.subscriptionId],
|
fields: [payments.subscriptionId],
|
||||||
references: [userSubscriptions.id],
|
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 }) => ({
|
export const queryLogsRelations = relations(queryLogs, ({ one }) => ({
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { AppModule } from "./app.module";
|
|||||||
import { fileUploadValidation } from "./common/middleware/file-upload-validation.middleware";
|
import { fileUploadValidation } from "./common/middleware/file-upload-validation.middleware";
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create(AppModule);
|
const app = await NestFactory.create(AppModule, { rawBody: true });
|
||||||
|
|
||||||
const configService = app.get(ConfigService);
|
const configService = app.get(ConfigService);
|
||||||
const port = configService.get<number>("port", 4000);
|
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 {
|
export class PaymentsController {
|
||||||
constructor(private paymentsService: PaymentsService) {}
|
constructor(private paymentsService: PaymentsService) {}
|
||||||
|
|
||||||
@Post("iyzico/initialize")
|
@Get("bank-info")
|
||||||
async initializeIyzico(
|
async getBankInfo() {
|
||||||
@CurrentUser("id") userId: string,
|
return this.paymentsService.getActiveBankInfo();
|
||||||
@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,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post("eft")
|
@Post("eft")
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { SubscriptionsModule } from "../subscriptions/subscriptions.module";
|
import { SubscriptionsModule } from "../subscriptions/subscriptions.module";
|
||||||
|
import { BankAccountsModule } from "./bank-accounts/bank-accounts.module";
|
||||||
import { PaymentsController } from "./payments.controller";
|
import { PaymentsController } from "./payments.controller";
|
||||||
import { PaymentsService } from "./payments.service";
|
import { PaymentsService } from "./payments.service";
|
||||||
|
import { StripeModule } from "./stripe/stripe.module";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [SubscriptionsModule],
|
imports: [SubscriptionsModule, BankAccountsModule, StripeModule],
|
||||||
controllers: [PaymentsController],
|
controllers: [PaymentsController],
|
||||||
providers: [PaymentsService],
|
providers: [PaymentsService],
|
||||||
exports: [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 {
|
||||||
import { ConfigService } from "@nestjs/config";
|
BadRequestException,
|
||||||
|
Inject,
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
NotFoundException,
|
||||||
|
ServiceUnavailableException,
|
||||||
|
} from "@nestjs/common";
|
||||||
import { and, desc, eq } from "drizzle-orm";
|
import { and, desc, eq } from "drizzle-orm";
|
||||||
import { DATABASE, type Database } from "../database/database.provider";
|
import { DATABASE, type Database } from "../database/database.provider";
|
||||||
import { payments, plans, userSubscriptions } from "../database/schema/core";
|
import { payments, plans, userSubscriptions } from "../database/schema/core";
|
||||||
import { PostHogService } from "../posthog/posthog.service";
|
import { PostHogService } from "../posthog/posthog.service";
|
||||||
import { StorageService } from "../storage/storage.service";
|
import { StorageService } from "../storage/storage.service";
|
||||||
import { SubscriptionsService } from "../subscriptions/subscriptions.service";
|
import { SubscriptionsService } from "../subscriptions/subscriptions.service";
|
||||||
|
import { BankAccountsService } from "./bank-accounts/bank-accounts.service";
|
||||||
|
|
||||||
const PLAN_KEY_TO_BRAND_COUNT: Record<string, number> = {
|
const PLAN_KEY_TO_BRAND_COUNT: Record<string, number> = {
|
||||||
brand1: 1,
|
brand1: 1,
|
||||||
@@ -20,10 +27,10 @@ export class PaymentsService {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(DATABASE) private db: Database,
|
@Inject(DATABASE) private db: Database,
|
||||||
private configService: ConfigService,
|
|
||||||
private subscriptionsService: SubscriptionsService,
|
private subscriptionsService: SubscriptionsService,
|
||||||
private storageService: StorageService,
|
private storageService: StorageService,
|
||||||
private posthog: PostHogService,
|
private posthog: PostHogService,
|
||||||
|
private bankAccounts: BankAccountsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private async resolvePlanId(planKey: string): Promise<string> {
|
private async resolvePlanId(planKey: string): Promise<string> {
|
||||||
@@ -61,90 +68,33 @@ export class PaymentsService {
|
|||||||
return subscription;
|
return subscription;
|
||||||
}
|
}
|
||||||
|
|
||||||
async initializeIyzico(
|
async getActiveBankInfo() {
|
||||||
userId: string,
|
const account = await this.bankAccounts.findActive();
|
||||||
planKey: string,
|
if (!account) return null;
|
||||||
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}`);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
paymentId: payment.id,
|
id: account.id,
|
||||||
status: "pending",
|
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(
|
async createEftPayment(
|
||||||
userId: string,
|
userId: string,
|
||||||
planKey: string,
|
planKey: string,
|
||||||
billingPeriod: "monthly" | "yearly",
|
billingPeriod: "monthly" | "yearly",
|
||||||
brandIds: string[],
|
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 sub = await this.createSubscriptionForPayment(userId, planKey, billingPeriod, brandIds);
|
||||||
|
|
||||||
const [plan] = await this.db.select().from(plans).where(eq(plans.id, sub.planId)).limit(1);
|
const [plan] = await this.db.select().from(plans).where(eq(plans.id, sub.planId)).limit(1);
|
||||||
@@ -159,16 +109,33 @@ export class PaymentsService {
|
|||||||
currency: "TRY",
|
currency: "TRY",
|
||||||
method: "eft",
|
method: "eft",
|
||||||
status: "pending",
|
status: "pending",
|
||||||
|
bankAccountId: activeAccount.id,
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
|
this.posthog.captureForUser(userId, "payment_initiated", {
|
||||||
|
method: "eft",
|
||||||
|
payment_id: payment.id,
|
||||||
|
plan: planKey,
|
||||||
|
period: billingPeriod,
|
||||||
|
amount,
|
||||||
|
bank_account_id: activeAccount.id,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
paymentId: payment.id,
|
paymentId: payment.id,
|
||||||
bankInfo: {
|
bankInfo: {
|
||||||
bankName: "Ziraat Bankası",
|
id: activeAccount.id,
|
||||||
iban: "TR33 0001 0000 1234 5678 9012 34",
|
bankName: activeAccount.bankName,
|
||||||
accountHolder: "Sase Teknoloji A.Ş.",
|
accountHolder: activeAccount.accountHolder,
|
||||||
description: `SASE-${payment.id.substring(0, 8).toUpperCase()}`,
|
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() })
|
.set({ eftReceiptUrl: url, updatedAt: new Date() })
|
||||||
.where(eq(payments.id, paymentId));
|
.where(eq(payments.id, paymentId));
|
||||||
|
|
||||||
|
this.posthog.captureForUser(userId, "receipt_uploaded", {
|
||||||
|
payment_id: paymentId,
|
||||||
|
});
|
||||||
|
|
||||||
return { receiptUrl: url };
|
return { receiptUrl: url };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,6 +205,17 @@ export class PaymentsService {
|
|||||||
.where(eq(payments.id, paymentId));
|
.where(eq(payments.id, paymentId));
|
||||||
|
|
||||||
if (payment) {
|
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", {
|
this.posthog.captureForUser(payment.userId, "payment_failed", {
|
||||||
method: "eft",
|
method: "eft",
|
||||||
payment_id: paymentId,
|
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 endDate = subscription?.endDate;
|
||||||
const days =
|
const days =
|
||||||
subscription?.status === "trial" && endDate
|
subscription?.status === "trial" && endDate
|
||||||
? Math.max(
|
? Math.max(0, Math.ceil((new Date(endDate).getTime() - Date.now()) / (1000 * 60 * 60 * 24)))
|
||||||
0,
|
|
||||||
Math.ceil((new Date(endDate).getTime() - Date.now()) / (1000 * 60 * 60 * 24)),
|
|
||||||
)
|
|
||||||
: null;
|
: null;
|
||||||
const visible = !isLoading && days !== null && days <= 7;
|
const visible = !isLoading && days !== null && days <= 7;
|
||||||
const dismissKey = endDate ? getDismissKey(endDate) : null;
|
const dismissKey = endDate ? getDismissKey(endDate) : null;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
"confirm": "Confirm",
|
"confirm": "Confirm",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
|
"change": "Change",
|
||||||
"close": "Close",
|
"close": "Close",
|
||||||
"back": "Back",
|
"back": "Back",
|
||||||
"next": "Next",
|
"next": "Next",
|
||||||
@@ -122,6 +123,35 @@
|
|||||||
},
|
},
|
||||||
"subscription": {
|
"subscription": {
|
||||||
"title": "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.",
|
"noSubscription": "You don't have an active subscription.",
|
||||||
"choosePlan": "Choose Plan",
|
"choosePlan": "Choose Plan",
|
||||||
"subscribe": "Subscribe",
|
"subscribe": "Subscribe",
|
||||||
@@ -219,7 +249,7 @@
|
|||||||
"trustCancelAnytime": "Cancel anytime",
|
"trustCancelAnytime": "Cancel anytime",
|
||||||
"trustRefund": "7-day refund guarantee",
|
"trustRefund": "7-day refund guarantee",
|
||||||
"paymentTrustSSL": "256-bit SSL",
|
"paymentTrustSSL": "256-bit SSL",
|
||||||
"paymentTrustProvider": "Iyzico infrastructure",
|
"paymentTrustProvider": "Stripe infrastructure",
|
||||||
"paymentTrustKVKK": "KVKK compliant",
|
"paymentTrustKVKK": "KVKK compliant",
|
||||||
"trialDaysLeft": "{days} days left",
|
"trialDaysLeft": "{days} days left",
|
||||||
"trialBanner": {
|
"trialBanner": {
|
||||||
@@ -235,7 +265,7 @@
|
|||||||
"selectedBrands": "Selected Brands",
|
"selectedBrands": "Selected Brands",
|
||||||
"totalAmount": "Total Amount",
|
"totalAmount": "Total Amount",
|
||||||
"paymentMethod": "Payment Method",
|
"paymentMethod": "Payment Method",
|
||||||
"creditCard": "Credit Card (iyzico)",
|
"creditCard": "Credit Card",
|
||||||
"eftTransfer": "EFT/Wire Transfer",
|
"eftTransfer": "EFT/Wire Transfer",
|
||||||
"payWithCard": "Pay with Card",
|
"payWithCard": "Pay with Card",
|
||||||
"paying": "Processing payment...",
|
"paying": "Processing payment...",
|
||||||
@@ -264,11 +294,45 @@
|
|||||||
"goToDashboard": "Go to Dashboard",
|
"goToDashboard": "Go to Dashboard",
|
||||||
"initializeFailed": "Payment initialization failed. Please try again.",
|
"initializeFailed": "Payment initialization failed. Please try again.",
|
||||||
"processingPayment": "Processing payment...",
|
"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",
|
"eftPaid": "I Made EFT/Wire Transfer",
|
||||||
"eftStatus": {
|
"eftStatus": {
|
||||||
"created": "EFT/Wire transfer record created",
|
"created": "EFT/Wire transfer record created",
|
||||||
"receiptUploaded": "Receipt uploaded"
|
"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": {
|
"billing": {
|
||||||
@@ -291,6 +355,7 @@
|
|||||||
},
|
},
|
||||||
"methodLabels": {
|
"methodLabels": {
|
||||||
"iyzico": "Credit Card",
|
"iyzico": "Credit Card",
|
||||||
|
"stripe": "Credit Card",
|
||||||
"eft": "EFT/Wire"
|
"eft": "EFT/Wire"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
"confirm": "Onayla",
|
"confirm": "Onayla",
|
||||||
"delete": "Sil",
|
"delete": "Sil",
|
||||||
"edit": "Düzenle",
|
"edit": "Düzenle",
|
||||||
|
"change": "Değiştir",
|
||||||
"close": "Kapat",
|
"close": "Kapat",
|
||||||
"back": "Geri",
|
"back": "Geri",
|
||||||
"next": "İleri",
|
"next": "İleri",
|
||||||
@@ -122,6 +123,35 @@
|
|||||||
},
|
},
|
||||||
"subscription": {
|
"subscription": {
|
||||||
"title": "Abonelik",
|
"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.",
|
"noSubscription": "Aktif aboneliğiniz yok.",
|
||||||
"choosePlan": "Plan Seç",
|
"choosePlan": "Plan Seç",
|
||||||
"subscribe": "Abone Ol",
|
"subscribe": "Abone Ol",
|
||||||
@@ -219,7 +249,7 @@
|
|||||||
"trustCancelAnytime": "İstediğin zaman iptal",
|
"trustCancelAnytime": "İstediğin zaman iptal",
|
||||||
"trustRefund": "7 gün iade garantisi",
|
"trustRefund": "7 gün iade garantisi",
|
||||||
"paymentTrustSSL": "256-bit SSL",
|
"paymentTrustSSL": "256-bit SSL",
|
||||||
"paymentTrustProvider": "Iyzico altyapısı",
|
"paymentTrustProvider": "Stripe altyapısı",
|
||||||
"paymentTrustKVKK": "KVKK uyumlu",
|
"paymentTrustKVKK": "KVKK uyumlu",
|
||||||
"trialDaysLeft": "{days} gün kaldı",
|
"trialDaysLeft": "{days} gün kaldı",
|
||||||
"trialBanner": {
|
"trialBanner": {
|
||||||
@@ -235,7 +265,7 @@
|
|||||||
"selectedBrands": "Seçilen Markalar",
|
"selectedBrands": "Seçilen Markalar",
|
||||||
"totalAmount": "Toplam Tutar",
|
"totalAmount": "Toplam Tutar",
|
||||||
"paymentMethod": "Ödeme Yöntemi",
|
"paymentMethod": "Ödeme Yöntemi",
|
||||||
"creditCard": "Kredi Kartı (iyzico)",
|
"creditCard": "Kredi Kartı",
|
||||||
"eftTransfer": "EFT/Havale",
|
"eftTransfer": "EFT/Havale",
|
||||||
"payWithCard": "Kartla Öde",
|
"payWithCard": "Kartla Öde",
|
||||||
"paying": "Ödeme yapılıyor...",
|
"paying": "Ödeme yapılıyor...",
|
||||||
@@ -264,11 +294,45 @@
|
|||||||
"goToDashboard": "Panele Git",
|
"goToDashboard": "Panele Git",
|
||||||
"initializeFailed": "Ödeme başlatılamadı. Lütfen tekrar deneyin.",
|
"initializeFailed": "Ödeme başlatılamadı. Lütfen tekrar deneyin.",
|
||||||
"processingPayment": "Ödeme işleniyor...",
|
"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",
|
"eftPaid": "EFT/Havale Yaptım",
|
||||||
"eftStatus": {
|
"eftStatus": {
|
||||||
"created": "EFT/Havale kaydı oluşturuldu",
|
"created": "EFT/Havale kaydı oluşturuldu",
|
||||||
"receiptUploaded": "Dekont yüklendi"
|
"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": {
|
"billing": {
|
||||||
@@ -291,6 +355,7 @@
|
|||||||
},
|
},
|
||||||
"methodLabels": {
|
"methodLabels": {
|
||||||
"iyzico": "Kredi Kartı",
|
"iyzico": "Kredi Kartı",
|
||||||
|
"stripe": "Kredi Kartı",
|
||||||
"eft": "EFT/Havale"
|
"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"),
|
CORS_ORIGIN: z.string().default("http://localhost:3000"),
|
||||||
|
|
||||||
IYZICO_API_KEY: z.string().optional(),
|
STRIPE_SECRET_KEY: z.string().optional(),
|
||||||
IYZICO_SECRET_KEY: z.string().optional(),
|
STRIPE_PUBLISHABLE_KEY: z.string().optional(),
|
||||||
IYZICO_BASE_URL: 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_BASE_URL: z.string().optional(),
|
||||||
PL24_COMPANY_CODE: 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:
|
rxjs:
|
||||||
specifier: ^7.8.0
|
specifier: ^7.8.0
|
||||||
version: 7.8.2
|
version: 7.8.2
|
||||||
|
stripe:
|
||||||
|
specifier: ^22.1.1
|
||||||
|
version: 22.1.1(@types/node@22.19.11)
|
||||||
undici:
|
undici:
|
||||||
specifier: ^7.22.0
|
specifier: ^7.22.0
|
||||||
version: 7.22.0
|
version: 7.22.0
|
||||||
@@ -5529,6 +5532,15 @@ packages:
|
|||||||
strip-literal@3.1.0:
|
strip-literal@3.1.0:
|
||||||
resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==}
|
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:
|
strnum@2.1.2:
|
||||||
resolution: {integrity: sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==}
|
resolution: {integrity: sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==}
|
||||||
|
|
||||||
@@ -11678,6 +11690,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
js-tokens: 9.0.1
|
js-tokens: 9.0.1
|
||||||
|
|
||||||
|
stripe@22.1.1(@types/node@22.19.11):
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/node': 22.19.11
|
||||||
|
|
||||||
strnum@2.1.2: {}
|
strnum@2.1.2: {}
|
||||||
|
|
||||||
strtok3@10.3.4:
|
strtok3@10.3.4:
|
||||||
|
|||||||
Reference in New Issue
Block a user