Files
kovakemlak-crm/src/lib/appwrite/subscription-actions.ts
T
egecankomur 7660901eb0 fix(paytr): encode hyphens in tenantId as Z in merchantOid
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.
2026-05-14 23:12:36 +03:00

349 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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[];
}