diff --git a/components/Transaction/Invoice.tsx b/components/Transaction/Invoice.tsx new file mode 100644 index 00000000..ed0bb51b --- /dev/null +++ b/components/Transaction/Invoice.tsx @@ -0,0 +1,60 @@ +import React from 'react' +import { XEC_TX_EXPLORER_URL, BCH_TX_EXPLORER_URL, NETWORK_TICKERS_FROM_ID, XEC_NETWORK_ID } from 'constants/index' +import moment from 'moment' +import logoImageSource from 'assets/logo.png' +import Image from 'next/image' + +const Receipt = React.forwardRef((props, ref) => { + const { data } = props + const { + invoiceNumber, + amount, + recipientName, + recipientAddress, + description, + customerName, + customerAddress, + createdAt, + transaction + } = data + const formattedDate = new Date(createdAt).toLocaleString('en-US', { + month: 'short', + day: '2-digit', + year: 'numeric', + hour: 'numeric', + minute: '2-digit', + hour12: true + }).replace(',', '') + const transactionNetworkId = transaction.networkId + const url = transactionNetworkId === XEC_NETWORK_ID ? XEC_TX_EXPLORER_URL : BCH_TX_EXPLORER_URL + return ( +
+
+
+ PayButton +
+
+

INVOICE

+

#{ invoiceNumber }

+
+
+

Generated at: { formattedDate }

+

Transaction ID: {transaction.hash}

+

Transaction Date & Time: { moment(transaction.timestamp * 1000).tz('utc').format('lll') }

+ +

Senders

+

{ customerName } - { customerAddress }

+ +

Recipients

+

{ recipientName } - { recipientAddress }

+ +
+ +

Notes: { description }

+

Amount: { amount } { NETWORK_TICKERS_FROM_ID[transactionNetworkId]}

+ +
+ ) +}) + +export default Receipt diff --git a/components/Transaction/InvoiceModal.tsx b/components/Transaction/InvoiceModal.tsx index 82fcb639..134b8100 100644 --- a/components/Transaction/InvoiceModal.tsx +++ b/components/Transaction/InvoiceModal.tsx @@ -1,26 +1,22 @@ -import React, { useState, useEffect, ReactElement } from 'react' +import React, { useState, useEffect, ReactElement, useRef } from 'react' import style from './transaction.module.css' import Button from 'components/Button' import { CreateInvoicePOSTParameters } from 'utils/validators' import axios from 'axios' import { Prisma } from '@prisma/client' - -export interface InvoiceData { - id?: string - invoiceNumber: Prisma.Decimal - amount: number - recipientName: string - recipientAddress: string - description: string - customerName: string - customerAddress: string -} +import { useReactToPrint } from 'react-to-print' +import PrintableReceipt from './Invoice' +import XECIcon from 'assets/xec-logo.png' +import BCHIcon from 'assets/bch-logo.png' +import { XEC_NETWORK_ID } from 'constants/index' +import Image from 'next/image' +import { InvoiceWithTransaction } from 'services/invoiceService' interface InvoiceModalProps { isOpen: boolean onClose: () => void transaction: any - invoiceData: InvoiceData | null + invoiceData: InvoiceWithTransaction | null mode: 'create' | 'edit' | 'view' } @@ -31,25 +27,52 @@ export default function InvoiceModal ({ transaction, mode }: InvoiceModalProps): ReactElement | null { - const [formData, setFormData] = useState({ + const contentRef = useRef(null) + const handlePrint = useReactToPrint({ + contentRef, + onBeforePrint: async () => { + return await new Promise((resolve) => { + setLoading(true) + setTimeout(() => { + setLoading(false) + resolve() + }, 1000) + }) + } + }) + + const [formData, setFormData] = useState({ + id: '', invoiceNumber: '', - amount: Number(transaction?.amount), + amount: transaction?.amount ?? new Prisma.Decimal(0), recipientName: '', - recipientAddress: transaction?.address?.address, + recipientAddress: transaction?.address ?? '', description: '', customerName: '', - customerAddress: '' + customerAddress: '', + userId: transaction?.userId ?? '', + transaction: transaction ?? null, + transactionId: transaction?.id ?? null, + createdAt: new Date(), + updatedAt: new Date() }) + const [loading, setLoading] = useState(false) useEffect(() => { setFormData(invoiceData ?? { invoiceNumber: '', - amount: Number(transaction?.amount), + amount: transaction?.amount, recipientName: '', - recipientAddress: transaction?.address?.address, + recipientAddress: transaction?.address, description: '', customerName: '', - customerAddress: '' + customerAddress: '', + userId: transaction?.userId ?? '', + transaction: transaction ?? null, + transactionId: transaction?.id ?? null, + createdAt: new Date(), + updatedAt: new Date(), + id: '' }) }, [transaction, mode, invoiceData]) @@ -63,26 +86,34 @@ export default function InvoiceModal ({ const handleModalClose = (): void => { setFormData({ invoiceNumber: '', - amount: 0, + amount: new Prisma.Decimal(0), recipientName: '', recipientAddress: '', description: '', customerName: '', - customerAddress: '' + customerAddress: '', + userId: transaction?.userId ?? '', + transaction: transaction ?? null, + transactionId: transaction?.id ?? null, + createdAt: new Date(), + updatedAt: new Date(), + id: '' }) onClose() } async function handleSubmit (e: React.FormEvent): Promise { e.preventDefault() - + setLoading(true) if (mode === 'edit') { await updateInvoice() } else { await createInvoice() } + setLoading(false) onClose() } + async function createInvoice (): Promise { const payload: CreateInvoicePOSTParameters = { ...formData, @@ -133,12 +164,17 @@ export default function InvoiceModal ({ />
- +
+ +
+ { transaction.networkId === XEC_NETWORK_ID ? XEC : BCH} +
+
@@ -193,13 +229,13 @@ export default function InvoiceModal ({ />
-
{!isReadOnly && (
- +
)}
@@ -228,12 +264,17 @@ export default function InvoiceModal ({ Customer Address: {formData.customerAddress}
-
- +
+ +
} +
+ {/*
*/} + +
) diff --git a/components/Transaction/PaybuttonTransactions.tsx b/components/Transaction/PaybuttonTransactions.tsx index 94b5685f..fbd9535a 100644 --- a/components/Transaction/PaybuttonTransactions.tsx +++ b/components/Transaction/PaybuttonTransactions.tsx @@ -5,17 +5,11 @@ import BCHIcon from 'assets/bch-logo.png' import EyeIcon from 'assets/eye-icon.png' import CheckIcon from 'assets/check-icon.png' import XIcon from 'assets/x-icon.png' -import Plus from 'assets/plus.png' -import Pencil from 'assets/pencil.png' -import FileText from 'assets/file-text.png' import TableContainerGetter, { DataGetterReturn } from '../TableContainer/TableContainerGetter' import { compareNumericString } from 'utils/index' import moment from 'moment-timezone' import { XEC_TX_EXPLORER_URL, BCH_TX_EXPLORER_URL } from 'constants/index' -import InvoiceModal, { InvoiceData } from './InvoiceModal' -import style from './transaction.module.css' -import { TransactionWithAddressAndPricesAndInvoices } from 'services/transactionService' interface IProps { addressSyncing: { @@ -43,58 +37,9 @@ function fetchTransactionsByPaybuttonId (paybuttonId: string): (page: number, pa } } -const fetchNextInvoiceNumberByUserId = async (): Promise => { - const response = await fetch('/api/invoices/invoiceNumber/', { - headers: { - Timezone: moment.tz.guess() - } - }) - const result = await response?.json() - return result?.invoiceNumber -} - export default ({ paybuttonId, addressSyncing, tableRefreshCount, timezone = moment.tz.guess() }: IProps): JSX.Element => { - const [isModalOpen, setIsModalOpen] = useState(false) - const [invoiceData, setInvoiceData] = useState(null) - const [invoiceDataTransaction, setInvoiceDataTransaction] = useState(null) - const [localRefreshCount, setLocalRefreshCount] = useState(tableRefreshCount) - - const [invoiceMode, setInvoiceMode] = useState<'create' | 'edit' | 'view'>('create') - - const onCreateInvoice = async (transaction: TransactionWithAddressAndPricesAndInvoices): Promise => { - const nextInvoiceNumber = await fetchNextInvoiceNumberByUserId() - const invoiceData = { - invoiceNumber: nextInvoiceNumber ?? '', - amount: Number(transaction.amount), - recipientName: '', - recipientAddress: transaction.address.address, - description: '', - customerName: '', - customerAddress: '' - } - setInvoiceDataTransaction(transaction) - setInvoiceData(invoiceData) - setInvoiceMode('create') - setIsModalOpen(true) - } - - const onEditInvoice = (invoiceData: InvoiceData): void => { - setInvoiceData(invoiceData) - setInvoiceMode('edit') - setIsModalOpen(true) - } + const [localRefreshCount] = useState(tableRefreshCount) - const onSeeInvoice = (invoiceData: InvoiceData): void => { - setInvoiceData(invoiceData) - setInvoiceMode('view') - setIsModalOpen(true) - } - - const handleCloseModal = (): void => { - setIsModalOpen(false) - setInvoiceData(null) - setLocalRefreshCount(prev => prev + 1) - } const columns = useMemo( () => [ { @@ -167,76 +112,13 @@ export default ({ paybuttonId, addressSyncing, tableRefreshCount, timezone = mom ) } - }, - { - Header: () => (
Actions
), - id: 'actions', - Cell: (cellProps) => { - const invoices = cellProps.row.original.invoices - const hasInvoice = invoices?.length > 0 - - return ( -
- {!hasInvoice - ? ( -
- -
New button
-
- ) - : ( - <> -
- -
-
- -
- - )} -
- ) - } } - ], [] ) return ( <> - ) } diff --git a/components/Transaction/transaction.module.css b/components/Transaction/transaction.module.css index dff9198f..ecd8a7cc 100644 --- a/components/Transaction/transaction.module.css +++ b/components/Transaction/transaction.module.css @@ -221,6 +221,11 @@ body[data-theme='dark'] .form_ctn select:not([multiple]) { box-shadow: none; } +.form_ctn input:disabled { + background-color: var(--secondary-bg-color) !important; + color: var(--primary-text-color); +} + .form_ctn .delete_btn { background-color: unset; border: none; @@ -351,7 +356,7 @@ body[data-theme='dark'] .edit_invoice img { filter: invert(1); } -body[data-theme='dark'] .see_invoice img { +body[data-theme='dark'] .view_invoice img { filter: invert(1); } @@ -365,7 +370,7 @@ body[data-theme='dark'] .see_invoice img { brightness(100%) contrast(102%); } -.see_invoice_ctn:hover .see_invoice { +.view_invoice_ctn:hover .view_invoice { filter: invert(60%) sepia(14%) saturate(5479%) hue-rotate(195deg) brightness(100%) contrast(102%); } \ No newline at end of file diff --git a/package.json b/package.json index 90552444..6a632246 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "react-select": "^5.8.3", "react-table": "^7.8.0", "react-timezone-select": "^3.2.8", + "react-to-print": "^3.1.0", "simpledotcss": "^2.1.0", "socket.io": "^4.4.1", "socket.io-client": "^4.7.1", diff --git a/pages/api/invoices/index.ts b/pages/api/invoices/index.ts index a63deb1a..374e7961 100644 --- a/pages/api/invoices/index.ts +++ b/pages/api/invoices/index.ts @@ -1,6 +1,7 @@ import { setSession } from 'utils/setSession' import { RESPONSE_MESSAGES } from 'constants/index' import { CreateInvoiceParams, UpdateInvoiceParams, createInvoice, updateInvoice } from 'services/invoiceService' +import { Decimal } from '@prisma/client/runtime/library' export default async ( req: any, @@ -11,8 +12,15 @@ export default async ( if (req.method === 'POST') { try { const createInvoiceParams: CreateInvoiceParams = { - ...req.body, - userId: session.userId + userId: session.userId, + transactionId: req.body.transaction?.id, + invoiceNumber: req.body.invoiceNumber, + amount: new Decimal(req.body.amount), + description: req.body.description, + recipientName: req.body.recipientName, + recipientAddress: req.body.recipientAddress, + customerName: req.body.customerName, + customerAddress: req.body.customerAddress } const invoice = await createInvoice(createInvoiceParams) res.status(200).json({ @@ -30,7 +38,11 @@ export default async ( } else if (req.method === 'PUT') { try { const updateInvoiceParams: UpdateInvoiceParams = { - ...req.body + description: req.body.description, + recipientName: req.body.recipientName, + recipientAddress: req.body.recipientAddress, + customerName: req.body.customerName, + customerAddress: req.body.customerAddress } const invoiceId = req.query.invoiceId as string const invoice = await updateInvoice(session.userId, diff --git a/pages/payments/index.tsx b/pages/payments/index.tsx index 79290036..bc2b1cb0 100644 --- a/pages/payments/index.tsx +++ b/pages/payments/index.tsx @@ -10,15 +10,22 @@ import Link from 'next/link' import XECIcon from 'assets/xec-logo.png' import BCHIcon from 'assets/bch-logo.png' import EyeIcon from 'assets/eye-icon.png' -import { formatQuoteValue, compareNumericString, removeUnserializableFields } from 'utils/index' +import { formatQuoteValue, compareNumericString, removeUnserializableFields, removeDateFields } from 'utils/index' import { XEC_NETWORK_ID, BCH_TX_EXPLORER_URL, XEC_TX_EXPLORER_URL, NETWORK_TICKERS_FROM_ID, DECIMALS } from 'constants/index' import moment from 'moment-timezone' import TopBar from 'components/TopBar' import { fetchUserWithSupertokens, UserWithSupertokens } from 'services/userService' -import { UserProfile } from '@prisma/client' +import { Organization, UserProfile } from '@prisma/client' import Button from 'components/Button' import style from './payments.module.css' import SettingsIcon from '../../assets/settings-slider-icon.png' +import Plus from 'assets/plus.png' +import Pencil from 'assets/pencil.png' +import FileText from 'assets/file-text.png' +import InvoiceModal from 'components/Transaction/InvoiceModal' +import { TransactionWithAddressAndPricesAndInvoices } from 'services/transactionService' +import { fetchOrganizationForUser } from 'services/organizationService' +import { InvoiceWithTransaction } from 'services/invoiceService' export const getServerSideProps: GetServerSideProps = async (context) => { // this runs on the backend, so we must call init on supertokens-node SDK @@ -39,12 +46,18 @@ export const getServerSideProps: GetServerSideProps = async (context) => { if (session === undefined) return const userId = session.getUserId() const user = await fetchUserWithSupertokens(userId) - removeUnserializableFields(user.userProfile) + const organization = await fetchOrganizationForUser(userId) + removeUnserializableFields(user.userProfile) + let serializableOrg = null + if (organization !== null) { + serializableOrg = removeDateFields(organization) + } return { props: { user, - userId + userId, + organization: serializableOrg } } } @@ -52,9 +65,10 @@ export const getServerSideProps: GetServerSideProps = async (context) => { interface PaybuttonsProps { user: UserWithSupertokens userId: string + organization: Organization } -export default function Payments ({ user, userId }: PaybuttonsProps): React.ReactElement { +export default function Payments ({ user, userId, organization }: PaybuttonsProps): React.ReactElement { const timezone = user?.userProfile.preferredTimezone === '' ? moment.tz.guess() : user?.userProfile?.preferredTimezone const [selectedCurrencyCSV, setSelectedCurrencyCSV] = useState('') const [paybuttonNetworks, setPaybuttonNetworks] = useState>(new Set()) @@ -64,7 +78,59 @@ export default function Payments ({ user, userId }: PaybuttonsProps): React.Reac const [showFilters, setShowFilters] = useState(false) const [tableLoading, setTableLoading] = useState(true) const [refreshCount, setRefreshCount] = useState(0) + const [invoiceData, setInvoiceData] = useState(null) + const [invoiceDataTransaction, setInvoiceDataTransaction] = useState(null) + const [invoiceMode, setInvoiceMode] = useState<'create' | 'edit' | 'view'>('create') + const [isModalOpen, setIsModalOpen] = useState(false) + const fetchNextInvoiceNumberByUserId = async (): Promise => { + const response = await fetch('/api/invoices/invoiceNumber/', { + headers: { + Timezone: moment.tz.guess() + } + }) + const result = await response?.json() + return result?.invoiceNumber + } + const handleCloseModal = (): void => { + setIsModalOpen(false) + setInvoiceData(null) + setRefreshCount(prev => prev + 1) + } + const onCreateInvoice = async (transaction: TransactionWithAddressAndPricesAndInvoices): Promise => { + const nextInvoiceNumber = await fetchNextInvoiceNumberByUserId() + const invoiceData = { + invoiceNumber: nextInvoiceNumber ?? '', + amount: transaction.amount, + recipientName: '', + recipientAddress: transaction.address ?? '', + description: '', + customerName: organization?.name ?? '', + customerAddress: '', + userId: '', + transaction, + transactionId: transaction.id, + createdAt: new Date(), + updatedAt: new Date(), + id: '' + } + setInvoiceDataTransaction(transaction) + setInvoiceData(invoiceData) + setInvoiceMode('create') + setIsModalOpen(true) + } + + const onEditInvoice = (invoiceData: InvoiceWithTransaction): void => { + setInvoiceData(invoiceData) + setInvoiceMode('edit') + setIsModalOpen(true) + } + + const onViewInvoice = (invoiceData: InvoiceWithTransaction): void => { + setInvoiceData(invoiceData) + setInvoiceMode('view') + setIsModalOpen(true) + } useEffect(() => { setRefreshCount(prev => prev + 1) }, [selectedButtonIds]) @@ -77,6 +143,7 @@ export default function Payments ({ user, userId }: PaybuttonsProps): React.Reac return await res.json() } } + const getDataAndSetUpCurrencyCSV = async (): Promise => { const paybuttons = await fetchPaybuttons() const networkIds: Set = new Set() @@ -212,6 +279,70 @@ export default function Payments ({ user, userId }: PaybuttonsProps): React.Reac ) } + }, + { + Header: () => (
Invoice
), + id: 'actions', + disableSortBy: true, + Cell: (cellProps) => { + const transaction = cellProps.row.original + const invoices = transaction.invoices ?? [] + const hasInvoice = invoices.filter(i => i !== null).length > 0 + let invoice = {} as InvoiceWithTransaction + if (hasInvoice) { + invoice = { + transaction, + ...transaction.invoices[0] + } + } + + return ( +
+ {!hasInvoice + ? ( +
+ +
New button
+
+ ) + : ( + <> +
+ +
+
+ +
+ + )} +
+ ) + } } ], [] @@ -357,6 +488,13 @@ export default function Payments ({ user, userId }: PaybuttonsProps): React.Reac tableRefreshCount={refreshCount} emptyMessage={tableLoading ? 'Loading...' : 'No Payments to show yet'} /> + ) } diff --git a/pages/payments/payments.module.css b/pages/payments/payments.module.css index 4e50b3e2..cd955824 100644 --- a/pages/payments/payments.module.css +++ b/pages/payments/payments.module.css @@ -59,7 +59,7 @@ body[data-theme='dark'] .show_filters_button img { .filter_button:active { transform: scale(0.95); - } +} .active { background-color: var(--accent-color); @@ -80,10 +80,250 @@ body[data-theme='dark'] .show_filters_button img { .wallet_label { margin-top: 20px !important; - } +} - .filter_btns { +.filter_btns { display: flex; align-items: center; gap: 10px; - } \ No newline at end of file +} + +.form_ctn_outer { + background-color: rgba(255, 255, 255, 0.3); + backdrop-filter: blur(5px); + position: fixed; + top: 0; + left: 0px; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + z-index: 99999; +} + +.form_ctn_inner { + width: 100%; + max-width: 600px; + align-self: middle; +} + +.form_ctn_inner h4 { + margin: 0; + margin-left: 5px; + margin-bottom: 5px; +} + +.form_ctn { + background-color: var(--secondary-bg-color); + width: 100%; + padding: 25px; + border-radius: 10px; + border: 1px solid var(--secondary-text-color); + overflow-y: auto; + max-height: 90vh; +} + +body[data-theme='dark'] .form_ctn_outer { + background-color: rgba(0, 0, 0, 0.3); +} + +.form_ctn p { + margin-top: 0; +} + +.form_ctn form { + display: flex; + flex-direction: column; +} + +.form_ctn label { + margin-bottom: 5px; +} + +body[data-theme='dark'] .form_ctn select { + color: #fff; +} + +.form_ctn select { + background-color: var(--secondary-bg-color); +} + +.form_ctn input, +.form_ctn textarea { + border: 1px solid var(--secondary-text-color) !important; + width: 100%; +} + +body[data-theme='dark'] .form_ctn input, +body[data-theme='dark'] .form_ctn textarea, +body[data-theme='dark'] .form_ctn select { + background-color: var(--primary-bg-color); + border-color: #898EA4 !important; +} + +body[data-theme='dark'] .form_ctn select:not([multiple]) { + background-image: linear-gradient(45deg,transparent 49%,#fff 51%),linear-gradient(135deg,#fff 51%,transparent 49%); +} + +.labelMargin { + margin-top: 10px; + display: flex; + justify-content: space-between; + align-items: center; +} + +.form_ctn button:last-child { + margin-right: 0px; + box-shadow: none; +} + +.form_ctn .delete_btn { + background-color: unset; + border: none; + margin-right: 0px; + margin-left: 10px; + color: var(--primary-text-color); + border-radius: 0px; + padding-left: 0px; + padding-right: 0px; + opacity: 0.6; + position: relative; + transition: all ease-in-out 100ms; +} + +.form_ctn .delete_btn:hover { + color: red; + opacity: 1; +} + +.form_ctn .delete_btn div { + width: 15px; + border-radius: 0px !important; + position: absolute; + right: 0px; + top: 12px; + opacity: -10; + transition: all ease-in-out 100ms; +} + +.form_ctn .delete_btn:hover div { + right: -18px; + opacity: 1; +} + +.form_ctn .delete_confirm_btn { + padding-left: 20px; + padding-right: 20px; + border: 1px solid var(--primary-text-color); + background-color: var(--primary-bg-color); + color: red; + transition: all ease-in-out 100ms; + font-weight: 600; +} + +.delete_button_form_ctn { + word-break: normal; +} + +.form_ctn .delete_confirm_btn:hover { + border: 1px solid var(--primary-text-color); + background-color: rgb(149, 0, 0); + color: var(--secondary-bg-color); +} + +.tooltiptext { + visibility: hidden; + opacity: 0; + background-color: var(--secondary-bg-color); + color: var(--primary-text-color); + text-align: center; + padding: 2px 0px; + border-radius: 5px; + position: absolute; + z-index: 1; + bottom: -30px; + width: 80px; + right: -5px; + flex-basis: 1; + font-size: 12px; + z-index: 99999999; + box-sizing: border-box; + transition: all 100ms ease-in-out; +} + +.tooltiptext::before { + content: ''; + position: absolute; + left: 50%; + width: 0; + height: 0; + border-left: 7px solid transparent; + border-right: 7px solid transparent; + transform: translate(-50%, -9px); + border-bottom: 7px solid var(--secondary-bg-color); +} + +.create_invoice:hover .tooltiptext { + visibility: visible; + bottom: -35px; + opacity: 1; +} + +.create_invoice_ctn { + position: relative; + position: sticky; + bottom: 0; + float: right; +} + +.create_invoice { + background-color: var(--secondary-bg-color); + font-size: 44px; + display: inline-block; + border-radius: 100px; + padding: 20px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all ease-in-out 100ms; + position: relative; + border: 1px solid var(--secondary-text-color); +} + +.invoice_view_item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px; + border-bottom: 1px solid var(--primary-text-color); +} + +body[data-theme='dark'] .create_invoice img { + filter: invert(1); +} + +body[data-theme='dark'] .edit_invoice img { + filter: invert(1); +} + +body[data-theme='dark'] .view_invoice img { + filter: invert(1); +} + +.create_invoice_ctn:hover .create_invoice { + filter: invert(60%) sepia(14%) saturate(5479%) hue-rotate(195deg) + brightness(100%) contrast(102%); +} + +.edit_invoice_ctn:hover .edit_invoice{ + filter: invert(60%) sepia(14%) saturate(5479%) hue-rotate(195deg) + brightness(100%) contrast(102%); +} + +.view_invoice_ctn:hover .view_invoice { + filter: invert(60%) sepia(14%) saturate(5479%) hue-rotate(195deg) + brightness(100%) contrast(102%); +} + diff --git a/redis/paymentCache.ts b/redis/paymentCache.ts index 1b1323cc..c59d6fea 100755 --- a/redis/paymentCache.ts +++ b/redis/paymentCache.ts @@ -1,6 +1,12 @@ import { redis } from 'redis/clientInstance' import { Address, Prisma } from '@prisma/client' -import { generateTransactionsWithPaybuttonsAndPricesForAddress, getTransactionValue, TransactionsWithPaybuttonsAndPrices, TransactionWithAddressAndPrices } from 'services/transactionService' +import { + generateTransactionsWithPaybuttonsAndPricesForAddress, + getTransactionValue, + TransactionsWithPaybuttonsAndPrices, + TransactionWithAddressAndPrices, + TransactionWithAddressAndPricesAndInvoices +} from 'services/transactionService' import { fetchAllUserAddresses, AddressPaymentInfo } from 'services/addressService' import { fetchPaybuttonArrayByUserId } from 'services/paybuttonService' @@ -80,6 +86,7 @@ export const generatePaymentFromTx = async (tx: TransactionsWithPaybuttonsAndPri console.warn('Orphan address:', tx.address.address) } return { + id: tx.id, timestamp: tx.timestamp, values, amount: tx.amount, @@ -90,6 +97,41 @@ export const generatePaymentFromTx = async (tx: TransactionsWithPaybuttonsAndPri } } +export const generatePaymentFromTxWithInvoices = async (tx: TransactionWithAddressAndPricesAndInvoices, userId?: string): Promise => { + const values = getTransactionValue(tx) + let buttonDisplayDataList: Array<{ name: string, id: string}> = [] + if (tx.address.paybuttons !== undefined) { + buttonDisplayDataList = tx.address.paybuttons.map( + (conn) => { + return { + name: conn.paybutton.name, + id: conn.paybutton.id, + providerUserId: conn.paybutton.providerUserId + } + } + ) + } else { + console.warn('Orphan address:', tx.address.address) + } + let invoices = null + if (tx.invoices.length > 0) { + invoices = tx.invoices.filter(invoice => { + return invoice !== null && invoice.userId === userId + }) + } + return { + id: tx.id, + timestamp: tx.timestamp, + values, + amount: tx.amount, + networkId: tx.address.networkId, + hash: tx.hash, + buttonDisplayDataList, + address: tx.address.address, + invoices: invoices ?? [] + } +} + export const generateAndCacheGroupedPaymentsAndInfoForAddress = async (address: Address): Promise => { let paymentList: Payment[] = [] let balance = new Prisma.Decimal(0) diff --git a/redis/types.ts b/redis/types.ts index a319a2be..4e5037ba 100644 --- a/redis/types.ts +++ b/redis/types.ts @@ -1,3 +1,4 @@ +import { Invoice } from '@prisma/client' import { Decimal } from '@prisma/client/runtime/library' import { QuoteValues } from 'services/priceService' @@ -48,6 +49,7 @@ export interface ButtonDisplayData { } export interface Payment { + id?: string timestamp: number values: QuoteValues amount?: Decimal @@ -55,6 +57,7 @@ export interface Payment { hash: string buttonDisplayDataList: ButtonDisplayData[] address?: string + invoices?: Invoice[] } export interface ButtonData { diff --git a/services/invoiceService.ts b/services/invoiceService.ts index 1ab5dfc1..f5e102fd 100644 --- a/services/invoiceService.ts +++ b/services/invoiceService.ts @@ -1,6 +1,6 @@ import { Decimal } from '@prisma/client/runtime/library' import prisma from 'prisma/clientInstance' -import { Invoice } from '@prisma/client' +import { Invoice, Prisma } from '@prisma/client' import { RESPONSE_MESSAGES } from 'constants/index' export interface CreateInvoiceParams { @@ -22,6 +22,23 @@ export interface UpdateInvoiceParams { customerName: string customerAddress: string } +const invoiceWithTransaction = Prisma.validator()({ + include: { + transaction: { + select: { + address: { + select: { + networkId: true + } + }, + timestamp: true, + hash: true + } + } + } +}) + +export type InvoiceWithTransaction = Prisma.InvoiceGetPayload export async function createInvoice (params: CreateInvoiceParams): Promise { return await prisma.invoice.create({ @@ -69,6 +86,7 @@ export async function getInvoiceById (invoiceId: string, userId: string): Promis export async function updateInvoice (userId: string, invoiceId: string, params: UpdateInvoiceParams): Promise { const invoice = await getInvoiceById(invoiceId, userId) + if (invoice === null) { throw new Error(RESPONSE_MESSAGES.NO_INVOICE_FOUND_404.message) } diff --git a/services/transactionService.ts b/services/transactionService.ts index f43d0a0f..57a03c06 100644 --- a/services/transactionService.ts +++ b/services/transactionService.ts @@ -7,7 +7,7 @@ import _ from 'lodash' import { CacheSet } from 'redis/index' import { SimplifiedTransaction } from 'ws-service/types' import { OpReturnData, parseAddress } from 'utils/validators' -import { generatePaymentFromTx } from 'redis/paymentCache' +import { generatePaymentFromTxWithInvoices } from 'redis/paymentCache' import { ButtonDisplayData, Payment } from 'redis/types' export function getTransactionValue (transaction: TransactionWithPrices | TransactionsWithPaybuttonsAndPrices | SimplifiedTransaction): QuoteValues { @@ -663,13 +663,32 @@ export async function getPaymentsByUserIdOrderedByButtonName ( 'priceValue', pb.value, 'quoteId', pb.quoteId ) - ) AS prices + ) AS prices, + JSON_ARRAYAGG( + IF(i.id IS NOT NULL, + JSON_OBJECT( + 'id', i.id, + 'invoiceNumber', i.invoiceNumber, + 'userId', i.userId, + 'amount', i.amount, + 'description', i.description, + 'recipientName', i.recipientName, + 'recipientAddress', i.recipientAddress, + 'customerName', i.customerName, + 'customerAddress', i.customerAddress, + 'createdAt', i.createdAt, + 'updatedAt', i.updatedAt + ), + NULL + ) + ) AS invoices FROM \`Transaction\` t INNER JOIN \`Address\` a ON t.\`addressId\` = a.\`id\` INNER JOIN \`AddressesOnButtons\` ab ON a.\`id\` = ab.\`addressId\` INNER JOIN \`Paybutton\` p ON ab.\`paybuttonId\` = p.\`id\` LEFT JOIN \`PricesOnTransactions\` pt ON t.\`id\` = pt.\`transactionId\` LEFT JOIN \`Price\` pb ON pt.\`priceId\` = pb.\`id\` + LEFT JOIN \`Invoice\` i ON i.\`transactionId\` = t.\`id\` WHERE t.\`amount\` > 0 AND EXISTS ( SELECT 1 @@ -706,14 +725,23 @@ export async function getPaymentsByUserIdOrderedByButtonName ( id: tx.paybuttonId, providerUserId: tx.paybuttonProviderUserId }) + let invoices = null + if (JSON.parse(tx.invoices).length > 0) { + invoices = JSON.parse(tx.invoices).filter((invoice: any) => { + return invoice !== null && invoice.userId === userId + }) + } + if (tx.amount > 0) { payments.push({ + id: tx.id, amount: tx.amount, timestamp: tx.timestamp, values: ret, networkId: tx.networkId, hash: tx.hash, - buttonDisplayDataList + buttonDisplayDataList, + invoices }) } }) @@ -783,7 +811,7 @@ export async function fetchAllPaymentsByUserIdWithPagination ( const transactions = await prisma.transaction.findMany({ where, - include: includePaybuttonsAndPrices, + include: includePaybuttonsAndPricesAndInvoices, orderBy: orderByQuery, skip: page * Number(pageSize), take: Number(pageSize) @@ -793,7 +821,7 @@ export async function fetchAllPaymentsByUserIdWithPagination ( for (let index = 0; index < transactions.length; index++) { const tx = transactions[index] if (Number(tx.amount) > 0) { - const payment = await generatePaymentFromTx(tx) + const payment = await generatePaymentFromTxWithInvoices(tx, userId) transformedData.push(payment) } } diff --git a/yarn.lock b/yarn.lock index 0a9587ce..15bb3a8c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5420,11 +5420,6 @@ lru-cache@^7.14.1: resolved "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.1.tgz" integrity sha512-8/HcIENyQnfUTCDizRu9rrDyG6XG/21M4X7/YEGZeD76ZJilFPAUVb/2zysFf7VVO1LEjCDFyHp8pMMvozIrvg== -lucide-react@^0.510.0: - version "0.510.0" - resolved "https://registry.yarnpkg.com/lucide-react/-/lucide-react-0.510.0.tgz#933b19d893b30ac5cb355e4b91eeefc13088caa3" - integrity sha512-p8SQRAMVh7NhsAIETokSqDrc5CHnDLbV29mMnzaXx+Vc/hnqQzwI2r0FMWCcoTXnbw2KEjy48xwpGdEL+ck06Q== - luxon@^3.2.1: version "3.2.1" resolved "https://registry.npmjs.org/luxon/-/luxon-3.2.1.tgz" @@ -6268,6 +6263,11 @@ react-timezone-select@^3.2.8: spacetime "^7.6.0" timezone-soft "^1.5.2" +react-to-print@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/react-to-print/-/react-to-print-3.1.0.tgz#70f2f3e43314ce9e71bb0a69fad47208e89c1320" + integrity sha512-hiJZVmJtaRm9EHoUTG2bordyeRxVSGy9oFVV7fSvzOWwctPp6jbz2R6NFkaokaTYBxC7wTM/fMV5eCXsNpEwsA== + react-transition-group@^4.3.0, react-transition-group@^4.4.0: version "4.4.5" resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-4.4.5.tgz#e53d4e3f3344da8521489fbef8f2581d42becdd1"