diff --git a/src/app/(dashboard)/pricing/page.tsx b/src/app/(dashboard)/pricing/page.tsx
index ee139c1..ed72e13 100644
--- a/src/app/(dashboard)/pricing/page.tsx
+++ b/src/app/(dashboard)/pricing/page.tsx
@@ -21,8 +21,9 @@ import {
} from "@/lib/appwrite/plan-limits";
import {
downgradeToFreeAction,
- startMockCheckoutAction,
+ startCheckoutAction,
} from "@/lib/appwrite/subscription-actions";
+import { isShopierEnabled } from "@/lib/payments/shopier";
import { PLAN_CATALOG } from "@/lib/appwrite/subscription-types";
import { requireTenant } from "@/lib/appwrite/tenant-guard";
@@ -79,6 +80,7 @@ export default async function PricingPage() {
const isPro = currentPlan === "pro";
const canManage = ctx.role === "owner";
const usage = await getPlanUsage(ctx);
+ const shopierActive = isShopierEnabled();
const resources: PlanResource[] = ["customers", "financeEntries", "software", "members"];
@@ -208,11 +210,11 @@ export default async function PricingPage() {
Sahip yetkisi gerekli
) : tier.id === "pro" ? (
-
) : (
@@ -278,15 +280,16 @@ export default async function PricingPage() {
-
-
-
- Test modu: Pro plan şu anda mock
- ödeme akışıyla çalışır. Shopier entegrasyonu yakında — gerçek tahsilat ancak entegrasyon
- tamamlandıktan sonra başlayacak.
-
-
-
+ {!shopierActive && (
+
+
+
+ Test modu: Pro plan şu anda mock
+ ödeme akışıyla çalışır. Shopier entegrasyonu aktif edilince gerçek tahsilat başlayacak.
+
+
+
+ )}
);
}
diff --git a/src/app/(dashboard)/settings/billing/checkout/[orderId]/shopier/page.tsx b/src/app/(dashboard)/settings/billing/checkout/[orderId]/shopier/page.tsx
new file mode 100644
index 0000000..700c94c
--- /dev/null
+++ b/src/app/(dashboard)/settings/billing/checkout/[orderId]/shopier/page.tsx
@@ -0,0 +1,49 @@
+import { redirect } from "next/navigation";
+
+import { getPaymentByOrderId } from "@/lib/appwrite/subscription-queries";
+import { PLAN_CATALOG } from "@/lib/appwrite/subscription-types";
+import { requireTenant } from "@/lib/appwrite/tenant-guard";
+import { buildShopierFormFields, SHOPIER_ENDPOINT } from "@/lib/payments/shopier";
+
+import { ShopierAutoSubmit } from "./shopier-auto-submit";
+
+export default async function ShopierRedirectPage({
+ params,
+}: {
+ params: Promise<{ orderId: string }>;
+}) {
+ const { orderId } = await params;
+ const ctx = await requireTenant();
+ if (ctx.role !== "owner") redirect("/settings/billing");
+
+ const payment = await getPaymentByOrderId(ctx.tenantId, orderId);
+ if (!payment || payment.provider !== "shopier") redirect("/settings/billing");
+ if (payment.status === "success") redirect("/settings/billing?upgraded=1");
+ if (payment.status === "failed") redirect("/settings/billing?cancelled=1");
+
+ const catalog = PLAN_CATALOG[payment.plan];
+
+ // Split full name into first + surname (best effort)
+ const nameParts = (ctx.user.name || ctx.user.email.split("@")[0]).trim().split(" ");
+ const buyerName = nameParts[0] ?? "Kullanıcı";
+ const buyerSurname = nameParts.slice(1).join(" ") || buyerName;
+
+ const fields = buildShopierFormFields({
+ orderId: payment.orderId,
+ amount: payment.amount,
+ currency: (payment.currency as "TRY" | "USD" | "EUR") ?? "TRY",
+ productName: `İşletmem ${catalog.name} Plan`,
+ buyerName,
+ buyerSurname,
+ buyerEmail: ctx.user.email,
+ buyerId: ctx.user.id,
+ });
+
+ return (
+
+ );
+}
diff --git a/src/app/(dashboard)/settings/billing/checkout/[orderId]/shopier/result/page.tsx b/src/app/(dashboard)/settings/billing/checkout/[orderId]/shopier/result/page.tsx
new file mode 100644
index 0000000..0fac3fd
--- /dev/null
+++ b/src/app/(dashboard)/settings/billing/checkout/[orderId]/shopier/result/page.tsx
@@ -0,0 +1,67 @@
+import Link from "next/link";
+import { redirect } from "next/navigation";
+import { CheckCircle, Loader2, XCircle } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import { getPaymentByOrderId } from "@/lib/appwrite/subscription-queries";
+import { requireTenant } from "@/lib/appwrite/tenant-guard";
+
+export default async function ShopierResultPage({
+ params,
+ searchParams,
+}: {
+ params: Promise<{ orderId: string }>;
+ searchParams: Promise<{ ok?: string }>;
+}) {
+ const { orderId } = await params;
+ const { ok } = await searchParams;
+ const ctx = await requireTenant();
+
+ const payment = await getPaymentByOrderId(ctx.tenantId, orderId);
+ if (!payment) redirect("/settings/billing");
+
+ // Callback may not have arrived yet — if Shopier said ok but DB still pending, show processing.
+ const shopierSaysOk = ok === "1";
+ const dbStatus = payment.status;
+
+ if (dbStatus === "success") {
+ redirect("/settings/billing?upgraded=1");
+ }
+ if (dbStatus === "failed" || (!shopierSaysOk && dbStatus === "pending")) {
+ return (
+
+
+
+
Ödeme başarısız
+
+ 3D Secure doğrulaması reddedildi veya işlem iptal edildi.
+
+
+
+ Geri dön
+
+
+ );
+ }
+
+ // pending + shopierSaysOk → callback not yet received, auto-refresh
+ return (
+
+
+
+
Ödeme işleniyor
+
+ Bankadan onay bekleniyor, birkaç saniye içinde yönlendirileceksiniz.
+
+
+ {/* Meta refresh — simpler than polling for this rare in-between state */}
+ {/* eslint-disable-next-line @next/next/no-head-element */}
+
+
+
+ Yenile
+
+
+
+ );
+}
diff --git a/src/app/(dashboard)/settings/billing/checkout/[orderId]/shopier/shopier-auto-submit.tsx b/src/app/(dashboard)/settings/billing/checkout/[orderId]/shopier/shopier-auto-submit.tsx
new file mode 100644
index 0000000..9592fc8
--- /dev/null
+++ b/src/app/(dashboard)/settings/billing/checkout/[orderId]/shopier/shopier-auto-submit.tsx
@@ -0,0 +1,46 @@
+"use client";
+
+import { useEffect, useRef } from "react";
+import { Loader2, Lock } from "lucide-react";
+
+type Props = {
+ action: string;
+ fields: Record;
+ orderId: string;
+};
+
+export function ShopierAutoSubmit({ action, fields, orderId: _orderId }: Props) {
+ const formRef = useRef(null);
+
+ useEffect(() => {
+ // Small delay so the user sees the loading state before redirect
+ const t = setTimeout(() => {
+ formRef.current?.submit();
+ }, 800);
+ return () => clearTimeout(t);
+ }, []);
+
+ return (
+
+
+
+
+
+
+
Shopier ödeme sayfasına yönlendiriliyor
+
+ Güvenli 3D Secure ödeme için Shopier'e aktarılıyorsunuz…
+
+
+
+
+
+ {/* Hidden auto-submit form */}
+
+
+ );
+}
diff --git a/src/app/api/payments/shopier/callback/route.ts b/src/app/api/payments/shopier/callback/route.ts
new file mode 100644
index 0000000..2270b39
--- /dev/null
+++ b/src/app/api/payments/shopier/callback/route.ts
@@ -0,0 +1,112 @@
+import { NextRequest, NextResponse } from "next/server";
+import { Query } from "node-appwrite";
+
+import { logAudit } from "@/lib/appwrite/audit";
+import { DATABASE_ID, TABLES, type SubscriptionPayment } from "@/lib/appwrite/schema";
+import { createAdminClient } from "@/lib/appwrite/server";
+import {
+ verifyCallbackSignature,
+ type ShopierCallbackPayload,
+} from "@/lib/payments/shopier";
+
+const PRO_VALIDITY_DAYS = 30;
+
+export async function POST(req: NextRequest): Promise {
+ // Shopier sends application/x-www-form-urlencoded
+ let body: Record;
+ try {
+ const text = await req.text();
+ const params = new URLSearchParams(text);
+ body = Object.fromEntries(params.entries());
+ } catch {
+ return NextResponse.json({ error: "invalid body" }, { status: 400 });
+ }
+
+ const payload: ShopierCallbackPayload = {
+ platform_order_id: body.platform_order_id ?? "",
+ status: body.status ?? "",
+ installment_count: body.installment_count ?? "0",
+ random_nr: body.random_nr ?? "",
+ API_key: body.API_key ?? "",
+ signature: body.signature ?? "",
+ };
+
+ if (!verifyCallbackSignature(payload)) {
+ console.error("[shopier/callback] signature mismatch", { orderId: payload.platform_order_id });
+ return NextResponse.json({ error: "signature mismatch" }, { status: 403 });
+ }
+
+ const { tablesDB } = createAdminClient();
+
+ // Find the pending payment row
+ const result = await tablesDB.listRows({
+ databaseId: DATABASE_ID,
+ tableId: TABLES.subscriptionPayments,
+ queries: [
+ Query.equal("orderId", payload.platform_order_id),
+ Query.equal("provider", "shopier"),
+ Query.limit(1),
+ ],
+ });
+
+ const payment = result.rows[0] as unknown as SubscriptionPayment | undefined;
+ if (!payment) {
+ console.error("[shopier/callback] payment not found", payload.platform_order_id);
+ return NextResponse.json({ error: "payment not found" }, { status: 404 });
+ }
+
+ // Idempotency — already processed
+ if (payment.status === "success" || payment.status === "failed") {
+ return NextResponse.json({ ok: true });
+ }
+
+ const now = new Date();
+ const isSuccess = payload.status === "success";
+
+ await tablesDB.updateRow(DATABASE_ID, TABLES.subscriptionPayments, payment.$id, {
+ status: isSuccess ? "success" : "failed",
+ processedAt: now.toISOString(),
+ providerPayload: JSON.stringify({
+ shopier: true,
+ callbackStatus: payload.status,
+ installmentCount: payload.installment_count,
+ }),
+ });
+
+ if (isSuccess && payment.tenantId) {
+ // Fetch current tenant settings
+ const settingsResult = await tablesDB.listRows({
+ databaseId: DATABASE_ID,
+ tableId: TABLES.tenantSettings,
+ queries: [Query.equal("tenantId", payment.tenantId), Query.limit(1)],
+ });
+ const settings = settingsResult.rows[0] as unknown as { $id: string } | undefined;
+
+ if (settings) {
+ const expires = new Date(now.getTime() + PRO_VALIDITY_DAYS * 24 * 60 * 60 * 1000);
+ await tablesDB.updateRow(DATABASE_ID, TABLES.tenantSettings, settings.$id, {
+ plan: payment.plan,
+ planStartedAt: now.toISOString(),
+ planExpiresAt: expires.toISOString(),
+ lastPaymentId: payment.$id,
+ });
+ }
+
+ await logAudit({
+ tenantId: payment.tenantId,
+ userId: payment.createdBy,
+ action: "update",
+ entityType: "subscription_payment",
+ entityId: payment.$id,
+ changes: {
+ provider: "shopier",
+ status: "success",
+ plan: payment.plan,
+ installmentCount: payload.installment_count,
+ },
+ });
+ }
+
+ // Shopier expects a 200 OK response with no body (or just "OK")
+ return new NextResponse("OK", { status: 200 });
+}
diff --git a/src/lib/appwrite/subscription-actions.ts b/src/lib/appwrite/subscription-actions.ts
index 049c98f..0af1f3d 100644
--- a/src/lib/appwrite/subscription-actions.ts
+++ b/src/lib/appwrite/subscription-actions.ts
@@ -11,6 +11,7 @@ import { DATABASE_ID, TABLES, type SubscriptionPayment, type TenantPlan } from "
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;
@@ -218,3 +219,52 @@ export async function downgradeToFreeAction(): Promise {
revalidatePath("/settings/billing");
redirect(`/settings/billing?downgraded=1`);
}
+
+export async function startShopierCheckoutAction(formData: FormData): Promise {
+ 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 {
+ if (isShopierEnabled()) {
+ return startShopierCheckoutAction(formData);
+ }
+ return startMockCheckoutAction(formData);
+}
diff --git a/src/lib/payments/shopier.ts b/src/lib/payments/shopier.ts
new file mode 100644
index 0000000..77d9de8
--- /dev/null
+++ b/src/lib/payments/shopier.ts
@@ -0,0 +1,127 @@
+import "server-only";
+
+import { createHmac, timingSafeEqual } from "crypto";
+
+export const SHOPIER_ENDPOINT = "https://www.shopier.com/ShowProduct/api_pay4.php";
+
+const API_KEY = process.env.SHOPIER_API_KEY ?? "";
+const API_SECRET = process.env.SHOPIER_API_SECRET ?? "";
+const WEBSITE_INDEX = process.env.SHOPIER_WEBSITE_INDEX ?? "1";
+
+// Callback base URL can be overridden with a tunnel URL for localhost testing.
+// e.g. SHOPIER_CALLBACK_BASE_URL=https://abc123.trycloudflare.com
+function callbackBaseUrl(): string {
+ return (
+ process.env.SHOPIER_CALLBACK_BASE_URL?.replace(/\/$/, "") ??
+ process.env.APP_URL?.replace(/\/$/, "") ??
+ "http://localhost:3000"
+ );
+}
+
+function appBaseUrl(): string {
+ return process.env.APP_URL?.replace(/\/$/, "") ?? "http://localhost:3000";
+}
+
+export type CurrencyCode = "TRY" | "USD" | "EUR";
+const CURRENCY_INDEX: Record = {
+ TRY: "0",
+ USD: "1",
+ EUR: "2",
+};
+
+// Signature over: platform_order_id + total_order_value + currency + random_nr
+// Key: API_SECRET | Encoding: base64
+function signRequest(orderId: string, amount: string, currency: string, randomNr: string): string {
+ const message = orderId + amount + currency + randomNr;
+ return createHmac("sha256", API_SECRET).update(message).digest("base64");
+}
+
+export type ShopierCheckoutParams = {
+ orderId: string;
+ amount: number;
+ currency?: CurrencyCode;
+ productName: string;
+ buyerName: string;
+ buyerSurname: string;
+ buyerEmail: string;
+ buyerId: string;
+ buyerAccountAgeDays?: number;
+};
+
+export type ShopierFormFields = Record;
+
+export function buildShopierFormFields(params: ShopierCheckoutParams): ShopierFormFields {
+ if (!API_KEY || !API_SECRET) {
+ throw new Error("SHOPIER_API_KEY ve SHOPIER_API_SECRET ortam değişkenleri eksik.");
+ }
+
+ const currency: CurrencyCode = params.currency ?? "TRY";
+ const currencyIndex = CURRENCY_INDEX[currency];
+ // Amount as string with exactly 2 decimal places ("299.00")
+ const amountStr = params.amount.toFixed(2);
+ // 8-digit random number
+ const randomNr = String(Math.floor(10000000 + Math.random() * 90000000));
+
+ const signature = signRequest(params.orderId, amountStr, currencyIndex, randomNr);
+
+ const base = callbackBaseUrl();
+ const appBase = appBaseUrl();
+
+ return {
+ API_key: API_KEY,
+ website_index: WEBSITE_INDEX,
+ platform_order_id: params.orderId,
+ product_name: params.productName,
+ product_type: "0", // 0 = digital/virtual
+ buyer_name: params.buyerName,
+ buyer_surname: params.buyerSurname,
+ buyer_email: params.buyerEmail,
+ buyer_account_age: String(params.buyerAccountAgeDays ?? 0),
+ buyer_id_nr: params.buyerId,
+ total_order_value: amountStr,
+ currency: currencyIndex,
+ current_language: "0", // 0 = TR
+ random_nr: randomNr,
+ signature,
+ // Server-to-server async callback — needs public URL (use cloudflared tunnel for localhost)
+ callbackUrl: `${base}/api/payments/shopier/callback`,
+ // User-facing redirect after 3DS
+ okUrl: `${appBase}/settings/billing/checkout/${params.orderId}/shopier/result?ok=1`,
+ failUrl: `${appBase}/settings/billing/checkout/${params.orderId}/shopier/result?ok=0`,
+ };
+}
+
+// ---------- Callback verification ----------
+
+export type ShopierCallbackPayload = {
+ platform_order_id: string;
+ status: string; // "success" | "fail"
+ installment_count: string;
+ random_nr: string;
+ API_key: string;
+ signature: string;
+};
+
+// Signature over: platform_order_id + status + installment_count + random_nr
+export function verifyCallbackSignature(payload: ShopierCallbackPayload): boolean {
+ if (!API_SECRET) return false;
+ const message =
+ payload.platform_order_id +
+ payload.status +
+ payload.installment_count +
+ payload.random_nr;
+ const expected = createHmac("sha256", API_SECRET).update(message).digest("base64");
+ try {
+ return timingSafeEqual(Buffer.from(expected), Buffer.from(payload.signature));
+ } catch {
+ return false;
+ }
+}
+
+export function isShopierEnabled(): boolean {
+ return (
+ process.env.PAYMENT_PROVIDER === "shopier" &&
+ Boolean(process.env.SHOPIER_API_KEY) &&
+ Boolean(process.env.SHOPIER_API_SECRET)
+ );
+}