feat: Polar.sh payment integration, replace Shopier store approach

This commit is contained in:
kovakmedya
2026-05-04 17:45:30 +03:00
parent f43818a51a
commit e8a766c60a
9 changed files with 395 additions and 342 deletions
@@ -1,49 +0,0 @@
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}
/>
);
}
@@ -1,67 +0,0 @@
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>
);
}
@@ -1,46 +0,0 @@
"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 { verifyPolarWebhook } from "@/lib/payments/polar";
const PRO_VALIDITY_DAYS = 30;
export async function POST(req: NextRequest): Promise<NextResponse> {
const webhookId = req.headers.get("webhook-id") ?? "";
const webhookTimestamp = req.headers.get("webhook-timestamp") ?? "";
const webhookSignature = req.headers.get("webhook-signature") ?? "";
let rawBody: string;
try {
rawBody = await req.text();
} catch {
return NextResponse.json({ error: "invalid body" }, { status: 400 });
}
if (!verifyPolarWebhook(webhookId, webhookTimestamp, webhookSignature, rawBody)) {
console.error("[polar/callback] signature mismatch");
return NextResponse.json({ error: "signature mismatch" }, { status: 403 });
}
let event: { type: string; data: Record<string, unknown> };
try {
event = JSON.parse(rawBody) as typeof event;
} catch {
return NextResponse.json({ error: "invalid json" }, { status: 400 });
}
// order.created veya checkout.updated (status=confirmed) eventlerini işle
const isOrderCreated = event.type === "order.created";
const isCheckoutConfirmed =
event.type === "checkout.updated" &&
(event.data as { status?: string }).status === "confirmed";
if (!isOrderCreated && !isCheckoutConfirmed) {
return new NextResponse("OK", { status: 200 });
}
// metadata'dan crm_order_id al
const metadata = (event.data as { metadata?: Record<string, string> }).metadata ?? {};
const crmOrderId = metadata.crm_order_id ?? "";
if (!crmOrderId) {
console.error("[polar/callback] no crm_order_id in metadata", event.type);
return new NextResponse("OK", { status: 200 });
}
const { tablesDB } = createAdminClient();
const result = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.subscriptionPayments,
queries: [
Query.equal("orderId", crmOrderId),
Query.equal("provider", "polar"),
Query.limit(1),
],
});
const payment = result.rows[0] as unknown as SubscriptionPayment | undefined;
if (!payment) {
console.error("[polar/callback] payment not found", crmOrderId);
return new NextResponse("OK", { status: 200 });
}
// Idempotency
if (payment.status === "success" || payment.status === "failed") {
return new NextResponse("OK", { status: 200 });
}
const now = new Date();
const expires = new Date(now.getTime() + PRO_VALIDITY_DAYS * 24 * 60 * 60 * 1000);
await tablesDB.updateRow(DATABASE_ID, TABLES.subscriptionPayments, payment.$id, {
status: "success",
processedAt: now.toISOString(),
providerPayload: JSON.stringify({ polar: true, eventType: event.type, ...metadata }),
});
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) {
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: "polar", status: "success", plan: payment.plan },
});
return new NextResponse("OK", { status: 200 });
}
+63 -38
View File
@@ -5,76 +5,104 @@ 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,
verifyShopierWebhookSignature,
type ShopierWebhookOrder,
} 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>;
const signature = req.headers.get("Shopier-Signature") ?? "";
const event = req.headers.get("Shopier-Event") ?? "";
// Raw body'yi hem imza doğrulama hem de parse için kullan
let rawBody: string;
try {
const text = await req.text();
const params = new URLSearchParams(text);
body = Object.fromEntries(params.entries());
rawBody = await req.text();
} 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 });
if (!verifyShopierWebhookSignature(signature, rawBody)) {
console.error("[shopier/callback] signature mismatch", { event });
return NextResponse.json({ error: "signature mismatch" }, { status: 403 });
}
// Yalnızca sipariş oluşturma eventini işle
if (event !== "order.created") {
return new NextResponse("OK", { status: 200 });
}
let order: ShopierWebhookOrder;
try {
order = JSON.parse(rawBody) as ShopierWebhookOrder;
} catch {
return NextResponse.json({ error: "invalid json" }, { status: 400 });
}
// Yalnızca ödenmiş siparişleri işle
if (order.paymentStatus !== "paid") {
return new NextResponse("OK", { status: 200 });
}
const buyerEmail = order.shippingInfo?.email ?? "";
if (!buyerEmail) {
console.error("[shopier/callback] no buyer email in order", order.id);
return new NextResponse("OK", { status: 200 });
}
const { tablesDB } = createAdminClient();
// Find the pending payment row
const result = await tablesDB.listRows({
// Bekleyen Shopier ödemelerini al, email ile eşleştir
const pendingResult = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.subscriptionPayments,
queries: [
Query.equal("orderId", payload.platform_order_id),
Query.equal("provider", "shopier"),
Query.limit(1),
Query.equal("status", "pending"),
Query.orderDesc("$createdAt"),
Query.limit(20),
],
});
const payment = result.rows[0] as unknown as SubscriptionPayment | undefined;
const pendingPayments = pendingResult.rows as unknown as SubscriptionPayment[];
const payment = pendingPayments.find((p) => {
try {
const payload = JSON.parse(p.providerPayload ?? "{}") as { userEmail?: string };
return payload.userEmail?.toLowerCase() === buyerEmail.toLowerCase();
} catch {
return false;
}
});
if (!payment) {
console.error("[shopier/callback] payment not found", payload.platform_order_id);
return NextResponse.json({ error: "payment not found" }, { status: 404 });
console.error("[shopier/callback] no matching pending payment for email", buyerEmail);
// 200 döndür — Shopier'in retry yapmasını engelle, durumu logla
return new NextResponse("OK", { status: 200 });
}
// Idempotency — already processed
// Idempotency — zaten işlendiyse atla
if (payment.status === "success" || payment.status === "failed") {
return NextResponse.json({ ok: true });
return new NextResponse("OK", { status: 200 });
}
const now = new Date();
const isSuccess = payload.status === "success";
const expires = new Date(now.getTime() + PRO_VALIDITY_DAYS * 24 * 60 * 60 * 1000);
await tablesDB.updateRow(DATABASE_ID, TABLES.subscriptionPayments, payment.$id, {
status: isSuccess ? "success" : "failed",
status: "success",
processedAt: now.toISOString(),
providerPayload: JSON.stringify({
shopier: true,
callbackStatus: payload.status,
installmentCount: payload.installment_count,
shopierOrderId: order.id,
buyerEmail,
total: order.totals?.total,
currency: order.currency,
dateCreated: order.dateCreated,
}),
});
if (isSuccess && payment.tenantId) {
// Fetch current tenant settings
// Tenant planını aktive et
const settingsResult = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.tenantSettings,
@@ -83,7 +111,6 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
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(),
@@ -102,11 +129,9 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
provider: "shopier",
status: "success",
plan: payment.plan,
installmentCount: payload.installment_count,
shopierOrderId: order.id,
},
});
}
// Shopier expects a 200 OK response with no body (or just "OK")
return new NextResponse("OK", { status: 200 });
}
+1 -1
View File
@@ -314,7 +314,7 @@ export interface CreditCardStatement extends Row {
}
export type SubscriptionStatus = "pending" | "success" | "failed" | "refunded";
export type SubscriptionProvider = "mock" | "shopier";
export type SubscriptionProvider = "mock" | "shopier" | "polar";
export type CardBrand = "visa" | "mastercard" | "amex" | "troy" | "unknown";
+60 -7
View File
@@ -11,7 +11,8 @@ 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";
import { getShopierPlanUrl, isShopierEnabled } from "../payments/shopier";
import { createPolarCheckout, isPolarEnabled } from "../payments/polar";
const PRO_VALIDITY_DAYS = 30;
@@ -227,6 +228,9 @@ export async function startShopierCheckoutAction(formData: FormData): Promise<vo
const ctx = await requireTenant();
requireRole(ctx, ["owner"]);
const storeUrl = getShopierPlanUrl(plan);
if (!storeUrl) throw new Error("Shopier mağaza URL'i ayarlanmamış.");
const catalog = PLAN_CATALOG[plan];
const orderId = generateOrderId();
@@ -244,6 +248,8 @@ export async function startShopierCheckoutAction(formData: FormData): Promise<vo
currency: catalog.currency,
status: "pending",
provider: "shopier",
// Webhook'ta tenant eşleştirmek için alıcı emailini sakla
providerPayload: JSON.stringify({ userEmail: ctx.user.email }),
},
teamRowPermissions(ctx.tenantId),
);
@@ -257,14 +263,61 @@ export async function startShopierCheckoutAction(formData: FormData): Promise<vo
changes: { plan, amount: catalog.price, provider: "shopier" },
});
redirect(`/settings/billing/checkout/${orderId}/shopier`);
// Kullanıcıyı doğrudan Shopier mağaza ürün sayfasına yönlendir
redirect(storeUrl);
}
// 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);
export async function startPolarCheckoutAction(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 appUrl = process.env.APP_URL?.replace(/\/$/, "") ?? "http://localhost:3000";
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: "polar",
},
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: "polar" },
});
const checkout = await createPolarCheckout({
orderId,
tenantId: ctx.tenantId,
userEmail: ctx.user.email,
successUrl: `${appUrl}/settings/billing?upgraded=1`,
});
redirect(checkout.url);
}
// Unified entry point — PAYMENT_PROVIDER env ile yönlendirir.
export async function startCheckoutAction(formData: FormData): Promise<void> {
if (isPolarEnabled()) return startPolarCheckoutAction(formData);
if (isShopierEnabled()) return startShopierCheckoutAction(formData);
return startMockCheckoutAction(formData);
}
+93
View File
@@ -0,0 +1,93 @@
import "server-only";
import { createHmac, timingSafeEqual } from "crypto";
const POLAR_API_BASE = "https://api.polar.sh";
const ACCESS_TOKEN = process.env.POLAR_ACCESS_TOKEN ?? "";
const WEBHOOK_SECRET = process.env.POLAR_WEBHOOK_SECRET ?? "";
export const POLAR_PRODUCT_ID = process.env.POLAR_PRODUCT_ID ?? "";
export function isPolarEnabled(): boolean {
return (
process.env.PAYMENT_PROVIDER === "polar" &&
Boolean(ACCESS_TOKEN) &&
Boolean(POLAR_PRODUCT_ID)
);
}
export type PolarCheckout = {
id: string;
url: string;
};
export async function createPolarCheckout(params: {
orderId: string;
tenantId: string;
userEmail: string;
successUrl: string;
}): Promise<PolarCheckout> {
const res = await fetch(`${POLAR_API_BASE}/v1/checkouts/`, {
method: "POST",
headers: {
Authorization: `Bearer ${ACCESS_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
products: [POLAR_PRODUCT_ID],
customer_email: params.userEmail,
success_url: params.successUrl,
metadata: {
crm_order_id: params.orderId,
tenant_id: params.tenantId,
},
}),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Polar checkout oluşturulamadı: ${text}`);
}
return res.json() as Promise<PolarCheckout>;
}
// Standard Webhooks imza doğrulama
// Header: webhook-id, webhook-timestamp, webhook-signature
// Signed content: "{webhook-id}.{webhook-timestamp}.{rawBody}"
// Signature: "v1," + base64(HMAC-SHA256(secret, signedContent))
export function verifyPolarWebhook(
webhookId: string,
webhookTimestamp: string,
webhookSignature: string,
rawBody: string,
): boolean {
if (!WEBHOOK_SECRET) return false;
// Timestamp replay saldırısı koruması (5 dakika tolerans)
const ts = parseInt(webhookTimestamp, 10);
if (isNaN(ts) || Math.abs(Date.now() / 1000 - ts) > 300) return false;
const signedContent = `${webhookId}.${webhookTimestamp}.${rawBody}`;
// Secret base64 olabilir
let secretBytes: Buffer;
try {
secretBytes = Buffer.from(WEBHOOK_SECRET.replace(/^whsec_/, ""), "base64");
} catch {
secretBytes = Buffer.from(WEBHOOK_SECRET);
}
const expected = createHmac("sha256", secretBytes).update(signedContent).digest("base64");
const expectedFull = `v1,${expected}`;
// Header birden fazla imza içerebilir (space ile ayrılmış)
const signatures = webhookSignature.split(" ");
return signatures.some((sig) => {
try {
return timingSafeEqual(Buffer.from(expectedFull), Buffer.from(sig));
} catch {
return false;
}
});
}
+41 -109
View File
@@ -2,126 +2,58 @@ import "server-only";
import { createHmac, timingSafeEqual } from "crypto";
export const SHOPIER_ENDPOINT = "https://www.shopier.com/ShowProduct/api_pay4.php";
// Shopier Developer Portal > App > Webhooks bölümünden alınan token.
const WEBHOOK_TOKEN = process.env.SHOPIER_WEBHOOK_TOKEN ?? "";
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";
// Her plan için Shopier mağazasındaki ürün URL'i.
const PLAN_URLS: Record<string, string> = {
pro: process.env.SHOPIER_STORE_PRO_URL ?? "",
};
// 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 {
export function isShopierEnabled(): boolean {
return (
process.env.SHOPIER_CALLBACK_BASE_URL?.replace(/\/$/, "") ??
process.env.APP_URL?.replace(/\/$/, "") ??
"http://localhost:3000"
process.env.PAYMENT_PROVIDER === "shopier" &&
Boolean(WEBHOOK_TOKEN) &&
Boolean(PLAN_URLS.pro)
);
}
function appBaseUrl(): string {
return process.env.APP_URL?.replace(/\/$/, "") ?? "http://localhost:3000";
export function getShopierPlanUrl(plan: string): string {
return PLAN_URLS[plan] ?? "";
}
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");
// Shopier HS256 imzasını doğrular.
// İmza formatı: HMAC-SHA256(webhook_token, raw_body) → base64
// Eğer ilk gerçek webhook'ta uyuşmazlık olursa hex formatını deneyin:
// .digest("hex") yerine .digest("base64")
export function verifyShopierWebhookSignature(signature: string, rawBody: string): boolean {
if (!WEBHOOK_TOKEN) return false;
const expected = createHmac("sha256", WEBHOOK_TOKEN).update(rawBody).digest("base64");
try {
return timingSafeEqual(Buffer.from(expected), Buffer.from(payload.signature));
return timingSafeEqual(Buffer.from(expected), Buffer.from(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)
);
}
// order.created webhook payload (Shopier Order modeli alt kümesi)
export type ShopierWebhookOrder = {
id: string;
status: string; // "fulfilled" | "unfulfilled"
paymentStatus: string; // "paid" | "unpaid"
dateCreated: string;
currency: string;
totals: {
total: string;
subtotal: string;
discount: string;
shipping: string;
};
shippingInfo: {
email: string;
firstName: string;
lastName: string;
phone?: string;
company?: string;
};
};