feat: subscription upgrade-only flow, discount codes, proration, enterprise inquiry form, payment history invoices page, fix mobile sidebar close on navigate

This commit is contained in:
egecankomur
2026-05-14 19:09:11 +03:00
parent 37b0928da6
commit 668fb7108b
11 changed files with 666 additions and 75 deletions
+156 -20
View File
@@ -2,13 +2,13 @@
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { Query } from "node-appwrite";
import { ID, Query } from "node-appwrite";
import { DATABASE_ID, TABLES, type TenantPlan, type PlanPeriod } from "./schema";
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, planPrice } from "./subscription-types";
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";
@@ -25,6 +25,7 @@ export async function activatePlanInDb(
plan: TenantPlan,
provider: string,
period: PlanPeriod = "monthly",
opts?: { amount?: number; orderId?: string; discountCode?: string },
): Promise<void> {
const { tablesDB } = createAdminClient();
@@ -46,6 +47,24 @@ export async function activatePlanInDb(
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> {
@@ -64,17 +83,45 @@ export async function deactivatePlanInDb(tenantId: string): Promise<void> {
});
}
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 (plan !== "pro") throw new Error("Geçersiz plan.");
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);
// Mock: direkt aktive et
await activatePlanInDb(ctx.tenantId, plan, "mock");
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");
}
@@ -82,15 +129,15 @@ export async function startMockCheckoutAction(formData: FormData): Promise<void>
export async function startShopierCheckoutAction(formData: FormData): Promise<void> {
const plan = String(formData.get("plan") ?? "") as TenantPlan;
if (plan !== "pro") throw new Error("Geçersiz plan.");
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ış.");
// Shopier mağaza sayfasına yönlendir — ödeme tamamlanınca webhook gelir
redirect(storeUrl);
}
@@ -98,12 +145,13 @@ export async function startShopierCheckoutAction(formData: FormData): Promise<vo
export async function startPolarCheckoutAction(formData: FormData): Promise<void> {
const plan = String(formData.get("plan") ?? "") as TenantPlan;
if (plan !== "pro") throw new Error("Geçersiz plan.");
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];
const catalog = PLAN_CATALOG[plan as Exclude<TenantPlan, "free">];
const orderId = generateOrderId();
const appUrl = process.env.APP_URL?.replace(/\/$/, "") ?? "http://localhost:3001";
@@ -114,9 +162,7 @@ export async function startPolarCheckoutAction(formData: FormData): Promise<void
successUrl: `${appUrl}/settings/billing?upgraded=1`,
});
// orderId'yi tenant_settings'e geçici olarak kaydedebiliriz ama
// minimal yaklaşımda metadata.tenant_id yeterli — webhook okur
void catalog; // fiyat bilgisi ileride log için kullanılabilir
void catalog;
redirect(checkout.url);
}
@@ -125,6 +171,7 @@ export async function startPolarCheckoutAction(formData: FormData): Promise<void
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.");
@@ -132,8 +179,26 @@ export async function getPayTRTokenAction(formData: FormData): Promise<string> {
const ctx = await requireTenant();
requireRole(ctx, ["owner"]);
const currentPlan = getEffectivePlan(ctx);
assertUpgrade(currentPlan, planId as TenantPlan);
const catalog = PLAN_CATALOG[planId];
const price = planPrice(catalog, period);
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)
@@ -152,9 +217,10 @@ export async function getPayTRTokenAction(formData: FormData): Promise<string> {
// Format: {tenantId}T{timestamp}{random}P{plan}X{period}
const merchantOid = `${ctx.tenantId}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"})`,
`KovakEmlak ${catalog.name} (${period === "yearly" ? "Yıllık" : "Aylık"})${discountLabel}`,
price.toFixed(2),
1,
],
@@ -177,7 +243,7 @@ export async function getPayTRTokenAction(formData: FormData): Promise<string> {
});
}
// ── Unified entry point ────────────────────────────────────────────────────────
// ── Unified entry point (Polar/Shopier only — PayTR uses getPayTRTokenAction) ──
export async function startCheckoutAction(formData: FormData): Promise<void> {
if (isPolarEnabled()) return startPolarCheckoutAction(formData);
@@ -185,13 +251,63 @@ export async function startCheckoutAction(formData: FormData): Promise<void> {
return startMockCheckoutAction(formData);
}
// ── Downgrade ──────────────────────────────────────────────────────────────────
// ── Enterprise inquiry ─────────────────────────────────────────────────────────
export async function downgradeToFreeAction(): Promise<void> {
export async function requestEnterpriseInquiryAction(
formData: FormData,
): Promise<{ ok: boolean; error?: string }> {
const ctx = await requireTenant();
requireRole(ctx, ["owner"]);
await deactivatePlanInDb(ctx.tenantId);
redirect("/settings/billing?downgraded=1");
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 ────────────────────────────────────────────────────────
@@ -209,3 +325,23 @@ export async function getCurrentPlanAction(): Promise<{
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[];
}