From 00a8351f66cc7173aad1a8ef164fc4600ddc9fee Mon Sep 17 00:00:00 2001 From: kovakmedya Date: Thu, 30 Apr 2026 21:57:51 +0300 Subject: [PATCH] feat: Shopier payment integration with 3DS callback + unified checkout action --- src/app/(dashboard)/pricing/page.tsx | 27 ++-- .../checkout/[orderId]/shopier/page.tsx | 49 +++++++ .../[orderId]/shopier/result/page.tsx | 67 +++++++++ .../[orderId]/shopier/shopier-auto-submit.tsx | 46 +++++++ .../api/payments/shopier/callback/route.ts | 112 +++++++++++++++ src/lib/appwrite/subscription-actions.ts | 50 +++++++ src/lib/payments/shopier.ts | 127 ++++++++++++++++++ 7 files changed, 466 insertions(+), 12 deletions(-) create mode 100644 src/app/(dashboard)/settings/billing/checkout/[orderId]/shopier/page.tsx create mode 100644 src/app/(dashboard)/settings/billing/checkout/[orderId]/shopier/result/page.tsx create mode 100644 src/app/(dashboard)/settings/billing/checkout/[orderId]/shopier/shopier-auto-submit.tsx create mode 100644 src/app/api/payments/shopier/callback/route.ts create mode 100644 src/lib/payments/shopier.ts 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. +

+
+ +
+ ); + } + + // 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 */} + + +
+ ); +} 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 */} +
+ {Object.entries(fields).map(([name, value]) => ( + + ))} +
+
+ ); +} 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) + ); +}