"use server"; import { headers } from "next/headers"; import { redirect } from "next/navigation"; import { ID, Query } from "node-appwrite"; import { DATABASE_ID, TABLES, type TenantPlan, type PlanPeriod, type PaymentEvent } from "./schema"; import { createAdminClient } from "./server"; import { requireRole, requireTenant } from "./tenant-guard"; import { getEffectivePlan } from "./plan-limits"; import { PLAN_CATALOG, PLAN_RANK, planPrice, validateDiscountCode } from "./subscription-types"; import { getShopierPlanUrl, isShopierEnabled } from "../payments/shopier"; import { createPolarCheckout, isPolarEnabled } from "../payments/polar"; import { getPayTRToken } from "../payments/paytr"; function generateOrderId(): string { const t = Date.now().toString(36); const r = Math.random().toString(36).slice(2, 10); return `ord_${t}_${r}`; } // Webhook handler'larından da çağrılabilir — provider "polar" | "shopier" | "paytr" | "mock" export async function activatePlanInDb( tenantId: string, plan: TenantPlan, provider: string, period: PlanPeriod = "monthly", opts?: { amount?: number; orderId?: string; discountCode?: string }, ): Promise { const { tablesDB } = createAdminClient(); const result = await tablesDB.listRows({ databaseId: DATABASE_ID, tableId: TABLES.tenantSettings, queries: [Query.equal("tenantId", tenantId), Query.limit(1)], }); const row = result.rows[0]; if (!row) throw new Error(`tenant_settings bulunamadı: ${tenantId}`); const validityDays = period === "yearly" ? 365 : 30; const now = new Date(); const expires = new Date(now.getTime() + validityDays * 24 * 60 * 60 * 1000); await tablesDB.updateRow(DATABASE_ID, TABLES.tenantSettings, row.$id, { plan, planPeriod: period, planExpiresAt: expires.toISOString(), planProvider: provider, }); // Ödeme geçmişi kaydı if (plan !== "free" && opts?.amount != null) { try { const eventData: Record = { tenantId, plan, period, amount: opts.amount, provider, }; if (opts.orderId) eventData.orderId = opts.orderId; if (opts.discountCode) eventData.discountCode = opts.discountCode; await tablesDB.createRow(DATABASE_ID, TABLES.paymentEvents, ID.unique(), eventData); } catch { // best-effort — ödeme aktivasyonunu bloke etme } } } export async function deactivatePlanInDb(tenantId: string): Promise { const { tablesDB } = createAdminClient(); const result = await tablesDB.listRows({ databaseId: DATABASE_ID, tableId: TABLES.tenantSettings, queries: [Query.equal("tenantId", tenantId), Query.limit(1)], }); const row = result.rows[0]; if (!row) return; await tablesDB.updateRow(DATABASE_ID, TABLES.tenantSettings, row.$id, { plan: "free", planExpiresAt: null, planProvider: null, }); } function assertUpgrade(currentPlan: TenantPlan, targetPlan: TenantPlan) { if (PLAN_RANK[targetPlan] <= PLAN_RANK[currentPlan]) { throw new Error("Yalnızca daha üst bir plana geçiş yapabilirsiniz."); } } /** Returns prorated price to charge when upgrading mid-cycle. Min 1 TRY. */ function calculateProration( currentPlan: TenantPlan, currentPeriod: PlanPeriod, planExpiresAt: string | undefined | null, newPlanPrice: number, ): number { if (!planExpiresAt || currentPlan === "free") return newPlanPrice; const currentEntry = PLAN_CATALOG[currentPlan as Exclude]; if (!currentEntry) return newPlanPrice; const periodDays = currentPeriod === "yearly" ? 365 : 30; const msRemaining = new Date(planExpiresAt).getTime() - Date.now(); const daysRemaining = Math.max(0, msRemaining / (1000 * 60 * 60 * 24)); const unusedFraction = Math.min(1, daysRemaining / periodDays); const credit = planPrice(currentEntry, currentPeriod) * unusedFraction; return Math.max(1, Math.round(newPlanPrice - credit)); } // ── Mock checkout (geliştirme ortamı) ───────────────────────────────────────── export async function startMockCheckoutAction(formData: FormData): Promise { const plan = String(formData.get("plan") ?? "") as TenantPlan; if (!["starter", "pro", "enterprise"].includes(plan)) throw new Error("Geçersiz plan."); const ctx = await requireTenant(); requireRole(ctx, ["owner"]); assertUpgrade(getEffectivePlan(ctx), plan as TenantPlan); const catalog = PLAN_CATALOG[plan as Exclude]; const period = String(formData.get("period") ?? "monthly") as PlanPeriod; const amount = planPrice(catalog, period); await activatePlanInDb(ctx.tenantId, plan as TenantPlan, "mock", period, { amount }); redirect("/settings/billing?upgraded=1"); } // ── Shopier checkout ─────────────────────────────────────────────────────────── export async function startShopierCheckoutAction(formData: FormData): Promise { const plan = String(formData.get("plan") ?? "") as TenantPlan; if (!["starter", "pro"].includes(plan)) throw new Error("Geçersiz plan."); const ctx = await requireTenant(); requireRole(ctx, ["owner"]); assertUpgrade(getEffectivePlan(ctx), plan as TenantPlan); const storeUrl = getShopierPlanUrl(plan); if (!storeUrl) throw new Error("Shopier mağaza URL'i ayarlanmamış."); redirect(storeUrl); } // ── Polar checkout ───────────────────────────────────────────────────────────── export async function startPolarCheckoutAction(formData: FormData): Promise { const plan = String(formData.get("plan") ?? "") as TenantPlan; if (!["starter", "pro"].includes(plan)) throw new Error("Geçersiz plan."); const ctx = await requireTenant(); requireRole(ctx, ["owner"]); assertUpgrade(getEffectivePlan(ctx), plan as TenantPlan); const catalog = PLAN_CATALOG[plan as Exclude]; const orderId = generateOrderId(); const appUrl = process.env.APP_URL?.replace(/\/$/, "") ?? "http://localhost:3001"; const checkout = await createPolarCheckout({ orderId, tenantId: ctx.tenantId, userEmail: ctx.user.email, successUrl: `${appUrl}/settings/billing?upgraded=1`, }); void catalog; redirect(checkout.url); } // ── PayTR checkout ───────────────────────────────────────────────────────────── export async function getPayTRTokenAction(formData: FormData): Promise { const planId = String(formData.get("plan") ?? "") as Exclude; const period = String(formData.get("period") ?? "monthly") as PlanPeriod; const discountCodeRaw = String(formData.get("discountCode") ?? "").trim(); if (!["starter", "pro", "enterprise"].includes(planId)) throw new Error("Geçersiz plan."); if (!["monthly", "yearly"].includes(period)) throw new Error("Geçersiz dönem."); const ctx = await requireTenant(); requireRole(ctx, ["owner"]); const currentPlan = getEffectivePlan(ctx); assertUpgrade(currentPlan, planId as TenantPlan); const catalog = PLAN_CATALOG[planId]; let price = planPrice(catalog, period); // Proration: credit for unused days on current paid plan price = calculateProration( currentPlan, ctx.settings?.planPeriod ?? "monthly", ctx.settings?.planExpiresAt, price, ); // Discount code if (discountCodeRaw) { const discountFraction = validateDiscountCode(discountCodeRaw); if (!discountFraction) throw new Error("Geçersiz indirim kodu."); price = Math.max(1, Math.round(price * (1 - discountFraction))); } // APP_URL: server-to-server callback (HTTPS zorunlu, prod'da veya ngrok) // APP_BROWSER_URL: browser redirect (dev'de localhost, prod'da aynı domain) const appUrl = process.env.APP_URL?.replace(/\/$/, "") ?? "http://localhost:3000"; const browserUrl = (process.env.APP_BROWSER_URL ?? appUrl).replace(/\/$/, ""); const requestHeaders = await headers(); const userIp = requestHeaders.get("x-forwarded-for")?.split(",")[0]?.trim() ?? requestHeaders.get("x-real-ip") ?? "1.2.3.4"; const timestamp = Date.now().toString(); const random = Math.random().toString(36).slice(2, 8); // Hyphens encoded as Z (Appwrite hex IDs never contain uppercase letters) // Format: {encodedTenantId}T{timestamp}{random}P{plan}X{period} const encodedTenantId = ctx.tenantId.replace(/-/g, "Z"); const merchantOid = `${encodedTenantId}T${timestamp}${random}P${planId}X${period}`; const discountLabel = discountCodeRaw ? ` + ${discountCodeRaw.toUpperCase()} İndirimi` : ""; const userBasket: Array<[string, string, number]> = [ [ `KovakEmlak ${catalog.name} (${period === "yearly" ? "Yıllık" : "Aylık"})${discountLabel}`, price.toFixed(2), 1, ], ]; const officeName = ctx.settings?.officeName ?? ctx.user.name ?? "Müşteri"; return getPayTRToken({ merchantOid, email: ctx.user.email, userName: officeName, userAddress: ctx.settings?.address ?? "Türkiye", userPhone: ctx.settings?.phone ?? "05000000000", paymentAmountKurus: price * 100, userBasket, userIp, notifyUrl: `${appUrl}/api/payments/paytr/callback`, okUrl: `${browserUrl}/settings/billing?upgraded=1`, failUrl: `${browserUrl}/settings/billing?failed=1`, }); } // ── Unified entry point (Polar/Shopier only — PayTR uses getPayTRTokenAction) ── export async function startCheckoutAction(formData: FormData): Promise { if (isPolarEnabled()) return startPolarCheckoutAction(formData); if (isShopierEnabled()) return startShopierCheckoutAction(formData); return startMockCheckoutAction(formData); } // ── Enterprise inquiry ───────────────────────────────────────────────────────── export async function requestEnterpriseInquiryAction( formData: FormData, ): Promise<{ ok: boolean; error?: string }> { const ctx = await requireTenant(); requireRole(ctx, ["owner"]); const officeName = ctx.settings?.officeName ?? ctx.user.name ?? "Bilinmiyor"; const userEmail = ctx.user.email; const tenantId = ctx.tenantId; const teamSize = String(formData.get("teamSize") ?? "").trim(); const listingCount = String(formData.get("listingCount") ?? "").trim(); const needsCustomDev = String(formData.get("needsCustomDev") ?? "").trim(); const notes = String(formData.get("notes") ?? "").trim(); if (!teamSize || !listingCount || !needsCustomDev) { return { ok: false, error: "Lütfen tüm zorunlu alanları doldurun." }; } const customDevLabel = needsCustomDev === "yes" ? "Evet" : needsCustomDev === "no" ? "Hayır" : "Belirsiz"; try { const { messaging } = createAdminClient(); // Confirmation email to the tenant owner await messaging.createEmail( `enterprise-inquiry-${tenantId}-${Date.now()}`, "Enterprise Plan Talebiniz Alındı — KovakEmlak", `

