feat: replace Iyzico with Stripe + single-page subscription stepper
Subscription/checkout flow rewritten end-to-end. The plan-card "Devam Et" button silently wiped brand selection on re-click and the actual proceed button lived offscreen — fixed by collapsing plan/brands/payment/confirm into a single vertical stepper with one sticky CTA. Backend - Stripe Hosted Checkout (`/payments/stripe/checkout`) + webhook (`/payments/stripe/webhook`, raw body) replacing the stubbed Iyzico module. Webhook activates subscription on `checkout.session.completed`, expires the pending subscription on cancel/expire so users can retry. - New `bank_accounts` table — multiple rows, single `is_active` enforced by a partial unique index. Admin CRUD under `/admin/bank-accounts` with multipart QR upload to MinIO; atomic `/activate` swap in a transaction; `GET /payments/bank-info` returns the active row. - `payments` gains `stripe_session_id`, `stripe_payment_intent_id`, `bank_account_id`. EFT flow now reads the active bank account at payment time and stores the FK for reconciliation. - Env: `IYZICO_*` removed, `STRIPE_*` added (validated by zod schema). - `main.ts` `rawBody: true` for Stripe signature verification. - Drizzle 0003 snapshot id collision fixed (VIEW-only migration shared prevId with 0002, blocking new generates). Frontend - `/dashboard/subscription` rewritten as a 4-step vertical stepper with step-aware sticky bottom CTA; plan re-selection is idempotent and preserves brand state. `/dashboard/subscription/pay` deleted; Stripe returns to the same page via `?stripe=success|cancelled` and the UI polls `/subscriptions/me` until the webhook activates the row. - New components: `bank-transfer-card.tsx` (DB-driven IBAN + Kolay Adres + uploaded QR image + receipt upload) and `stripe-checkout-button.tsx`. - Active subscription view, trial onboarding/urgency banner, downgrade and cancel dialogs preserved. - TR/EN i18n: new `subscription.steps.*`, `subscription.stickyCta.*`, `payment.stripe.*`, `payment.bank.*`; provider label updated. PostHog: `method: "iyzico"` → `"stripe"`; new events `iban_copied`, `kolay_adres_copied`, `qr_viewed`, `eft_initiated`, `stripe_redirect_returned`. Deploy runs `db:migrate` which applies 0004_hot_quicksilver (additive: new table + nullable columns; safe to apply on prod). Operator must add `STRIPE_SECRET_KEY`, `STRIPE_PUBLISHABLE_KEY`, `STRIPE_WEBHOOK_SECRET` to env and create the first `bank_accounts` row via the admin endpoint before the bank tab works. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
22
apps/api/drizzle/0004_hot_quicksilver.sql
Normal file
22
apps/api/drizzle/0004_hot_quicksilver.sql
Normal file
@@ -0,0 +1,22 @@
|
||||
CREATE TABLE "bank_accounts" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"bank_name" varchar(100) NOT NULL,
|
||||
"account_holder" varchar(200) NOT NULL,
|
||||
"iban" varchar(34) NOT NULL,
|
||||
"kolay_adres" varchar(100),
|
||||
"kolay_adres_type" varchar(20),
|
||||
"qr_image_url" text,
|
||||
"description_template" varchar(200) DEFAULT 'SASE-{{paymentId}}' NOT NULL,
|
||||
"is_active" boolean DEFAULT false NOT NULL,
|
||||
"display_order" integer DEFAULT 0 NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "payments" ADD COLUMN "stripe_session_id" text;--> statement-breakpoint
|
||||
ALTER TABLE "payments" ADD COLUMN "stripe_payment_intent_id" text;--> statement-breakpoint
|
||||
ALTER TABLE "payments" ADD COLUMN "bank_account_id" uuid;--> statement-breakpoint
|
||||
CREATE INDEX "bank_accounts_active_idx" ON "bank_accounts" USING btree ("is_active");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "bank_accounts_one_active_idx" ON "bank_accounts" USING btree ("is_active") WHERE "is_active" = true;--> statement-breakpoint
|
||||
ALTER TABLE "payments" ADD CONSTRAINT "payments_bank_account_id_bank_accounts_id_fk" FOREIGN KEY ("bank_account_id") REFERENCES "public"."bank_accounts"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "payments_stripe_session_id_idx" ON "payments" USING btree ("stripe_session_id");
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"id": "b797d2fd-854c-43bd-9924-cd1ca469a239",
|
||||
"prevId": "03894be0-b7bc-4a6a-940d-37b9f4a71b27",
|
||||
"id": "d8a51462-659a-4425-8a7f-090dd5a70926",
|
||||
"prevId": "b797d2fd-854c-43bd-9924-cd1ca469a239",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
|
||||
5326
apps/api/drizzle/meta/0004_snapshot.json
Normal file
5326
apps/api/drizzle/meta/0004_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -63,6 +63,7 @@
|
||||
"posthog-node": "^5.34.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.0",
|
||||
"stripe": "^22.1.1",
|
||||
"undici": "^7.22.0",
|
||||
"zod": "^3.24.0"
|
||||
},
|
||||
|
||||
@@ -25,10 +25,16 @@ export default () => ({
|
||||
cors: {
|
||||
origin: (process.env.CORS_ORIGIN || "http://localhost:3000").split(","),
|
||||
},
|
||||
iyzico: {
|
||||
apiKey: process.env.IYZICO_API_KEY,
|
||||
secretKey: process.env.IYZICO_SECRET_KEY,
|
||||
baseUrl: process.env.IYZICO_BASE_URL,
|
||||
stripe: {
|
||||
secretKey: process.env.STRIPE_SECRET_KEY,
|
||||
publishableKey: process.env.STRIPE_PUBLISHABLE_KEY,
|
||||
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET,
|
||||
successUrl:
|
||||
process.env.STRIPE_SUCCESS_URL ||
|
||||
"http://localhost:3000/dashboard/subscription?stripe=success",
|
||||
cancelUrl:
|
||||
process.env.STRIPE_CANCEL_URL ||
|
||||
"http://localhost:3000/dashboard/subscription?stripe=cancelled",
|
||||
},
|
||||
pl24: {
|
||||
baseUrl: process.env.PL24_BASE_URL || "https://www.partslink24.com",
|
||||
|
||||
@@ -162,6 +162,28 @@ export const userBrands = pgTable(
|
||||
],
|
||||
);
|
||||
|
||||
// ─── Bank Accounts (EFT/Havale destination accounts, one active at a time) ────
|
||||
export const bankAccounts = pgTable(
|
||||
"bank_accounts",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
bankName: varchar("bank_name", { length: 100 }).notNull(),
|
||||
accountHolder: varchar("account_holder", { length: 200 }).notNull(),
|
||||
iban: varchar("iban", { length: 34 }).notNull(),
|
||||
kolayAdres: varchar("kolay_adres", { length: 100 }),
|
||||
kolayAdresType: varchar("kolay_adres_type", { length: 20 }),
|
||||
qrImageUrl: text("qr_image_url"),
|
||||
descriptionTemplate: varchar("description_template", { length: 200 })
|
||||
.notNull()
|
||||
.default("SASE-{{paymentId}}"),
|
||||
isActive: boolean("is_active").default(false).notNull(),
|
||||
displayOrder: integer("display_order").default(0).notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => [index("bank_accounts_active_idx").on(table.isActive)],
|
||||
);
|
||||
|
||||
// ─── Payments ───────────────────────────────────────
|
||||
export const payments = pgTable(
|
||||
"payments",
|
||||
@@ -178,6 +200,9 @@ export const payments = pgTable(
|
||||
method: varchar("method", { length: 20 }).notNull(),
|
||||
status: varchar("status", { length: 20 }).default("pending").notNull(),
|
||||
iyzicoPaymentId: text("iyzico_payment_id"),
|
||||
stripeSessionId: text("stripe_session_id"),
|
||||
stripePaymentIntentId: text("stripe_payment_intent_id"),
|
||||
bankAccountId: uuid("bank_account_id").references(() => bankAccounts.id),
|
||||
eftReceiptUrl: text("eft_receipt_url"),
|
||||
adminNote: text("admin_note"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
@@ -186,6 +211,7 @@ export const payments = pgTable(
|
||||
(table) => [
|
||||
index("payments_user_id_idx").on(table.userId),
|
||||
index("payments_status_idx").on(table.status),
|
||||
index("payments_stripe_session_id_idx").on(table.stripeSessionId),
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { relations } from "drizzle-orm";
|
||||
import {
|
||||
accounts,
|
||||
bankAccounts,
|
||||
brands,
|
||||
catalogVehicles,
|
||||
categories,
|
||||
@@ -75,6 +76,14 @@ export const paymentsRelations = relations(payments, ({ one }) => ({
|
||||
fields: [payments.subscriptionId],
|
||||
references: [userSubscriptions.id],
|
||||
}),
|
||||
bankAccount: one(bankAccounts, {
|
||||
fields: [payments.bankAccountId],
|
||||
references: [bankAccounts.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const bankAccountsRelations = relations(bankAccounts, ({ many }) => ({
|
||||
payments: many(payments),
|
||||
}));
|
||||
|
||||
export const queryLogsRelations = relations(queryLogs, ({ one }) => ({
|
||||
|
||||
@@ -10,7 +10,7 @@ import { AppModule } from "./app.module";
|
||||
import { fileUploadValidation } from "./common/middleware/file-upload-validation.middleware";
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
const app = await NestFactory.create(AppModule, { rawBody: true });
|
||||
|
||||
const configService = app.get(ConfigService);
|
||||
const port = configService.get<number>("port", 4000);
|
||||
|
||||
127
apps/api/src/payments/bank-accounts/bank-accounts.controller.ts
Normal file
127
apps/api/src/payments/bank-accounts/bank-accounts.controller.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from "@nestjs/common";
|
||||
import { FileInterceptor } from "@nestjs/platform-express";
|
||||
import { Roles } from "../../common/decorators/roles.decorator";
|
||||
import { RolesGuard } from "../../common/guards/roles.guard";
|
||||
import {
|
||||
BankAccountsService,
|
||||
type CreateBankAccountInput,
|
||||
type UpdateBankAccountInput,
|
||||
} from "./bank-accounts.service";
|
||||
|
||||
function parseBool(v: unknown): boolean | undefined {
|
||||
if (v === undefined || v === null) return undefined;
|
||||
if (typeof v === "boolean") return v;
|
||||
const s = String(v).toLowerCase().trim();
|
||||
if (s === "true" || s === "1") return true;
|
||||
if (s === "false" || s === "0") return false;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parseInt32(v: unknown): number | undefined {
|
||||
if (v === undefined || v === null || v === "") return undefined;
|
||||
const n = Number(v);
|
||||
if (!Number.isFinite(n)) return undefined;
|
||||
return Math.trunc(n);
|
||||
}
|
||||
|
||||
@Controller("admin/bank-accounts")
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles("admin")
|
||||
export class BankAccountsAdminController {
|
||||
constructor(private bankAccounts: BankAccountsService) {}
|
||||
|
||||
@Get()
|
||||
async list() {
|
||||
return this.bankAccounts.findAll();
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
async get(@Param("id") id: string) {
|
||||
return this.bankAccounts.findOne(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@UseInterceptors(FileInterceptor("qr"))
|
||||
async create(@Body() body: Record<string, unknown>, @UploadedFile() qr?: Express.Multer.File) {
|
||||
if (!body.bankName || !body.accountHolder || !body.iban) {
|
||||
throw new BadRequestException("bankName, accountHolder ve iban zorunlu");
|
||||
}
|
||||
|
||||
const input: CreateBankAccountInput = {
|
||||
bankName: String(body.bankName),
|
||||
accountHolder: String(body.accountHolder),
|
||||
iban: String(body.iban),
|
||||
kolayAdres: body.kolayAdres ? String(body.kolayAdres) : null,
|
||||
kolayAdresType:
|
||||
body.kolayAdresType === "email" ||
|
||||
body.kolayAdresType === "phone" ||
|
||||
body.kolayAdresType === "tckn"
|
||||
? body.kolayAdresType
|
||||
: null,
|
||||
descriptionTemplate: body.descriptionTemplate ? String(body.descriptionTemplate) : undefined,
|
||||
displayOrder: parseInt32(body.displayOrder),
|
||||
isActive: parseBool(body.isActive),
|
||||
};
|
||||
|
||||
const qrFile = qr ? { buffer: qr.buffer, mimetype: qr.mimetype } : undefined;
|
||||
return this.bankAccounts.create(input, qrFile);
|
||||
}
|
||||
|
||||
@Patch(":id")
|
||||
@UseInterceptors(FileInterceptor("qr"))
|
||||
async update(
|
||||
@Param("id") id: string,
|
||||
@Body() body: Record<string, unknown>,
|
||||
@UploadedFile() qr?: Express.Multer.File,
|
||||
) {
|
||||
const input: UpdateBankAccountInput = {};
|
||||
if (body.bankName !== undefined) input.bankName = String(body.bankName);
|
||||
if (body.accountHolder !== undefined) input.accountHolder = String(body.accountHolder);
|
||||
if (body.iban !== undefined) input.iban = String(body.iban);
|
||||
if (body.kolayAdres !== undefined) {
|
||||
input.kolayAdres =
|
||||
body.kolayAdres === null || body.kolayAdres === "" ? null : String(body.kolayAdres);
|
||||
}
|
||||
if (body.kolayAdresType !== undefined) {
|
||||
const t = body.kolayAdresType;
|
||||
input.kolayAdresType = t === "email" || t === "phone" || t === "tckn" ? t : null;
|
||||
}
|
||||
if (body.descriptionTemplate !== undefined) {
|
||||
input.descriptionTemplate = String(body.descriptionTemplate);
|
||||
}
|
||||
if (body.displayOrder !== undefined) {
|
||||
const n = parseInt32(body.displayOrder);
|
||||
if (n !== undefined) input.displayOrder = n;
|
||||
}
|
||||
|
||||
const qrFile = qr ? { buffer: qr.buffer, mimetype: qr.mimetype } : undefined;
|
||||
return this.bankAccounts.update(id, input, qrFile);
|
||||
}
|
||||
|
||||
@Delete(":id")
|
||||
async remove(@Param("id") id: string) {
|
||||
return this.bankAccounts.remove(id);
|
||||
}
|
||||
|
||||
@Post(":id/activate")
|
||||
async activate(@Param("id") id: string) {
|
||||
return this.bankAccounts.activate(id);
|
||||
}
|
||||
|
||||
@Post("deactivate-all")
|
||||
async deactivateAll() {
|
||||
return this.bankAccounts.deactivateAll();
|
||||
}
|
||||
}
|
||||
10
apps/api/src/payments/bank-accounts/bank-accounts.module.ts
Normal file
10
apps/api/src/payments/bank-accounts/bank-accounts.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { BankAccountsAdminController } from "./bank-accounts.controller";
|
||||
import { BankAccountsService } from "./bank-accounts.service";
|
||||
|
||||
@Module({
|
||||
controllers: [BankAccountsAdminController],
|
||||
providers: [BankAccountsService],
|
||||
exports: [BankAccountsService],
|
||||
})
|
||||
export class BankAccountsModule {}
|
||||
218
apps/api/src/payments/bank-accounts/bank-accounts.service.ts
Normal file
218
apps/api/src/payments/bank-accounts/bank-accounts.service.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { asc, desc, eq, ne } from "drizzle-orm";
|
||||
import { DATABASE, type Database } from "../../database/database.provider";
|
||||
import { bankAccounts } from "../../database/schema/core";
|
||||
import { StorageService } from "../../storage/storage.service";
|
||||
|
||||
export interface CreateBankAccountInput {
|
||||
bankName: string;
|
||||
accountHolder: string;
|
||||
iban: string;
|
||||
kolayAdres?: string | null;
|
||||
kolayAdresType?: "email" | "phone" | "tckn" | null;
|
||||
descriptionTemplate?: string;
|
||||
displayOrder?: number;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateBankAccountInput {
|
||||
bankName?: string;
|
||||
accountHolder?: string;
|
||||
iban?: string;
|
||||
kolayAdres?: string | null;
|
||||
kolayAdresType?: "email" | "phone" | "tckn" | null;
|
||||
descriptionTemplate?: string;
|
||||
displayOrder?: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class BankAccountsService {
|
||||
private readonly logger = new Logger(BankAccountsService.name);
|
||||
|
||||
constructor(
|
||||
@Inject(DATABASE) private db: Database,
|
||||
private storageService: StorageService,
|
||||
) {}
|
||||
|
||||
async findActive() {
|
||||
const [row] = await this.db
|
||||
.select()
|
||||
.from(bankAccounts)
|
||||
.where(eq(bankAccounts.isActive, true))
|
||||
.limit(1);
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
async findAll() {
|
||||
return this.db
|
||||
.select()
|
||||
.from(bankAccounts)
|
||||
.orderBy(
|
||||
desc(bankAccounts.isActive),
|
||||
asc(bankAccounts.displayOrder),
|
||||
asc(bankAccounts.bankName),
|
||||
);
|
||||
}
|
||||
|
||||
async findOne(id: string) {
|
||||
const [row] = await this.db.select().from(bankAccounts).where(eq(bankAccounts.id, id)).limit(1);
|
||||
if (!row) throw new NotFoundException("Banka hesabı bulunamadı");
|
||||
return row;
|
||||
}
|
||||
|
||||
async create(input: CreateBankAccountInput, qrFile?: { buffer: Buffer; mimetype: string }) {
|
||||
this.validateIban(input.iban);
|
||||
|
||||
const normalizedIban = this.normalizeIban(input.iban);
|
||||
|
||||
// If this is going to be active, deactivate any existing active row first.
|
||||
if (input.isActive) {
|
||||
await this.db
|
||||
.update(bankAccounts)
|
||||
.set({ isActive: false, updatedAt: new Date() })
|
||||
.where(eq(bankAccounts.isActive, true));
|
||||
}
|
||||
|
||||
const [row] = await this.db
|
||||
.insert(bankAccounts)
|
||||
.values({
|
||||
bankName: input.bankName,
|
||||
accountHolder: input.accountHolder,
|
||||
iban: normalizedIban,
|
||||
kolayAdres: input.kolayAdres ?? null,
|
||||
kolayAdresType: input.kolayAdresType ?? null,
|
||||
descriptionTemplate: input.descriptionTemplate ?? "SASE-{{paymentId}}",
|
||||
displayOrder: input.displayOrder ?? 0,
|
||||
isActive: input.isActive ?? false,
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (qrFile) {
|
||||
const url = await this.uploadQr(row.id, qrFile);
|
||||
const [updated] = await this.db
|
||||
.update(bankAccounts)
|
||||
.set({ qrImageUrl: url, updatedAt: new Date() })
|
||||
.where(eq(bankAccounts.id, row.id))
|
||||
.returning();
|
||||
return updated;
|
||||
}
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
input: UpdateBankAccountInput,
|
||||
qrFile?: { buffer: Buffer; mimetype: string },
|
||||
) {
|
||||
await this.findOne(id); // 404 if missing
|
||||
|
||||
if (input.iban !== undefined) {
|
||||
this.validateIban(input.iban);
|
||||
}
|
||||
|
||||
const patch: Partial<typeof bankAccounts.$inferInsert> = {
|
||||
...input,
|
||||
iban: input.iban !== undefined ? this.normalizeIban(input.iban) : undefined,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
// Strip undefined keys so we don't overwrite columns with null by accident.
|
||||
for (const key of Object.keys(patch) as (keyof typeof patch)[]) {
|
||||
if (patch[key] === undefined) delete patch[key];
|
||||
}
|
||||
|
||||
if (qrFile) {
|
||||
patch.qrImageUrl = await this.uploadQr(id, qrFile);
|
||||
}
|
||||
|
||||
const [updated] = await this.db
|
||||
.update(bankAccounts)
|
||||
.set(patch)
|
||||
.where(eq(bankAccounts.id, id))
|
||||
.returning();
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
async remove(id: string) {
|
||||
const row = await this.findOne(id);
|
||||
if (row.isActive) {
|
||||
throw new ConflictException("Aktif hesap silinemez. Önce başka bir hesabı aktive edin.");
|
||||
}
|
||||
await this.db.delete(bankAccounts).where(eq(bankAccounts.id, id));
|
||||
return { deleted: true };
|
||||
}
|
||||
|
||||
async activate(id: string) {
|
||||
await this.findOne(id); // 404 if missing
|
||||
|
||||
return this.db.transaction(async (tx) => {
|
||||
// Deactivate any other currently-active row(s)
|
||||
await tx
|
||||
.update(bankAccounts)
|
||||
.set({ isActive: false, updatedAt: new Date() })
|
||||
.where(eq(bankAccounts.isActive, true));
|
||||
|
||||
// Activate the requested row
|
||||
const [activated] = await tx
|
||||
.update(bankAccounts)
|
||||
.set({ isActive: true, updatedAt: new Date() })
|
||||
.where(eq(bankAccounts.id, id))
|
||||
.returning();
|
||||
|
||||
this.logger.log(`Bank account ${id} (${activated.bankName}) activated`);
|
||||
return activated;
|
||||
});
|
||||
}
|
||||
|
||||
async deactivateAll() {
|
||||
await this.db
|
||||
.update(bankAccounts)
|
||||
.set({ isActive: false, updatedAt: new Date() })
|
||||
.where(eq(bankAccounts.isActive, true));
|
||||
return { deactivated: true };
|
||||
}
|
||||
|
||||
resolveDescription(template: string, paymentId: string): string {
|
||||
const short = paymentId.substring(0, 8).toUpperCase();
|
||||
return template.replace(/\{\{\s*paymentId\s*\}\}/gi, short);
|
||||
}
|
||||
|
||||
private validateIban(iban: string) {
|
||||
const normalized = this.normalizeIban(iban);
|
||||
if (!/^TR\d{24}$/.test(normalized)) {
|
||||
throw new BadRequestException(
|
||||
"Geçersiz IBAN. TR ile başlamalı ve toplam 26 karakter olmalı.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeIban(iban: string): string {
|
||||
return iban.replace(/\s+/g, "").toUpperCase();
|
||||
}
|
||||
|
||||
private async uploadQr(
|
||||
bankAccountId: string,
|
||||
file: { buffer: Buffer; mimetype: string },
|
||||
): Promise<string> {
|
||||
const allowed = ["image/png", "image/jpeg", "image/webp"];
|
||||
if (!allowed.includes(file.mimetype)) {
|
||||
throw new BadRequestException("QR sadece PNG/JPG/WEBP olabilir");
|
||||
}
|
||||
if (file.buffer.length > 2 * 1024 * 1024) {
|
||||
throw new BadRequestException("QR dosyası 2MB'dan büyük olamaz");
|
||||
}
|
||||
const ext =
|
||||
file.mimetype === "image/png" ? "png" : file.mimetype === "image/webp" ? "webp" : "jpg";
|
||||
const key = `bank-qr/${bankAccountId}-${Date.now()}.${ext}`;
|
||||
return this.storageService.upload(key, file.buffer, file.mimetype);
|
||||
}
|
||||
}
|
||||
@@ -20,28 +20,9 @@ import { PaymentsService } from "./payments.service";
|
||||
export class PaymentsController {
|
||||
constructor(private paymentsService: PaymentsService) {}
|
||||
|
||||
@Post("iyzico/initialize")
|
||||
async initializeIyzico(
|
||||
@CurrentUser("id") userId: string,
|
||||
@Body() body: { planKey: string; billingPeriod: "monthly" | "yearly"; brandIds: string[] },
|
||||
) {
|
||||
return this.paymentsService.initializeIyzico(
|
||||
userId,
|
||||
body.planKey,
|
||||
body.billingPeriod,
|
||||
body.brandIds,
|
||||
);
|
||||
}
|
||||
|
||||
@Post("iyzico/callback")
|
||||
async iyzicoCallback(
|
||||
@Body() body: { paymentId: string; iyzicoPaymentId: string; status: string },
|
||||
) {
|
||||
return this.paymentsService.handleIyzicoCallback(
|
||||
body.paymentId,
|
||||
body.iyzicoPaymentId,
|
||||
body.status,
|
||||
);
|
||||
@Get("bank-info")
|
||||
async getBankInfo() {
|
||||
return this.paymentsService.getActiveBankInfo();
|
||||
}
|
||||
|
||||
@Post("eft")
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { SubscriptionsModule } from "../subscriptions/subscriptions.module";
|
||||
import { BankAccountsModule } from "./bank-accounts/bank-accounts.module";
|
||||
import { PaymentsController } from "./payments.controller";
|
||||
import { PaymentsService } from "./payments.service";
|
||||
import { StripeModule } from "./stripe/stripe.module";
|
||||
|
||||
@Module({
|
||||
imports: [SubscriptionsModule],
|
||||
imports: [SubscriptionsModule, BankAccountsModule, StripeModule],
|
||||
controllers: [PaymentsController],
|
||||
providers: [PaymentsService],
|
||||
exports: [PaymentsService],
|
||||
|
||||
@@ -1,416 +0,0 @@
|
||||
import { BadRequestException, NotFoundException } from "@nestjs/common";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { PaymentsService } from "./payments.service";
|
||||
|
||||
function createMockDb(overrides: Record<string, unknown> = {}) {
|
||||
function chainable(terminalValue: unknown) {
|
||||
const chain: Record<string, unknown> = {};
|
||||
const methods = [
|
||||
"select",
|
||||
"from",
|
||||
"where",
|
||||
"orderBy",
|
||||
"limit",
|
||||
"offset",
|
||||
"innerJoin",
|
||||
"leftJoin",
|
||||
"insert",
|
||||
"values",
|
||||
"update",
|
||||
"set",
|
||||
"delete",
|
||||
"returning",
|
||||
"onConflictDoNothing",
|
||||
"groupBy",
|
||||
];
|
||||
for (const m of methods) chain[m] = vi.fn().mockReturnValue(chain);
|
||||
chain.limit = vi.fn().mockReturnValue(terminalValue);
|
||||
chain.returning = vi.fn().mockReturnValue(terminalValue);
|
||||
chain.orderBy = vi.fn().mockReturnValue(terminalValue);
|
||||
return chain;
|
||||
}
|
||||
|
||||
return {
|
||||
select: vi.fn().mockImplementation(() => chainable(overrides._selectRows ?? [])),
|
||||
insert: vi.fn().mockImplementation(() => chainable(overrides._insertRows ?? [])),
|
||||
update: vi.fn().mockImplementation(() => chainable(overrides._updateRows ?? [])),
|
||||
delete: vi.fn().mockImplementation(() => chainable(overrides._deleteRows ?? [])),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createService(
|
||||
dbOverrides: Record<string, unknown> = {},
|
||||
subServiceOverrides: Record<string, any> = {},
|
||||
) {
|
||||
const db = typeof dbOverrides.select === "function" ? dbOverrides : createMockDb(dbOverrides);
|
||||
const configService = { get: vi.fn().mockReturnValue("test-value") };
|
||||
const subscriptionsService = {
|
||||
activateSubscription: vi.fn().mockResolvedValue(undefined),
|
||||
create: vi.fn().mockResolvedValue({ id: "sub-1", planId: "plan-1", status: "pending" }),
|
||||
addBrandsToSubscription: vi.fn().mockResolvedValue(undefined),
|
||||
...subServiceOverrides,
|
||||
};
|
||||
const storageService = {
|
||||
upload: vi.fn().mockResolvedValue("https://storage.test/receipt.pdf"),
|
||||
};
|
||||
const posthogService = {
|
||||
capture: vi.fn(),
|
||||
captureForUser: vi.fn(),
|
||||
};
|
||||
|
||||
const service = new PaymentsService(
|
||||
db as any,
|
||||
configService as any,
|
||||
subscriptionsService as any,
|
||||
storageService as any,
|
||||
posthogService as any,
|
||||
);
|
||||
|
||||
return { service, db, configService, subscriptionsService, storageService, posthogService };
|
||||
}
|
||||
|
||||
describe("PaymentsService", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("initializeIyzico", () => {
|
||||
it("should create subscription, payment and return paymentId", async () => {
|
||||
const db = {
|
||||
select: vi.fn().mockReturnValue({
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
limit: vi.fn().mockReturnValue([
|
||||
{
|
||||
id: "plan-1",
|
||||
brandCount: 1,
|
||||
priceMonthly: 20000,
|
||||
priceYearly: 200000,
|
||||
isActive: true,
|
||||
},
|
||||
]),
|
||||
}),
|
||||
insert: vi.fn().mockReturnValue({
|
||||
values: vi.fn().mockReturnThis(),
|
||||
returning: vi.fn().mockReturnValue([{ id: "pay-1", status: "pending" }]),
|
||||
}),
|
||||
};
|
||||
const { service, subscriptionsService } = createService(db);
|
||||
|
||||
const result = await service.initializeIyzico("u1", "brand1", "monthly", ["brand-id-1"]);
|
||||
expect(result.paymentId).toBe("pay-1");
|
||||
expect(result.status).toBe("pending");
|
||||
expect(subscriptionsService.create).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should throw BadRequestException for invalid plan key", async () => {
|
||||
const { service } = createService();
|
||||
|
||||
await expect(service.initializeIyzico("u1", "invalid", "monthly", [])).rejects.toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleIyzicoCallback", () => {
|
||||
it("should activate subscription on success and capture payment_success", async () => {
|
||||
const db = {
|
||||
select: vi.fn().mockReturnValue({
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
limit: vi.fn().mockReturnValue([
|
||||
{
|
||||
id: "pay-1",
|
||||
subscriptionId: "sub-1",
|
||||
status: "pending",
|
||||
userId: "u1",
|
||||
amount: 20000,
|
||||
},
|
||||
]),
|
||||
}),
|
||||
update: vi.fn().mockReturnValue({
|
||||
set: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
};
|
||||
const { service, subscriptionsService, posthogService } = createService(db);
|
||||
|
||||
const result = await service.handleIyzicoCallback("pay-1", "iyz-123", "success");
|
||||
expect(result.status).toBe("completed");
|
||||
expect(subscriptionsService.activateSubscription).toHaveBeenCalledWith("sub-1");
|
||||
expect(posthogService.captureForUser).toHaveBeenCalledWith(
|
||||
"u1",
|
||||
"payment_success",
|
||||
expect.objectContaining({
|
||||
method: "iyzico",
|
||||
payment_id: "pay-1",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should set failed status on failure callback and capture payment_failed", async () => {
|
||||
const db = {
|
||||
select: vi.fn().mockReturnValue({
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
limit: vi.fn().mockReturnValue([
|
||||
{
|
||||
id: "pay-1",
|
||||
subscriptionId: "sub-1",
|
||||
status: "pending",
|
||||
userId: "u1",
|
||||
amount: 20000,
|
||||
},
|
||||
]),
|
||||
}),
|
||||
update: vi.fn().mockReturnValue({
|
||||
set: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
};
|
||||
const { service, subscriptionsService, posthogService } = createService(db);
|
||||
|
||||
const result = await service.handleIyzicoCallback("pay-1", "iyz-123", "failure");
|
||||
expect(result.status).toBe("failed");
|
||||
expect(subscriptionsService.activateSubscription).not.toHaveBeenCalled();
|
||||
expect(posthogService.captureForUser).toHaveBeenCalledWith(
|
||||
"u1",
|
||||
"payment_failed",
|
||||
expect.objectContaining({
|
||||
method: "iyzico",
|
||||
payment_id: "pay-1",
|
||||
reason: "failure",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw NotFoundException when payment not found", async () => {
|
||||
const db = {
|
||||
select: vi.fn().mockReturnValue({
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
limit: vi.fn().mockReturnValue([]),
|
||||
}),
|
||||
};
|
||||
const { service } = createService(db);
|
||||
|
||||
await expect(service.handleIyzicoCallback("nonexistent", "iyz-1", "success")).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createEftPayment", () => {
|
||||
it("should create subscription, EFT payment and return bank info", async () => {
|
||||
const db = {
|
||||
select: vi.fn().mockReturnValue({
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
limit: vi.fn().mockReturnValue([
|
||||
{
|
||||
id: "plan-1",
|
||||
brandCount: 0,
|
||||
priceMonthly: 99900,
|
||||
priceYearly: 999000,
|
||||
isActive: true,
|
||||
},
|
||||
]),
|
||||
}),
|
||||
insert: vi.fn().mockReturnValue({
|
||||
values: vi.fn().mockReturnThis(),
|
||||
returning: vi
|
||||
.fn()
|
||||
.mockReturnValue([{ id: "pay-eft-1", method: "eft", status: "pending" }]),
|
||||
}),
|
||||
};
|
||||
const { service, subscriptionsService } = createService(db);
|
||||
|
||||
const result = await service.createEftPayment("u1", "full", "yearly", []);
|
||||
expect(result.paymentId).toBe("pay-eft-1");
|
||||
expect(result.bankInfo).toBeDefined();
|
||||
expect(result.bankInfo.bankName).toBe("Ziraat Bankası");
|
||||
expect(subscriptionsService.create).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should throw BadRequestException for invalid plan key", async () => {
|
||||
const { service } = createService();
|
||||
|
||||
await expect(service.createEftPayment("u1", "nonexistent", "monthly", [])).rejects.toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("uploadEftReceipt", () => {
|
||||
it("should upload receipt and return url", async () => {
|
||||
const db = {
|
||||
select: vi.fn().mockReturnValue({
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
limit: vi.fn().mockReturnValue([{ id: "pay-1", userId: "u1", method: "eft" }]),
|
||||
}),
|
||||
update: vi.fn().mockReturnValue({
|
||||
set: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
};
|
||||
const { service } = createService(db);
|
||||
|
||||
const result = await service.uploadEftReceipt(
|
||||
"pay-1",
|
||||
"u1",
|
||||
Buffer.from("pdf"),
|
||||
"receipt.pdf",
|
||||
);
|
||||
expect(result.receiptUrl).toBe("https://storage.test/receipt.pdf");
|
||||
});
|
||||
|
||||
it("should throw NotFoundException when payment not found", async () => {
|
||||
const db = {
|
||||
select: vi.fn().mockReturnValue({
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
limit: vi.fn().mockReturnValue([]),
|
||||
}),
|
||||
};
|
||||
const { service } = createService(db);
|
||||
|
||||
await expect(
|
||||
service.uploadEftReceipt("pay-x", "u1", Buffer.from("pdf"), "r.pdf"),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it("should throw BadRequestException when not EFT method", async () => {
|
||||
const db = {
|
||||
select: vi.fn().mockReturnValue({
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
limit: vi.fn().mockReturnValue([{ id: "pay-1", userId: "u1", method: "iyzico" }]),
|
||||
}),
|
||||
};
|
||||
const { service } = createService(db);
|
||||
|
||||
await expect(
|
||||
service.uploadEftReceipt("pay-1", "u1", Buffer.from("pdf"), "r.pdf"),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
describe("approveEft", () => {
|
||||
it("should approve, activate subscription, and capture payment_success", async () => {
|
||||
const db = {
|
||||
select: vi.fn().mockReturnValue({
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
limit: vi
|
||||
.fn()
|
||||
.mockReturnValue([
|
||||
{ id: "pay-1", subscriptionId: "sub-1", method: "eft", userId: "u1", amount: 20000 },
|
||||
]),
|
||||
}),
|
||||
update: vi.fn().mockReturnValue({
|
||||
set: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
};
|
||||
const { service, subscriptionsService, posthogService } = createService(db);
|
||||
|
||||
const result = await service.approveEft("pay-1", "Looks good");
|
||||
expect(result.status).toBe("completed");
|
||||
expect(subscriptionsService.activateSubscription).toHaveBeenCalledWith("sub-1");
|
||||
expect(posthogService.captureForUser).toHaveBeenCalledWith(
|
||||
"u1",
|
||||
"payment_success",
|
||||
expect.objectContaining({
|
||||
method: "eft",
|
||||
payment_id: "pay-1",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw NotFoundException when payment not found", async () => {
|
||||
const db = {
|
||||
select: vi.fn().mockReturnValue({
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
limit: vi.fn().mockReturnValue([]),
|
||||
}),
|
||||
};
|
||||
const { service } = createService(db);
|
||||
|
||||
await expect(service.approveEft("nonexistent")).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it("should throw BadRequestException when not EFT method", async () => {
|
||||
const db = {
|
||||
select: vi.fn().mockReturnValue({
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
limit: vi.fn().mockReturnValue([{ id: "pay-1", method: "iyzico" }]),
|
||||
}),
|
||||
};
|
||||
const { service } = createService(db);
|
||||
|
||||
await expect(service.approveEft("pay-1")).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rejectEft", () => {
|
||||
it("should reject and capture payment_failed", async () => {
|
||||
const payment = {
|
||||
id: "pay-1",
|
||||
subscriptionId: "sub-1",
|
||||
userId: "u1",
|
||||
amount: 20000,
|
||||
method: "eft",
|
||||
};
|
||||
const db = {
|
||||
select: vi.fn().mockReturnValue({
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
limit: vi.fn().mockReturnValue([payment]),
|
||||
}),
|
||||
update: vi.fn().mockReturnValue({
|
||||
set: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
};
|
||||
const { service, posthogService } = createService(db);
|
||||
|
||||
const result = await service.rejectEft("pay-1", "Bad receipt");
|
||||
expect(result.status).toBe("failed");
|
||||
expect(posthogService.captureForUser).toHaveBeenCalledWith(
|
||||
"u1",
|
||||
"payment_failed",
|
||||
expect.objectContaining({
|
||||
method: "eft",
|
||||
payment_id: "pay-1",
|
||||
reason: "Bad receipt",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getMyPayments", () => {
|
||||
it("should return list of payments", async () => {
|
||||
const paymentsList = [{ id: "pay-1" }, { id: "pay-2" }];
|
||||
const db = createMockDb({ _selectRows: paymentsList });
|
||||
const { service } = createService(db);
|
||||
|
||||
const result = await service.getMyPayments("u1");
|
||||
expect(result).toEqual(paymentsList);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPendingEftPayments", () => {
|
||||
it("should return pending EFT payments", async () => {
|
||||
const pending = [{ id: "pay-1", method: "eft", status: "pending" }];
|
||||
const db = createMockDb({ _selectRows: pending });
|
||||
const { service } = createService(db);
|
||||
|
||||
const result = await service.getPendingEftPayments();
|
||||
expect(result).toEqual(pending);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,18 @@
|
||||
import { BadRequestException, Inject, Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import {
|
||||
BadRequestException,
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
ServiceUnavailableException,
|
||||
} from "@nestjs/common";
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
import { DATABASE, type Database } from "../database/database.provider";
|
||||
import { payments, plans, userSubscriptions } from "../database/schema/core";
|
||||
import { PostHogService } from "../posthog/posthog.service";
|
||||
import { StorageService } from "../storage/storage.service";
|
||||
import { SubscriptionsService } from "../subscriptions/subscriptions.service";
|
||||
import { BankAccountsService } from "./bank-accounts/bank-accounts.service";
|
||||
|
||||
const PLAN_KEY_TO_BRAND_COUNT: Record<string, number> = {
|
||||
brand1: 1,
|
||||
@@ -20,10 +27,10 @@ export class PaymentsService {
|
||||
|
||||
constructor(
|
||||
@Inject(DATABASE) private db: Database,
|
||||
private configService: ConfigService,
|
||||
private subscriptionsService: SubscriptionsService,
|
||||
private storageService: StorageService,
|
||||
private posthog: PostHogService,
|
||||
private bankAccounts: BankAccountsService,
|
||||
) {}
|
||||
|
||||
private async resolvePlanId(planKey: string): Promise<string> {
|
||||
@@ -61,90 +68,33 @@ export class PaymentsService {
|
||||
return subscription;
|
||||
}
|
||||
|
||||
async initializeIyzico(
|
||||
userId: string,
|
||||
planKey: string,
|
||||
billingPeriod: "monthly" | "yearly",
|
||||
brandIds: string[],
|
||||
) {
|
||||
const sub = await this.createSubscriptionForPayment(userId, planKey, billingPeriod, brandIds);
|
||||
|
||||
const amount =
|
||||
billingPeriod === "yearly"
|
||||
? (await this.db.select().from(plans).where(eq(plans.id, sub.planId)).limit(1))[0]
|
||||
.priceYearly
|
||||
: (await this.db.select().from(plans).where(eq(plans.id, sub.planId)).limit(1))[0]
|
||||
.priceMonthly;
|
||||
|
||||
const [payment] = await this.db
|
||||
.insert(payments)
|
||||
.values({
|
||||
userId,
|
||||
subscriptionId: sub.id,
|
||||
amount,
|
||||
currency: "TRY",
|
||||
method: "iyzico",
|
||||
status: "pending",
|
||||
})
|
||||
.returning();
|
||||
|
||||
// TODO: Integrate with actual iyzico API
|
||||
this.logger.log(`iyzico payment initialized for subscription ${sub.id}`);
|
||||
|
||||
async getActiveBankInfo() {
|
||||
const account = await this.bankAccounts.findActive();
|
||||
if (!account) return null;
|
||||
return {
|
||||
paymentId: payment.id,
|
||||
status: "pending",
|
||||
id: account.id,
|
||||
bankName: account.bankName,
|
||||
accountHolder: account.accountHolder,
|
||||
iban: account.iban,
|
||||
kolayAdres: account.kolayAdres,
|
||||
kolayAdresType: account.kolayAdresType,
|
||||
qrImageUrl: account.qrImageUrl,
|
||||
};
|
||||
}
|
||||
|
||||
async handleIyzicoCallback(paymentId: string, iyzicoPaymentId: string, status: string) {
|
||||
const [payment] = await this.db
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq(payments.id, paymentId))
|
||||
.limit(1);
|
||||
|
||||
if (!payment) throw new NotFoundException("Ödeme bulunamadı");
|
||||
|
||||
const newStatus = status === "success" ? "completed" : "failed";
|
||||
|
||||
await this.db
|
||||
.update(payments)
|
||||
.set({
|
||||
status: newStatus,
|
||||
iyzicoPaymentId,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(payments.id, paymentId));
|
||||
|
||||
if (newStatus === "completed") {
|
||||
await this.subscriptionsService.activateSubscription(payment.subscriptionId);
|
||||
this.posthog.captureForUser(payment.userId, "payment_success", {
|
||||
method: "iyzico",
|
||||
payment_id: paymentId,
|
||||
iyzico_payment_id: iyzicoPaymentId,
|
||||
subscription_id: payment.subscriptionId,
|
||||
amount: Number(payment.amount),
|
||||
});
|
||||
} else {
|
||||
this.posthog.captureForUser(payment.userId, "payment_failed", {
|
||||
method: "iyzico",
|
||||
payment_id: paymentId,
|
||||
iyzico_payment_id: iyzicoPaymentId,
|
||||
subscription_id: payment.subscriptionId,
|
||||
reason: status,
|
||||
});
|
||||
}
|
||||
|
||||
return { status: newStatus };
|
||||
}
|
||||
|
||||
async createEftPayment(
|
||||
userId: string,
|
||||
planKey: string,
|
||||
billingPeriod: "monthly" | "yearly",
|
||||
brandIds: string[],
|
||||
) {
|
||||
const activeAccount = await this.bankAccounts.findActive();
|
||||
if (!activeAccount) {
|
||||
throw new ServiceUnavailableException(
|
||||
"Havale şu an kullanılamıyor. Lütfen kart ile ödemeyi deneyin.",
|
||||
);
|
||||
}
|
||||
|
||||
const sub = await this.createSubscriptionForPayment(userId, planKey, billingPeriod, brandIds);
|
||||
|
||||
const [plan] = await this.db.select().from(plans).where(eq(plans.id, sub.planId)).limit(1);
|
||||
@@ -159,16 +109,33 @@ export class PaymentsService {
|
||||
currency: "TRY",
|
||||
method: "eft",
|
||||
status: "pending",
|
||||
bankAccountId: activeAccount.id,
|
||||
})
|
||||
.returning();
|
||||
|
||||
this.posthog.captureForUser(userId, "payment_initiated", {
|
||||
method: "eft",
|
||||
payment_id: payment.id,
|
||||
plan: planKey,
|
||||
period: billingPeriod,
|
||||
amount,
|
||||
bank_account_id: activeAccount.id,
|
||||
});
|
||||
|
||||
return {
|
||||
paymentId: payment.id,
|
||||
bankInfo: {
|
||||
bankName: "Ziraat Bankası",
|
||||
iban: "TR33 0001 0000 1234 5678 9012 34",
|
||||
accountHolder: "Sase Teknoloji A.Ş.",
|
||||
description: `SASE-${payment.id.substring(0, 8).toUpperCase()}`,
|
||||
id: activeAccount.id,
|
||||
bankName: activeAccount.bankName,
|
||||
accountHolder: activeAccount.accountHolder,
|
||||
iban: activeAccount.iban,
|
||||
kolayAdres: activeAccount.kolayAdres,
|
||||
kolayAdresType: activeAccount.kolayAdresType,
|
||||
qrImageUrl: activeAccount.qrImageUrl,
|
||||
description: this.bankAccounts.resolveDescription(
|
||||
activeAccount.descriptionTemplate,
|
||||
payment.id,
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -191,6 +158,10 @@ export class PaymentsService {
|
||||
.set({ eftReceiptUrl: url, updatedAt: new Date() })
|
||||
.where(eq(payments.id, paymentId));
|
||||
|
||||
this.posthog.captureForUser(userId, "receipt_uploaded", {
|
||||
payment_id: paymentId,
|
||||
});
|
||||
|
||||
return { receiptUrl: url };
|
||||
}
|
||||
|
||||
@@ -234,6 +205,17 @@ export class PaymentsService {
|
||||
.where(eq(payments.id, paymentId));
|
||||
|
||||
if (payment) {
|
||||
// Also expire the pending subscription so the user can retry without "zaten aktif" conflict.
|
||||
await this.db
|
||||
.update(userSubscriptions)
|
||||
.set({ status: "expired", updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(userSubscriptions.id, payment.subscriptionId),
|
||||
eq(userSubscriptions.status, "pending"),
|
||||
),
|
||||
);
|
||||
|
||||
this.posthog.captureForUser(payment.userId, "payment_failed", {
|
||||
method: "eft",
|
||||
payment_id: paymentId,
|
||||
|
||||
55
apps/api/src/payments/stripe/stripe.controller.ts
Normal file
55
apps/api/src/payments/stripe/stripe.controller.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Headers,
|
||||
Post,
|
||||
RawBodyRequest,
|
||||
Req,
|
||||
} from "@nestjs/common";
|
||||
import type { Request } from "express";
|
||||
import { CurrentUser } from "../../common/decorators/current-user.decorator";
|
||||
import { Public } from "../../common/decorators/public.decorator";
|
||||
import { StripeService } from "./stripe.service";
|
||||
|
||||
interface AuthedUser {
|
||||
id: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
@Controller("payments/stripe")
|
||||
export class StripeController {
|
||||
constructor(private stripeService: StripeService) {}
|
||||
|
||||
@Post("checkout")
|
||||
async createCheckout(
|
||||
@CurrentUser() user: AuthedUser,
|
||||
@Body() body: { planKey: string; billingPeriod: "monthly" | "yearly"; brandIds: string[] },
|
||||
) {
|
||||
if (!body?.planKey || !body?.billingPeriod) {
|
||||
throw new BadRequestException("planKey ve billingPeriod zorunlu");
|
||||
}
|
||||
return this.stripeService.createCheckoutSession(
|
||||
user.id,
|
||||
user.email,
|
||||
body.planKey,
|
||||
body.billingPeriod,
|
||||
body.brandIds ?? [],
|
||||
);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post("webhook")
|
||||
async webhook(
|
||||
@Req() req: RawBodyRequest<Request>,
|
||||
@Headers("stripe-signature") signature: string,
|
||||
) {
|
||||
if (!signature) {
|
||||
throw new BadRequestException("Missing stripe-signature header");
|
||||
}
|
||||
if (!req.rawBody) {
|
||||
throw new BadRequestException("Missing raw body — server not configured with rawBody:true");
|
||||
}
|
||||
return this.stripeService.handleWebhook(req.rawBody, signature);
|
||||
}
|
||||
}
|
||||
12
apps/api/src/payments/stripe/stripe.module.ts
Normal file
12
apps/api/src/payments/stripe/stripe.module.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { SubscriptionsModule } from "../../subscriptions/subscriptions.module";
|
||||
import { StripeController } from "./stripe.controller";
|
||||
import { StripeService } from "./stripe.service";
|
||||
|
||||
@Module({
|
||||
imports: [SubscriptionsModule],
|
||||
controllers: [StripeController],
|
||||
providers: [StripeService],
|
||||
exports: [StripeService],
|
||||
})
|
||||
export class StripeModule {}
|
||||
286
apps/api/src/payments/stripe/stripe.service.ts
Normal file
286
apps/api/src/payments/stripe/stripe.service.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
ServiceUnavailableException,
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import Stripe from "stripe";
|
||||
import { DATABASE, type Database } from "../../database/database.provider";
|
||||
|
||||
// Stripe v22 hides its namespace types behind `export = StripeConstructor`, so we
|
||||
// reach the canonical Stripe namespace through the internal core .d.ts path.
|
||||
// This is purely a type-time import — no runtime effect.
|
||||
type StripeNs = import("stripe/cjs/stripe.core").Stripe;
|
||||
type StripeEvent = import("stripe/cjs/stripe.core").Stripe.Event;
|
||||
type CheckoutSession = import("stripe/cjs/stripe.core").Stripe.Checkout.Session;
|
||||
import { payments, plans, userSubscriptions } from "../../database/schema/core";
|
||||
import { PostHogService } from "../../posthog/posthog.service";
|
||||
import { SubscriptionsService } from "../../subscriptions/subscriptions.service";
|
||||
|
||||
const PLAN_KEY_TO_BRAND_COUNT: Record<string, number> = {
|
||||
brand1: 1,
|
||||
brand2: 2,
|
||||
brand3: 3,
|
||||
full: 0,
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class StripeService {
|
||||
private readonly logger = new Logger(StripeService.name);
|
||||
private readonly stripe: StripeNs | null;
|
||||
private readonly webhookSecret: string | undefined;
|
||||
private readonly successUrl: string;
|
||||
private readonly cancelUrl: string;
|
||||
|
||||
constructor(
|
||||
@Inject(DATABASE) private db: Database,
|
||||
private configService: ConfigService,
|
||||
private subscriptionsService: SubscriptionsService,
|
||||
private posthog: PostHogService,
|
||||
) {
|
||||
const secretKey = this.configService.get<string>("stripe.secretKey");
|
||||
this.webhookSecret = this.configService.get<string>("stripe.webhookSecret");
|
||||
this.successUrl =
|
||||
this.configService.get<string>("stripe.successUrl") ??
|
||||
"http://localhost:3000/dashboard/subscription?stripe=success";
|
||||
this.cancelUrl =
|
||||
this.configService.get<string>("stripe.cancelUrl") ??
|
||||
"http://localhost:3000/dashboard/subscription?stripe=cancelled";
|
||||
|
||||
if (secretKey) {
|
||||
this.stripe = new Stripe(secretKey, {
|
||||
apiVersion: "2026-04-22.dahlia",
|
||||
});
|
||||
this.logger.log("Stripe client initialized");
|
||||
} else {
|
||||
this.stripe = null;
|
||||
this.logger.warn("STRIPE_SECRET_KEY not set — Stripe payments disabled");
|
||||
}
|
||||
}
|
||||
|
||||
isEnabled(): boolean {
|
||||
return this.stripe !== null;
|
||||
}
|
||||
|
||||
async createCheckoutSession(
|
||||
userId: string,
|
||||
userEmail: string,
|
||||
planKey: string,
|
||||
billingPeriod: "monthly" | "yearly",
|
||||
brandIds: string[],
|
||||
) {
|
||||
if (!this.stripe) {
|
||||
throw new ServiceUnavailableException("Stripe ödeme şu an kullanılamıyor");
|
||||
}
|
||||
|
||||
const brandCount = PLAN_KEY_TO_BRAND_COUNT[planKey];
|
||||
if (brandCount === undefined) {
|
||||
throw new BadRequestException(`Geçersiz plan anahtarı: ${planKey}`);
|
||||
}
|
||||
|
||||
const [plan] = await this.db
|
||||
.select()
|
||||
.from(plans)
|
||||
.where(and(eq(plans.brandCount, brandCount), eq(plans.isActive, true)))
|
||||
.limit(1);
|
||||
|
||||
if (!plan) throw new NotFoundException("Plan bulunamadı");
|
||||
|
||||
const subscription = await this.subscriptionsService.create(userId, {
|
||||
planId: plan.id,
|
||||
brandIds,
|
||||
billingPeriod,
|
||||
});
|
||||
|
||||
if (brandIds.length > 0 && planKey !== "full") {
|
||||
await this.subscriptionsService.addBrandsToSubscription(subscription.id, userId, brandIds);
|
||||
}
|
||||
|
||||
const amount = billingPeriod === "yearly" ? plan.priceYearly : plan.priceMonthly;
|
||||
const productName = `${plan.name} (${billingPeriod === "yearly" ? "Yıllık" : "Aylık"})`;
|
||||
|
||||
const [payment] = await this.db
|
||||
.insert(payments)
|
||||
.values({
|
||||
userId,
|
||||
subscriptionId: subscription.id,
|
||||
amount,
|
||||
currency: "TRY",
|
||||
method: "stripe",
|
||||
status: "pending",
|
||||
})
|
||||
.returning();
|
||||
|
||||
const session = await this.stripe.checkout.sessions.create({
|
||||
mode: "payment",
|
||||
payment_method_types: ["card"],
|
||||
customer_email: userEmail,
|
||||
line_items: [
|
||||
{
|
||||
price_data: {
|
||||
currency: "try",
|
||||
product_data: { name: productName },
|
||||
unit_amount: amount, // already in kuruş (smallest unit)
|
||||
},
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
success_url: `${this.successUrl}&session_id={CHECKOUT_SESSION_ID}`,
|
||||
cancel_url: this.cancelUrl,
|
||||
client_reference_id: payment.id,
|
||||
metadata: {
|
||||
payment_id: payment.id,
|
||||
subscription_id: subscription.id,
|
||||
user_id: userId,
|
||||
plan_key: planKey,
|
||||
billing_period: billingPeriod,
|
||||
},
|
||||
});
|
||||
|
||||
await this.db
|
||||
.update(payments)
|
||||
.set({ stripeSessionId: session.id, updatedAt: new Date() })
|
||||
.where(eq(payments.id, payment.id));
|
||||
|
||||
this.posthog.captureForUser(userId, "payment_initiated", {
|
||||
method: "stripe",
|
||||
payment_id: payment.id,
|
||||
plan: planKey,
|
||||
period: billingPeriod,
|
||||
amount,
|
||||
});
|
||||
|
||||
return {
|
||||
paymentId: payment.id,
|
||||
sessionId: session.id,
|
||||
redirectUrl: session.url ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
async handleWebhook(rawBody: Buffer, signature: string) {
|
||||
if (!this.stripe || !this.webhookSecret) {
|
||||
throw new ServiceUnavailableException("Stripe webhook not configured");
|
||||
}
|
||||
|
||||
let event: StripeEvent;
|
||||
try {
|
||||
event = this.stripe.webhooks.constructEvent(
|
||||
rawBody,
|
||||
signature,
|
||||
this.webhookSecret,
|
||||
) as StripeEvent;
|
||||
} catch (err) {
|
||||
this.logger.warn(`Stripe webhook signature verification failed: ${(err as Error).message}`);
|
||||
throw new BadRequestException("Invalid signature");
|
||||
}
|
||||
|
||||
this.logger.log(`Stripe webhook received: ${event.type} (${event.id})`);
|
||||
|
||||
switch (event.type) {
|
||||
case "checkout.session.completed": {
|
||||
const session = event.data.object as CheckoutSession;
|
||||
await this.handleCheckoutCompleted(session);
|
||||
break;
|
||||
}
|
||||
case "checkout.session.async_payment_failed":
|
||||
case "checkout.session.expired": {
|
||||
const session = event.data.object as CheckoutSession;
|
||||
await this.handleCheckoutFailed(session, event.type);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
this.logger.debug(`Unhandled Stripe event type: ${event.type}`);
|
||||
}
|
||||
|
||||
return { received: true };
|
||||
}
|
||||
|
||||
private async handleCheckoutCompleted(session: CheckoutSession) {
|
||||
const paymentId = session.client_reference_id ?? session.metadata?.payment_id;
|
||||
if (!paymentId) {
|
||||
this.logger.warn(`checkout.session.completed missing payment_id (session ${session.id})`);
|
||||
return;
|
||||
}
|
||||
|
||||
const [payment] = await this.db
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq(payments.id, paymentId))
|
||||
.limit(1);
|
||||
|
||||
if (!payment) {
|
||||
this.logger.warn(`Payment ${paymentId} not found for completed session ${session.id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (payment.status === "completed") {
|
||||
this.logger.debug(`Payment ${paymentId} already completed — skipping`);
|
||||
return;
|
||||
}
|
||||
|
||||
const paymentIntentId =
|
||||
typeof session.payment_intent === "string" ? session.payment_intent : null;
|
||||
|
||||
await this.db
|
||||
.update(payments)
|
||||
.set({
|
||||
status: "completed",
|
||||
stripePaymentIntentId: paymentIntentId,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(payments.id, paymentId));
|
||||
|
||||
await this.subscriptionsService.activateSubscription(payment.subscriptionId);
|
||||
|
||||
this.posthog.captureForUser(payment.userId, "payment_success", {
|
||||
method: "stripe",
|
||||
payment_id: paymentId,
|
||||
subscription_id: payment.subscriptionId,
|
||||
amount: Number(payment.amount),
|
||||
stripe_session_id: session.id,
|
||||
stripe_payment_intent_id: paymentIntentId,
|
||||
});
|
||||
|
||||
this.logger.log(`Subscription ${payment.subscriptionId} activated via Stripe ${session.id}`);
|
||||
}
|
||||
|
||||
private async handleCheckoutFailed(session: CheckoutSession, reason: string) {
|
||||
const paymentId = session.client_reference_id ?? session.metadata?.payment_id;
|
||||
if (!paymentId) return;
|
||||
|
||||
const [payment] = await this.db
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq(payments.id, paymentId))
|
||||
.limit(1);
|
||||
|
||||
if (!payment || payment.status !== "pending") return;
|
||||
|
||||
await this.db
|
||||
.update(payments)
|
||||
.set({ status: "failed", adminNote: reason, updatedAt: new Date() })
|
||||
.where(eq(payments.id, paymentId));
|
||||
|
||||
// Also expire the pending subscription so the user can retry without "zaten aktif" conflict.
|
||||
await this.db
|
||||
.update(userSubscriptions)
|
||||
.set({ status: "expired", updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(userSubscriptions.id, payment.subscriptionId),
|
||||
eq(userSubscriptions.status, "pending"),
|
||||
),
|
||||
);
|
||||
|
||||
this.posthog.captureForUser(payment.userId, "payment_failed", {
|
||||
method: "stripe",
|
||||
payment_id: paymentId,
|
||||
subscription_id: payment.subscriptionId,
|
||||
reason,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user