diff --git a/src/app/(dashboard)/settings/billing/checkout/[orderId]/shopier/page.tsx b/src/app/(dashboard)/settings/billing/checkout/[orderId]/shopier/page.tsx
deleted file mode 100644
index 700c94c..0000000
--- a/src/app/(dashboard)/settings/billing/checkout/[orderId]/shopier/page.tsx
+++ /dev/null
@@ -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 (
-
- );
-}
diff --git a/src/app/(dashboard)/settings/billing/checkout/[orderId]/shopier/result/page.tsx b/src/app/(dashboard)/settings/billing/checkout/[orderId]/shopier/result/page.tsx
deleted file mode 100644
index 0fac3fd..0000000
--- a/src/app/(dashboard)/settings/billing/checkout/[orderId]/shopier/result/page.tsx
+++ /dev/null
@@ -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 (
-
-
-
-
Ödeme başarısız
-
- 3D Secure doğrulaması reddedildi veya işlem iptal edildi.
-
-
-
-
- );
- }
-
- // pending + shopierSaysOk → callback not yet received, auto-refresh
- return (
-
-
-
-
Ödeme işleniyor
-
- Bankadan onay bekleniyor, birkaç saniye içinde yönlendirileceksiniz.
-
-
- {/* Meta refresh — simpler than polling for this rare in-between state */}
- {/* eslint-disable-next-line @next/next/no-head-element */}
-
-
-
- );
-}
diff --git a/src/app/(dashboard)/settings/billing/checkout/[orderId]/shopier/shopier-auto-submit.tsx b/src/app/(dashboard)/settings/billing/checkout/[orderId]/shopier/shopier-auto-submit.tsx
deleted file mode 100644
index 9592fc8..0000000
--- a/src/app/(dashboard)/settings/billing/checkout/[orderId]/shopier/shopier-auto-submit.tsx
+++ /dev/null
@@ -1,46 +0,0 @@
-"use client";
-
-import { useEffect, useRef } from "react";
-import { Loader2, Lock } from "lucide-react";
-
-type Props = {
- action: string;
- fields: Record;
- orderId: string;
-};
-
-export function ShopierAutoSubmit({ action, fields, orderId: _orderId }: Props) {
- const formRef = useRef(null);
-
- useEffect(() => {
- // Small delay so the user sees the loading state before redirect
- const t = setTimeout(() => {
- formRef.current?.submit();
- }, 800);
- return () => clearTimeout(t);
- }, []);
-
- return (
-
-
-
-
-
-
-
Shopier ödeme sayfasına yönlendiriliyor
-
- Güvenli 3D Secure ödeme için Shopier'e aktarılıyorsunuz…
-
-
-
-
-
- {/* Hidden auto-submit form */}
-
-
- );
-}
diff --git a/src/app/api/payments/polar/callback/route.ts b/src/app/api/payments/polar/callback/route.ts
new file mode 100644
index 0000000..61a7750
--- /dev/null
+++ b/src/app/api/payments/polar/callback/route.ts
@@ -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 {
+ 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 };
+ 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 }).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 });
+}
diff --git a/src/app/api/payments/shopier/callback/route.ts b/src/app/api/payments/shopier/callback/route.ts
index 2270b39..8dc8ed5 100644
--- a/src/app/api/payments/shopier/callback/route.ts
+++ b/src/app/api/payments/shopier/callback/route.ts
@@ -5,108 +5,133 @@ 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 {
- // Shopier sends application/x-www-form-urlencoded
- let body: Record;
+ 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
- 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;
+ // Tenant planını aktive et
+ 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,
- },
+ if (settings) {
+ await tablesDB.updateRow(DATABASE_ID, TABLES.tenantSettings, settings.$id, {
+ plan: payment.plan,
+ planStartedAt: now.toISOString(),
+ planExpiresAt: expires.toISOString(),
+ lastPaymentId: payment.$id,
});
}
- // Shopier expects a 200 OK response with no body (or just "OK")
+ await logAudit({
+ tenantId: payment.tenantId,
+ userId: payment.createdBy,
+ action: "update",
+ entityType: "subscription_payment",
+ entityId: payment.$id,
+ changes: {
+ provider: "shopier",
+ status: "success",
+ plan: payment.plan,
+ shopierOrderId: order.id,
+ },
+ });
+
return new NextResponse("OK", { status: 200 });
}
diff --git a/src/lib/appwrite/schema.ts b/src/lib/appwrite/schema.ts
index 3d962bb..14021a5 100644
--- a/src/lib/appwrite/schema.ts
+++ b/src/lib/appwrite/schema.ts
@@ -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";
diff --git a/src/lib/appwrite/subscription-actions.ts b/src/lib/appwrite/subscription-actions.ts
index 0af1f3d..e114a95 100644
--- a/src/lib/appwrite/subscription-actions.ts
+++ b/src/lib/appwrite/subscription-actions.ts
@@ -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 {
+ 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 {
- if (isShopierEnabled()) {
- return startShopierCheckoutAction(formData);
- }
+ if (isPolarEnabled()) return startPolarCheckoutAction(formData);
+ if (isShopierEnabled()) return startShopierCheckoutAction(formData);
return startMockCheckoutAction(formData);
}
diff --git a/src/lib/payments/polar.ts b/src/lib/payments/polar.ts
new file mode 100644
index 0000000..3f69007
--- /dev/null
+++ b/src/lib/payments/polar.ts
@@ -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 {
+ 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;
+}
+
+// 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;
+ }
+ });
+}
diff --git a/src/lib/payments/shopier.ts b/src/lib/payments/shopier.ts
index 77d9de8..ec6337a 100644
--- a/src/lib/payments/shopier.ts
+++ b/src/lib/payments/shopier.ts
@@ -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 = {
+ 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 = {
- 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;
-
-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;
+ };
+};