Bernt Christian Egeland 7 месяцев назад
Родитель
Сommit
2b3e830e45

+ 72 - 0
src/app/(authenticated)/settings/payment/payment-settings.tsx

@@ -31,6 +31,7 @@ export function PaymentSettings({ settings, orgId }: { settings: Record<string,
 
   const [stripeEnabled, setStripeEnabled] = useState(enabledList.includes("stripe"));
   const [vippsEnabled, setVippsEnabled] = useState(enabledList.includes("vipps"));
+  const [paypalEnabled, setPaypalEnabled] = useState(enabledList.includes("paypal"));
 
   // Stripe fields
   const [stripeSecretKey, setStripeSecretKey] = useState(settings[SETTING_KEYS.PAYMENT_STRIPE_SECRET_KEY] || "");
@@ -44,6 +45,11 @@ export function PaymentSettings({ settings, orgId }: { settings: Record<string,
   const [vippsMsn, setVippsMsn] = useState(settings[SETTING_KEYS.PAYMENT_VIPPS_MSN] || "");
   const [vippsTestMode, setVippsTestMode] = useState(settings[SETTING_KEYS.PAYMENT_VIPPS_USE_TEST] === "true");
 
+  // PayPal fields
+  const [paypalClientId, setPaypalClientId] = useState(settings[SETTING_KEYS.PAYMENT_PAYPAL_CLIENT_ID] || "");
+  const [paypalClientSecret, setPaypalClientSecret] = useState(settings[SETTING_KEYS.PAYMENT_PAYPAL_CLIENT_SECRET] || "");
+  const [paypalSandbox, setPaypalSandbox] = useState(settings[SETTING_KEYS.PAYMENT_PAYPAL_USE_SANDBOX] === "true");
+
   const [copiedUrl, setCopiedUrl] = useState<string | null>(null);
   const [appUrl, setAppUrl] = useState("");
 
@@ -57,6 +63,7 @@ export function PaymentSettings({ settings, orgId }: { settings: Record<string,
     const providers: string[] = [];
     if (stripeEnabled) providers.push("stripe");
     if (vippsEnabled) providers.push("vipps");
+    if (paypalEnabled) providers.push("paypal");
 
     await setSettings({
       [SETTING_KEYS.INVOICE_BANK_ACCOUNT]: bankAccount,
@@ -72,6 +79,9 @@ export function PaymentSettings({ settings, orgId }: { settings: Record<string,
       [SETTING_KEYS.PAYMENT_VIPPS_SUBSCRIPTION_KEY]: vippsSubscriptionKey,
       [SETTING_KEYS.PAYMENT_VIPPS_MSN]: vippsMsn,
       [SETTING_KEYS.PAYMENT_VIPPS_USE_TEST]: vippsTestMode ? "true" : "false",
+      [SETTING_KEYS.PAYMENT_PAYPAL_CLIENT_ID]: paypalClientId,
+      [SETTING_KEYS.PAYMENT_PAYPAL_CLIENT_SECRET]: paypalClientSecret,
+      [SETTING_KEYS.PAYMENT_PAYPAL_USE_SANDBOX]: paypalSandbox ? "true" : "false",
     });
 
     setSaving(false);
@@ -338,6 +348,68 @@ export function PaymentSettings({ settings, orgId }: { settings: Record<string,
             )}
           </div>
 
+          {/* PayPal */}
+          <div className="space-y-4 rounded-lg border p-4">
+            <div className="flex items-center justify-between">
+              <div>
+                <Label className="text-base font-semibold">PayPal</Label>
+                <p className="text-xs text-muted-foreground">Accept PayPal payments</p>
+              </div>
+              <Switch checked={paypalEnabled} onCheckedChange={setPaypalEnabled} />
+            </div>
+
+            {paypalEnabled && (
+              <div className="space-y-4 pt-2">
+                <div className="space-y-2">
+                  <Label htmlFor="paypalClientId">Client ID</Label>
+                  <Input
+                    id="paypalClientId"
+                    type="password"
+                    placeholder="PayPal Client ID"
+                    value={paypalClientId}
+                    onChange={(e) => setPaypalClientId(e.target.value)}
+                  />
+                </div>
+                <div className="space-y-2">
+                  <Label htmlFor="paypalClientSecret">Client Secret</Label>
+                  <Input
+                    id="paypalClientSecret"
+                    type="password"
+                    placeholder="PayPal Client Secret"
+                    value={paypalClientSecret}
+                    onChange={(e) => setPaypalClientSecret(e.target.value)}
+                  />
+                </div>
+                <div className="flex items-center gap-3">
+                  <Switch checked={paypalSandbox} onCheckedChange={setPaypalSandbox} />
+                  <Label>Sandbox Mode</Label>
+                </div>
+                <div className="space-y-1">
+                  <Label className="text-xs text-muted-foreground">Webhook URL</Label>
+                  <div className="flex items-center gap-2">
+                    <code className="flex-1 rounded bg-muted px-3 py-2 text-xs break-all">
+                      {appUrl}/api/webhooks/paypal
+                    </code>
+                    <Button
+                      variant="outline"
+                      size="sm"
+                      onClick={() => copyWebhookUrl(`${appUrl}/api/webhooks/paypal`)}
+                    >
+                      {copiedUrl === `${appUrl}/api/webhooks/paypal` ? (
+                        <Check className="h-3.5 w-3.5" />
+                      ) : (
+                        <Copy className="h-3.5 w-3.5" />
+                      )}
+                    </Button>
+                  </div>
+                  <p className="text-xs text-muted-foreground">
+                    Add this URL in your PayPal Developer Dashboard under Webhooks. Subscribe to <code className="text-xs">PAYMENT.CAPTURE.COMPLETED</code>.
+                  </p>
+                </div>
+              </div>
+            )}
+          </div>
+
           <SaveButton>
             <Separator />
             <div className="flex items-center gap-3">

+ 20 - 0
src/app/(public)/share/invoice/[orgId]/[token]/invoice-view.tsx

@@ -229,12 +229,17 @@ export function InvoiceView({
     const sessionId = params.get('session_id')
     const reference = params.get('reference')
 
+    const paypalOrderId = params.get('paypal_order_id')
+
     if (sessionId) {
       verifyPayment('stripe', sessionId)
       window.history.replaceState({}, '', window.location.pathname)
     } else if (reference) {
       verifyPayment('vipps', reference)
       window.history.replaceState({}, '', window.location.pathname)
+    } else if (paypalOrderId) {
+      verifyPayment('paypal', paypalOrderId)
+      window.history.replaceState({}, '', window.location.pathname)
     }
   }, [verifyPayment])
 
@@ -426,6 +431,21 @@ export function InvoiceView({
                   Vipps
                 </button>
               )}
+              {enabledProviders.includes('paypal') && (
+                <button
+                  onClick={() => handlePayment('paypal')}
+                  disabled={paymentLoading !== null}
+                  className="inline-flex flex-1 items-center justify-center gap-2 rounded-lg bg-[#0070ba] px-5 py-3 text-sm font-semibold text-white transition-colors hover:bg-[#005ea6] disabled:opacity-50 sm:flex-none"
+                >
+                  {paymentLoading === 'paypal' ? (
+                    <Loader2 className="h-4 w-4 animate-spin" />
+                  ) : (
+                    <span className="text-base font-black leading-none">P</span>
+                  )}
+                  Pay {formatCurrency(Number.parseFloat(paymentAmount) || 0, currencyCode)} with
+                  PayPal
+                </button>
+              )}
             </div>
           </div>
         </div>

+ 1 - 1
src/app/api/share/invoice/[orgId]/[token]/checkout/route.ts

@@ -5,7 +5,7 @@ import { getPaymentProvider, getEnabledProviders } from "@/lib/payment-providers
 import { rateLimit } from "@/lib/rate-limit";
 
 const checkoutSchema = z.object({
-  provider: z.enum(["stripe", "vipps"]),
+  provider: z.enum(["stripe", "vipps", "paypal"]),
   amount: z.number().positive(),
 });
 

+ 1 - 1
src/app/api/share/invoice/[orgId]/[token]/verify/route.ts

@@ -5,7 +5,7 @@ import { getPaymentProvider, getEnabledProviders } from "@/lib/payment-providers
 import { rateLimit } from "@/lib/rate-limit";
 
 const verifySchema = z.object({
-  provider: z.enum(["stripe", "vipps"]),
+  provider: z.enum(["stripe", "vipps", "paypal"]),
   externalId: z.string().min(1),
 });
 

+ 111 - 0
src/app/api/webhooks/paypal/route.ts

@@ -0,0 +1,111 @@
+import { NextResponse } from "next/server";
+import { db } from "@/lib/db";
+import { SETTING_KEYS } from "@/features/settings/Schema/settingsSchema";
+import { getPaymentProvider } from "@/lib/payment-providers";
+
+export async function POST(request: Request) {
+  try {
+    const body = await request.json();
+
+    // PayPal webhook event
+    const eventType = body.event_type as string | undefined;
+
+    if (eventType !== "PAYMENT.CAPTURE.COMPLETED") {
+      return NextResponse.json({ received: true });
+    }
+
+    const resource = body.resource;
+    if (!resource) {
+      return NextResponse.json(
+        { error: "Missing resource" },
+        { status: 400 },
+      );
+    }
+
+    // Extract custom_id which contains "serviceRecordId:orgId"
+    const customId =
+      resource.custom_id ||
+      resource.supplementary_data?.related_ids?.custom_id;
+    const orderId =
+      resource.supplementary_data?.related_ids?.order_id || resource.id;
+
+    if (!customId || !orderId) {
+      return NextResponse.json(
+        { error: "Missing custom_id or order_id" },
+        { status: 400 },
+      );
+    }
+
+    const [serviceRecordId, orgId] = customId.split(":");
+    if (!serviceRecordId || !orgId) {
+      return NextResponse.json(
+        { error: "Invalid custom_id format" },
+        { status: 400 },
+      );
+    }
+
+    return await processPayPalPayment(orderId, orgId, serviceRecordId);
+  } catch (error) {
+    console.error("[PayPal Webhook] Error:", error);
+    return NextResponse.json(
+      { error: "Webhook processing failed" },
+      { status: 500 },
+    );
+  }
+}
+
+async function processPayPalPayment(
+  orderId: string,
+  orgId: string,
+  serviceRecordId: string,
+) {
+  // Idempotent check
+  const existing = await db.payment.findFirst({
+    where: { externalId: orderId },
+  });
+  if (existing) {
+    return NextResponse.json({ received: true });
+  }
+
+  // Load org settings
+  const settings = await db.appSetting.findMany({
+    where: { organizationId: orgId },
+  });
+  const settingsMap: Record<string, string> = {};
+  for (const s of settings) settingsMap[s.key] = s.value;
+
+  if (!settingsMap[SETTING_KEYS.PAYMENT_PAYPAL_CLIENT_ID]) {
+    return NextResponse.json(
+      { error: "PayPal not configured for this org" },
+      { status: 400 },
+    );
+  }
+
+  // Verify the order with PayPal API
+  const provider = getPaymentProvider("paypal", settingsMap);
+  const result = await provider.verifyPayment(orderId);
+
+  if (!result || !result.paid) {
+    return NextResponse.json({ received: true, status: "not_paid" });
+  }
+
+  // Verify service record exists and belongs to this org
+  const record = await db.serviceRecord.findUnique({
+    where: { id: serviceRecordId },
+    include: { vehicle: { select: { organizationId: true } } },
+  });
+
+  if (record && record.vehicle.organizationId === orgId) {
+    await db.payment.create({
+      data: {
+        amount: result.amount,
+        method: "paypal",
+        provider: "paypal",
+        externalId: orderId,
+        serviceRecordId,
+      },
+    });
+  }
+
+  return NextResponse.json({ received: true });
+}

+ 3 - 0
src/features/settings/Schema/settingsSchema.ts

@@ -41,6 +41,9 @@ export const SETTING_KEYS = {
   PAYMENT_VIPPS_SUBSCRIPTION_KEY: "payment.vipps.subscriptionKey",
   PAYMENT_VIPPS_MSN: "payment.vipps.merchantSerialNumber",
   PAYMENT_VIPPS_USE_TEST: "payment.vipps.useTestMode",
+  PAYMENT_PAYPAL_CLIENT_ID: "payment.paypal.clientId",
+  PAYMENT_PAYPAL_CLIENT_SECRET: "payment.paypal.clientSecret",
+  PAYMENT_PAYPAL_USE_SANDBOX: "payment.paypal.useSandbox",
   PAYMENT_TERMS_OF_SALE: "payment.termsOfSale",
   PAYMENT_TERMS_OF_SALE_URL: "payment.termsOfSaleUrl",
   LICENSE_KEY: "license.key",

+ 13 - 0
src/lib/payment-providers/index.ts

@@ -2,6 +2,7 @@ import { SETTING_KEYS } from "@/features/settings/Schema/settingsSchema";
 import type { PaymentProvider } from "./types";
 import { StripeProvider } from "./stripe";
 import { VippsProvider } from "./vipps";
+import { PayPalProvider } from "./paypal";
 
 export type { PaymentProvider, CheckoutRequest, CheckoutResult, VerifyResult } from "./types";
 
@@ -33,6 +34,18 @@ export function getPaymentProvider(
         useTestMode: settings[SETTING_KEYS.PAYMENT_VIPPS_USE_TEST] === "true",
       });
     }
+    case "paypal": {
+      const clientId = settings[SETTING_KEYS.PAYMENT_PAYPAL_CLIENT_ID];
+      const clientSecret = settings[SETTING_KEYS.PAYMENT_PAYPAL_CLIENT_SECRET];
+      if (!clientId || !clientSecret) {
+        throw new Error("PayPal credentials not fully configured");
+      }
+      return new PayPalProvider({
+        clientId,
+        clientSecret,
+        useSandbox: settings[SETTING_KEYS.PAYMENT_PAYPAL_USE_SANDBOX] === "true",
+      });
+    }
     default:
       throw new Error(`Unknown payment provider: ${provider}`);
   }

+ 172 - 0
src/lib/payment-providers/paypal.ts

@@ -0,0 +1,172 @@
+import type {
+  PaymentProvider,
+  CheckoutRequest,
+  CheckoutResult,
+  VerifyResult,
+} from "./types";
+
+interface PayPalConfig {
+  clientId: string;
+  clientSecret: string;
+  useSandbox: boolean;
+}
+
+export class PayPalProvider implements PaymentProvider {
+  private config: PayPalConfig;
+  private baseUrl: string;
+
+  constructor(config: PayPalConfig) {
+    this.config = config;
+    this.baseUrl = config.useSandbox
+      ? "https://api-m.sandbox.paypal.com"
+      : "https://api-m.paypal.com";
+  }
+
+  private async getAccessToken(): Promise<string> {
+    const credentials = Buffer.from(
+      `${this.config.clientId}:${this.config.clientSecret}`,
+    ).toString("base64");
+
+    const res = await fetch(`${this.baseUrl}/v1/oauth2/token`, {
+      method: "POST",
+      headers: {
+        Authorization: `Basic ${credentials}`,
+        "Content-Type": "application/x-www-form-urlencoded",
+      },
+      body: "grant_type=client_credentials",
+    });
+
+    if (!res.ok) {
+      throw new Error(`PayPal auth failed: ${res.status}`);
+    }
+
+    const data = await res.json();
+    return data.access_token;
+  }
+
+  async createCheckout(req: CheckoutRequest): Promise<CheckoutResult> {
+    const accessToken = await this.getAccessToken();
+
+    const res = await fetch(`${this.baseUrl}/v2/checkout/orders`, {
+      method: "POST",
+      headers: {
+        "Content-Type": "application/json",
+        Authorization: `Bearer ${accessToken}`,
+      },
+      body: JSON.stringify({
+        intent: "CAPTURE",
+        purchase_units: [
+          {
+            amount: {
+              currency_code: req.currency,
+              value: req.amount.toFixed(2),
+            },
+            description: req.description,
+            invoice_id: req.invoiceNumber,
+            custom_id: `${req.serviceRecordId}:${req.orgId}`,
+          },
+        ],
+        payment_source: {
+          paypal: {
+            experience_context: {
+              return_url: `${req.successUrl}?paypal_order_id={order.id}`,
+              cancel_url: req.cancelUrl,
+              user_action: "PAY_NOW",
+              brand_name: req.description.split(" - ")[0] || "Invoice Payment",
+            },
+          },
+        },
+      }),
+    });
+
+    if (!res.ok) {
+      const errorText = await res.text();
+      throw new Error(
+        `PayPal order creation failed: ${res.status} ${errorText}`,
+      );
+    }
+
+    const data = await res.json();
+
+    const approveLink = data.links?.find(
+      (l: { rel: string; href: string }) => l.rel === "payer-action",
+    );
+
+    if (!approveLink) {
+      throw new Error("PayPal did not return a payer-action link");
+    }
+
+    return {
+      redirectUrl: approveLink.href,
+      externalId: data.id,
+    };
+  }
+
+  async verifyPayment(orderId: string): Promise<VerifyResult | null> {
+    try {
+      const accessToken = await this.getAccessToken();
+
+      const res = await fetch(
+        `${this.baseUrl}/v2/checkout/orders/${orderId}/capture`,
+        {
+          method: "POST",
+          headers: {
+            "Content-Type": "application/json",
+            Authorization: `Bearer ${accessToken}`,
+          },
+        },
+      );
+
+      if (!res.ok) {
+        // If already captured, try to get the order details instead
+        if (res.status === 422) {
+          return await this.getOrderDetails(orderId, accessToken);
+        }
+        return null;
+      }
+
+      const data = await res.json();
+
+      if (data.status === "COMPLETED") {
+        const amount = Number.parseFloat(
+          data.purchase_units?.[0]?.payments?.captures?.[0]?.amount?.value ||
+            "0",
+        );
+        return { paid: true, amount };
+      }
+
+      return { paid: false, amount: 0 };
+    } catch {
+      return null;
+    }
+  }
+
+  private async getOrderDetails(
+    orderId: string,
+    accessToken: string,
+  ): Promise<VerifyResult | null> {
+    const res = await fetch(
+      `${this.baseUrl}/v2/checkout/orders/${orderId}`,
+      {
+        headers: {
+          Authorization: `Bearer ${accessToken}`,
+        },
+      },
+    );
+
+    if (!res.ok) return null;
+
+    const data = await res.json();
+
+    if (data.status === "COMPLETED") {
+      const amount = Number.parseFloat(
+        data.purchase_units?.[0]?.payments?.captures?.[0]?.amount?.value ||
+          data.purchase_units?.[0]?.amount?.value ||
+          "0",
+      );
+      return { paid: true, amount };
+    }
+
+    return { paid: false, amount: 0 };
+  }
+}