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,77 +1,124 @@
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;
currency: string; currency: string;
price: number; price: number;
duration: number; duration: number;
duration_unit: DurationUnit; duration_unit: DurationUnit;
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,
setIsLoading(true); 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);
return axios return axios
.post<OrderResponseBody>("/api/paypal", {currencyCode: currency, price}) .post<OrderResponseBody>("/api/paypal", {
.then((response) => response.data) currencyCode: currency,
.then((data) => data.id); price,
}; trackingId,
})
.then((response) => response.data)
.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");
}
if (request.status !== 200) { const request = await axios.post<{ ok: boolean; reason?: string }>(
toast.error("Something went wrong, please try again later"); "/api/paypal/approve",
return; { id: data.orderID, duration, duration_unit, trackingId }
} );
toast.success("Your account has been credited more time!"); if (request.status !== 200) {
return onSuccess(duration, duration_unit); toast.error("Something went wrong, please try again later");
}; return;
}
const onError = async (data: Record<string, unknown>) => { toast.success("Your account has been credited more time!");
setIsLoading(false); return onSuccess(duration, duration_unit);
}; };
const onCancel = async (data: Record<string, unknown>, actions: OnCancelledActions) => { const onError = async (data: Record<string, unknown>) => {
setIsLoading(false); setIsLoading(false);
}; };
return loadScript ? ( const onCancel = async (
<PayPalScriptProvider data: Record<string, unknown>,
options={{ actions: OnCancelledActions
clientId: clientID, ) => {
currency, setIsLoading(false);
intent: "capture", };
commit: true,
vault: true, if (trackingId) {
}}> return loadScript ? (
<PayPalButtons <PayPalScriptProvider
className="w-full" options={{
style={{layout: "vertical"}} clientId: clientID,
createOrder={createOrder} currency,
onApprove={onApprove} intent: "capture",
onCancel={onCancel} commit: true,
onError={onError} vault: true,
/> }}
</PayPalScriptProvider> >
) : ( <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} />
/> </PayPalScriptProvider>
); ) : (
<PayPalButtons
className="w-full"
style={{ layout: "vertical" }}
createOrder={createOrder}
onApprove={onApprove}
onCancel={onCancel}
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,57 +1,78 @@
// 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;
};
const request = await axios.post( if (!trackingId)
`${process.env.PAYPAL_ACCESS_TOKEN_URL}/v2/checkout/orders/${id}/capture`, return res.status(401).json({ ok: false, reason: "Missing tracking id!" });
{},
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
);
if (request.data.status === "COMPLETED") { const request = await axios.post(
const user = req.session.user; `${process.env.PAYPAL_ACCESS_TOKEN_URL}/v2/checkout/orders/${id}/capture`,
const subscriptionExpirationDate = req.session.user.subscriptionExpirationDate; {},
const today = moment(new Date()); {
const dateToBeAddedTo = !subscriptionExpirationDate headers: {
? today Authorization: `Bearer ${accessToken}`,
: moment(subscriptionExpirationDate).isAfter(today) "PayPal-Client-Metadata-Id": trackingId,
? moment(subscriptionExpirationDate) },
: today; }
);
const updatedExpirationDate = dateToBeAddedTo.add(duration, duration_unit); if (request.data.status === "COMPLETED") {
await setDoc( const user = req.session.user;
doc(db, "users", req.session.user.id), const subscriptionExpirationDate =
{subscriptionExpirationDate: updatedExpirationDate.toISOString(), status: "active"}, req.session.user.subscriptionExpirationDate;
{merge: true}, 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 { try {
await setDoc( await setDoc(
@@ -72,31 +93,40 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
console.error('Failed to insert paypal payment!', err); console.error('Failed to insert paypal payment!', err);
} }
if (user.type === "corporate") { if (user.type === "corporate") {
const snapshot = await getDocs(collection(db, "groups")); const snapshot = await getDocs(collection(db, "groups"));
const groups: Group[] = ( const groups: Group[] = (
snapshot.docs.map((doc) => ({ snapshot.docs.map((doc) => ({
id: doc.id, id: doc.id,
...doc.data(), ...doc.data(),
})) as Group[] })) as Group[]
).filter((x) => x.admin === user.id); ).filter((x) => x.admin === user.id);
await Promise.all( await Promise.all(
groups groups
.flatMap((x) => x.participants) .flatMap((x) => x.participants)
.map( .map(
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",