feat: Shopier payment integration with 3DS callback + unified checkout action
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
+46
@@ -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'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 });
|
||||
}
|
||||
Reference in New Issue
Block a user