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
+15 -12
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>
<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.
</p>
</CardContent>
</Card>
{!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 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 });
}