diff --git a/src/components/PaymobPayment.tsx b/src/components/PaymobPayment.tsx index fe27543f..eaec5a78 100644 --- a/src/components/PaymobPayment.tsx +++ b/src/components/PaymobPayment.tsx @@ -1,38 +1,73 @@ +import {PaymentIntention} from "@/interfaces/paymob"; import {DurationUnit} from "@/interfaces/paypal"; import {User} from "@/interfaces/user"; import axios from "axios"; import {useState} from "react"; import Button from "./Low/Button"; +import Input from "./Low/Input"; +import Modal from "./Modal"; interface Props { user: User; currency: string; price: number; - title: string; - description: string; - paymentID: string; + packageID: string; + setIsPaymentLoading: (v: boolean) => void; duration: number; duration_unit: DurationUnit; - setIsLoading: (isLoading: boolean) => void; onSuccess: (duration: number, duration_unit: DurationUnit) => void; } -export default function PaymobPayment({ - user, - price, - currency, - title, - description, - paymentID, - duration, - duration_unit, - setIsLoading, - onSuccess, -}: Props) { - const [iframeURL, setIFrameURL] = useState(); +export default function PaymobPayment({user, price, packageID, setIsPaymentLoading, currency, duration, duration_unit, onSuccess}: Props) { + const [isLoading, setIsLoading] = useState(false); + const [isModalOpen, setIsModalOpen] = useState(false); + + const [firstName, setFirstName] = useState(user.name.split(" ")[0]); + const [lastName, setLastName] = useState([...user.name.split(" ")].pop()); + const [street, setStreet] = useState(""); + const [apartment, setApartment] = useState(""); + const [building, setBuilding] = useState(""); + const [state, setState] = useState(""); + const [floor, setFloor] = useState(""); const handleCardPayment = async () => { try { + setIsPaymentLoading(true); + + const paymentIntention: PaymentIntention = { + amount: price * 1000, + currency: "OMR", + items: [], + payment_methods: [1540], + customer: { + email: user.email, + first_name: user.name.split(" ")[0], + last_name: [...user.name.split(" ")].pop() || "N/A", + extras: { + re: user.id, + }, + }, + billing_data: { + apartment: apartment || "N/A", + building: building || "N/A", + country: user.demographicInformation?.country || "N/A", + email: user.email, + first_name: user.name.split(" ")[0], + last_name: [...user.name.split(" ")].pop() || "N/A", + floor: floor || "N/A", + phone_number: user.demographicInformation?.phone || "N/A", + state: state || "N/A", + street: street || "N/A", + }, + extras: { + userID: user.id, + packageID: packageID, + }, + }; + + const response = await axios.post<{iframeURL: string}>(`/api/paymob`, paymentIntention); + + window.open(response.data.iframeURL, "_blank", "noopener,noreferrer"); } catch (error) { console.error("Error starting card payment process:", error); } @@ -40,8 +75,29 @@ export default function PaymobPayment({ return ( <> - - {iframeURL} + setIsModalOpen(false)}> +
+
+ + +
+
+ + + +
+
+ + +
+ +
+
+ ); } diff --git a/src/interfaces/paymob.ts b/src/interfaces/paymob.ts new file mode 100644 index 00000000..c5dce4b7 --- /dev/null +++ b/src/interfaces/paymob.ts @@ -0,0 +1,118 @@ +export interface PaymentIntention { + amount: number; + currency: string; + payment_methods: number[]; + items: any[]; + billing_data: BillingData; + customer: Customer; + extras: IntentionExtras; +} + +interface BillingData { + apartment: string; + first_name: string; + last_name: string; + street: string; + building: string; + phone_number: string; + country: string; + email: string; + floor: string; + state: string; +} + +interface Customer { + first_name: string; + last_name: string; + email: string; + extras: IntentionExtras; +} + +type IntentionExtras = {[key: string]: string}; + +export interface IntentionResult { + payment_keys: PaymentKeysItem[]; + id: string; + intention_detail: IntentionDetail; + client_secret: string; + payment_methods: PaymentMethodsItem[]; + special_reference: null; + extras: Extras; + confirmed: boolean; + status: string; + created: string; + card_detail: null; + object: string; +} + +interface PaymentKeysItem { + integration: number; + key: string; + gateway_type: string; + iframe_id: null; +} + +interface IntentionDetail { + amount: number; + items: ItemsItem[]; + currency: string; +} + +interface ItemsItem { + name: string; + amount: number; + description: string; + quantity: number; +} + +interface PaymentMethodsItem { + integration_id: number; + alias: null; + name: null; + method_type: string; + currency: string; + live: boolean; + use_cvc_with_moto: boolean; +} + +interface Extras { + creation_extras: IntentionExtras; + confirmation_extras: null; +} + +export interface TransactionResult { + paymob_request_id: null; + intention: IntentionResult; + hmac: string; + transaction: Transaction; +} + +interface Transaction { + amount_cents: number; + created_at: string; + currency: string; + error_occured: boolean; + has_parent_transaction: boolean; + id: number; + integration_id: number; + is_3d_secure: boolean; + is_auth: boolean; + is_capture: boolean; + is_refunded: boolean; + is_standalone_payment: boolean; + is_voided: boolean; + order: Order; + owner: number; + pending: boolean; + source_data: Source_data; + success: boolean; + receipt: string; +} +interface Order { + id: number; +} +interface Source_data { + pan: string; + sub_type: string; + type: string; +} diff --git a/src/pages/(status)/PaymentDue.tsx b/src/pages/(status)/PaymentDue.tsx index 3cb5b680..a5920a92 100644 --- a/src/pages/(status)/PaymentDue.tsx +++ b/src/pages/(status)/PaymentDue.tsx @@ -107,44 +107,37 @@ export default function PaymentDue({user, hasExpired = false, clientID, reload}: To add to your use of EnCoach, please purchase one of the time packages available below:
- - {packages.map((p) => ( -
-
- EnCoach's Logo - - EnCoach - {p.duration}{" "} - {capitalize( - p.duration === 1 ? p.duration_unit.slice(0, p.duration_unit.length - 1) : p.duration_unit, - )} + {packages.map((p) => ( +
+
+ EnCoach's Logo + + EnCoach - {p.duration}{" "} + {capitalize( + p.duration === 1 ? p.duration_unit.slice(0, p.duration_unit.length - 1) : p.duration_unit, + )} + +
+
+ {!appliedDiscount && ( + + {p.price} + {getSymbolFromCurrency(p.currency)} -
-
- {!appliedDiscount && ( - + )} + {appliedDiscount && ( +
+ {p.price} {getSymbolFromCurrency(p.currency)} - )} - {appliedDiscount && ( -
- - {p.price} - {getSymbolFromCurrency(p.currency)} - - - {(p.price - p.price * (appliedDiscount / 100)).toFixed(2)} - {getSymbolFromCurrency(p.currency)} - -
- )} - {/* + {(p.price - p.price * (appliedDiscount / 100)).toFixed(2)} + {getSymbolFromCurrency(p.currency)} + +
+ )} + {/* */} - { - setTimeout(reload, 500); - }} - currency={p.currency} - duration={p.duration} - duration_unit={p.duration_unit} - price={+(p.price - p.price * (appliedDiscount / 100)).toFixed(2)} - /> -
-
- This includes: -
    -
  • - Train your abilities for the IELTS exam
  • -
  • - Gain insights into your weaknesses and strengths
  • -
  • - Allow yourself to correctly prepare for the exam
  • -
-
+ { + setTimeout(reload, 500); + }} + currency={p.currency} + duration={p.duration} + duration_unit={p.duration_unit} + price={+(p.price - p.price * (appliedDiscount / 100)).toFixed(2)} + />
- ))} - +
+ This includes: +
    +
  • - Train your abilities for the IELTS exam
  • +
  • - Gain insights into your weaknesses and strengths
  • +
  • - Allow yourself to correctly prepare for the exam
  • +
+
+
+ ))} + z
)} diff --git a/src/pages/api/paymob.ts b/src/pages/api/paymob.ts deleted file mode 100644 index 2cab06cd..00000000 --- a/src/pages/api/paymob.ts +++ /dev/null @@ -1,101 +0,0 @@ -// 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 {Group} from "@/interfaces/user"; -import {Payment} from "@/interfaces/paypal"; -import {v4} from "uuid"; -import ShortUniqueId from "short-unique-id"; -import axios from "axios"; - -const db = getFirestore(app); - -export default withIronSessionApiRoute(handler, sessionOptions); - -interface BillingData { - apartment: string; - email: string; - floor: string; - first_name: string; - street: string; - building: string; - phone_number: string; - shipping_method: string; - postal_code: string; - city: string; - country: string; - last_name: string; - state: string; -} - -async function handler(req: NextApiRequest, res: NextApiResponse) { - if (!req.session.user) { - res.status(401).json({ok: false}); - return; - } - - if (req.method === "GET") await get(req, res); - if (req.method === "POST") await post(req, res); -} - -async function get(req: NextApiRequest, res: NextApiResponse) { - const snapshot = await getDocs(collection(db, "payments")); - - res.status(200).json( - snapshot.docs.map((doc) => ({ - id: doc.id, - ...doc.data(), - })), - ); -} - -async function post(req: NextApiRequest, res: NextApiResponse) { - const body = req.body as Payment; - - const shortUID = new ShortUniqueId(); - await setDoc(doc(db, "payments", shortUID.randomUUID(8)), body); - res.status(200).json({ok: true}); -} - -const authenticatePaymob = async () => { - const response = await axios.post<{token: string}>( - "https://oman.paymob.com/api/auth/tokens", - { - api_key: process.env.PAYMOB_API_KEY, - }, - {headers: {Authorization: `Bearer ${process.env.PAYMOB_SECRET_KEY}`}}, - ); - - return response.data.token; -}; - -const createOrder = async (token: string) => { - const response = await axios.post<{id: number}>( - "https://oman.paymob.com/api/ecommerce/orders", - {auth_token: token, delivery_needed: "false", currency: "OMR", amount_cents: "100", items: []}, - {headers: {Authorization: `Bearer ${token}`}}, - ); - - return response.data.id; -}; - -const createTransactionIFrame = async (token: string, orderID: number, billingData: BillingData) => { - const response = await axios.post<{token: string}>( - "https://oman.paymob.com/api/acceptance/payment_keys", - { - auth_token: token, - amount_cents: "100", - order_id: orderID, - currency: "OMR", - expiration: 3600, - integration_id: 1540, - lock_order_when_paid: "true", - billing_data: billingData, - }, - {headers: {Authorization: `Bearer ${token}`}}, - ); - - return response.data.token; -}; diff --git a/src/pages/api/paymob/index.ts b/src/pages/api/paymob/index.ts new file mode 100644 index 00000000..58bd7c85 --- /dev/null +++ b/src/pages/api/paymob/index.ts @@ -0,0 +1,52 @@ +// 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 {Group} from "@/interfaces/user"; +import {Payment} from "@/interfaces/paypal"; +import {v4} from "uuid"; +import ShortUniqueId from "short-unique-id"; +import axios from "axios"; +import {IntentionResult, PaymentIntention} from "@/interfaces/paymob"; + +const db = getFirestore(app); + +export default withIronSessionApiRoute(handler, sessionOptions); + +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (!req.session.user) { + res.status(401).json({ok: false}); + return; + } + + if (req.method === "GET") await get(req, res); + if (req.method === "POST") await post(req, res); +} + +async function get(req: NextApiRequest, res: NextApiResponse) { + const snapshot = await getDocs(collection(db, "payments")); + + res.status(200).json( + snapshot.docs.map((doc) => ({ + id: doc.id, + ...doc.data(), + })), + ); +} + +async function post(req: NextApiRequest, res: NextApiResponse) { + const intention = req.body as PaymentIntention; + + const response = await axios.post( + "https://oman.paymob.com/v1/intention/", + {...intention, payment_methods: [1540], items: []}, + {headers: {Authorization: `Token ${process.env.PAYMOB_SECRET_KEY}`}}, + ); + const intentionResult = response.data; + + res.status(200).json({ + iframeURL: `https://oman.paymob.com/unifiedcheckout/?publicKey=${process.env.PAYMOB_PUBLIC_KEY}&clientSecret=${intentionResult.client_secret}`, + }); +} diff --git a/src/pages/api/paymob/webhook.ts b/src/pages/api/paymob/webhook.ts new file mode 100644 index 00000000..257afdbe --- /dev/null +++ b/src/pages/api/paymob/webhook.ts @@ -0,0 +1,82 @@ +// 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, getDoc, query, where} from "firebase/firestore"; +import {withIronSessionApiRoute} from "iron-session/next"; +import {sessionOptions} from "@/lib/session"; +import {Group, User} from "@/interfaces/user"; +import {Package, Payment} from "@/interfaces/paypal"; +import {v4} from "uuid"; +import ShortUniqueId from "short-unique-id"; +import axios from "axios"; +import {IntentionResult, PaymentIntention, TransactionResult} from "@/interfaces/paymob"; +import moment from "moment"; + +const db = getFirestore(app); + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method === "POST") await post(req, res); +} + +async function post(req: NextApiRequest, res: NextApiResponse) { + const transactionResult = req.body as TransactionResult; + const authToken = await authenticatePaymob(); + + if (!checkTransaction(authToken, transactionResult.transaction.order.id)) return res.status(404).json({ok: false}); + if (!transactionResult.transaction.success) return res.status(200).json({ok: false}); + + const {userID, packageID} = transactionResult.intention.extras.creation_extras; + + const userSnapshot = await getDoc(doc(db, "users", userID)); + const packageSnapshot = await getDoc(doc(db, "packages", packageID)); + + if (!userSnapshot.exists() || !packageSnapshot.exists()) return res.status(404).json({ok: false}); + + const user = {...userSnapshot.data(), id: userSnapshot.id} as User; + const pack = {...packageSnapshot.data(), id: packageSnapshot.id} as Package; + + const subscriptionExpirationDate = user.subscriptionExpirationDate; + if (!subscriptionExpirationDate) return res.status(200).json({ok: false}); + + const initialDate = moment(subscriptionExpirationDate).isAfter(moment()) ? moment(subscriptionExpirationDate) : moment(); + + const updatedSubscriptionExpirationDate = moment(initialDate).add(pack.duration, pack.duration_unit).toISOString(); + + await setDoc(userSnapshot.ref, {subscriptionExpirationDate: updatedSubscriptionExpirationDate}, {merge: true}); + + if (user.type === "corporate") { + const groupsSnapshot = await getDocs(query(collection(db, "groups"), where("admin", "==", user.id))); + const groups = groupsSnapshot.docs.map((g) => ({...g.data(), id: g.id})) as Group[]; + + const participants = (await Promise.all( + groups.flatMap((x) => x.participants).map(async (x) => ({...(await getDoc(doc(db, "users", x))).data(), id: x})), + )) as User[]; + const sameExpiryDateParticipants = participants.filter((x) => x.subscriptionExpirationDate === subscriptionExpirationDate); + + for (const participant of sameExpiryDateParticipants) { + await setDoc(doc(db, "users", participant.id), {subscriptionExpirationDate: updatedSubscriptionExpirationDate}, {merge: true}); + } + } + + res.status(200).json({ + ok: true, + }); +} + +const authenticatePaymob = async () => { + const response = await axios.post<{token: string}>( + "https://oman.paymob.com/api/auth/tokens", + { + api_key: process.env.PAYMOB_API_KEY, + }, + {headers: {Authorization: `Bearer ${process.env.PAYMOB_SECRET_KEY}`}}, + ); + + return response.data.token; +}; + +const checkTransaction = async (token: string, orderID: number) => { + const response = await axios.post("https://oman.paymob.com/api/ecommerce/orders/transaction_inquiry", {auth_token: token, order_id: orderID}); + + return response.status === 200; +};