init: kovakemlak-crm project scaffold
- Next.js 16 + Appwrite multi-tenant emlak CRM - Database: kovakemlak-db (properties, customers, customer_searches, property_matches, presentations, investors, activities, tenant_settings) - Same stack as isletmem-kovakcrm (shadcn/ui template base) - Modules: portföy, müşteri takibi, arama kriterleri, otomatik eşleştirme, sunum linki, yatırımcı portalı
This commit is contained in:
@@ -0,0 +1,323 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { ID, Permission, Query, Role } from "node-appwrite";
|
||||
|
||||
import { logAudit } from "./audit";
|
||||
import { persistCardFromMockCheckout } from "./saved-card-actions";
|
||||
import { getDefaultCard } from "./saved-card-queries";
|
||||
import { DATABASE_ID, TABLES, type SubscriptionPayment, type TenantPlan } from "./schema";
|
||||
import { createAdminClient } from "./server";
|
||||
import { requireRole, requireTenant } from "./tenant-guard";
|
||||
import { PLAN_CATALOG } from "./subscription-types";
|
||||
import { getShopierPlanUrl, isShopierEnabled } from "../payments/shopier";
|
||||
import { createPolarCheckout, isPolarEnabled } from "../payments/polar";
|
||||
|
||||
const PRO_VALIDITY_DAYS = 30;
|
||||
|
||||
function teamRowPermissions(tenantId: string) {
|
||||
return [
|
||||
Permission.read(Role.team(tenantId, "owner")),
|
||||
Permission.read(Role.team(tenantId, "admin")),
|
||||
Permission.update(Role.team(tenantId, "owner")),
|
||||
Permission.delete(Role.team(tenantId, "owner")),
|
||||
];
|
||||
}
|
||||
|
||||
function generateOrderId(): string {
|
||||
const t = Date.now().toString(36);
|
||||
const r = Math.random().toString(36).slice(2, 10);
|
||||
return `ord_${t}_${r}`;
|
||||
}
|
||||
|
||||
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"]);
|
||||
|
||||
const catalog = PLAN_CATALOG[plan];
|
||||
const orderId = generateOrderId();
|
||||
|
||||
const { tablesDB } = createAdminClient();
|
||||
await tablesDB.createRow(
|
||||
DATABASE_ID,
|
||||
TABLES.subscriptionPayments,
|
||||
ID.unique(),
|
||||
{
|
||||
tenantId: ctx.tenantId,
|
||||
createdBy: ctx.user.id,
|
||||
orderId,
|
||||
plan,
|
||||
amount: catalog.price,
|
||||
currency: catalog.currency,
|
||||
status: "pending",
|
||||
provider: "mock",
|
||||
},
|
||||
teamRowPermissions(ctx.tenantId),
|
||||
);
|
||||
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "create",
|
||||
entityType: "subscription_payment",
|
||||
entityId: orderId,
|
||||
changes: { plan, amount: catalog.price, provider: "mock" },
|
||||
});
|
||||
|
||||
redirect(`/settings/billing/checkout/${orderId}`);
|
||||
}
|
||||
|
||||
async function findPendingPaymentByOrderId(
|
||||
tenantId: string,
|
||||
orderId: string,
|
||||
): Promise<SubscriptionPayment | null> {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const result = await tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.subscriptionPayments,
|
||||
queries: [Query.equal("orderId", orderId), Query.equal("tenantId", tenantId), Query.limit(1)],
|
||||
});
|
||||
return (result.rows[0] as unknown as SubscriptionPayment) ?? null;
|
||||
}
|
||||
|
||||
export async function confirmMockPaymentAction(formData: FormData): Promise<void> {
|
||||
const orderId = String(formData.get("orderId") ?? "");
|
||||
if (!orderId) throw new Error("orderId eksik.");
|
||||
|
||||
const ctx = await requireTenant();
|
||||
requireRole(ctx, ["owner"]);
|
||||
|
||||
const payment = await findPendingPaymentByOrderId(ctx.tenantId, orderId);
|
||||
if (!payment) throw new Error("Ödeme bulunamadı.");
|
||||
if (payment.status === "success") {
|
||||
redirect(`/settings/billing?upgraded=1`);
|
||||
}
|
||||
if (payment.provider !== "mock") {
|
||||
throw new Error("Bu ödeme mock olarak onaylanamaz.");
|
||||
}
|
||||
|
||||
const saveCard = String(formData.get("saveCard") ?? "") === "true";
|
||||
const useSavedCardId = String(formData.get("savedCardId") ?? "");
|
||||
|
||||
const { tablesDB } = createAdminClient();
|
||||
const now = new Date();
|
||||
const expires = new Date(now.getTime() + PRO_VALIDITY_DAYS * 24 * 60 * 60 * 1000);
|
||||
|
||||
await tablesDB.updateRow(DATABASE_ID, TABLES.subscriptionPayments, payment.$id, {
|
||||
status: "success",
|
||||
processedAt: now.toISOString(),
|
||||
providerPayload: JSON.stringify({
|
||||
mock: true,
|
||||
confirmedBy: ctx.user.id,
|
||||
usedSavedCardId: useSavedCardId || undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
if (ctx.settings) {
|
||||
await tablesDB.updateRow(DATABASE_ID, TABLES.tenantSettings, ctx.settings.$id, {
|
||||
plan: payment.plan,
|
||||
planStartedAt: now.toISOString(),
|
||||
planExpiresAt: expires.toISOString(),
|
||||
lastPaymentId: payment.$id,
|
||||
});
|
||||
}
|
||||
|
||||
if (saveCard && !useSavedCardId) {
|
||||
const last4 = String(formData.get("cardLast4") ?? "").replace(/\D/g, "").slice(-4);
|
||||
const month = parseInt(String(formData.get("cardExpiryMonth") ?? "0"), 10);
|
||||
const year = parseInt(String(formData.get("cardExpiryYear") ?? "0"), 10);
|
||||
const brand = String(formData.get("cardBrand") ?? "unknown");
|
||||
const holder = String(formData.get("cardHolder") ?? "").trim();
|
||||
|
||||
if (last4.length === 4 && month > 0 && year >= 2026) {
|
||||
const existingDefault = await getDefaultCard(ctx.tenantId);
|
||||
try {
|
||||
await persistCardFromMockCheckout({
|
||||
brand,
|
||||
last4,
|
||||
expiryMonth: month,
|
||||
expiryYear: year,
|
||||
holderName: holder,
|
||||
makeDefault: !existingDefault,
|
||||
});
|
||||
} catch {
|
||||
// best-effort — payment already succeeded, don't fail upgrade if card save errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "update",
|
||||
entityType: "subscription_payment",
|
||||
entityId: payment.$id,
|
||||
changes: { status: "success", plan: payment.plan, expires: expires.toISOString() },
|
||||
});
|
||||
|
||||
revalidatePath("/settings/billing");
|
||||
redirect(`/settings/billing?upgraded=1`);
|
||||
}
|
||||
|
||||
export async function cancelMockPaymentAction(formData: FormData): Promise<void> {
|
||||
const orderId = String(formData.get("orderId") ?? "");
|
||||
if (!orderId) throw new Error("orderId eksik.");
|
||||
|
||||
const ctx = await requireTenant();
|
||||
requireRole(ctx, ["owner"]);
|
||||
|
||||
const payment = await findPendingPaymentByOrderId(ctx.tenantId, orderId);
|
||||
if (!payment) {
|
||||
redirect(`/settings/billing`);
|
||||
}
|
||||
if (payment && payment.status === "pending") {
|
||||
const { tablesDB } = createAdminClient();
|
||||
await tablesDB.updateRow(DATABASE_ID, TABLES.subscriptionPayments, payment.$id, {
|
||||
status: "failed",
|
||||
processedAt: new Date().toISOString(),
|
||||
providerPayload: JSON.stringify({ mock: true, cancelledBy: ctx.user.id }),
|
||||
});
|
||||
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "update",
|
||||
entityType: "subscription_payment",
|
||||
entityId: payment.$id,
|
||||
changes: { status: "failed" },
|
||||
});
|
||||
}
|
||||
|
||||
revalidatePath("/settings/billing");
|
||||
redirect(`/settings/billing?cancelled=1`);
|
||||
}
|
||||
|
||||
export async function downgradeToFreeAction(): Promise<void> {
|
||||
const ctx = await requireTenant();
|
||||
requireRole(ctx, ["owner"]);
|
||||
|
||||
if (!ctx.settings) throw new Error("Ayar yok.");
|
||||
|
||||
const { tablesDB } = createAdminClient();
|
||||
await tablesDB.updateRow(DATABASE_ID, TABLES.tenantSettings, ctx.settings.$id, {
|
||||
plan: "free",
|
||||
planExpiresAt: null,
|
||||
});
|
||||
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "update",
|
||||
entityType: "tenant_settings",
|
||||
entityId: ctx.settings.$id,
|
||||
changes: { plan: "free" },
|
||||
});
|
||||
|
||||
revalidatePath("/settings/billing");
|
||||
redirect(`/settings/billing?downgraded=1`);
|
||||
}
|
||||
|
||||
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ış.");
|
||||
|
||||
const catalog = PLAN_CATALOG[plan];
|
||||
const orderId = generateOrderId();
|
||||
|
||||
const { tablesDB } = createAdminClient();
|
||||
await tablesDB.createRow(
|
||||
DATABASE_ID,
|
||||
TABLES.subscriptionPayments,
|
||||
ID.unique(),
|
||||
{
|
||||
tenantId: ctx.tenantId,
|
||||
createdBy: ctx.user.id,
|
||||
orderId,
|
||||
plan,
|
||||
amount: catalog.price,
|
||||
currency: catalog.currency,
|
||||
status: "pending",
|
||||
provider: "shopier",
|
||||
// Webhook'ta tenant eşleştirmek için alıcı emailini sakla
|
||||
providerPayload: JSON.stringify({ userEmail: ctx.user.email }),
|
||||
},
|
||||
teamRowPermissions(ctx.tenantId),
|
||||
);
|
||||
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "create",
|
||||
entityType: "subscription_payment",
|
||||
entityId: orderId,
|
||||
changes: { plan, amount: catalog.price, provider: "shopier" },
|
||||
});
|
||||
|
||||
// Kullanıcıyı doğrudan Shopier mağaza ürün sayfasına yönlendir
|
||||
redirect(storeUrl);
|
||||
}
|
||||
|
||||
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:3000";
|
||||
|
||||
const { tablesDB } = createAdminClient();
|
||||
await tablesDB.createRow(
|
||||
DATABASE_ID,
|
||||
TABLES.subscriptionPayments,
|
||||
ID.unique(),
|
||||
{
|
||||
tenantId: ctx.tenantId,
|
||||
createdBy: ctx.user.id,
|
||||
orderId,
|
||||
plan,
|
||||
amount: catalog.price,
|
||||
currency: catalog.currency,
|
||||
status: "pending",
|
||||
provider: "polar",
|
||||
},
|
||||
teamRowPermissions(ctx.tenantId),
|
||||
);
|
||||
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "create",
|
||||
entityType: "subscription_payment",
|
||||
entityId: orderId,
|
||||
changes: { plan, amount: catalog.price, provider: "polar" },
|
||||
});
|
||||
|
||||
const checkout = await createPolarCheckout({
|
||||
orderId,
|
||||
tenantId: ctx.tenantId,
|
||||
userEmail: ctx.user.email,
|
||||
successUrl: `${appUrl}/settings/billing?upgraded=1`,
|
||||
});
|
||||
|
||||
redirect(checkout.url);
|
||||
}
|
||||
|
||||
// Unified entry point — PAYMENT_PROVIDER env ile yönlendirir.
|
||||
export async function startCheckoutAction(formData: FormData): Promise<void> {
|
||||
if (isPolarEnabled()) return startPolarCheckoutAction(formData);
|
||||
if (isShopierEnabled()) return startShopierCheckoutAction(formData);
|
||||
return startMockCheckoutAction(formData);
|
||||
}
|
||||
Reference in New Issue
Block a user