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

271 lines
8.2 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 { isShopierEnabled } from "../payments/shopier";
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 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",
},
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" },
});
redirect(`/settings/billing/checkout/${orderId}/shopier`);
}
// Unified entry point — branches on PAYMENT_PROVIDER env variable.
// Set PAYMENT_PROVIDER=shopier in production; leave unset (or "mock") for testing.
export async function startCheckoutAction(formData: FormData): Promise<void> {
if (isShopierEnabled()) {
return startShopierCheckoutAction(formData);
}
return startMockCheckoutAction(formData);
}