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";
|
} from "@/lib/appwrite/plan-limits";
|
||||||
import {
|
import {
|
||||||
downgradeToFreeAction,
|
downgradeToFreeAction,
|
||||||
startMockCheckoutAction,
|
startCheckoutAction,
|
||||||
} from "@/lib/appwrite/subscription-actions";
|
} from "@/lib/appwrite/subscription-actions";
|
||||||
|
import { isShopierEnabled } from "@/lib/payments/shopier";
|
||||||
import { PLAN_CATALOG } from "@/lib/appwrite/subscription-types";
|
import { PLAN_CATALOG } from "@/lib/appwrite/subscription-types";
|
||||||
import { requireTenant } from "@/lib/appwrite/tenant-guard";
|
import { requireTenant } from "@/lib/appwrite/tenant-guard";
|
||||||
|
|
||||||
@@ -79,6 +80,7 @@ export default async function PricingPage() {
|
|||||||
const isPro = currentPlan === "pro";
|
const isPro = currentPlan === "pro";
|
||||||
const canManage = ctx.role === "owner";
|
const canManage = ctx.role === "owner";
|
||||||
const usage = await getPlanUsage(ctx);
|
const usage = await getPlanUsage(ctx);
|
||||||
|
const shopierActive = isShopierEnabled();
|
||||||
|
|
||||||
const resources: PlanResource[] = ["customers", "financeEntries", "software", "members"];
|
const resources: PlanResource[] = ["customers", "financeEntries", "software", "members"];
|
||||||
|
|
||||||
@@ -208,11 +210,11 @@ export default async function PricingPage() {
|
|||||||
Sahip yetkisi gerekli
|
Sahip yetkisi gerekli
|
||||||
</Button>
|
</Button>
|
||||||
) : tier.id === "pro" ? (
|
) : tier.id === "pro" ? (
|
||||||
<form action={startMockCheckoutAction} className="w-full">
|
<form action={startCheckoutAction} className="w-full">
|
||||||
<input type="hidden" name="plan" value="pro" />
|
<input type="hidden" name="plan" value="pro" />
|
||||||
<Button type="submit" className="w-full" size="lg">
|
<Button type="submit" className="w-full" size="lg">
|
||||||
<Crown className="size-4" />
|
<Crown className="size-4" />
|
||||||
Pro'ya geç (Test)
|
{shopierActive ? "Pro'ya geç" : "Pro'ya geç (Test)"}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
) : (
|
) : (
|
||||||
@@ -278,15 +280,16 @@ export default async function PricingPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{!shopierActive && (
|
||||||
<Card className="bg-muted/20">
|
<Card className="bg-muted/20">
|
||||||
<CardContent className="text-muted-foreground py-4 text-xs">
|
<CardContent className="text-muted-foreground py-4 text-xs">
|
||||||
<p>
|
<p>
|
||||||
<span className="text-foreground font-medium">Test modu:</span> Pro plan şu anda mock
|
<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
|
ödeme akışıyla çalışır. Shopier entegrasyonu aktif edilince gerçek tahsilat başlayacak.
|
||||||
tamamlandıktan sonra başlayacak.
|
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
)}
|
||||||
</div>
|
</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 });
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import { DATABASE_ID, TABLES, type SubscriptionPayment, type TenantPlan } from "
|
|||||||
import { createAdminClient } from "./server";
|
import { createAdminClient } from "./server";
|
||||||
import { requireRole, requireTenant } from "./tenant-guard";
|
import { requireRole, requireTenant } from "./tenant-guard";
|
||||||
import { PLAN_CATALOG } from "./subscription-types";
|
import { PLAN_CATALOG } from "./subscription-types";
|
||||||
|
import { isShopierEnabled } from "../payments/shopier";
|
||||||
|
|
||||||
const PRO_VALIDITY_DAYS = 30;
|
const PRO_VALIDITY_DAYS = 30;
|
||||||
|
|
||||||
@@ -218,3 +219,52 @@ export async function downgradeToFreeAction(): Promise<void> {
|
|||||||
revalidatePath("/settings/billing");
|
revalidatePath("/settings/billing");
|
||||||
redirect(`/settings/billing?downgraded=1`);
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user