Files
isletmem-kovakcrm/src/lib/appwrite/subscription-actions.ts
T

324 lines
9.8 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 { 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);
}