diff --git a/src/components/PayPalPayment.tsx b/src/components/PayPalPayment.tsx index 5a19f2dc..20cf9787 100644 --- a/src/components/PayPalPayment.tsx +++ b/src/components/PayPalPayment.tsx @@ -1,77 +1,124 @@ -import {DurationUnit} from "@/interfaces/paypal"; -import {CreateOrderActions, CreateOrderData, OnApproveActions, OnApproveData, OnCancelledActions, OrderResponseBody} from "@paypal/paypal-js"; -import {PayPalButtons, PayPalScriptProvider, usePayPalScriptReducer} from "@paypal/react-paypal-js"; +import { DurationUnit } from "@/interfaces/paypal"; +import { + CreateOrderActions, + CreateOrderData, + OnApproveActions, + OnApproveData, + OnCancelledActions, + OrderResponseBody, +} from "@paypal/paypal-js"; +import { + PayPalButtons, + PayPalScriptProvider, + usePayPalScriptReducer, +} from "@paypal/react-paypal-js"; import axios from "axios"; -import {useEffect, useState} from "react"; -import {toast} from "react-toastify"; +import { useState, useEffect } from "react"; +import { toast } from "react-toastify"; interface Props { - clientID: string; - currency: string; - price: number; - duration: number; - duration_unit: DurationUnit; - loadScript?: boolean; - setIsLoading: (isLoading: boolean) => void; - onSuccess: (duration: number, duration_unit: DurationUnit) => void; + clientID: string; + currency: string; + price: number; + duration: number; + duration_unit: DurationUnit; + loadScript?: boolean; + setIsLoading: (isLoading: boolean) => void; + onSuccess: (duration: number, duration_unit: DurationUnit) => void; + trackingId?: string; } -export default function PayPalPayment({clientID, price, currency, duration, duration_unit, loadScript, setIsLoading, onSuccess}: Props) { - const createOrder = async (data: CreateOrderData, actions: CreateOrderActions): Promise => { - setIsLoading(true); +export default function PayPalPayment({ + clientID, + price, + currency, + duration, + duration_unit, + loadScript, + setIsLoading, + onSuccess, + trackingId, +}: Props) { + const createOrder = async ( + data: CreateOrderData, + actions: CreateOrderActions + ): Promise => { + if (!trackingId) { + throw new Error("trackingId is not set"); + } + setIsLoading(true); - return axios - .post("/api/paypal", {currencyCode: currency, price}) - .then((response) => response.data) - .then((data) => data.id); - }; + return axios + .post("/api/paypal", { + currencyCode: currency, + price, + trackingId, + }) + .then((response) => response.data) + .then((data) => data.id); + }; - const onApprove = async (data: OnApproveData, actions: OnApproveActions) => { - const request = await axios.post<{ok: boolean; reason?: string}>("/api/paypal/approve", {id: data.orderID, duration, duration_unit}); + const onApprove = async (data: OnApproveData, actions: OnApproveActions) => { + if (!trackingId) { + throw new Error("trackingId is not set"); + } - if (request.status !== 200) { - toast.error("Something went wrong, please try again later"); - return; - } + const request = await axios.post<{ ok: boolean; reason?: string }>( + "/api/paypal/approve", + { id: data.orderID, duration, duration_unit, trackingId } + ); - toast.success("Your account has been credited more time!"); - return onSuccess(duration, duration_unit); - }; + if (request.status !== 200) { + toast.error("Something went wrong, please try again later"); + return; + } - const onError = async (data: Record) => { - setIsLoading(false); - }; + toast.success("Your account has been credited more time!"); + return onSuccess(duration, duration_unit); + }; - const onCancel = async (data: Record, actions: OnCancelledActions) => { - setIsLoading(false); - }; + const onError = async (data: Record) => { + setIsLoading(false); + }; - return loadScript ? ( - - - - ) : ( - - ); + const onCancel = async ( + data: Record, + actions: OnCancelledActions + ) => { + setIsLoading(false); + }; + + if (trackingId) { + return loadScript ? ( + + + + ) : ( + + ); + } + + return null; } diff --git a/src/hooks/usePaypalTracking.tsx b/src/hooks/usePaypalTracking.tsx new file mode 100644 index 00000000..290c6f92 --- /dev/null +++ b/src/hooks/usePaypalTracking.tsx @@ -0,0 +1,20 @@ +import { useEffect, useState } from "react"; +import axios from "axios"; + +export const usePaypalTracking = () => { + const [trackingId, setTrackingId] = useState(); + useEffect(() => { + axios + .put<{ ok: boolean; trackingId: string }>("/api/paypal/raas") + .then((response) => { + if (response.data.ok) { + setTrackingId(response.data.trackingId); + } + }) + .catch((error) => { + console.error(error); + }); + }, []); + + return trackingId; +}; diff --git a/src/pages/(status)/PaymentDue.tsx b/src/pages/(status)/PaymentDue.tsx index b358c860..4a2cbc07 100644 --- a/src/pages/(status)/PaymentDue.tsx +++ b/src/pages/(status)/PaymentDue.tsx @@ -14,6 +14,7 @@ import {BsArrowRepeat} from "react-icons/bs"; import InviteCard from "@/components/Medium/InviteCard"; import {useRouter} from "next/router"; import {PayPalScriptProvider} from "@paypal/react-paypal-js"; +import { usePaypalTracking } from "@/hooks/usePaypalTracking"; interface Props { user: User; @@ -31,7 +32,8 @@ export default function PaymentDue({user, hasExpired = false, clientID, reload}: const {users} = useUsers(); const {groups} = useGroups(); const {invites, isLoading: isInvitesLoading, reload: reloadInvites} = useInvites({to: user?.id}); - + const trackingId = usePaypalTracking(); + const isIndividual = () => { if (user?.type === "developer") return true; if (user?.type !== "student") return false; @@ -121,6 +123,7 @@ export default function PaymentDue({user, hasExpired = false, clientID, reload}: onSuccess={() => { setTimeout(reload, 500); }} + trackingId={trackingId} />
@@ -165,6 +168,7 @@ export default function PaymentDue({user, hasExpired = false, clientID, reload}: setTimeout(reload, 500); }} loadScript + trackingId={trackingId} />
diff --git a/src/pages/api/paypal/approve.ts b/src/pages/api/paypal/approve.ts index 9b105c88..2a176a3b 100644 --- a/src/pages/api/paypal/approve.ts +++ b/src/pages/api/paypal/approve.ts @@ -1,58 +1,79 @@ // Next.js API route support: https://nextjs.org/docs/api-routes/introduction -import type {NextApiRequest, NextApiResponse} from "next"; -import {app} from "@/firebase"; -import {getFirestore, collection, getDocs, setDoc, doc} from "firebase/firestore"; -import {withIronSessionApiRoute} from "iron-session/next"; -import {sessionOptions} from "@/lib/session"; +import type { NextApiRequest, NextApiResponse } from "next"; +import { app } from "@/firebase"; +import { + getFirestore, + collection, + getDocs, + setDoc, + doc, +} from "firebase/firestore"; +import { withIronSessionApiRoute } from "iron-session/next"; +import { sessionOptions } from "@/lib/session"; import axios from "axios"; -import {DurationUnit, TokenError, TokenSuccess} from "@/interfaces/paypal"; -import {base64} from "@firebase/util"; -import {v4} from "uuid"; -import {OrderResponseBody} from "@paypal/paypal-js"; -import {getAccessToken} from "@/utils/paypal"; +import { DurationUnit, TokenError, TokenSuccess } from "@/interfaces/paypal"; +import { base64 } from "@firebase/util"; +import { v4 } from "uuid"; +import { OrderResponseBody } from "@paypal/paypal-js"; +import { getAccessToken } from "@/utils/paypal"; import moment from "moment"; -import {Group} from "@/interfaces/user"; +import { Group } from "@/interfaces/user"; const db = getFirestore(app); export default withIronSessionApiRoute(handler, sessionOptions); async function handler(req: NextApiRequest, res: NextApiResponse) { - if (req.method !== "POST") return res.status(404).json({ok: false, reason: "Method not supported!"}); - if (!req.session.user) return res.status(401).json({ok: false}); + if (req.method !== "POST") + return res.status(404).json({ ok: false, reason: "Method not supported!" }); + if (!req.session.user) return res.status(401).json({ ok: false }); - const accessToken = await getAccessToken(); - if (!accessToken) return res.status(401).json({ok: false, reason: "Authorization failed!"}); + const accessToken = await getAccessToken(); + if (!accessToken) + return res.status(401).json({ ok: false, reason: "Authorization failed!" }); - const {id, duration, duration_unit} = req.body as {id: string; duration: number; duration_unit: DurationUnit}; + const { id, duration, duration_unit, trackingId } = req.body as { + id: string; + duration: number; + duration_unit: DurationUnit; + trackingId: string; + }; - const request = await axios.post( - `${process.env.PAYPAL_ACCESS_TOKEN_URL}/v2/checkout/orders/${id}/capture`, - {}, - { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - }, - ); + if (!trackingId) + return res.status(401).json({ ok: false, reason: "Missing tracking id!" }); - if (request.data.status === "COMPLETED") { - const user = req.session.user; - const subscriptionExpirationDate = req.session.user.subscriptionExpirationDate; - const today = moment(new Date()); - const dateToBeAddedTo = !subscriptionExpirationDate - ? today - : moment(subscriptionExpirationDate).isAfter(today) - ? moment(subscriptionExpirationDate) - : today; + const request = await axios.post( + `${process.env.PAYPAL_ACCESS_TOKEN_URL}/v2/checkout/orders/${id}/capture`, + {}, + { + headers: { + Authorization: `Bearer ${accessToken}`, + "PayPal-Client-Metadata-Id": trackingId, + }, + } + ); - const updatedExpirationDate = dateToBeAddedTo.add(duration, duration_unit); - await setDoc( - doc(db, "users", req.session.user.id), - {subscriptionExpirationDate: updatedExpirationDate.toISOString(), status: "active"}, - {merge: true}, - ); + if (request.data.status === "COMPLETED") { + const user = req.session.user; + const subscriptionExpirationDate = + req.session.user.subscriptionExpirationDate; + const today = moment(new Date()); + const dateToBeAddedTo = !subscriptionExpirationDate + ? today + : moment(subscriptionExpirationDate).isAfter(today) + ? moment(subscriptionExpirationDate) + : today; + const updatedExpirationDate = dateToBeAddedTo.add(duration, duration_unit); + await setDoc( + doc(db, "users", req.session.user.id), + { + subscriptionExpirationDate: updatedExpirationDate.toISOString(), + status: "active", + }, + { merge: true } + ); + try { await setDoc( doc(db, 'paypalpayments', v4()), @@ -72,31 +93,40 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { console.error('Failed to insert paypal payment!', err); } - if (user.type === "corporate") { - const snapshot = await getDocs(collection(db, "groups")); - const groups: Group[] = ( - snapshot.docs.map((doc) => ({ - id: doc.id, - ...doc.data(), - })) as Group[] - ).filter((x) => x.admin === user.id); + if (user.type === "corporate") { + const snapshot = await getDocs(collection(db, "groups")); + const groups: Group[] = ( + snapshot.docs.map((doc) => ({ + id: doc.id, + ...doc.data(), + })) as Group[] + ).filter((x) => x.admin === user.id); - await Promise.all( - groups - .flatMap((x) => x.participants) - .map( - async (x) => - await setDoc( - doc(db, "users", x), - {subscriptionExpirationDate: updatedExpirationDate.toISOString(), status: "active"}, - {merge: true}, - ), - ), - ); - } + await Promise.all( + groups + .flatMap((x) => x.participants) + .map( + async (x) => + await setDoc( + doc(db, "users", x), + { + subscriptionExpirationDate: + updatedExpirationDate.toISOString(), + status: "active", + }, + { merge: true } + ) + ) + ); + } - return res.status(200).json({ok: true}); - } + return res.status(200).json({ ok: true }); + } - res.status(404).json({ok: false, reason: "Order ID not found or purchase was not approved!"}); + res + .status(404) + .json({ + ok: false, + reason: "Order ID not found or purchase was not approved!", + }); } diff --git a/src/pages/api/paypal/index.ts b/src/pages/api/paypal/index.ts index 139a33b7..9a44eac4 100644 --- a/src/pages/api/paypal/index.ts +++ b/src/pages/api/paypal/index.ts @@ -20,7 +20,9 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { const accessToken = await getAccessToken(); if (!accessToken) return res.status(401).json({ok: false, reason: "Authorization failed!"}); - const {currencyCode, price} = req.body as {currencyCode: string; price: number}; + const {currencyCode, price, trackingId} = req.body as {currencyCode: string; price: number, trackingId: string}; + + if(!trackingId) return res.status(401).json({ok: false, reason: "Missing tracking id!"}); const request = await axios.post( `${process.env.PAYPAL_ACCESS_TOKEN_URL}/v2/checkout/orders`, @@ -34,11 +36,24 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { reference_id: v4(), }, ], + payment_source: { + paypal: { + email_address: req.session.user.email || "", + experience_context: { + payment_method_preference: "IMMEDIATE_PAYMENT_REQUIRED", + locale: "en-US", + landing_page: "LOGIN", + shipping_preference: "NO_SHIPPING", + user_action: "PAY_NOW", + }, + }, + }, intent: "CAPTURE", }, { headers: { Authorization: `Bearer ${accessToken}`, + 'PayPal-Client-Metadata-Id': trackingId, }, }, ); diff --git a/src/pages/api/paypal/raas.ts b/src/pages/api/paypal/raas.ts new file mode 100644 index 00000000..c4eb90af --- /dev/null +++ b/src/pages/api/paypal/raas.ts @@ -0,0 +1,55 @@ +// Next.js API route support: https://nextjs.org/docs/api-routes/introduction +import type { NextApiRequest, NextApiResponse } from "next"; +import { app } from "@/firebase"; +import { getFirestore, collection, getDocs } from "firebase/firestore"; +import { withIronSessionApiRoute } from "iron-session/next"; +import { sessionOptions } from "@/lib/session"; +import axios from "axios"; +import { v4 } from "uuid"; +import { OrderResponseBody } from "@paypal/paypal-js"; +import { getAccessToken } from "@/utils/paypal"; + +const db = getFirestore(app); + +export default withIronSessionApiRoute(handler, sessionOptions); + +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== "PUT") + return res.status(404).json({ ok: false, reason: "Method not supported!" }); + + if (!req.session.user) return res.status(401).json({ ok: false }); + + const accessToken = await getAccessToken(); + if (!accessToken) + return res.status(401).json({ ok: false, reason: "Authorization failed!" }); + + const trackingId = `${req.session.user.id}-${Date.now()}`; + + try { + const request = await axios.put( + `${process.env.PAYPAL_ACCESS_TOKEN_URL}/v1/risk/transaction-contexts/${process.env.PAYPAL_MERCHANT_ID}/${trackingId}`, + { + additional_data: [ + { + key: "user_id", + value: req.session.user.id, + }, + ], + }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + } + ); + + return res.status(request.status).json({ + ok: true, + trackingId, + }); + } catch (err) { + return res + .status(500) + .json({ ok: false, reason: "Failed to create tracking ID" }); + } +} diff --git a/src/pages/tickets.tsx b/src/pages/tickets.tsx index e89ff857..21c6fd92 100644 --- a/src/pages/tickets.tsx +++ b/src/pages/tickets.tsx @@ -170,7 +170,9 @@ export default function Tickets() { {dateSorting === "asc" && } ) as any, - cell: (info) => moment(info.getValue()).format("DD/MM/YYYY - HH:mm"), + cell: (info) => ( + {moment(info.getValue()).format("DD/MM/YYYY - HH:mm")} + ), }), columnHelper.accessor("subject", { header: "Subject",