7660901eb0
PayTR rejects merchant_oid with non-alphanumeric chars. Real Appwrite tenant IDs are hex (a-z0-9) and safe, but demo-tenant-001 contains hyphens. Encode - as Z (never appears in Appwrite hex IDs) in the merchantOid; decode back in the callback.
349 lines
14 KiB
TypeScript
349 lines
14 KiB
TypeScript
"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<void> {
|
||
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<string, unknown> = {
|
||
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<void> {
|
||
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<TenantPlan, "free">];
|
||
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<void> {
|
||
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<TenantPlan, "free">];
|
||
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<void> {
|
||
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<void> {
|
||
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<TenantPlan, "free">];
|
||
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<string> {
|
||
const planId = String(formData.get("plan") ?? "") as Exclude<TenantPlan, "free">;
|
||
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<void> {
|
||
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",
|
||
`<p>Merhaba ${officeName},</p>
|
||
<p>Enterprise plan talebiniz alındı. Ekibimiz en kısa sürede <strong>${userEmail}</strong> adresine size ulaşacak.</p>
|
||
<table style="border-collapse:collapse;margin:16px 0;font-size:14px;">
|
||
<tr><td style="padding:6px 12px 6px 0;color:#888;">Ofis adı</td><td style="padding:6px 0"><strong>${officeName}</strong></td></tr>
|
||
<tr><td style="padding:6px 12px 6px 0;color:#888;">Ekip büyüklüğü</td><td style="padding:6px 0"><strong>${teamSize} kişi</strong></td></tr>
|
||
<tr><td style="padding:6px 12px 6px 0;color:#888;">Aktif ilan sayısı</td><td style="padding:6px 0"><strong>${listingCount}</strong></td></tr>
|
||
<tr><td style="padding:6px 12px 6px 0;color:#888;">Özel modül geliştirme</td><td style="padding:6px 0"><strong>${customDevLabel}</strong></td></tr>
|
||
${notes ? `<tr><td style="padding:6px 12px 6px 0;color:#888;vertical-align:top;">Notlar</td><td style="padding:6px 0">${notes}</td></tr>` : ""}
|
||
</table>
|
||
<p style="color:#888;font-size:12px;">Bu e-posta otomatik olarak gönderilmiştir.</p>`,
|
||
[],
|
||
[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<void> {
|
||
// Ü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<PaymentEvent[]> {
|
||
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[];
|
||
}
|