Files
kovakemlak-crm/src/lib/appwrite/subscription-actions.ts
T
egecankomur 3cce632eb3 feat(billing): payment infrastructure pre-prep
db: add plan, planExpiresAt, planProvider to tenant_settings (Appwrite MCP)

- schema.ts: TenantPlan type, TenantSettings plan fields
- subscription-types.ts: Emlak plan catalog (Free / Pro 499₺/ay)
- plan-limits.ts: resource limits (properties/customers/members/presentations)
  + getPlanUsage, requirePlanCapacity, PlanLimitError helpers
- subscription-actions.ts: startCheckoutAction (Polar→Shopier→mock fallback),
  activatePlanInDb / deactivatePlanInDb for webhook handlers,
  downgradeToFreeAction, getCurrentPlanAction
- /api/payments/polar/callback: verify webhook → activatePlanInDb on order/subscription events
- /api/payments/shopier/callback: verify HMAC → activate on fulfilled+paid (tenant
  email-matching TODO pending Shopier metadata support)
- /settings/billing: CurrentPlanCard with usage progress bars + UpgradeSection
- sidebar: Plan & Faturalama nav item
- PlanLimitDialog: Emlak-specific feature list
2026-05-08 15:26:18 +03:00

153 lines
5.7 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 { redirect } from "next/navigation";
import { Query } from "node-appwrite";
import { DATABASE_ID, TABLES, type TenantPlan } from "./schema";
import { createAdminClient } from "./server";
import { requireRole, requireTenant } from "./tenant-guard";
import { getEffectivePlan } from "./plan-limits";
import { PLAN_CATALOG } from "./subscription-types";
import { getShopierPlanUrl, isShopierEnabled } from "../payments/shopier";
import { createPolarCheckout, isPolarEnabled } from "../payments/polar";
const PRO_VALIDITY_DAYS = 30;
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" | "mock"
export async function activatePlanInDb(
tenantId: string,
plan: TenantPlan,
provider: string,
): Promise<void> {
const { tablesDB } = createAdminClient();
// tenant_settings satırını bul
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 now = new Date();
const expires = new Date(now.getTime() + PRO_VALIDITY_DAYS * 24 * 60 * 60 * 1000);
await tablesDB.updateRow(DATABASE_ID, TABLES.tenantSettings, row.$id, {
plan,
planExpiresAt: expires.toISOString(),
planProvider: provider,
});
}
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,
});
}
// ── 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.");
const ctx = await requireTenant();
requireRole(ctx, ["owner"]);
// Mock: direkt aktive et
await activatePlanInDb(ctx.tenantId, plan, "mock");
redirect("/settings/billing?upgraded=1");
}
// ── Shopier checkout ───────────────────────────────────────────────────────────
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.");
const ctx = await requireTenant();
requireRole(ctx, ["owner"]);
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);
}
// ── Polar checkout ─────────────────────────────────────────────────────────────
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.");
const ctx = await requireTenant();
requireRole(ctx, ["owner"]);
const catalog = PLAN_CATALOG[plan];
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`,
});
// 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
redirect(checkout.url);
}
// ── Unified entry point ────────────────────────────────────────────────────────
export async function startCheckoutAction(formData: FormData): Promise<void> {
if (isPolarEnabled()) return startPolarCheckoutAction(formData);
if (isShopierEnabled()) return startShopierCheckoutAction(formData);
return startMockCheckoutAction(formData);
}
// ── Downgrade ──────────────────────────────────────────────────────────────────
export async function downgradeToFreeAction(): Promise<void> {
const ctx = await requireTenant();
requireRole(ctx, ["owner"]);
await deactivatePlanInDb(ctx.tenantId);
redirect("/settings/billing?downgraded=1");
}
// ── 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,
};
}