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:
Sase Dev
2026-05-13 22:42:28 +00:00
parent 0b6d4bb0a7
commit 6a0b36a7a0
31 changed files with 8756 additions and 4302 deletions

View 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");

View File

@@ -1,6 +1,6 @@
{
"id": "b797d2fd-854c-43bd-9924-cd1ca469a239",
"prevId": "03894be0-b7bc-4a6a-940d-37b9f4a71b27",
"id": "d8a51462-659a-4425-8a7f-090dd5a70926",
"prevId": "b797d2fd-854c-43bd-9924-cd1ca469a239",
"version": "7",
"dialect": "postgresql",
"tables": {

File diff suppressed because it is too large Load Diff

View File

@@ -63,6 +63,7 @@
"posthog-node": "^5.34.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.0",
"stripe": "^22.1.1",
"undici": "^7.22.0",
"zod": "^3.24.0"
},

View File

@@ -25,10 +25,16 @@ export default () => ({
cors: {
origin: (process.env.CORS_ORIGIN || "http://localhost:3000").split(","),
},
iyzico: {
apiKey: process.env.IYZICO_API_KEY,
secretKey: process.env.IYZICO_SECRET_KEY,
baseUrl: process.env.IYZICO_BASE_URL,
stripe: {
secretKey: process.env.STRIPE_SECRET_KEY,
publishableKey: process.env.STRIPE_PUBLISHABLE_KEY,
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET,
successUrl:
process.env.STRIPE_SUCCESS_URL ||
"http://localhost:3000/dashboard/subscription?stripe=success",
cancelUrl:
process.env.STRIPE_CANCEL_URL ||
"http://localhost:3000/dashboard/subscription?stripe=cancelled",
},
pl24: {
baseUrl: process.env.PL24_BASE_URL || "https://www.partslink24.com",

View File

@@ -162,6 +162,28 @@ export const userBrands = pgTable(
],
);
// ─── Bank Accounts (EFT/Havale destination accounts, one active at a time) ────
export const bankAccounts = pgTable(
"bank_accounts",
{
id: uuid("id").primaryKey().defaultRandom(),
bankName: varchar("bank_name", { length: 100 }).notNull(),
accountHolder: varchar("account_holder", { length: 200 }).notNull(),
iban: varchar("iban", { length: 34 }).notNull(),
kolayAdres: varchar("kolay_adres", { length: 100 }),
kolayAdresType: varchar("kolay_adres_type", { length: 20 }),
qrImageUrl: text("qr_image_url"),
descriptionTemplate: varchar("description_template", { length: 200 })
.notNull()
.default("SASE-{{paymentId}}"),
isActive: boolean("is_active").default(false).notNull(),
displayOrder: integer("display_order").default(0).notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [index("bank_accounts_active_idx").on(table.isActive)],
);
// ─── Payments ───────────────────────────────────────
export const payments = pgTable(
"payments",
@@ -178,6 +200,9 @@ export const payments = pgTable(
method: varchar("method", { length: 20 }).notNull(),
status: varchar("status", { length: 20 }).default("pending").notNull(),
iyzicoPaymentId: text("iyzico_payment_id"),
stripeSessionId: text("stripe_session_id"),
stripePaymentIntentId: text("stripe_payment_intent_id"),
bankAccountId: uuid("bank_account_id").references(() => bankAccounts.id),
eftReceiptUrl: text("eft_receipt_url"),
adminNote: text("admin_note"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
@@ -186,6 +211,7 @@ export const payments = pgTable(
(table) => [
index("payments_user_id_idx").on(table.userId),
index("payments_status_idx").on(table.status),
index("payments_stripe_session_id_idx").on(table.stripeSessionId),
],
);

View File

@@ -1,6 +1,7 @@
import { relations } from "drizzle-orm";
import {
accounts,
bankAccounts,
brands,
catalogVehicles,
categories,
@@ -75,6 +76,14 @@ export const paymentsRelations = relations(payments, ({ one }) => ({
fields: [payments.subscriptionId],
references: [userSubscriptions.id],
}),
bankAccount: one(bankAccounts, {
fields: [payments.bankAccountId],
references: [bankAccounts.id],
}),
}));
export const bankAccountsRelations = relations(bankAccounts, ({ many }) => ({
payments: many(payments),
}));
export const queryLogsRelations = relations(queryLogs, ({ one }) => ({

View File

@@ -10,7 +10,7 @@ import { AppModule } from "./app.module";
import { fileUploadValidation } from "./common/middleware/file-upload-validation.middleware";
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const app = await NestFactory.create(AppModule, { rawBody: true });
const configService = app.get(ConfigService);
const port = configService.get<number>("port", 4000);

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

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

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

View File

@@ -20,28 +20,9 @@ import { PaymentsService } from "./payments.service";
export class PaymentsController {
constructor(private paymentsService: PaymentsService) {}
@Post("iyzico/initialize")
async initializeIyzico(
@CurrentUser("id") userId: string,
@Body() body: { planKey: string; billingPeriod: "monthly" | "yearly"; brandIds: string[] },
) {
return this.paymentsService.initializeIyzico(
userId,
body.planKey,
body.billingPeriod,
body.brandIds,
);
}
@Post("iyzico/callback")
async iyzicoCallback(
@Body() body: { paymentId: string; iyzicoPaymentId: string; status: string },
) {
return this.paymentsService.handleIyzicoCallback(
body.paymentId,
body.iyzicoPaymentId,
body.status,
);
@Get("bank-info")
async getBankInfo() {
return this.paymentsService.getActiveBankInfo();
}
@Post("eft")

View File

@@ -1,10 +1,12 @@
import { Module } from "@nestjs/common";
import { SubscriptionsModule } from "../subscriptions/subscriptions.module";
import { BankAccountsModule } from "./bank-accounts/bank-accounts.module";
import { PaymentsController } from "./payments.controller";
import { PaymentsService } from "./payments.service";
import { StripeModule } from "./stripe/stripe.module";
@Module({
imports: [SubscriptionsModule],
imports: [SubscriptionsModule, BankAccountsModule, StripeModule],
controllers: [PaymentsController],
providers: [PaymentsService],
exports: [PaymentsService],

View File

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

View File

@@ -1,11 +1,18 @@
import { BadRequestException, Inject, Injectable, Logger, NotFoundException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import {
BadRequestException,
Inject,
Injectable,
Logger,
NotFoundException,
ServiceUnavailableException,
} from "@nestjs/common";
import { and, desc, eq } from "drizzle-orm";
import { DATABASE, type Database } from "../database/database.provider";
import { payments, plans, userSubscriptions } from "../database/schema/core";
import { PostHogService } from "../posthog/posthog.service";
import { StorageService } from "../storage/storage.service";
import { SubscriptionsService } from "../subscriptions/subscriptions.service";
import { BankAccountsService } from "./bank-accounts/bank-accounts.service";
const PLAN_KEY_TO_BRAND_COUNT: Record<string, number> = {
brand1: 1,
@@ -20,10 +27,10 @@ export class PaymentsService {
constructor(
@Inject(DATABASE) private db: Database,
private configService: ConfigService,
private subscriptionsService: SubscriptionsService,
private storageService: StorageService,
private posthog: PostHogService,
private bankAccounts: BankAccountsService,
) {}
private async resolvePlanId(planKey: string): Promise<string> {
@@ -61,90 +68,33 @@ export class PaymentsService {
return subscription;
}
async initializeIyzico(
userId: string,
planKey: string,
billingPeriod: "monthly" | "yearly",
brandIds: string[],
) {
const sub = await this.createSubscriptionForPayment(userId, planKey, billingPeriod, brandIds);
const amount =
billingPeriod === "yearly"
? (await this.db.select().from(plans).where(eq(plans.id, sub.planId)).limit(1))[0]
.priceYearly
: (await this.db.select().from(plans).where(eq(plans.id, sub.planId)).limit(1))[0]
.priceMonthly;
const [payment] = await this.db
.insert(payments)
.values({
userId,
subscriptionId: sub.id,
amount,
currency: "TRY",
method: "iyzico",
status: "pending",
})
.returning();
// TODO: Integrate with actual iyzico API
this.logger.log(`iyzico payment initialized for subscription ${sub.id}`);
async getActiveBankInfo() {
const account = await this.bankAccounts.findActive();
if (!account) return null;
return {
paymentId: payment.id,
status: "pending",
id: account.id,
bankName: account.bankName,
accountHolder: account.accountHolder,
iban: account.iban,
kolayAdres: account.kolayAdres,
kolayAdresType: account.kolayAdresType,
qrImageUrl: account.qrImageUrl,
};
}
async handleIyzicoCallback(paymentId: string, iyzicoPaymentId: string, status: string) {
const [payment] = await this.db
.select()
.from(payments)
.where(eq(payments.id, paymentId))
.limit(1);
if (!payment) throw new NotFoundException("Ödeme bulunamadı");
const newStatus = status === "success" ? "completed" : "failed";
await this.db
.update(payments)
.set({
status: newStatus,
iyzicoPaymentId,
updatedAt: new Date(),
})
.where(eq(payments.id, paymentId));
if (newStatus === "completed") {
await this.subscriptionsService.activateSubscription(payment.subscriptionId);
this.posthog.captureForUser(payment.userId, "payment_success", {
method: "iyzico",
payment_id: paymentId,
iyzico_payment_id: iyzicoPaymentId,
subscription_id: payment.subscriptionId,
amount: Number(payment.amount),
});
} else {
this.posthog.captureForUser(payment.userId, "payment_failed", {
method: "iyzico",
payment_id: paymentId,
iyzico_payment_id: iyzicoPaymentId,
subscription_id: payment.subscriptionId,
reason: status,
});
}
return { status: newStatus };
}
async createEftPayment(
userId: string,
planKey: string,
billingPeriod: "monthly" | "yearly",
brandIds: string[],
) {
const activeAccount = await this.bankAccounts.findActive();
if (!activeAccount) {
throw new ServiceUnavailableException(
"Havale şu an kullanılamıyor. Lütfen kart ile ödemeyi deneyin.",
);
}
const sub = await this.createSubscriptionForPayment(userId, planKey, billingPeriod, brandIds);
const [plan] = await this.db.select().from(plans).where(eq(plans.id, sub.planId)).limit(1);
@@ -159,16 +109,33 @@ export class PaymentsService {
currency: "TRY",
method: "eft",
status: "pending",
bankAccountId: activeAccount.id,
})
.returning();
this.posthog.captureForUser(userId, "payment_initiated", {
method: "eft",
payment_id: payment.id,
plan: planKey,
period: billingPeriod,
amount,
bank_account_id: activeAccount.id,
});
return {
paymentId: payment.id,
bankInfo: {
bankName: "Ziraat Bankası",
iban: "TR33 0001 0000 1234 5678 9012 34",
accountHolder: "Sase Teknoloji A.Ş.",
description: `SASE-${payment.id.substring(0, 8).toUpperCase()}`,
id: activeAccount.id,
bankName: activeAccount.bankName,
accountHolder: activeAccount.accountHolder,
iban: activeAccount.iban,
kolayAdres: activeAccount.kolayAdres,
kolayAdresType: activeAccount.kolayAdresType,
qrImageUrl: activeAccount.qrImageUrl,
description: this.bankAccounts.resolveDescription(
activeAccount.descriptionTemplate,
payment.id,
),
},
};
}
@@ -191,6 +158,10 @@ export class PaymentsService {
.set({ eftReceiptUrl: url, updatedAt: new Date() })
.where(eq(payments.id, paymentId));
this.posthog.captureForUser(userId, "receipt_uploaded", {
payment_id: paymentId,
});
return { receiptUrl: url };
}
@@ -234,6 +205,17 @@ export class PaymentsService {
.where(eq(payments.id, paymentId));
if (payment) {
// Also expire the pending subscription so the user can retry without "zaten aktif" conflict.
await this.db
.update(userSubscriptions)
.set({ status: "expired", updatedAt: new Date() })
.where(
and(
eq(userSubscriptions.id, payment.subscriptionId),
eq(userSubscriptions.status, "pending"),
),
);
this.posthog.captureForUser(payment.userId, "payment_failed", {
method: "eft",
payment_id: paymentId,

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

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

View 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,
});
}
}

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

View File

@@ -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")} &rarr;
</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>
);
}

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

View File

@@ -39,10 +39,7 @@ export function TrialUrgencyBanner() {
const endDate = subscription?.endDate;
const days =
subscription?.status === "trial" && endDate
? Math.max(
0,
Math.ceil((new Date(endDate).getTime() - Date.now()) / (1000 * 60 * 60 * 24)),
)
? Math.max(0, Math.ceil((new Date(endDate).getTime() - Date.now()) / (1000 * 60 * 60 * 24)))
: null;
const visible = !isLoading && days !== null && days <= 7;
const dismissKey = endDate ? getDismissKey(endDate) : null;

View File

@@ -6,6 +6,7 @@
"confirm": "Confirm",
"delete": "Delete",
"edit": "Edit",
"change": "Change",
"close": "Close",
"back": "Back",
"next": "Next",
@@ -122,6 +123,35 @@
},
"subscription": {
"title": "Subscription",
"subtitle": "Pick a plan, choose your brands, complete payment — all on one page.",
"step": "Step",
"selected": "Selected",
"tapToSelect": "Select",
"selectExactBrands": "Please select exactly {count} brands.",
"steps": {
"plan": {
"title": "Choose a plan",
"short": "Plan",
"description": "Switch between monthly and yearly."
},
"brands": {
"title": "Choose your brands",
"short": "Brands",
"description": "Select {count} brands to include in your plan."
},
"payment": {
"title": "Payment method",
"short": "Payment"
},
"confirmation": {
"short": "Done"
}
},
"stickyCta": {
"toBrands": "Choose brands",
"toPayment": "Go to payment",
"selectBrands": "Select {count} more brand(s)"
},
"noSubscription": "You don't have an active subscription.",
"choosePlan": "Choose Plan",
"subscribe": "Subscribe",
@@ -219,7 +249,7 @@
"trustCancelAnytime": "Cancel anytime",
"trustRefund": "7-day refund guarantee",
"paymentTrustSSL": "256-bit SSL",
"paymentTrustProvider": "Iyzico infrastructure",
"paymentTrustProvider": "Stripe infrastructure",
"paymentTrustKVKK": "KVKK compliant",
"trialDaysLeft": "{days} days left",
"trialBanner": {
@@ -235,7 +265,7 @@
"selectedBrands": "Selected Brands",
"totalAmount": "Total Amount",
"paymentMethod": "Payment Method",
"creditCard": "Credit Card (iyzico)",
"creditCard": "Credit Card",
"eftTransfer": "EFT/Wire Transfer",
"payWithCard": "Pay with Card",
"paying": "Processing payment...",
@@ -264,11 +294,45 @@
"goToDashboard": "Go to Dashboard",
"initializeFailed": "Payment initialization failed. Please try again.",
"processingPayment": "Processing payment...",
"iyzicoTrustNotice": "Secure payment with 3D Secure. Powered by iyzico. You will be redirected to the iyzico payment page when you click the button.",
"eftPaid": "I Made EFT/Wire Transfer",
"eftStatus": {
"created": "EFT/Wire transfer record created",
"receiptUploaded": "Receipt uploaded"
},
"paymentFailed": "Payment failed. Please try again.",
"stripe": {
"secureCheckoutTitle": "Secure payment with 3D Secure",
"secureCheckoutDescription": "Card details are processed on Stripe's PCI-DSS Level 1 infrastructure. Sase never sees them.",
"payButton": "Pay {amount}",
"cancelled": "Payment cancelled. You can try again.",
"verifyingTitle": "Verifying your payment",
"verifyingDescription": "Your subscription will activate as soon as Stripe confirms. Stay on this page for a few seconds.",
"trustProvider": "Stripe payment infrastructure"
},
"bank": {
"bankLabel": "Bank",
"accountHolderLabel": "Account holder",
"ibanLabel": "IBAN",
"kolayAdresLabel": "Easy Address",
"kolayAdresType": {
"email": "Email",
"phone": "Phone",
"tckn": "TR ID"
},
"descriptionLabel": "Reference",
"amountLabel": "Amount",
"copyIban": "Copy IBAN",
"copyKolayAdres": "Copy easy address",
"copyDescription": "Copy reference",
"qrAlt": "Mobile banking QR code",
"qrHint": "Scan in your bank app",
"qrUnavailable": "QR coming soon",
"reveal": "Show",
"hide": "Hide",
"previewDescription": "Fills in once your payment is created",
"proceedToUpload": "I made the transfer — upload receipt",
"unavailableTitle": "Bank transfer unavailable",
"unavailableDescription": "Please use card payment or try again shortly."
}
},
"billing": {
@@ -291,6 +355,7 @@
},
"methodLabels": {
"iyzico": "Credit Card",
"stripe": "Credit Card",
"eft": "EFT/Wire"
}
},

View File

@@ -6,6 +6,7 @@
"confirm": "Onayla",
"delete": "Sil",
"edit": "Düzenle",
"change": "Değiştir",
"close": "Kapat",
"back": "Geri",
"next": "İleri",
@@ -122,6 +123,35 @@
},
"subscription": {
"title": "Abonelik",
"subtitle": "Planını seç, markalarını belirle, ödemeni yap. Hepsi tek sayfada.",
"step": "Adım",
"selected": "Seçildi",
"tapToSelect": "Seç",
"selectExactBrands": "Lütfen tam olarak {count} marka seçin.",
"steps": {
"plan": {
"title": "Plan seç",
"short": "Plan",
"description": "Aylık veya yıllık dilim arasında geç."
},
"brands": {
"title": "Markaları seç",
"short": "Markalar",
"description": "Erişim isteyeceğin {count} marka seçmelisin."
},
"payment": {
"title": "Ödeme yöntemi",
"short": "Ödeme"
},
"confirmation": {
"short": "Onay"
}
},
"stickyCta": {
"toBrands": "Markaları seç",
"toPayment": "Ödeme yöntemine geç",
"selectBrands": "{count} marka daha seç"
},
"noSubscription": "Aktif aboneliğiniz yok.",
"choosePlan": "Plan Seç",
"subscribe": "Abone Ol",
@@ -219,7 +249,7 @@
"trustCancelAnytime": "İstediğin zaman iptal",
"trustRefund": "7 gün iade garantisi",
"paymentTrustSSL": "256-bit SSL",
"paymentTrustProvider": "Iyzico altyapısı",
"paymentTrustProvider": "Stripe altyapısı",
"paymentTrustKVKK": "KVKK uyumlu",
"trialDaysLeft": "{days} gün kaldı",
"trialBanner": {
@@ -235,7 +265,7 @@
"selectedBrands": "Seçilen Markalar",
"totalAmount": "Toplam Tutar",
"paymentMethod": "Ödeme Yöntemi",
"creditCard": "Kredi Kartı (iyzico)",
"creditCard": "Kredi Kartı",
"eftTransfer": "EFT/Havale",
"payWithCard": "Kartla Öde",
"paying": "Ödeme yapılıyor...",
@@ -264,11 +294,45 @@
"goToDashboard": "Panele Git",
"initializeFailed": "Ödeme başlatılamadı. Lütfen tekrar deneyin.",
"processingPayment": "Ödeme işleniyor...",
"iyzicoTrustNotice": "3D Secure ile güvenli ödeme. iyzico altyapısı kullanılmaktadır. Butona tıkladığınızda iyzico ödeme sayfasına yönlendirileceksiniz.",
"eftPaid": "EFT/Havale Yaptım",
"eftStatus": {
"created": "EFT/Havale kaydı oluşturuldu",
"receiptUploaded": "Dekont yüklendi"
},
"paymentFailed": "Ödeme başarısız oldu. Lütfen tekrar deneyin.",
"stripe": {
"secureCheckoutTitle": "3D Secure ile güvenli ödeme",
"secureCheckoutDescription": "Kart bilgilerin Stripe'ın PCI-DSS Seviye 1 altyapısında işlenir. Sase bu bilgileri görmez.",
"payButton": "{amount} ile öde",
"cancelled": "Ödeme iptal edildi. Tekrar deneyebilirsin.",
"verifyingTitle": "Ödemen doğrulanıyor",
"verifyingDescription": "Stripe onayı geldiğinde aboneliğin otomatik aktifleşecek. Bu sayfayı kapatmadan birkaç saniye bekle.",
"trustProvider": "Stripe ödeme altyapısı"
},
"bank": {
"bankLabel": "Banka",
"accountHolderLabel": "Hesap Sahibi",
"ibanLabel": "IBAN",
"kolayAdresLabel": "Kolay Adres",
"kolayAdresType": {
"email": "E-posta",
"phone": "Telefon",
"tckn": "TCKN"
},
"descriptionLabel": "Açıklama",
"amountLabel": "Tutar",
"copyIban": "IBAN'ı kopyala",
"copyKolayAdres": "Kolay Adres'i kopyala",
"copyDescription": "Açıklamayı kopyala",
"qrAlt": "Mobil bankacılık QR kodu",
"qrHint": "Bankan ile tara",
"qrUnavailable": "QR yakında",
"reveal": "Göster",
"hide": "Gizle",
"previewDescription": "Ödeme oluştuğunda burası dolar",
"proceedToUpload": "Havaleyi yaptım, dekont yükle",
"unavailableTitle": "Havale şu an aktif değil",
"unavailableDescription": "Kart ile ödemeyi tercih edebilir veya birazdan tekrar denersin."
}
},
"billing": {
@@ -291,6 +355,7 @@
},
"methodLabels": {
"iyzico": "Kredi Kartı",
"stripe": "Kredi Kartı",
"eft": "EFT/Havale"
}
},

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -28,9 +28,17 @@ export const envSchema = z.object({
CORS_ORIGIN: z.string().default("http://localhost:3000"),
IYZICO_API_KEY: z.string().optional(),
IYZICO_SECRET_KEY: z.string().optional(),
IYZICO_BASE_URL: z.string().optional(),
STRIPE_SECRET_KEY: z.string().optional(),
STRIPE_PUBLISHABLE_KEY: z.string().optional(),
STRIPE_WEBHOOK_SECRET: z.string().optional(),
STRIPE_SUCCESS_URL: z
.string()
.url()
.default("http://localhost:3000/dashboard/subscription?stripe=success"),
STRIPE_CANCEL_URL: z
.string()
.url()
.default("http://localhost:3000/dashboard/subscription?stripe=cancelled"),
PL24_BASE_URL: z.string().optional(),
PL24_COMPANY_CODE: z.string().optional(),

16
pnpm-lock.yaml generated
View File

@@ -147,6 +147,9 @@ importers:
rxjs:
specifier: ^7.8.0
version: 7.8.2
stripe:
specifier: ^22.1.1
version: 22.1.1(@types/node@22.19.11)
undici:
specifier: ^7.22.0
version: 7.22.0
@@ -5529,6 +5532,15 @@ packages:
strip-literal@3.1.0:
resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==}
stripe@22.1.1:
resolution: {integrity: sha512-cmodIYP27tBkJ8G7DuGgWw0PFuemlFZbuF3Wwr1TrjFjUa3T7NIgCe6TVwX8BO2ynu+xtTuDGfHafNDCPt9lXA==}
engines: {node: '>=18'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
strnum@2.1.2:
resolution: {integrity: sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==}
@@ -11678,6 +11690,10 @@ snapshots:
dependencies:
js-tokens: 9.0.1
stripe@22.1.1(@types/node@22.19.11):
optionalDependencies:
'@types/node': 22.19.11
strnum@2.1.2: {}
strtok3@10.3.4: