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:
@@ -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[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user