From f64b50df9e74abf39f37f8412b6d18fe5bd76170 Mon Sep 17 00:00:00 2001 From: Tiago Ribeiro Date: Mon, 30 Dec 2024 18:39:02 +0000 Subject: [PATCH] Updated part of the payment --- src/components/High/Layout.tsx | 12 +- src/components/Low/Input.tsx | 5 +- src/components/Navbar.tsx | 10 +- src/components/PaymobPayment.tsx | 17 +- src/interfaces/paymob.ts | 2 +- src/pages/(status)/PaymentDue.tsx | 338 ++++++++++++++------------- src/pages/api/entities/[id]/index.ts | 5 + src/pages/api/paymob/webhook.ts | 64 ++--- src/pages/entities/[id]/index.tsx | 56 +++++ src/pages/payment.tsx | 17 +- src/stores/examEditor/defaults.ts | 288 +++++++++++------------ 11 files changed, 457 insertions(+), 357 deletions(-) diff --git a/src/components/High/Layout.tsx b/src/components/High/Layout.tsx index c29d62c4..502384e1 100644 --- a/src/components/High/Layout.tsx +++ b/src/components/High/Layout.tsx @@ -1,8 +1,8 @@ import useEntities from "@/hooks/useEntities"; import { EntityWithRoles } from "@/interfaces/entity"; -import {User} from "@/interfaces/user"; +import { User } from "@/interfaces/user"; import clsx from "clsx"; -import {useRouter} from "next/router"; +import { useRouter } from "next/router"; import { ToastContainer } from "react-toastify"; import Navbar from "../Navbar"; import Sidebar from "../Sidebar"; @@ -23,19 +23,19 @@ export default function Layout({ user, children, className, - bgColor="bg-white", + bgColor = "bg-white", hideSidebar, navDisabled = false, focusMode = false, onFocusLayerMouseEnter }: Props) { const router = useRouter(); - const {entities} = useEntities() + const { entities } = useEntities() return (
- {!hideSidebar && ( + {!hideSidebar && user && ( )}
- {!hideSidebar && ( + {!hideSidebar && user && ( void; } @@ -29,6 +30,7 @@ export default function Input({ className, roundness = "full", disabled = false, + thin = false, min, onChange, }: Props) { @@ -95,9 +97,10 @@ export default function Input({ min={type === "number" ? (min ?? 0) : undefined} placeholder={placeholder} className={clsx( - "px-8 py-6 text-sm font-normal bg-white border border-mti-gray-platinum focus:outline-none", + "px-8 text-sm font-normal bg-white border border-mti-gray-platinum focus:outline-none", "placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed", roundness === "full" ? "rounded-full" : "rounded-xl", + thin ? 'py-4' : 'py-6' )} required={required} defaultValue={defaultValue} diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index f2bc5a42..a18f11e5 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -65,28 +65,28 @@ export default function Navbar({ user, path, navDisabled = false, focusMode = fa { module: "reading", icon: () => , - achieved: user.levels?.reading || 0 >= user.desiredLevels?.reading || 9, + achieved: user?.levels?.reading || 0 >= user?.desiredLevels?.reading || 9, }, { module: "listening", icon: () => , - achieved: user.levels?.listening || 0 >= user.desiredLevels?.listening || 9, + achieved: user?.levels?.listening || 0 >= user?.desiredLevels?.listening || 9, }, { module: "writing", icon: () => , - achieved: user.levels?.writing || 0 >= user.desiredLevels?.writing || 9, + achieved: user?.levels?.writing || 0 >= user?.desiredLevels?.writing || 9, }, { module: "speaking", icon: () => , - achieved: user.levels?.speaking || 0 >= user.desiredLevels?.speaking || 9, + achieved: user?.levels?.speaking || 0 >= user?.desiredLevels?.speaking || 9, }, { module: "level", icon: () => , - achieved: user.levels?.level || 0 >= user.desiredLevels?.level || 9, + achieved: user?.levels?.level || 0 >= user?.desiredLevels?.level || 9, }, ]; diff --git a/src/components/PaymobPayment.tsx b/src/components/PaymobPayment.tsx index 47cec9d6..9df12935 100644 --- a/src/components/PaymobPayment.tsx +++ b/src/components/PaymobPayment.tsx @@ -1,15 +1,17 @@ -import {PaymentIntention} from "@/interfaces/paymob"; -import {DurationUnit} from "@/interfaces/paypal"; -import {User} from "@/interfaces/user"; +import { Entity } from "@/interfaces/entity"; +import { PaymentIntention } from "@/interfaces/paymob"; +import { DurationUnit } from "@/interfaces/paypal"; +import { User } from "@/interfaces/user"; import axios from "axios"; -import {useRouter} from "next/router"; -import {useState} from "react"; +import { useRouter } from "next/router"; +import { useState } from "react"; import Button from "./Low/Button"; import Input from "./Low/Input"; import Modal from "./Modal"; interface Props { user: User; + entity?: Entity currency: string; price: number; setIsPaymentLoading: (v: boolean) => void; @@ -18,7 +20,7 @@ interface Props { onSuccess: (duration: number, duration_unit: DurationUnit) => void; } -export default function PaymobPayment({user, price, setIsPaymentLoading, currency, duration, duration_unit, onSuccess}: Props) { +export default function PaymobPayment({ user, entity, price, setIsPaymentLoading, currency, duration, duration_unit, onSuccess }: Props) { const [isLoading, setIsLoading] = useState(false); const router = useRouter(); @@ -56,10 +58,11 @@ export default function PaymobPayment({user, price, setIsPaymentLoading, currenc userID: user.id, duration, duration_unit, + entity: entity?.id }, }; - const response = await axios.post<{iframeURL: string}>(`/api/paymob`, paymentIntention); + const response = await axios.post<{ iframeURL: string }>(`/api/paymob`, paymentIntention); router.push(response.data.iframeURL); } catch (error) { diff --git a/src/interfaces/paymob.ts b/src/interfaces/paymob.ts index f75445a4..13ed8693 100644 --- a/src/interfaces/paymob.ts +++ b/src/interfaces/paymob.ts @@ -28,7 +28,7 @@ interface Customer { extras: IntentionExtras; } -type IntentionExtras = {[key: string]: string | number}; +type IntentionExtras = { [key: string]: string | number | undefined }; export interface IntentionResult { payment_keys: PaymentKeysItem[]; diff --git a/src/pages/(status)/PaymentDue.tsx b/src/pages/(status)/PaymentDue.tsx index 82a081dc..5dfc9842 100644 --- a/src/pages/(status)/PaymentDue.tsx +++ b/src/pages/(status)/PaymentDue.tsx @@ -5,8 +5,8 @@ import usePackages from "@/hooks/usePackages"; import useUsers from "@/hooks/useUsers"; import { User } from "@/interfaces/user"; import clsx from "clsx"; -import { capitalize } from "lodash"; -import { useEffect, useState } from "react"; +import { capitalize, sortBy } from "lodash"; +import { useEffect, useMemo, useState } from "react"; import useInvites from "@/hooks/useInvites"; import { BsArrowRepeat } from "react-icons/bs"; import InviteCard from "@/components/Medium/InviteCard"; @@ -16,46 +16,50 @@ import useDiscounts from "@/hooks/useDiscounts"; import PaymobPayment from "@/components/PaymobPayment"; import moment from "moment"; import { EntityWithRoles } from "@/interfaces/entity"; +import { Discount, Package } from "@/interfaces/paypal"; +import { isAdmin } from "@/utils/users"; +import { useAllowedEntities } from "@/hooks/useEntityPermissions"; +import Select from "@/components/Low/Select"; interface Props { - user: User; + user: User + discounts: Discount[] + packages: Package[] entities: EntityWithRoles[] hasExpired?: boolean; reload: () => void; } -export default function PaymentDue({ user, hasExpired = false, reload }: Props) { +export default function PaymentDue({ user, discounts = [], entities = [], packages = [], hasExpired = false, reload }: Props) { const [isLoading, setIsLoading] = useState(false); - const [appliedDiscount, setAppliedDiscount] = useState(0); + const [entity, setEntity] = useState() const router = useRouter(); - const { packages } = usePackages(); - const { discounts } = useDiscounts(); const { users } = useUsers(); - const { groups } = useGroups({}); const { invites, isLoading: isInvitesLoading, reload: reloadInvites } = useInvites({ to: user?.id }); - useEffect(() => { - const userDiscounts = discounts.filter((x) => user.email.endsWith(`@${x.domain}`)); - if (userDiscounts.length === 0) return; - - const biggestDiscount = [...userDiscounts].sort((a, b) => b.percentage - a.percentage).shift(); - if (!biggestDiscount || (biggestDiscount.validUntil && moment(biggestDiscount.validUntil).isBefore(moment()))) return; - - setAppliedDiscount(biggestDiscount.percentage); - }, [discounts, user]); - - const isIndividual = () => { - if (user?.type === "developer") return true; + const isIndividual = useMemo(() => { + if (isAdmin(user)) return false; if (user?.type !== "student") return false; - const userGroups = groups.filter((g) => g.participants.includes(user?.id)); - if (userGroups.length === 0) return true; + return user.entities.length === 0 + }, [user]) - const userGroupsAdminTypes = userGroups.map((g) => users?.find((u) => u.id === g.admin)?.type).filter((t) => !!t); - return userGroupsAdminTypes.every((t) => t !== "corporate"); - }; + const appliedDiscount = useMemo(() => { + const biggestDiscount = [...discounts].sort((a, b) => b.percentage - a.percentage).shift(); + + if (!biggestDiscount || (biggestDiscount.validUntil && moment(biggestDiscount.validUntil).isBefore(moment()))) + return 0; + + return biggestDiscount.percentage + }, [discounts]) + + const entitiesThatCanBePaid = useAllowedEntities(user, entities, 'pay_entity') + + useEffect(() => { + if (entitiesThatCanBePaid.length > 0) setEntity(entitiesThatCanBePaid[0]) + }, [entitiesThatCanBePaid]) return ( <> @@ -74,169 +78,185 @@ export default function PaymentDue({ user, hasExpired = false, reload }: Props)
)} - {user ? ( - - {invites.length > 0 && ( -
-
-
- Invites - -
+ + {invites.length > 0 && ( +
+
+
+ Invites +
- - {invites.map((invite) => ( - { - reloadInvites(); - router.reload(); - }} - /> - ))} - -
- )} +
+ + {invites.map((invite) => ( + { + reloadInvites(); + router.reload(); + }} + /> + ))} + +
+ )} -
- {hasExpired && You do not have time credits for your account type!} - {isIndividual() && ( -
- - 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, - )} - -
-
- {appliedDiscount === 0 && ( - - {p.price} {p.currency} - - )} - {appliedDiscount > 0 && ( -
- - {p.price} {p.currency} - - - {(p.price - p.price * (appliedDiscount / 100)).toFixed(2)} {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
  • -
-
-
- ))} -
-
- )} - {!isIndividual() && - (user?.type === "corporate" || user?.type === "mastercorporate") && - user?.corporateInformation.payment && ( -
- - To add to your use of EnCoach and that of your students and teachers, please pay your designated package - below: - -
+
+ {hasExpired && You do not have time credits for your account type!} + {isIndividual && ( +
+ + To add to your use of EnCoach, please purchase one of the time packages available below: + +
+ {packages.map((p) => ( +
EnCoach's Logo - EnCoach - {12} Months + EnCoach - {p.duration}{" "} + {capitalize( + p.duration === 1 ? p.duration_unit.slice(0, p.duration_unit.length - 1) : p.duration_unit, + )}
- - {user.corporateInformation.payment.value} {user.corporateInformation.payment.currency} - + {appliedDiscount === 0 && ( + + {p.price} {p.currency} + + )} + {appliedDiscount > 0 && ( +
+ + {p.price} {p.currency} + + + {(p.price - p.price * (appliedDiscount / 100)).toFixed(2)} {p.currency} + +
+ )} { - setIsLoading(false); 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:
    -
  • - - Allow a total of 0 students and teachers to use EnCoach -
  • -
  • - Train their abilities for the IELTS exam
  • -
  • - Gain insights into your students' weaknesses and strengths
  • -
  • - Allow them to correctly prepare for the exam
  • +
  • - Train your abilities for the IELTS exam
  • +
  • - Gain insights into your weaknesses and strengths
  • +
  • - Allow yourself to correctly prepare for the exam
+ ))} +
+
+ )} + + {!isIndividual && entitiesThatCanBePaid.length > 0 && + entity?.payment && ( +
+
+ + ({ value: e.id, label: e.label, entity: e }))} + onChange={(e) => e?.value ? setEntity(e?.entity) : null} + className="!w-full max-w-[400px] self-center" + /> +
+ + An admin nor your agent have yet set the price intended to your requirements in terms of the amount of users + you desire and your expected monthly duration. - If you believe this to be a mistake, please contact the platform's administration, thank you for your - patience. + Please try again later or contact your agent or an admin, thank you for your patience.
)} - {!isIndividual() && - (user?.type === "corporate" || user?.type === "mastercorporate") && - !user.corporateInformation.payment && ( -
- - An admin nor your agent have yet set the price intended to your requirements in terms of the amount of users - you desire and your expected monthly duration. - - - Please try again later or contact your agent or an admin, thank you for your patience. - -
- )} -
- - ) : ( -
- )} +
+ ); } diff --git a/src/pages/api/entities/[id]/index.ts b/src/pages/api/entities/[id]/index.ts index 2f438f20..0323ac05 100644 --- a/src/pages/api/entities/[id]/index.ts +++ b/src/pages/api/entities/[id]/index.ts @@ -68,6 +68,11 @@ async function patch(req: NextApiRequest, res: NextApiResponse) { return res.status(200).json({ ok: entity.acknowledged }); } + if (req.body.payment) { + const entity = await db.collection("entities").updateOne({ id }, { $set: { payment: req.body.payment } }); + return res.status(200).json({ ok: entity.acknowledged }); + } + if (req.body.expiryDate !== undefined) { const entity = await getEntity(id) const result = await db.collection("entities").updateOne({ id }, { $set: { expiryDate: req.body.expiryDate } }); diff --git a/src/pages/api/paymob/webhook.ts b/src/pages/api/paymob/webhook.ts index 7b428c6c..0fd4b416 100644 --- a/src/pages/api/paymob/webhook.ts +++ b/src/pages/api/paymob/webhook.ts @@ -1,15 +1,19 @@ // Next.js API route support: https://nextjs.org/docs/api-routes/introduction -import type {NextApiRequest, NextApiResponse} from "next"; -import {withIronSessionApiRoute} from "iron-session/next"; -import {sessionOptions} from "@/lib/session"; -import {Group, User} from "@/interfaces/user"; -import {DurationUnit, Package, Payment} from "@/interfaces/paypal"; -import {v4} from "uuid"; +import type { NextApiRequest, NextApiResponse } from "next"; +import { withIronSessionApiRoute } from "iron-session/next"; +import { sessionOptions } from "@/lib/session"; +import { Group, User } from "@/interfaces/user"; +import { DurationUnit, 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 { IntentionResult, PaymentIntention, TransactionResult } from "@/interfaces/paymob"; import moment from "moment"; import client from "@/lib/mongodb"; +import { getEntity } from "@/utils/entities.be"; +import { Entity } from "@/interfaces/entity"; +import { getEntityUsers } from "@/utils/users.be"; +import { mapBy } from "@/utils"; const db = client.db(process.env.MONGODB_DB); @@ -22,21 +26,22 @@ async function post(req: NextApiRequest, res: NextApiResponse) { const authToken = await authenticatePaymob(); console.log("WEBHOOK: ", transactionResult); - if (!checkTransaction(authToken, transactionResult.transaction.order.id)) return res.status(404).json({ok: false}); - if (!transactionResult.transaction.success) return res.status(400).json({ok: false}); + if (!checkTransaction(authToken, transactionResult.transaction.order.id)) return res.status(404).json({ ok: false }); + if (!transactionResult.transaction.success) return res.status(400).json({ ok: false }); - const {userID, duration, duration_unit} = transactionResult.intention.extras.creation_extras as { + const { userID, duration, duration_unit, entity: entityID } = transactionResult.intention.extras.creation_extras as { userID: string; duration: number; duration_unit: DurationUnit; + entity: string }; const user = await db.collection("users").findOne({ id: userID as string }); - if (!user || !duration || !duration_unit) return res.status(404).json({ok: false}); + if (!user || !duration || !duration_unit) return res.status(404).json({ ok: false }); const subscriptionExpirationDate = user.subscriptionExpirationDate; - if (!subscriptionExpirationDate) return res.status(200).json({ok: false}); + if (!subscriptionExpirationDate) return res.status(200).json({ ok: false }); const initialDate = moment(subscriptionExpirationDate).isAfter(moment()) ? moment(subscriptionExpirationDate) : moment(); @@ -44,8 +49,8 @@ async function post(req: NextApiRequest, res: NextApiResponse) { await db.collection("users").updateOne( { id: userID as string }, - { $set: {subscriptionExpirationDate: updatedSubscriptionExpirationDate, status: "active"} } - ); + { $set: { subscriptionExpirationDate: updatedSubscriptionExpirationDate, status: "active" } } + ); await db.collection("paypalpayments").insertOne({ id: v4(), @@ -60,22 +65,19 @@ async function post(req: NextApiRequest, res: NextApiResponse) { value: transactionResult.transaction.amount_cents / 1000, }); - if (user.type === "corporate") { - const groups = await db.collection("groups").find({ admin: user.id }).toArray(); + if (entityID) { + const entity = await getEntity(entityID) + await db.collection("entities").updateOne({ id: entityID }, { $set: { expiryDate: req.body.expiryDate } }); - const participants = (await Promise.all( - groups.flatMap((x) => x.participants).map(async (x) => ({...(await db.collection("users").findOne({ id: x}))})), - )) as User[]; - const sameExpiryDateParticipants = participants.filter( - (x) => x.subscriptionExpirationDate === subscriptionExpirationDate && x.status !== "disabled", - ); + const users = await getEntityUsers(entityID, 0, { + subscriptionExpirationDate: entity?.expiryDate, + $and: [ + { type: { $ne: "admin" } }, + { type: { $ne: "developer" } }, + ] + }) - for (const participant of sameExpiryDateParticipants) { - await db.collection("users").updateOne( - { id: participant.id }, - { $set: {subscriptionExpirationDate: updatedSubscriptionExpirationDate, status: "active"} } - ); - } + await db.collection("users").updateMany({ id: { $in: mapBy(users, 'id') } }, { $set: { subscriptionExpirationDate: req.body.expiryDate } }) } res.status(200).json({ @@ -84,19 +86,19 @@ async function post(req: NextApiRequest, res: NextApiResponse) { } const authenticatePaymob = async () => { - const response = await axios.post<{token: string}>( + 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}`}}, + { 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}); + const response = await axios.post("https://oman.paymob.com/api/ecommerce/orders/transaction_inquiry", { auth_token: token, order_id: orderID }); return response.status === 200; }; diff --git a/src/pages/entities/[id]/index.tsx b/src/pages/entities/[id]/index.tsx index 324c2227..34b35978 100644 --- a/src/pages/entities/[id]/index.tsx +++ b/src/pages/entities/[id]/index.tsx @@ -2,6 +2,7 @@ import CardList from "@/components/High/CardList"; import Layout from "@/components/High/Layout"; import Select from "@/components/Low/Select"; +import Input from "@/components/Low/Input"; import Checkbox from "@/components/Low/Checkbox"; import Tooltip from "@/components/Low/Tooltip"; import { useEntityPermission } from "@/hooks/useEntityPermissions"; @@ -29,6 +30,7 @@ import { useRouter } from "next/router"; import { Divider } from "primereact/divider"; import { useEffect, useMemo, useState } from "react"; import ReactDatePicker from "react-datepicker"; +import { CURRENCIES } from "@/resources/paypal"; import { BsCheck, @@ -56,6 +58,11 @@ const expirationDateColor = (date: Date) => { if (today.add(7, "days").isAfter(momentDate)) return "!bg-mti-orange-ultralight border-mti-orange-light"; }; +const CURRENCIES_OPTIONS = CURRENCIES.map(({ label, currency }) => ({ + value: currency, + label, +})); + export const getServerSideProps = withIronSessionSsr(async ({ req, params }) => { const user = req.session.user as User; @@ -102,6 +109,8 @@ export default function Home({ user, entity, users, linkedUsers }: Props) { const [isLoading, setIsLoading] = useState(false); const [selectedUsers, setSelectedUsers] = useState([]); const [expiryDate, setExpiryDate] = useState(entity?.expiryDate) + const [paymentPrice, setPaymentPrice] = useState(entity?.payment?.price) + const [paymentCurrency, setPaymentCurrency] = useState(entity?.payment?.currency) const router = useRouter(); @@ -198,6 +207,23 @@ export default function Home({ user, entity, users, linkedUsers }: Props) { .finally(() => setIsLoading(false)); }; + const updatePayment = () => { + if (!isAdmin(user)) return; + + setIsLoading(true); + axios + .patch(`/api/entities/${entity.id}`, { payment: { price: paymentPrice, currency: paymentCurrency } }) + .then(() => { + toast.success("The entity has been updated successfully!"); + router.replace(router.asPath); + }) + .catch((e) => { + console.error(e); + toast.error("Something went wrong!"); + }) + .finally(() => setIsLoading(false)); + }; + const editLicenses = () => { if (!isAdmin(user)) return; @@ -430,6 +456,36 @@ export default function Home({ user, entity, users, linkedUsers }: Props) { Apply Change
+ + + +
+
+ setPaymentPrice(e ? parseInt(e) : undefined)} + type="number" + defaultValue={entity.payment?.price || 0} + thin + /> +