feat: Polar.sh payment integration, replace Shopier store approach
This commit is contained in:
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
-46
@@ -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'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 });
|
||||||
|
}
|
||||||
@@ -5,76 +5,104 @@ import { logAudit } from "@/lib/appwrite/audit";
|
|||||||
import { DATABASE_ID, TABLES, type SubscriptionPayment } from "@/lib/appwrite/schema";
|
import { DATABASE_ID, TABLES, type SubscriptionPayment } from "@/lib/appwrite/schema";
|
||||||
import { createAdminClient } from "@/lib/appwrite/server";
|
import { createAdminClient } from "@/lib/appwrite/server";
|
||||||
import {
|
import {
|
||||||
verifyCallbackSignature,
|
verifyShopierWebhookSignature,
|
||||||
type ShopierCallbackPayload,
|
type ShopierWebhookOrder,
|
||||||
} from "@/lib/payments/shopier";
|
} from "@/lib/payments/shopier";
|
||||||
|
|
||||||
const PRO_VALIDITY_DAYS = 30;
|
const PRO_VALIDITY_DAYS = 30;
|
||||||
|
|
||||||
export async function POST(req: NextRequest): Promise<NextResponse> {
|
export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||||
// Shopier sends application/x-www-form-urlencoded
|
const signature = req.headers.get("Shopier-Signature") ?? "";
|
||||||
let body: Record<string, string>;
|
const event = req.headers.get("Shopier-Event") ?? "";
|
||||||
|
|
||||||
|
// Raw body'yi hem imza doğrulama hem de parse için kullan
|
||||||
|
let rawBody: string;
|
||||||
try {
|
try {
|
||||||
const text = await req.text();
|
rawBody = await req.text();
|
||||||
const params = new URLSearchParams(text);
|
|
||||||
body = Object.fromEntries(params.entries());
|
|
||||||
} catch {
|
} catch {
|
||||||
return NextResponse.json({ error: "invalid body" }, { status: 400 });
|
return NextResponse.json({ error: "invalid body" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload: ShopierCallbackPayload = {
|
if (!verifyShopierWebhookSignature(signature, rawBody)) {
|
||||||
platform_order_id: body.platform_order_id ?? "",
|
console.error("[shopier/callback] signature mismatch", { event });
|
||||||
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 });
|
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();
|
const { tablesDB } = createAdminClient();
|
||||||
|
|
||||||
// Find the pending payment row
|
// Bekleyen Shopier ödemelerini al, email ile eşleştir
|
||||||
const result = await tablesDB.listRows({
|
const pendingResult = await tablesDB.listRows({
|
||||||
databaseId: DATABASE_ID,
|
databaseId: DATABASE_ID,
|
||||||
tableId: TABLES.subscriptionPayments,
|
tableId: TABLES.subscriptionPayments,
|
||||||
queries: [
|
queries: [
|
||||||
Query.equal("orderId", payload.platform_order_id),
|
|
||||||
Query.equal("provider", "shopier"),
|
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) {
|
if (!payment) {
|
||||||
console.error("[shopier/callback] payment not found", payload.platform_order_id);
|
console.error("[shopier/callback] no matching pending payment for email", buyerEmail);
|
||||||
return NextResponse.json({ error: "payment not found" }, { status: 404 });
|
// 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") {
|
if (payment.status === "success" || payment.status === "failed") {
|
||||||
return NextResponse.json({ ok: true });
|
return new NextResponse("OK", { status: 200 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = new Date();
|
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, {
|
await tablesDB.updateRow(DATABASE_ID, TABLES.subscriptionPayments, payment.$id, {
|
||||||
status: isSuccess ? "success" : "failed",
|
status: "success",
|
||||||
processedAt: now.toISOString(),
|
processedAt: now.toISOString(),
|
||||||
providerPayload: JSON.stringify({
|
providerPayload: JSON.stringify({
|
||||||
shopier: true,
|
shopierOrderId: order.id,
|
||||||
callbackStatus: payload.status,
|
buyerEmail,
|
||||||
installmentCount: payload.installment_count,
|
total: order.totals?.total,
|
||||||
|
currency: order.currency,
|
||||||
|
dateCreated: order.dateCreated,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isSuccess && payment.tenantId) {
|
// Tenant planını aktive et
|
||||||
// Fetch current tenant settings
|
|
||||||
const settingsResult = await tablesDB.listRows({
|
const settingsResult = await tablesDB.listRows({
|
||||||
databaseId: DATABASE_ID,
|
databaseId: DATABASE_ID,
|
||||||
tableId: TABLES.tenantSettings,
|
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;
|
const settings = settingsResult.rows[0] as unknown as { $id: string } | undefined;
|
||||||
|
|
||||||
if (settings) {
|
if (settings) {
|
||||||
const expires = new Date(now.getTime() + PRO_VALIDITY_DAYS * 24 * 60 * 60 * 1000);
|
|
||||||
await tablesDB.updateRow(DATABASE_ID, TABLES.tenantSettings, settings.$id, {
|
await tablesDB.updateRow(DATABASE_ID, TABLES.tenantSettings, settings.$id, {
|
||||||
plan: payment.plan,
|
plan: payment.plan,
|
||||||
planStartedAt: now.toISOString(),
|
planStartedAt: now.toISOString(),
|
||||||
@@ -102,11 +129,9 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
|
|||||||
provider: "shopier",
|
provider: "shopier",
|
||||||
status: "success",
|
status: "success",
|
||||||
plan: payment.plan,
|
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 });
|
return new NextResponse("OK", { status: 200 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -314,7 +314,7 @@ export interface CreditCardStatement extends Row {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type SubscriptionStatus = "pending" | "success" | "failed" | "refunded";
|
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";
|
export type CardBrand = "visa" | "mastercard" | "amex" | "troy" | "unknown";
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ 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";
|
import { getShopierPlanUrl, isShopierEnabled } from "../payments/shopier";
|
||||||
|
import { createPolarCheckout, isPolarEnabled } from "../payments/polar";
|
||||||
|
|
||||||
const PRO_VALIDITY_DAYS = 30;
|
const PRO_VALIDITY_DAYS = 30;
|
||||||
|
|
||||||
@@ -227,6 +228,9 @@ export async function startShopierCheckoutAction(formData: FormData): Promise<vo
|
|||||||
const ctx = await requireTenant();
|
const ctx = await requireTenant();
|
||||||
requireRole(ctx, ["owner"]);
|
requireRole(ctx, ["owner"]);
|
||||||
|
|
||||||
|
const storeUrl = getShopierPlanUrl(plan);
|
||||||
|
if (!storeUrl) throw new Error("Shopier mağaza URL'i ayarlanmamış.");
|
||||||
|
|
||||||
const catalog = PLAN_CATALOG[plan];
|
const catalog = PLAN_CATALOG[plan];
|
||||||
const orderId = generateOrderId();
|
const orderId = generateOrderId();
|
||||||
|
|
||||||
@@ -244,6 +248,8 @@ export async function startShopierCheckoutAction(formData: FormData): Promise<vo
|
|||||||
currency: catalog.currency,
|
currency: catalog.currency,
|
||||||
status: "pending",
|
status: "pending",
|
||||||
provider: "shopier",
|
provider: "shopier",
|
||||||
|
// Webhook'ta tenant eşleştirmek için alıcı emailini sakla
|
||||||
|
providerPayload: JSON.stringify({ userEmail: ctx.user.email }),
|
||||||
},
|
},
|
||||||
teamRowPermissions(ctx.tenantId),
|
teamRowPermissions(ctx.tenantId),
|
||||||
);
|
);
|
||||||
@@ -257,14 +263,61 @@ export async function startShopierCheckoutAction(formData: FormData): Promise<vo
|
|||||||
changes: { plan, amount: catalog.price, provider: "shopier" },
|
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.
|
export async function startPolarCheckoutAction(formData: FormData): Promise<void> {
|
||||||
// Set PAYMENT_PROVIDER=shopier in production; leave unset (or "mock") for testing.
|
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> {
|
export async function startCheckoutAction(formData: FormData): Promise<void> {
|
||||||
if (isShopierEnabled()) {
|
if (isPolarEnabled()) return startPolarCheckoutAction(formData);
|
||||||
return startShopierCheckoutAction(formData);
|
if (isShopierEnabled()) return startShopierCheckoutAction(formData);
|
||||||
}
|
|
||||||
return startMockCheckoutAction(formData);
|
return startMockCheckoutAction(formData);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -2,126 +2,58 @@ import "server-only";
|
|||||||
|
|
||||||
import { createHmac, timingSafeEqual } from "crypto";
|
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 ?? "";
|
// Her plan için Shopier mağazasındaki ürün URL'i.
|
||||||
const API_SECRET = process.env.SHOPIER_API_SECRET ?? "";
|
const PLAN_URLS: Record<string, string> = {
|
||||||
const WEBSITE_INDEX = process.env.SHOPIER_WEBSITE_INDEX ?? "1";
|
pro: process.env.SHOPIER_STORE_PRO_URL ?? "",
|
||||||
|
};
|
||||||
|
|
||||||
// Callback base URL can be overridden with a tunnel URL for localhost testing.
|
export function isShopierEnabled(): boolean {
|
||||||
// e.g. SHOPIER_CALLBACK_BASE_URL=https://abc123.trycloudflare.com
|
|
||||||
function callbackBaseUrl(): string {
|
|
||||||
return (
|
return (
|
||||||
process.env.SHOPIER_CALLBACK_BASE_URL?.replace(/\/$/, "") ??
|
process.env.PAYMENT_PROVIDER === "shopier" &&
|
||||||
process.env.APP_URL?.replace(/\/$/, "") ??
|
Boolean(WEBHOOK_TOKEN) &&
|
||||||
"http://localhost:3000"
|
Boolean(PLAN_URLS.pro)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function appBaseUrl(): string {
|
export function getShopierPlanUrl(plan: string): string {
|
||||||
return process.env.APP_URL?.replace(/\/$/, "") ?? "http://localhost:3000";
|
return PLAN_URLS[plan] ?? "";
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CurrencyCode = "TRY" | "USD" | "EUR";
|
// Shopier HS256 imzasını doğrular.
|
||||||
const CURRENCY_INDEX: Record<CurrencyCode, "0" | "1" | "2"> = {
|
// İmza formatı: HMAC-SHA256(webhook_token, raw_body) → base64
|
||||||
TRY: "0",
|
// Eğer ilk gerçek webhook'ta uyuşmazlık olursa hex formatını deneyin:
|
||||||
USD: "1",
|
// .digest("hex") yerine .digest("base64")
|
||||||
EUR: "2",
|
export function verifyShopierWebhookSignature(signature: string, rawBody: string): boolean {
|
||||||
};
|
if (!WEBHOOK_TOKEN) return false;
|
||||||
|
const expected = createHmac("sha256", WEBHOOK_TOKEN).update(rawBody).digest("base64");
|
||||||
// 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 {
|
try {
|
||||||
return timingSafeEqual(Buffer.from(expected), Buffer.from(payload.signature));
|
return timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isShopierEnabled(): boolean {
|
// order.created webhook payload (Shopier Order modeli alt kümesi)
|
||||||
return (
|
export type ShopierWebhookOrder = {
|
||||||
process.env.PAYMENT_PROVIDER === "shopier" &&
|
id: string;
|
||||||
Boolean(process.env.SHOPIER_API_KEY) &&
|
status: string; // "fulfilled" | "unfulfilled"
|
||||||
Boolean(process.env.SHOPIER_API_SECRET)
|
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;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user