Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[1/2] feat: Allow for creation of payment records manually #1864

Open
wants to merge 12 commits into
base: master
Choose a base branch
from
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
"@react-pdf/renderer": "^3.1.3",
"@sentry/nextjs": "^7.80.0",
"@stripe/react-stripe-js": "^1.16.1",
"@stripe/stripe-js": "^1.46.0",
"@stripe/stripe-js": "^3.1.0",
"@tanstack/react-query": "^4.16.1",
"@tryghost/content-api": "^1.11.4",
"axios": "^1.6.8",
Expand All @@ -62,6 +62,7 @@
"next-auth": "^4.24.5",
"next-i18next": "^14.0.3",
"nookies": "^2.5.2",
"papaparse": "^5.4.1",
"quill-blot-formatter": "^1.0.5",
"quill-html-edit-button": "^2.2.12",
"react": "18.2.0",
Expand Down Expand Up @@ -98,6 +99,7 @@
"@types/lodash.truncate": "^4.4.7",
"@types/lru-cache": "^5.1.1",
"@types/node": "14.14.37",
"@types/papaparse": "^5",
"@types/react": "18.2.14",
"@types/react-dom": "^18.0.0",
"@types/react-gtm-module": "2.0.0",
Expand Down
10 changes: 9 additions & 1 deletion src/common/hooks/bank-transactions.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { BankTransactionsHistoryResponse } from 'gql/bank-transactions'
import { BankTransactionsHistoryResponse, BankTransactionsInput } from 'gql/bank-transactions'
import { useSession } from 'next-auth/react'

import { useQuery } from '@tanstack/react-query'
Expand All @@ -21,3 +21,11 @@ export function useBankTransactionsList(
},
)
}