Merhaba ${officeName},

Enterprise plan talebiniz alındı. Ekibimiz en kısa sürede ${userEmail} adresine size ulaşacak.

${notes ? `` : ""}
Ofis adı${officeName}
Ekip büyüklüğü${teamSize} kişi
Aktif ilan sayısı${listingCount}
Özel modül geliştirme${customDevLabel}
Notlar${notes}

Bu e-posta otomatik olarak gönderilmiştir.

`, [], [ctx.user.id], [], ); } catch { // Email gönderimi başarısız olsa bile talebi kabul et } return { ok: true }; } // ── Downgrade (engellendi) ───────────────────────────────────────────────────── export async function downgradeToFreeAction(): Promise { // Ücretli plandan ücretsiz plana geçiş devre dışı. // Abonelik iptali için destek hattıyla iletişime geçin. throw new Error("Ücretli plandan ücretsiz plana geçiş yapılamaz. Abonelik iptali için info@kovakyazilim.com ile iletişime geçin."); } // ── Mevcut plan bilgisi ──────────────────────────────────────────────────────── export async function getCurrentPlanAction(): Promise<{ plan: TenantPlan; expiresAt: string | null; provider: string | null; }> { const ctx = await requireTenant(); const plan = getEffectivePlan(ctx); return { plan, expiresAt: ctx.settings?.planExpiresAt ?? null, provider: ctx.settings?.planProvider ?? null, }; } // ── Ödeme geçmişi (sadece owner) ────────────────────────────────────────────── export async function getPaymentHistoryAction(): Promise { const ctx = await requireTenant(); requireRole(ctx, ["owner"]); const { tablesDB } = createAdminClient(); const result = await tablesDB.listRows({ databaseId: DATABASE_ID, tableId: TABLES.paymentEvents, queries: [ Query.equal("tenantId", ctx.tenantId), Query.orderDesc("$createdAt"), Query.limit(50), ], }); return result.rows as unknown as PaymentEvent[]; }