Merged in feature-paymentAssetManagement (pull request #5)

Added file storage handling for Corporate and Commission transfer

Approved-by: Tiago Ribeiro
This commit is contained in:
João Ramos
2023-12-15 10:09:02 +00:00
committed by Tiago Ribeiro
11 changed files with 483 additions and 17 deletions

View File

@@ -0,0 +1,156 @@
import React, { ChangeEvent } from "react";
import { BsUpload, BsDownload, BsTrash, BsArrowRepeat } from "react-icons/bs";
import { FilesStorage } from "@/interfaces/storage.files";
import axios from "axios";
interface Asset {
file: string | File;
complete: boolean;
}
const PaymentAssetManager = (props: {
asset: string | undefined;
permissions: "read" | "write";
type: FilesStorage;
paymentId: string;
}) => {
const { asset, permissions, type, paymentId } = props;
const fileInputRef = React.useRef<HTMLInputElement>(null);
const fileInputReplaceRef = React.useRef<HTMLInputElement>(null);
const [managingAsset, setManagingAsset] = React.useState<Asset>({
file: asset || "",
complete: asset ? true : false,
});
const { file, complete } = managingAsset;
const deleteAsset = () => {
if (confirm("Are you sure you want to delete this document?")) {
axios
.delete(`/api/payments/files/${type}/${paymentId}`)
.then((response) => {
if (response.status === 200) {
console.log("File deleted successfully!");
setManagingAsset({
file: "",
complete: false,
});
return;
}
console.error("File deletion failed");
})
.catch((error) => {
console.error("Error occurred during file deletion:", error);
});
}
};
const renderFileInput = (
onChange: any,
ref: React.RefObject<HTMLInputElement>
) => (
<input
type="file"
ref={ref}
style={{ display: "none" }}
onChange={onChange}
multiple={false}
accept="application/pdf"
/>
);
const handleFileChange = async (e: Event, method: "post" | "patch") => {
const newFile = (e.target as HTMLInputElement).files?.[0];
if (newFile) {
setManagingAsset({
file: newFile,
complete: false,
});
const formData = new FormData();
formData.append("file", newFile);
axios[method](`/api/payments/files/${type}/${paymentId}`, formData, {
headers: {
"Content-Type": "multipart/form-data",
},
})
.then((response) => {
if (response.status === 200) {
console.log("File uploaded successfully!");
console.log("Uploaded File URL:", response.data.ref);
// Further actions upon successful upload
setManagingAsset({
file: response.data.ref,
complete: true,
});
return;
}
console.error("File upload failed");
})
.catch((error) => {
console.error("Error occurred during file upload:", error);
});
}
};
const downloadAsset = () => {
axios
.get(`/api/payments/files/${type}/${paymentId}`)
.then((response) => {
if (response.status === 200) {
console.log("Uploaded File URL:", response.data.url);
const link = document.createElement("a");
link.download = response.data.filename;
link.href = response.data.url;
link.click();
return;
}
console.error("Failed to download file");
})
.catch((error) => {
console.error("Error occurred during file upload:", error);
});
};
if (permissions === "read") {
if (file) return <BsDownload onClick={downloadAsset} />;
return null;
}
if (file) {
if (complete) {
return (
<>
<BsDownload onClick={downloadAsset} />
<BsArrowRepeat onClick={() => fileInputReplaceRef.current?.click()} />
<BsTrash onClick={deleteAsset} />
{renderFileInput(
(e: Event) => handleFileChange(e, "patch"),
fileInputReplaceRef
)}
{renderFileInput(
(e: Event) => handleFileChange(e, "post"),
fileInputRef
)}
</>
);
}
return <span className="loading loading-infinity w-8" />;
}
return (
<>
<BsUpload onClick={() => fileInputRef.current?.click()} />
{renderFileInput((e: Event) => handleFileChange(e, "post"), fileInputRef)}
</>
);
};
export default PaymentAssetManager;

View File

@@ -1,5 +1,6 @@
import {initializeApp} from "firebase/app"; import {initializeApp} from "firebase/app";
import * as admin from "firebase-admin/app"; import * as admin from "firebase-admin/app";
import { getStorage } from "firebase/storage";
const serviceAccount = require("@/constants/serviceAccountKey.json"); const serviceAccount = require("@/constants/serviceAccountKey.json");
@@ -19,3 +20,4 @@ export const adminApp = admin.initializeApp(
}, },
Math.random().toString(), Math.random().toString(),
); );
export const storage = getStorage(app);

View File

@@ -32,4 +32,6 @@ export interface Payment {
value: number; value: number;
isPaid: boolean; isPaid: boolean;
date: Date | string; date: Date | string;
corporateTransfer?: string;
commissionTransfer?: string;
} }

View File

@@ -0,0 +1 @@
export type FilesStorage = "commission" | "corporate";

