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 */} -
- {Object.entries(fields).map(([name, value]) => ( - - ))} -
-
- ); -} 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; + }; +};