feat: plan tier system, mock checkout, saved cards, tenant logo upload + mobile sheet fix

Plan & billing layer:
- New tables: subscription_payments, saved_cards (via Appwrite MCP)
- tenant_settings: plan/planStartedAt/planExpiresAt/lastPaymentId columns
- Free tier limits (50 customers / 100 finance entries / 5 software / 1 member)
  enforced via requirePlanCapacity gate in create actions
- PlanLimitDialog opens when limit hit; UsageBanner at 80% threshold
- /pricing rebuilt with Free + Pro tiers and Klinik/Ajans ecosystem teasers
- /settings/billing redesigned: compact plan summary, saved cards list,
  KVKK transparency block, payment history
- Usage stats moved to /pricing where they are decision-relevant

Mock checkout flow:
- 3D animated credit card with sync inputs and CVC flip
- Brand auto-detection (Visa / Mastercard / Amex / troy)
- Saved-card mode when previous cards exist; first card defaults to default
- 'Bu kartı kaydet' checkbox with explicit storage scope disclosure
- /settings/billing/checkout/[orderId] route

Saved cards:
- saved_cards bucket stores last4 + brand + expiry + holder only
- Default toggle, remove action, owner-only management
- Architecture ready for Shopier provider token swap-in

Tenant logo upload (first file upload feature):
- New Appwrite bucket: tenant-logos (max 2MB, image only, public read)
- uploadLogoAction with orphan cleanup, removeLogoAction
- LogoUploader UI: drag-drop, client-side preview, validation
- Sidebar shows logo when set, falls back to default icon

Mobile sheet fix:
- SheetContent uses h-dvh instead of h-full (dynamic viewport)
- SheetFooter pads pb-[max(1rem,env(safe-area-inset-bottom))]
- 13 form sheets switched py-4 → pt-4 to let safe-area apply

db: subscription_payments, saved_cards tables; tenant_settings plan columns;
    tenant-logos storage bucket
This commit is contained in:
kovakmedya
2026-04-30 21:36:01 +03:00
parent ab336b191f
commit 196036c0d8
46 changed files with 2607 additions and 85 deletions
+220
View File
@@ -0,0 +1,220 @@
"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";
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`);
}