feat: Shopier payment integration with 3DS callback + unified checkout action

This commit is contained in:
kovakmedya
2026-04-30 21:57:51 +03:00
parent 196036c0d8
commit 00a8351f66
7 changed files with 466 additions and 12 deletions
+8 -5
View File
@@ -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
</Button>
) : tier.id === "pro" ? (
<form action={startMockCheckoutAction} className="w-full">
<form action={startCheckoutAction} className="w-full">
<input type="hidden" name="plan" value="pro" />
<Button type="submit" className="w-full" size="lg">
<Crown className="size-4" />
Pro'ya geç (Test)
{shopierActive ? "Pro'ya geç" : "Pro'ya geç (Test)"}
</Button>
</form>
) : (
@@ -278,15 +280,16 @@ export default async function PricingPage() {
</div>
</section>
{!shopierActive && (
<Card className="bg-muted/20">
<CardContent className="text-muted-foreground py-4 text-xs">
<p>
<span className="text-foreground font-medium">Test modu:</span> Pro plan şu anda mock
ödeme akışıyla çalışır. Shopier entegrasyonu yakında gerçek tahsilat ancak entegrasyon
tamamlandıktan sonra başlayacak.
ödeme akışıyla çalışır. Shopier entegrasyonu aktif edilince gerçek tahsilat başlayacak.
</p>
</CardContent>
</Card>
)}
</div>
);
}
@@ -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 (
<ShopierAutoSubmit
action={SHOPIER_ENDPOINT}
fields={fields}
orderId={orderId}
/>
);
}
@@ -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 (
<div className="flex min-h-[60vh] flex-col items-center justify-center gap-6 px-6 text-center">
<XCircle className="text-destructive size-14" />
<div className="space-y-1">
<p className="text-lg font-semibold">Ödeme başarısız</p>
<p className="text-muted-foreground text-sm">
3D Secure doğrulaması reddedildi veya işlem iptal edildi.
</p>
</div>
<Button asChild variant="outline">
<Link href="/settings/billing">Geri dön</Link>
</Button>
</div>
);
}
// pending + shopierSaysOk → callback not yet received, auto-refresh
return (
<div className="flex min-h-[60vh] flex-col items-center justify-center gap-6 px-6 text-center">
<Loader2 className="text-primary size-14 animate-spin" />
<div className="space-y-1">
<p className="text-lg font-semibold">Ödeme işleniyor</p>
<p className="text-muted-foreground text-sm">
Bankadan onay bekleniyor, birkaç saniye içinde yönlendirileceksiniz.
</p>
</div>
{/* Meta refresh — simpler than polling for this rare in-between state */}
{/* eslint-disable-next-line @next/next/no-head-element */}
<meta httpEquiv="refresh" content="3" />
<Button asChild variant="ghost" size="sm">
<Link href={`/settings/billing/checkout/${orderId}/shopier/result?ok=1`}>
Yenile
</Link>
</Button>
</div>
);
}
@@ -0,0 +1,46 @@
"use client";
import { useEffect, useRef } from "react";
import { Loader2, Lock } from "lucide-react";
type Props = {
action: string;
fields: Record<string, string>;
orderId: string;
};
export function ShopierAutoSubmit({ action, fields, orderId: _orderId }: Props) {
const formRef = useRef<HTMLFormElement>(null);
useEffect(() => {
// Small delay so the user sees the loading state before redirect
const t = setTimeout(() => {
formRef.current?.submit();
}, 800);
return () => clearTimeout(t);
}, []);
return (
<div className="flex min-h-[60vh] flex-col items-center justify-center gap-6">
<div className="flex flex-col items-center gap-3 text-center">
<div className="bg-primary/10 flex size-14 items-center justify-center rounded-full">
<Lock className="text-primary size-6" />
</div>
<div className="space-y-1">
<p className="text-lg font-semibold">Shopier ödeme sayfasına yönlendiriliyor</p>
<p className="text-muted-foreground text-sm">
Güvenli 3D Secure ödeme için Shopier&apos;e aktarılıyorsunuz
</p>
</div>
<Loader2 className="text-muted-foreground size-5 animate-spin" />
</div>
{/* Hidden auto-submit form */}
<form ref={formRef} action={action} method="POST" className="hidden">
{Object.entries(fields).map(([name, value]) => (
<input key={name} type="hidden" name={name} value={value} />
))}
</form>
</div>
);
}
@@ -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<NextResponse> {
// Shopier sends application/x-www-form-urlencoded
let body: Record<string, string>;
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 });
}
+50
View File
@@ -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<void> {
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);
}
+127
View File
@@ -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<CurrencyCode, "0" | "1" | "2"> = {
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<string, string>;
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)
);
}