View File

@@ -4,9 +4,9 @@ import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session"; import {sessionOptions} from "@/lib/session";
import axios from "axios"; import axios from "axios";
import formidable from "formidable-serverless"; import formidable from "formidable-serverless";
import {getStorage, ref, uploadBytes} from "firebase/storage"; import {ref, uploadBytes} from "firebase/storage";
import fs from "fs"; import fs from "fs";
import {app} from "@/firebase"; import {storage} from "@/firebase";
export default withIronSessionApiRoute(handler, sessionOptions); export default withIronSessionApiRoute(handler, sessionOptions);
@@ -16,8 +16,6 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
return; return;
} }
const storage = getStorage(app);
const form = formidable({keepExtensions: true}); const form = formidable({keepExtensions: true});
await form.parse(req, async (err: any, fields: any, files: any) => { await form.parse(req, async (err: any, fields: any, files: any) => {
if (err) console.log(err); if (err) console.log(err);

View File

@@ -4,9 +4,9 @@ import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session"; import {sessionOptions} from "@/lib/session";
import axios from "axios"; import axios from "axios";
import formidable from "formidable-serverless"; import formidable from "formidable-serverless";
import {getStorage, ref, uploadBytes} from "firebase/storage"; import {ref, uploadBytes} from "firebase/storage";
import fs from "fs"; import fs from "fs";
import {app} from "@/firebase"; import {storage} from "@/firebase";
export default withIronSessionApiRoute(handler, sessionOptions); export default withIronSessionApiRoute(handler, sessionOptions);
@@ -16,8 +16,6 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
return; return;
} }
const storage = getStorage(app);
const form = formidable({keepExtensions: true}); const form = formidable({keepExtensions: true});
await form.parse(req, async (err: any, fields: any, files: any) => { await form.parse(req, async (err: any, fields: any, files: any) => {
if (err) console.log(err); if (err) console.log(err);

View File

@@ -0,0 +1,194 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type { NextApiRequest, NextApiResponse } from "next";
import { app, storage } from "@/firebase";
import {
getFirestore,
getDoc,
doc,
updateDoc,
deleteField,
} from "firebase/firestore";
import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session";
import { FilesStorage } from "@/interfaces/storage.files";
import { Payment } from "@/interfaces/paypal";
import fs from "fs";
import {
ref,
uploadBytes,
deleteObject,
getDownloadURL,
} from "firebase/storage";
import formidable from "formidable-serverless";
const db = getFirestore(app);
const getPaymentField = (type: FilesStorage) => {
switch (type) {
case "commission":
return "commissionTransfer";
case "corporate":
return "corporateTransfer";
default:
return null;
}
};
const handleDelete = async (
paymentId: string,
paymentField: "commissionTransfer" | "corporateTransfer"
) => {
const paymentRef = doc(db, "payments", paymentId);
const paymentDoc = await getDoc(paymentRef);
const { [paymentField]: paymentFieldPath } = paymentDoc.data() as Payment;
// Create a reference to the file to delete
const documentRef = ref(storage, paymentFieldPath);
await deleteObject(documentRef);
await updateDoc(paymentRef, {
[paymentField]: deleteField(),
});
};
const handleUpload = async (
req: NextApiRequest,
paymentId: string,
paymentField: "commissionTransfer" | "corporateTransfer"
) =>
new Promise((resolve, reject) => {
const form = formidable({ keepExtensions: true });
form.parse(req, async (err: any, fields: any, files: any) => {
if (err) {
reject(err);
return;
}
try {
const { file } = files;
const fileName = Date.now() + "-" + file.name;
const fileRef = ref(storage, fileName);
const binary = fs.readFileSync(file.path).buffer;
const snapshot = await uploadBytes(fileRef, binary);
fs.rmSync(file.path);
const paymentRef = doc(db, "payments", paymentId);
await updateDoc(paymentRef, {
[paymentField]: snapshot.ref.fullPath,
});
resolve(snapshot.ref.fullPath);
} catch (err) {
reject(err);
}
});
});
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") return await get(req, res);
if (req.method === "POST") return await post(req, res);
if (req.method === "DELETE") return await del(req, res);
if (req.method === "PATCH") return await patch(req, res);
res.status(404).json(undefined);
}
async function get(req: NextApiRequest, res: NextApiResponse) {
const { type, paymentId } = req.query as {
type: FilesStorage;
paymentId: string;
};
const paymentField = getPaymentField(type);
if (paymentField === null) {
res.status(500).json({ error: "Failed to identify payment field" });
return;
}
const paymentRef = doc(db, "payments", paymentId);
const { [paymentField]: paymentFieldPath } = (
await getDoc(paymentRef)
).data() as Payment;
// Create a reference to the file to delete
const documentRef = ref(storage, paymentFieldPath);
const url = await getDownloadURL(documentRef);
res.status(200).json({ url, name: documentRef.name });
}
async function post(req: NextApiRequest, res: NextApiResponse) {
const { type, paymentId } = req.query as {
type: FilesStorage;
paymentId: string;
};
const paymentField = getPaymentField(type);
if (paymentField === null) {
res.status(500).json({ error: "Failed to identify payment field" });
return;
}
try {
const ref = await handleUpload(req, paymentId, paymentField);
res.status(200).json({ ref });
} catch (error) {
res.status(500).json({ error });
}
}
async function del(req: NextApiRequest, res: NextApiResponse) {
const { type, paymentId } = req.query as {
type: FilesStorage;
paymentId: string;
};
const paymentField = getPaymentField(type);
if (paymentField === null) {
res.status(500).json({ error: "Failed to identify payment field" });
return;
}
try {
await handleDelete(paymentId, paymentField);
res.status(200).json({ ok: true });
} catch (err) {
console.error(err);
res.status(500).json({ error: "Failed to delete file" });
}
}
async function patch(req: NextApiRequest, res: NextApiResponse) {
const { type, paymentId } = req.query as {
type: FilesStorage;
paymentId: string;
};
const paymentField = getPaymentField(type);
if (paymentField === null) {
res.status(500).json({ error: "Failed to identify payment field" });
return;
}
try {
await handleDelete(paymentId, paymentField);
} catch (err) {
console.error(err);
res.status(500).json({ error: "Failed to delete file" });
return;
}
try {
const ref = await handleUpload(req, paymentId, paymentField);
res.status(200).json({ ref });
} catch (err) {
res.status(500).json({ error: "Failed to upload file" });
}
}
export const config = {
api: {
bodyParser: false,
},
};

View File

@@ -3,7 +3,7 @@ import type {NextApiRequest, NextApiResponse} from "next";
import {withIronSessionApiRoute} from "iron-session/next"; import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session"; import {sessionOptions} from "@/lib/session";
import {getDownloadURL, getStorage, ref} from "firebase/storage"; import {getDownloadURL, getStorage, ref} from "firebase/storage";
import {app} from "@/firebase"; import {app, storage} from "@/firebase";
import axios from "axios"; import axios from "axios";
export default withIronSessionApiRoute(handler, sessionOptions); export default withIronSessionApiRoute(handler, sessionOptions);
@@ -14,7 +14,6 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
return; return;
} }
const storage = getStorage(app);
const {path} = req.body as {path: string}; const {path} = req.body as {path: string};
const pathReference = ref(storage, path); const pathReference = ref(storage, path);

View File

@@ -1,6 +1,6 @@
// 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, storage} from "@/firebase";
import {getFirestore, collection, getDocs, getDoc, doc, setDoc, query, where} from "firebase/firestore"; import {getFirestore, collection, getDocs, getDoc, doc, setDoc, query, where} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next"; import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session"; import {sessionOptions} from "@/lib/session";
@@ -11,9 +11,8 @@ import {errorMessages} from "@/constants/errors";
import moment from "moment"; import moment from "moment";
import ShortUniqueId from "short-unique-id"; import ShortUniqueId from "short-unique-id";
import {Payment} from "@/interfaces/paypal"; import {Payment} from "@/interfaces/paypal";
import { toFixedNumber } from "@/utils/number";
const db = getFirestore(app); const db = getFirestore(app);
const storage = getStorage(app);
const auth = getAuth(app); const auth = getAuth(app);
export default withIronSessionApiRoute(handler, sessionOptions); export default withIronSessionApiRoute(handler, sessionOptions);
@@ -33,7 +32,7 @@ const managePaymentRecords = async (user: User, userId: string | undefined): Pro
corporate: userId, corporate: userId,
agent: user.corporateInformation.referralAgent, agent: user.corporateInformation.referralAgent,
agentCommission: user.corporateInformation.payment!.commission, agentCommission: user.corporateInformation.payment!.commission,
agentValue: (user.corporateInformation.payment!.commission / 100) * user.corporateInformation.payment!.value, agentValue: toFixedNumber((user.corporateInformation.payment!.commission / 100) * user.corporateInformation.payment!.value, 2),
currency: user.corporateInformation.payment!.currency, currency: user.corporateInformation.payment!.currency,
value: user.corporateInformation.payment!.value, value: user.corporateInformation.payment!.value,
isPaid: false, isPaid: false,

View File

@@ -24,6 +24,8 @@ import Select from "react-select";
import Input from "@/components/Low/Input"; import Input from "@/components/Low/Input";
import ReactDatePicker from "react-datepicker"; import ReactDatePicker from "react-datepicker";
import moment from "moment"; import moment from "moment";
import PaymentAssetManager from "@/components/PaymentAssetManager";
import { toFixedNumber } from "@/utils/number";
export const getServerSideProps = withIronSessionSsr(({req, res}) => { export const getServerSideProps = withIronSessionSsr(({req, res}) => {
const user = req.session.user; const user = req.session.user;
@@ -88,7 +90,7 @@ const PaymentCreator = ({onClose, reload}: {onClose: () => void; reload: () => v
corporate: corporate?.id, corporate: corporate?.id,
agent: referralAgent?.id, agent: referralAgent?.id,
agentCommission: commission, agentCommission: commission,
agentValue: (commission / 100) * price, agentValue: toFixedNumber((commission / 100) * price, 2),
currency, currency,
value: price, value: price,
isPaid: false, isPaid: false,
@@ -305,6 +307,107 @@ export default function PaymentRecord() {
.finally(reload); .finally(reload);
}; };
const getFileAssetsColumns = () => {
if (user) {
const containerClassName = "flex gap-2 text-mti-purple-light hover:text-mti-purple-dark ease-in-out duration-300 cursor-pointer";
switch (user.type) {
case "corporate":
return [
columnHelper.accessor("corporateTransfer", {
header: "Corporate transfer",
cell: (info) => (
<div className={containerClassName}>
<PaymentAssetManager
permissions={info.row.original.isPaid ? "read" : "write"}
asset={info.row.original.corporateTransfer}
paymentId={info.row.original.id}
type="corporate"
/>
</div>
),
}),
];
case "agent":
return [
columnHelper.accessor("commissionTransfer", {
header: "Commission transfer",
cell: (info) => (
<div className={containerClassName}>
<PaymentAssetManager
permissions="read"
asset={info.row.original.commissionTransfer}
paymentId={info.row.original.id}
type="commission"
/>
</div>
),
}),
];
case "admin":
return [
columnHelper.accessor("corporateTransfer", {
header: "Corporate transfer",
cell: (info) => (
<div className={containerClassName}>
<PaymentAssetManager
permissions="read"
asset={info.row.original.corporateTransfer}
paymentId={info.row.original.id}
type="corporate"
/>
</div>
),
}),
columnHelper.accessor("commissionTransfer", {
header: "Commission transfer",
cell: (info) => (
<div className={containerClassName}>
<PaymentAssetManager
permissions={info.row.original.isPaid ? "read" : "write"}
asset={info.row.original.commissionTransfer}
paymentId={info.row.original.id}
type="commission"
/>
</div>
),
}),
];
case "developer":
return [
columnHelper.accessor("corporateTransfer", {
header: "Corporate transfer",
cell: (info) => (
<div className={containerClassName}>
<PaymentAssetManager
permissions="write"
asset={info.row.original.corporateTransfer}
paymentId={info.row.original.id}
type="corporate"
/>
</div>
),
}),
columnHelper.accessor("commissionTransfer", {
header: "Commission transfer",
cell: (info) => (
<div className={containerClassName}>
<PaymentAssetManager
permissions="write"
asset={info.row.original.commissionTransfer}
paymentId={info.row.original.id}
type="commission"
/>
</div>
),
}),
];
default:
return [];
}
}
return [];
};
const defaultColumns = [ const defaultColumns = [
columnHelper.accessor("id", { columnHelper.accessor("id", {
header: "ID", header: "ID",
@@ -329,7 +432,7 @@ export default function PaymentRecord() {
header: "Amount", header: "Amount",
cell: (info) => ( cell: (info) => (
<span> <span>
{info.getValue()} {CURRENCIES.find((x) => x.currency === info.row.original.currency)?.label} {toFixedNumber(info.getValue(), 2)} {CURRENCIES.find((x) => x.currency === info.row.original.currency)?.label}
</span> </span>
), ),
}), }),
@@ -351,7 +454,7 @@ export default function PaymentRecord() {
header: "Commission Value", header: "Commission Value",
cell: (info) => ( cell: (info) => (
<span> <span>
{info.getValue()} {CURRENCIES.find((x) => x.currency === info.row.original.currency)?.label} {toFixedNumber(info.getValue(), 2)} {CURRENCIES.find((x) => x.currency === info.row.original.currency)?.label}
</span> </span>
), ),
}), }),
@@ -365,6 +468,7 @@ export default function PaymentRecord() {
</Checkbox> </Checkbox>
), ),
}), }),
...getFileAssetsColumns(),
{ {
header: "", header: "",
id: "actions", id: "actions",

13
src/utils/number.ts Normal file
View File

@@ -0,0 +1,13 @@
export function isDecimal(num: number) {
return num % 1 !== 0;
}
export function toFixedNumber(num: number, decimals: number = 2) {
// Rounds to 2 decimal places
if(isDecimal(num)) {
const multiplier = Math.pow(10, decimals);
return Math.round(num * multiplier) / multiplier;
}
return num;
}