export function useFindBankTransaction(id: string) {
const { data: session } = useSession()
return useQuery<BankTransactionsInput>(
[endpoints.bankTransactions.getTransactionById(id).url],
authQueryFnFactory<BankTransactionsInput>(session?.accessToken),
)
}
9 changes: 9 additions & 0 deletions src/common/hooks/donation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
DonationResponse,
DonorsCountResult,
PaymentAdminResponse,
StripeChargeResponse,
TPaymentResponse,
TotalDonatedMoneyResponse,
UserDonationResult,
Expand Down Expand Up @@ -114,3 +115,11 @@ export function getTotalDonatedMoney() {
export function useDonatedUsersCount() {
return useQuery<DonorsCountResult>([endpoints.donation.getDonorsCount.url])
}

export function useGetStripeChargeFromPID(stripeId: string) {
const { data: session } = useSession()
return useQuery<StripeChargeResponse>(
[endpoints.payments.referenceStripeWithInternal(stripeId).url],
{ queryFn: authQueryFnFactory(session?.accessToken) },
)
}
21 changes: 21 additions & 0 deletions src/common/util/lazyWithPreload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import React, { ComponentProps, ComponentType, LazyExoticComponent } from 'react'

export type PreloadableComponent<T extends ComponentType<ComponentProps<T>>> =
LazyExoticComponent<T> & {
preload(): Promise<T>
}

function lazyWithPreload<T extends ComponentType<ComponentProps<T>>>(
factory: () => Promise<{ default: T }>,
): PreloadableComponent<T> {
const Component: Partial<PreloadableComponent<T>> = React.lazy(factory)

Component.preload = async () => {
const LoadableComponent = await factory()
return LoadableComponent.default
}

return Component as PreloadableComponent<T>
}

export default lazyWithPreload
2 changes: 1 addition & 1 deletion src/components/admin/payments/PaymentsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export const ModalStore = new ModalStoreImpl()
export const RefundStore = new RefundStoreImpl()
export const InvalidateStore = new ModalStoreImpl()

export default function DocumentsPage() {
export default function PaymentsPage() {
const { t } = useTranslation()

return (
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import React, { createContext } from 'react'
import {
Actions,
CreatePayment,
createPaymentStepReducer,
} from './helpers/createPaymentStepReducer'
import CreatePaymentStepper from './CreatePaymentStepper'
import { observer } from 'mobx-react'

export type PaymentContext = {
payment: CreatePayment
dispatch: React.Dispatch<Actions>
}
export const PaymentContext = createContext<PaymentContext>({} as PaymentContext)

function CreatePaymentDialog() {
const [payment, dispatch] = createPaymentStepReducer()
return (
<PaymentContext.Provider value={{ payment, dispatch }}>
<CreatePaymentStepper />
</PaymentContext.Provider>
)
}

export default observer(CreatePaymentDialog)
113 changes: 113 additions & 0 deletions src/components/admin/payments/create-payment/CreatePaymentStepper.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { Button, Card, CardContent, Dialog, Grid, IconButton } from '@mui/material'

import { Form, Formik } from 'formik'
import React, { useContext } from 'react'
import * as yup from 'yup'
import { FileImportDialog } from './benevity/FileImportDialog'
import { benevityInputValidation, benevityValidation } from './benevity/helpers/validation'
import BenevityImportTypeSelector from './benevity/BenevityImportTypeSelector'
import CreatePaymentFromBenevityRecord from './benevity/CreatePaymentFromBenevityRecord'
import { PaymentContext } from './CreatePaymentDialog'
import PaymentTypeSelector from './PaymentTypeSelector'
import { BenevityManualImport } from './benevity/BenevityManualImport'
import { stripeInputValidation } from './stripe/helpers/validations'
import { StripeChargeLookupForm } from './stripe/StripeChargeLookupForm'
import { SelectedPaymentSource } from './helpers/createPaymentStepReducer'
import { CreatePaymentFromStripeCharge } from './stripe/CreatePaymentFromStripeCharge'
import { ModalStore } from '../PaymentsPage'
import ArrowBackIcon from '@mui/icons-material/ArrowBack'
import { Close } from '@mui/icons-material'
type Validation = yup.InferType<
typeof stripeInputValidation | typeof benevityValidation | typeof benevityInputValidation
>

type Steps<T> = {
[key in SelectedPaymentSource]: {
component: React.JSX.Element
validation?: yup.SchemaOf<T> | null
}[]
}

function CreatePaymentStepper() {
const paymentContext = useContext(PaymentContext)
const { isPaymentImportOpen, hideImport } = ModalStore
const { payment, dispatch } = paymentContext

const steps: Steps<Validation> = {
none: [{ component: <PaymentTypeSelector /> }],
stripe: [
{
component: <StripeChargeLookupForm />,
validation: stripeInputValidation,
},
{
component: <CreatePaymentFromStripeCharge />,
},
],
benevity: [
{
component: <BenevityImportTypeSelector />,
validation: null,
},
{
component:
payment.benevityImportType === 'file' ? <FileImportDialog /> : <BenevityManualImport />,
validation: payment.benevityImportType === 'file' ? null : benevityInputValidation,
},
{
component: <CreatePaymentFromBenevityRecord />,
validation: benevityValidation,
},
],
}
const onClose = () => {
dispatch({ type: 'RESET_MODAL' })
hideImport()
}

const handleOnBackClick = () => {
dispatch({ type: 'DECREMENT_STEP' })
}
return (
<Dialog open={isPaymentImportOpen} onClose={onClose} maxWidth={false}>
<Card sx={{ display: 'flex' }}>
<CardContent>
<Grid container direction="column" gap={3}>
<Grid
container
item
direction={'row'}
justifyContent={'space-between'}
px={1}
sx={{ display: payment.paymentSource !== 'none' ? 'flex' : 'none' }}>
<Button
startIcon={<ArrowBackIcon />}
onClick={handleOnBackClick}
sx={{ display: payment.paymentSource !== 'none' ? 'flex' : 'none', padding: 0 }}>
Назад
</Button>
<IconButton onClick={onClose} sx={{ alignSelf: 'flex-end', padding: 0 }}>
<Close color="error" />
</IconButton>
</Grid>
<Grid container item>
<Formik
validateOnBlur
onSubmit={async () => {
if (payment.step < steps[payment.paymentSource].length - 1) {
dispatch({ type: 'INCREMENT_STEP' })
}
}}
initialValues={{}}
validationSchema={steps[payment.paymentSource][payment.step].validation}>
<Form>{steps[payment.paymentSource][payment.step].component}</Form>
</Formik>
</Grid>
</Grid>
</CardContent>
</Card>
</Dialog>
)
}

export default CreatePaymentStepper
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { Box, Button, Typography } from '@mui/material'
import { useTranslation } from 'next-i18next'
import React, { useContext } from 'react'
import { SelectedPaymentSource } from './helpers/createPaymentStepReducer'
import { PaymentContext } from './CreatePaymentDialog'

export default function PaymentTypeSelector() {
const paymentContext = useContext(PaymentContext)
const { t } = useTranslation('')
const handleSubmit = (source: SelectedPaymentSource) => {
paymentContext.dispatch({ type: 'UPDATE_PAYMENT_SOURCE', payload: source })
}
return (
<>
<Typography variant="h6" component={'h2'} sx={{ marginBottom: '16px', textAlign: 'center' }}>
Ръчно добавяне на плащане
</Typography>
<Typography variant="body1" sx={{ marginBottom: '16px', textAlign: 'center' }}>
{t('')} <b />
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', justifyContent: 'center', gap: 2 }}>
<Button variant="outlined" onClick={() => handleSubmit('stripe')}>
През Stripe
</Button>
<Button variant="outlined" onClick={() => handleSubmit('benevity')}>
През Benevity
</Button>
</Box>
</>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { IconButton, TextField } from '@mui/material'
import { TranslatableField, translateError } from 'common/form/validation'
import { useField } from 'formik'
import { useTranslation } from 'next-i18next'
import { useEffect, useRef, useState } from 'react'
import EditIcon from '@mui/icons-material/Edit'

export const BenevityInput = ({
name,
suffix,
canEdit = false,
}: {
name: string
suffix?: string
canEdit?: boolean
}) => {
const { t } = useTranslation()
const [editable, setEditable] = useState(false)
const [field, meta] = useField(name)
const helperText = meta.touched ? translateError(meta.error as TranslatableField, t) : ''

const ref = useRef<HTMLDivElement | null>(null)

useEffect(() => {
if (!editable) return
const child = ref.current
child?.focus()
}, [editable])

const toggleEdit = () => {
setEditable((prev) => !prev)
}
const onBlur = (
formikBlur: typeof field.onBlur,
e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement, Element>,
) => {
formikBlur(e)
toggleEdit()
}

return (
<TextField
helperText={helperText}
error={Boolean(meta.error) && Boolean(meta.touched)}
variant="standard"
id={field.name}
type="text"
{...field}
onBlur={(e) => onBlur(field.onBlur, e)}
InputProps={{
disableUnderline: !editable,
inputRef: ref,
disabled: !editable,
sx: {
'& .MuiInputBase-input.Mui-disabled': {
WebkitTextFillColor: '#000000',
},
'& .MuiInputBase-input': {
width: `${String(field.value).length + 1}ch`,
maxWidth: !editable ? `12ch` : 'auto',
},
},
endAdornment: (
<>
{suffix && <span>{suffix}</span>}
{canEdit && (
<IconButton size="small" onClick={toggleEdit} sx={{ color: 'primary.light' }}>
<EditIcon />
</IconButton>
)}
</>
),
}}
/>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { Box, Button, Typography } from '@mui/material'
import { useTranslation } from 'next-i18next'
import { observer } from 'mobx-react'
import { useContext } from 'react'
import { PaymentContext } from '../CreatePaymentDialog'
import { BenevityImportType } from '../helpers/createPaymentStepReducer'

function BenevityImportFirstStep() {
const { t } = useTranslation()
const paymentContext = useContext(PaymentContext)

const handleImportTypeChange = (importType: BenevityImportType) => {
paymentContext.dispatch({ type: 'SET_BENEVITY_IMPORT_TYPE', payload: importType })
}

return (
<>
<Typography variant="h6" sx={{ marginBottom: '16px', textAlign: 'center' }}>
Прикачване на CSV файл
</Typography>
<Typography variant="body1" sx={{ marginBottom: '16px', textAlign: 'center' }}>
{t('Създаване на дарения от Benevity, чрез CSV файл')} <b />
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', justifyContent: 'center', gap: 2 }}>
<Button onClick={() => handleImportTypeChange('file')} variant="outlined">
{t('Чрез файл')}
</Button>
<Button onClick={() => handleImportTypeChange('manual')} variant="outlined">
{t('Ръчно въвеждане')}
</Button>
</Box>
</>
)
}

export default observer(BenevityImportFirstStep)
Loading
Loading