Merged in feature-paypal-simple (pull request #44)

Improvements on the Paypal Integration

Approved-by: Tiago Ribeiro
This commit is contained in:
João Ramos
2024-03-05 16:04:28 +00:00
committed by Tiago Ribeiro
7 changed files with 304 additions and 131 deletions

View File

@@ -1,9 +1,20 @@
import {DurationUnit} from "@/interfaces/paypal"; import { DurationUnit } from "@/interfaces/paypal";
import {CreateOrderActions, CreateOrderData, OnApproveActions, OnApproveData, OnCancelledActions, OrderResponseBody} from "@paypal/paypal-js"; import {
import {PayPalButtons, PayPalScriptProvider, usePayPalScriptReducer} from "@paypal/react-paypal-js"; CreateOrderActions,
CreateOrderData,
OnApproveActions,
OnApproveData,
OnCancelledActions,
OrderResponseBody,
} from "@paypal/paypal-js";
import {
PayPalButtons,
PayPalScriptProvider,
usePayPalScriptReducer,
} from "@paypal/react-paypal-js";
import axios from "axios"; import axios from "axios";
import {useEffect, useState} from "react"; import { useState, useEffect } from "react";
import {toast} from "react-toastify"; import { toast } from "react-toastify";
interface Props { interface Props {
clientID: string; clientID: string;
@@ -14,20 +25,48 @@ interface Props {
loadScript?: boolean; loadScript?: boolean;
setIsLoading: (isLoading: boolean) => void; setIsLoading: (isLoading: boolean) => void;
onSuccess: (duration: number, duration_unit: DurationUnit) => void; onSuccess: (duration: number, duration_unit: DurationUnit) => void;
trackingId?: string;
} }
export default function PayPalPayment({clientID, price, currency, duration, duration_unit, loadScript, setIsLoading, onSuccess}: Props) { export default function PayPalPayment({
const createOrder = async (data: CreateOrderData, actions: CreateOrderActions): Promise<string> => { clientID,
price,
currency,
duration,
duration_unit,
loadScript,
setIsLoading,
onSuccess,
trackingId,
}: Props) {
const createOrder = async (
data: CreateOrderData,
actions: CreateOrderActions
): Promise<string> => {
if (!trackingId) {
throw new Error("trackingId is not set");
}
setIsLoading(true); setIsLoading(true);
return axios return axios
.post<OrderResponseBody>("/api/paypal", {currencyCode: currency, price}) .post<OrderResponseBody>("/api/paypal", {
currencyCode: currency,
price,
trackingId,
})
.then((response) => response.data) .then((response) => response.data)
.then((data) => data.id); .then((data) => data.id);
}; };
const onApprove = async (data: OnApproveData, actions: OnApproveActions) => { 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}); if (!trackingId) {
throw new Error("trackingId is not set");
}
const request = await axios.post<{ ok: boolean; reason?: string }>(
"/api/paypal/approve",
{ id: data.orderID, duration, duration_unit, trackingId }
);
if (request.status !== 200) { if (request.status !== 200) {
toast.error("Something went wrong, please try again later"); toast.error("Something went wrong, please try again later");
@@ -42,10 +81,14 @@ export default function PayPalPayment({clientID, price, currency, duration, dura
setIsLoading(false); setIsLoading(false);
}; };
const onCancel = async (data: Record<string, unknown>, actions: OnCancelledActions) => { const onCancel = async (
data: Record<string, unknown>,
actions: OnCancelledActions
) => {
setIsLoading(false); setIsLoading(false);
}; };
if (trackingId) {
return loadScript ? ( return loadScript ? (
<PayPalScriptProvider <PayPalScriptProvider
options={{ options={{
@@ -54,10 +97,11 @@ export default function PayPalPayment({clientID, price, currency, duration, dura
intent: "capture", intent: "capture",
commit: true, commit: true,
vault: true, vault: true,
}}> }}
>
<PayPalButtons <PayPalButtons
className="w-full" className="w-full"
style={{layout: "vertical"}} style={{ layout: "vertical" }}
createOrder={createOrder} createOrder={createOrder}
onApprove={onApprove} onApprove={onApprove}
onCancel={onCancel} onCancel={onCancel}
@@ -67,11 +111,14 @@ export default function PayPalPayment({clientID, price, currency, duration, dura
) : ( ) : (
<PayPalButtons <PayPalButtons
className="w-full" className="w-full"
style={{layout: "vertical"}} style={{ layout: "vertical" }}
createOrder={createOrder} createOrder={createOrder}
onApprove={onApprove} onApprove={onApprove}
onCancel={onCancel} onCancel={onCancel}
onError={onError} onError={onError}
/> />
); );
}
return null;
} }

View File

@@ -0,0 +1,20 @@
import { useEffect, useState } from "react";
import axios from "axios";
export const usePaypalTracking = () => {
const [trackingId, setTrackingId] = useState<string>();
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;
};

View File

@@ -14,6 +14,7 @@ import {BsArrowRepeat} from "react-icons/bs";
import InviteCard from "@/components/Medium/InviteCard"; import InviteCard from "@/components/Medium/InviteCard";
import {useRouter} from "next/router"; import {useRouter} from "next/router";
import {PayPalScriptProvider} from "@paypal/react-paypal-js"; import {PayPalScriptProvider} from "@paypal/react-paypal-js";
import { usePaypalTracking } from "@/hooks/usePaypalTracking";
interface Props { interface Props {
user: User; user: User;
@@ -31,6 +32,7 @@ export default function PaymentDue({user, hasExpired = false, clientID, reload}:
const {users} = useUsers(); const {users} = useUsers();
const {groups} = useGroups(); const {groups} = useGroups();
const {invites, isLoading: isInvitesLoading, reload: reloadInvites} = useInvites({to: user?.id}); const {invites, isLoading: isInvitesLoading, reload: reloadInvites} = useInvites({to: user?.id});
const trackingId = usePaypalTracking();
const isIndividual = () => { const isIndividual = () => {
if (user?.type === "developer") return true; if (user?.type === "developer") return true;
@@ -121,6 +123,7 @@ export default function PaymentDue({user, hasExpired = false, clientID, reload}:
onSuccess={() => { onSuccess={() => {
setTimeout(reload, 500); setTimeout(reload, 500);
}} }}
trackingId={trackingId}
/> />
</div> </div>
<div className="flex flex-col items-start gap-1"> <div className="flex flex-col items-start gap-1">
@@ -165,6 +168,7 @@ export default function PaymentDue({user, hasExpired = false, clientID, reload}:
setTimeout(reload, 500); setTimeout(reload, 500);
}} }}
loadScript loadScript
trackingId={trackingId}
/> />
</div> </div>
<div className="flex flex-col items-start gap-1"> <div className="flex flex-col items-start gap-1">

View File

@@ -1,30 +1,46 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction // Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type {NextApiRequest, NextApiResponse} from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import {app} from "@/firebase"; import { app } from "@/firebase";
import {getFirestore, collection, getDocs, setDoc, doc} from "firebase/firestore"; import {
import {withIronSessionApiRoute} from "iron-session/next"; getFirestore,
import {sessionOptions} from "@/lib/session"; collection,
getDocs,
setDoc,
doc,
} from "firebase/firestore";
import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session";
import axios from "axios"; import axios from "axios";
import {DurationUnit, TokenError, TokenSuccess} from "@/interfaces/paypal"; import { DurationUnit, TokenError, TokenSuccess } from "@/interfaces/paypal";
import {base64} from "@firebase/util"; import { base64 } from "@firebase/util";
import {v4} from "uuid"; import { v4 } from "uuid";
import {OrderResponseBody} from "@paypal/paypal-js"; import { OrderResponseBody } from "@paypal/paypal-js";
import {getAccessToken} from "@/utils/paypal"; import { getAccessToken } from "@/utils/paypal";
import moment from "moment"; import moment from "moment";
import {Group} from "@/interfaces/user"; import { Group } from "@/interfaces/user";
const db = getFirestore(app); const db = getFirestore(app);
export default withIronSessionApiRoute(handler, sessionOptions); export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) { async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== "POST") return res.status(404).json({ok: false, reason: "Method not supported!"}); if (req.method !== "POST")
if (!req.session.user) return res.status(401).json({ok: false}); 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(); const accessToken = await getAccessToken();
if (!accessToken) return res.status(401).json({ok: false, reason: "Authorization failed!"}); 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;
};
if (!trackingId)
return res.status(401).json({ ok: false, reason: "Missing tracking id!" });
const request = await axios.post( const request = await axios.post(
`${process.env.PAYPAL_ACCESS_TOKEN_URL}/v2/checkout/orders/${id}/capture`, `${process.env.PAYPAL_ACCESS_TOKEN_URL}/v2/checkout/orders/${id}/capture`,
@@ -32,13 +48,15 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
{ {
headers: { headers: {
Authorization: `Bearer ${accessToken}`, Authorization: `Bearer ${accessToken}`,
"PayPal-Client-Metadata-Id": trackingId,
}, },
}, }
); );
if (request.data.status === "COMPLETED") { if (request.data.status === "COMPLETED") {
const user = req.session.user; const user = req.session.user;
const subscriptionExpirationDate = req.session.user.subscriptionExpirationDate; const subscriptionExpirationDate =
req.session.user.subscriptionExpirationDate;
const today = moment(new Date()); const today = moment(new Date());
const dateToBeAddedTo = !subscriptionExpirationDate const dateToBeAddedTo = !subscriptionExpirationDate
? today ? today
@@ -49,8 +67,11 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
const updatedExpirationDate = dateToBeAddedTo.add(duration, duration_unit); const updatedExpirationDate = dateToBeAddedTo.add(duration, duration_unit);
await setDoc( await setDoc(
doc(db, "users", req.session.user.id), doc(db, "users", req.session.user.id),
{subscriptionExpirationDate: updatedExpirationDate.toISOString(), status: "active"}, {
{merge: true}, subscriptionExpirationDate: updatedExpirationDate.toISOString(),
status: "active",
},
{ merge: true }
); );
try { try {
@@ -88,15 +109,24 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
async (x) => async (x) =>
await setDoc( await setDoc(
doc(db, "users", x), doc(db, "users", x),
{subscriptionExpirationDate: updatedExpirationDate.toISOString(), status: "active"}, {
{merge: true}, 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!",
});
} }

View File

@@ -20,7 +20,9 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
const accessToken = await getAccessToken(); const accessToken = await getAccessToken();
if (!accessToken) return res.status(401).json({ok: false, reason: "Authorization failed!"}); 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<OrderResponseBody>( const request = await axios.post<OrderResponseBody>(
`${process.env.PAYPAL_ACCESS_TOKEN_URL}/v2/checkout/orders`, `${process.env.PAYPAL_ACCESS_TOKEN_URL}/v2/checkout/orders`,
@@ -34,11 +36,24 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
reference_id: v4(), 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", intent: "CAPTURE",
}, },
{ {
headers: { headers: {
Authorization: `Bearer ${accessToken}`, Authorization: `Bearer ${accessToken}`,
'PayPal-Client-Metadata-Id': trackingId,
}, },
}, },
); );

View File

@@ -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" });
}
}

View File

@@ -170,7 +170,9 @@ export default function Tickets() {
{dateSorting === "asc" && <BsArrowUp />} {dateSorting === "asc" && <BsArrowUp />}
</button> </button>
) as any, ) as any,
cell: (info) => moment(info.getValue()).format("DD/MM/YYYY - HH:mm"), cell: (info) => (
<span className="whitespace-nowrap">{moment(info.getValue()).format("DD/MM/YYYY - HH:mm")}</span>
),
}), }),
columnHelper.accessor("subject", { columnHelper.accessor("subject", {
header: "Subject", header: "Subject",