Implement paypal to purchase buzz

This commit is contained in:
Luis Rojas
2024-02-06 10:34:43 -04:00
parent 1eea8813e1
commit 2e96a6904f
13 changed files with 376 additions and 31 deletions
+75
View File
@@ -43,6 +43,7 @@
"@microsoft/signalr": "^7.0.10",
"@next-auth/prisma-adapter": "^1.0.7",
"@okikio/sharedworker": "^1.0.4",
"@paypal/react-paypal-js": "^8.1.3",
"@prisma/client": "^5.9.1",
"@react-hook/window-size": "^3.1.1",
"@stripe/react-stripe-js": "^2.4.0",
@@ -2973,6 +2974,35 @@
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/@paypal/paypal-js": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/@paypal/paypal-js/-/paypal-js-7.1.1.tgz",
"integrity": "sha512-fVpExBrHINGHsyODYKBXSMinxrgPKzmVW7ZCaCqUaMl7kTSR1ZsVi2IhgLvsPpXF4LWSAMkPTP/J6aetQ6ZlNA==",
"dependencies": {
"promise-polyfill": "^8.3.0"
}
},
"node_modules/@paypal/react-paypal-js": {
"version": "8.1.3",
"resolved": "https://registry.npmjs.org/@paypal/react-paypal-js/-/react-paypal-js-8.1.3.tgz",
"integrity": "sha512-hEm27iYP/UHS3XPBhDdiK2U4PH1FxrOD5O3f9tstAVLJd82l/laCjq751HiESSm63PVOoFeKE41Fe1mYGab+oA==",
"dependencies": {
"@paypal/paypal-js": "^7.0.0",
"@paypal/sdk-constants": "^1.0.122"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/@paypal/sdk-constants": {
"version": "1.0.139",
"resolved": "https://registry.npmjs.org/@paypal/sdk-constants/-/sdk-constants-1.0.139.tgz",
"integrity": "sha512-rCRL2Ys5OAbSCLcJjaFlunJWrgY92UzGNrHb7wYnr//sEXSRJx1n8Na/jxSxiFBmp4GGa/TbgodJvM80fx1iyQ==",
"dependencies": {
"hi-base32": "^0.5.0"
}
},
"node_modules/@pkgr/utils": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/@pkgr/utils/-/utils-2.3.1.tgz",
@@ -8552,6 +8582,11 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/hi-base32": {
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/hi-base32/-/hi-base32-0.5.1.tgz",
"integrity": "sha512-EmBBpvdYh/4XxsnUybsPag6VikPYnN30td+vQk+GI3qpahVEG9+gTkG0aXVxTjBqQ5T6ijbWIu77O+C5WFWsnA=="
},
"node_modules/highlight-words": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/highlight-words/-/highlight-words-1.2.1.tgz",
@@ -11815,6 +11850,11 @@
"node": ">=10"
}
},
"node_modules/promise-polyfill": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/promise-polyfill/-/promise-polyfill-8.3.0.tgz",
"integrity": "sha512-H5oELycFml5yto/atYqmjyigJoAo3+OXwolYiH7OfQuYlAqhxNvTfiNMbV9hsC6Yp83yE5r2KTVmtrG6R9i6Pg=="
},
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
@@ -17163,6 +17203,31 @@
"resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.0.4.tgz",
"integrity": "sha512-003xWiCuvePbLaPHT+CRuaV4GlyCAVm6XYSbBZDHoWZGn1mNkVKFaDbGJjjxmEFvizUwlCoM6O18FCBMMky2zQ=="
},
"@paypal/paypal-js": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/@paypal/paypal-js/-/paypal-js-7.1.1.tgz",
"integrity": "sha512-fVpExBrHINGHsyODYKBXSMinxrgPKzmVW7ZCaCqUaMl7kTSR1ZsVi2IhgLvsPpXF4LWSAMkPTP/J6aetQ6ZlNA==",
"requires": {
"promise-polyfill": "^8.3.0"
}
},
"@paypal/react-paypal-js": {
"version": "8.1.3",
"resolved": "https://registry.npmjs.org/@paypal/react-paypal-js/-/react-paypal-js-8.1.3.tgz",
"integrity": "sha512-hEm27iYP/UHS3XPBhDdiK2U4PH1FxrOD5O3f9tstAVLJd82l/laCjq751HiESSm63PVOoFeKE41Fe1mYGab+oA==",
"requires": {
"@paypal/paypal-js": "^7.0.0",
"@paypal/sdk-constants": "^1.0.122"
}
},
"@paypal/sdk-constants": {
"version": "1.0.139",
"resolved": "https://registry.npmjs.org/@paypal/sdk-constants/-/sdk-constants-1.0.139.tgz",
"integrity": "sha512-rCRL2Ys5OAbSCLcJjaFlunJWrgY92UzGNrHb7wYnr//sEXSRJx1n8Na/jxSxiFBmp4GGa/TbgodJvM80fx1iyQ==",
"requires": {
"hi-base32": "^0.5.0"
}
},
"@pkgr/utils": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/@pkgr/utils/-/utils-2.3.1.tgz",
@@ -21281,6 +21346,11 @@
"space-separated-tokens": "^2.0.0"
}
},
"hi-base32": {
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/hi-base32/-/hi-base32-0.5.1.tgz",
"integrity": "sha512-EmBBpvdYh/4XxsnUybsPag6VikPYnN30td+vQk+GI3qpahVEG9+gTkG0aXVxTjBqQ5T6ijbWIu77O+C5WFWsnA=="
},
"highlight-words": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/highlight-words/-/highlight-words-1.2.1.tgz",
@@ -23538,6 +23608,11 @@
"tdigest": "^0.1.1"
}
},
"promise-polyfill": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/promise-polyfill/-/promise-polyfill-8.3.0.tgz",
"integrity": "sha512-H5oELycFml5yto/atYqmjyigJoAo3+OXwolYiH7OfQuYlAqhxNvTfiNMbV9hsC6Yp83yE5r2KTVmtrG6R9i6Pg=="
},
"prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
+1
View File
@@ -64,6 +64,7 @@
"@microsoft/signalr": "^7.0.10",
"@next-auth/prisma-adapter": "^1.0.7",
"@okikio/sharedworker": "^1.0.4",
"@paypal/react-paypal-js": "^8.1.3",
"@prisma/client": "^5.9.1",
"@react-hook/window-size": "^3.1.1",
"@stripe/react-stripe-js": "^2.4.0",
+74
View File
@@ -0,0 +1,74 @@
import { Box, useMantineTheme } from '@mantine/core';
import { PayPalButtons, FUNDING } from '@paypal/react-paypal-js';
import { useCallback, useRef } from 'react';
import { env } from '~/env/client.mjs';
import { showSuccessNotification } from '~/utils/notifications';
import { trpc } from '~/utils/trpc';
type Props = {
amount: number;
onError: (error: any) => void;
onSuccess?: (data: any) => void;
disabled?: boolean;
};
export const BuzzPaypalButton = ({ amount, onError, onSuccess, ...props }: Props) => {
const { mutateAsync: createBuzzOrderMutation } = trpc.paypal.createBuzzOrder.useMutation();
const { mutateAsync: processBuzzOrderMutation } = trpc.paypal.processBuzzOrder.useMutation();
const iframeRef = useRef<any>(null);
const createOrder = useCallback(async () => {
try {
if (amount <= 0) {
throw new Error('Invalid buzz amount');
}
const order = await createBuzzOrderMutation({ amount });
return order.id;
} catch (error) {
onError(error);
throw error;
}
}, [amount]);
const onApprove = useCallback(async (data: { orderID: string }) => {
try {
onSuccess?.(data);
await processBuzzOrderMutation({ orderId: data.orderID });
showSuccessNotification({
title: 'Payment successful',
message:
'Your payment has been processed successfully and buzz has been added to your account',
});
} catch (error) {
onError('Error processing payment');
throw error;
}
}, []);
if (!env.NEXT_PUBLIC_PAYPAL_CLIENT_ID) {
return null;
}
console.log(iframeRef.current);
return (
<Box style={{ colorScheme: 'none' }}>
<PayPalButtons
createOrder={createOrder}
onApprove={onApprove}
onError={onError}
forceReRender={[amount]}
style={{
height: 35,
shape: 'pill',
}}
ref={(r) => {
console.log(r);
iframeRef.current = r;
}}
{...props}
/>
</Box>
);
};
+21 -3
View File
@@ -27,6 +27,7 @@ import { useCurrentUser } from '~/hooks/useCurrentUser';
import { useIsMobile } from '~/hooks/useIsMobile';
import { constants } from '~/server/common/constants';
import { containerQuery } from '~/utils/mantine-css-helpers';
import { BuzzPaypalButton } from './BuzzPaypalButton';
const useStyles = createStyles((theme) => ({
chipGroup: {
@@ -138,12 +139,12 @@ export const BuzzPurchase = ({
},
});
const unitAmount = (selectedPrice?.unitAmount ?? customAmount) as number;
const buzzAmount = selectedPrice?.buzzAmount ?? unitAmount * 10;
const handleSubmit = async () => {
if (!selectedPrice && !customAmount) return setError('Please choose one option');
const unitAmount = (selectedPrice?.unitAmount ?? customAmount) as number;
const buzzAmount = selectedPrice?.buzzAmount ?? unitAmount * 10;
if (unitAmount < constants.buzz.minChargeAmount)
return setError(
`Minimum amount is $${formatPriceForDisplay(constants.buzz.minChargeAmount)} USD`
@@ -375,6 +376,23 @@ export const BuzzPurchase = ({
{processing ? 'Completing your purchase...' : 'Continue'}
</Button>
</Group>
{ctaEnabled && (
<Accordion variant="default" classNames={{ item: classes.accordionItem }}>
<Accordion.Item value="otherBuyingOptions">
<Accordion.Control>
<Text>Pay with other platforms</Text>
</Accordion.Control>
<Accordion.Panel>
<BuzzPaypalButton
onError={(error) => setError(error.message)}
onSuccess={() => onPurchaseSuccess?.()}
amount={buzzAmount}
disabled={!ctaEnabled || processing}
/>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
)}
</Stack>
);
};
-3
View File
@@ -6,19 +6,16 @@ export function Meta({ title, description, image, links = [], schema, deIndex }:
<title>{title}</title>
<meta name="title" content={title} />
<meta name="description" content={description} />
<meta property="og:type" content="website" />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:image" content={image} />
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:title" content={title} />
<meta property="twitter:description" content={description} />
<meta property="twitter:image" content={image} />
{image && <meta name="robots" content="max-image-preview:large" />}
{deIndex && <meta name="robots" content={deIndex} />}
{links.map((link, index) => (
<link key={link.href || index} {...link} />
))}
+7 -3
View File
@@ -132,7 +132,9 @@ export const serverSchema = z.object({
REPLICATION_LAG_DELAY: z.coerce.number().default(0),
RECAPTCHA_PROJECT_ID: z.string(),
AIR_WEBHOOK: z.string().url().optional(),
AIR_PAYMENT_LINK_ID: z.string().optional()
AIR_PAYMENT_LINK_ID: z.string().optional(),
PAYPAL_SECRET: z.string().optional(),
PAYPAL_CLIENT_ID: z.string().optional()
});
/**
@@ -166,7 +168,8 @@ export const clientSchema = z.object({
NEXT_PUBLIC_UI_HOMEPAGE_IMAGES: zc.booleanString.default(true),
NEXT_PUBLIC_LOG_TRPC: zc.booleanString.default(false),
NEXT_PUBLIC_RECAPTCHA_KEY: z.string(),
NEXT_PUBLIC_ADS: zc.booleanString.default(false)
NEXT_PUBLIC_ADS: zc.booleanString.default(false),
NEXT_PUBLIC_PAYPAL_CLIENT_ID: z.string().optional()
});
/**
@@ -201,5 +204,6 @@ export const clientEnv = {
NEXT_PUBLIC_UI_HOMEPAGE_IMAGES: process.env.NEXT_PUBLIC_UI_HOMEPAGE_IMAGES !== "false",
NEXT_PUBLIC_LOG_TRPC: process.env.NEXT_PUBLIC_LOG_TRPC !== "false",
NEXT_PUBLIC_RECAPTCHA_KEY: process.env.NEXT_PUBLIC_RECAPTCHA_KEY,
NEXT_PUBLIC_ADS: process.env.NEXT_PUBLIC_ADS === "true"
NEXT_PUBLIC_ADS: process.env.NEXT_PUBLIC_ADS === "true",
NEXT_PUBLIC_PAYPAL_CLIENT_ID: process.env.NEXT_PUBLIC_PAYPAL_CLIENT_ID
};
+25 -22
View File
@@ -50,6 +50,7 @@ import { ClientHistoryStore } from '~/store/ClientHistoryStore';
import { trpc } from '~/utils/trpc';
import '~/styles/globals.css';
import { ActivityReportingProvider } from '~/providers/ActivityReportingProvider';
import { PaypalProvider } from '~/providers/PaypalProvider';
dayjs.extend(duration);
dayjs.extend(isBetween);
@@ -230,28 +231,30 @@ function MyApp(props: CustomAppProps) {
<ReferralsProvider>
<FiltersProvider value={filters}>
<AdsProvider>
<HiddenPreferencesProvider>
<CivitaiLinkProvider>
<NotificationsProvider zIndex={9999}>
<BrowserRouterProvider>
<RecaptchaWidgetProvider>
<ChatContextProvider>
<BaseLayout>
<CustomModalsProvider>
<TosProvider>
{getLayout(<Component {...pageProps} />)}
</TosProvider>
<StripeSetupSuccessProvider />
<DialogProvider />
<RoutedDialogProvider />
</CustomModalsProvider>
</BaseLayout>
</ChatContextProvider>
</RecaptchaWidgetProvider>
</BrowserRouterProvider>
</NotificationsProvider>
</CivitaiLinkProvider>
</HiddenPreferencesProvider>
<PaypalProvider>
<HiddenPreferencesProvider>
<CivitaiLinkProvider>
<NotificationsProvider zIndex={9999}>
<BrowserRouterProvider>
<RecaptchaWidgetProvider>
<ChatContextProvider>
<BaseLayout>
<CustomModalsProvider>
<TosProvider>
{getLayout(<Component {...pageProps} />)}
</TosProvider>
<StripeSetupSuccessProvider />
<DialogProvider />
<RoutedDialogProvider />
</CustomModalsProvider>
</BaseLayout>
</ChatContextProvider>
</RecaptchaWidgetProvider>
</BrowserRouterProvider>
</NotificationsProvider>
</CivitaiLinkProvider>
</HiddenPreferencesProvider>
</PaypalProvider>
</AdsProvider>
</FiltersProvider>
</ReferralsProvider>
+15
View File
@@ -0,0 +1,15 @@
import { PayPalScriptProvider } from '@paypal/react-paypal-js';
import { env } from '~/env/client.mjs';
const initialOptions = {
clientId: env.NEXT_PUBLIC_PAYPAL_CLIENT_ID ?? '',
currency: 'USD',
intent: 'capture',
};
export function PaypalProvider({ children }: { children: React.ReactNode }) {
if (!env.NEXT_PUBLIC_PAYPAL_CLIENT_ID) {
return <>{children}</>;
}
return <PayPalScriptProvider options={initialOptions}>{children}</PayPalScriptProvider>;
}
@@ -0,0 +1,18 @@
import { createBuzzOrder, processBuzzOrder } from './../services/paypal.service';
import { Context } from '~/server/createContext';
import { PaypalPurchaseBuzzSchema, PaypalOrderSchema } from '../schema/paypal.schema';
export const createBuzzOrderHandler = async ({
input,
ctx,
}: {
input: PaypalPurchaseBuzzSchema;
ctx: DeepNonNullable<Context>;
}) => {
return await createBuzzOrder({ userId: ctx.user.id, ...input });
};
export const processBuzzOrderHandler = async ({ input }: { input: PaypalOrderSchema }) => {
return await processBuzzOrder(input.orderId);
};
+2
View File
@@ -52,6 +52,7 @@ import { userRouter } from './user.router';
import { userStripeConnectRouter } from './user-stripe-connect.router';
import { buzzWithdrawalRequestRouter } from './buzz-withdrawal-request.router';
import { integrationRouter } from './integration.router';
import { paypalRouter } from './paypal.router';
export const appRouter = router({
account: accountRouter,
@@ -106,6 +107,7 @@ export const appRouter = router({
userStripeConnect: userStripeConnectRouter,
buzzWithdrawalRequest: buzzWithdrawalRequestRouter,
integration: integrationRouter,
paypal: paypalRouter,
});
// export type definition of API
+13
View File
@@ -0,0 +1,13 @@
import {
createBuzzOrderHandler,
processBuzzOrderHandler,
} from './../controllers/paypal.controller';
import { router, protectedProcedure } from '~/server/trpc';
import { paypalOrderSchema, paypalPurchaseBuzzSchema } from '../schema/paypal.schema';
export const paypalRouter = router({
createBuzzOrder: protectedProcedure
.input(paypalPurchaseBuzzSchema)
.mutation(createBuzzOrderHandler),
processBuzzOrder: protectedProcedure.input(paypalOrderSchema).mutation(processBuzzOrderHandler),
});
+12
View File
@@ -0,0 +1,12 @@
import { z } from 'zod';
export type PaypalPurchaseBuzzSchema = z.infer<typeof paypalPurchaseBuzzSchema>;
export const paypalPurchaseBuzzSchema = z.object({
userId: z.number().optional(),
amount: z.number(),
});
export type PaypalOrderSchema = z.infer<typeof paypalOrderSchema>;
export const paypalOrderSchema = z.object({
orderId: z.string(),
});
+113
View File
@@ -0,0 +1,113 @@
import { env } from '~/env/server.mjs';
import { constants } from '../common/constants';
import { isDev } from '~/env/other';
import { createBuzzTransaction } from './buzz.service';
import { TransactionType } from '../schema/buzz.schema';
import { throwBadRequestError } from '../utils/errorHandling';
import { PaypalPurchaseBuzzSchema } from '../schema/paypal.schema';
const PAYPAL_URL = isDev ? 'https://api-m.sandbox.paypal.com' : 'https://api-m.paypal.com';
const Authorization = `Basic ${Buffer.from(`${env.PAYPAL_CLIENT_ID}:${env.PAYPAL_SECRET}`).toString(
'base64'
)}`;
export const createBuzzOrder = async ({ amount, userId }: PaypalPurchaseBuzzSchema) => {
const response = await fetch(`${PAYPAL_URL}/v2/checkout/orders`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization,
},
body: JSON.stringify({
intent: 'CAPTURE',
purchase_units: [
{
reference_id: `${userId}:${amount}`,
description: `Individual Buzz purchase - ${amount} Buzz`,
amount: {
currency_code: 'USD',
value: (amount / constants.buzz.buzzDollarRatio).toFixed(2),
},
},
],
payment_source: {
paypal: {
experience_context: {
payment_method_preference: 'IMMEDIATE_PAYMENT_REQUIRED',
brand_name: 'Civitai',
locale: 'en-US',
landing_page: 'LOGIN',
shipping_preference: 'NO_SHIPPING',
user_action: 'PAY_NOW',
},
},
},
}),
});
if (response.status === 200) {
const data = (await response.json()) as { id: string };
return {
id: data.id,
};
} else {
console.log(response);
throw new Error('Failed to create PayPal order');
}
};
export const processBuzzOrder = async (orderId: string) => {
const response = await fetch(`${PAYPAL_URL}/v2/checkout/orders/${orderId}`, {
headers: {
Authorization,
},
});
if (response.status === 200) {
const data = (await response.json()) as {
status: string;
purchase_units: { reference_id: string }[];
};
if (data.status === 'APPROVED') {
const referenceId = data.purchase_units[0].reference_id;
const [userId, buzzAmount] = referenceId.split(':').map((x) => parseInt(x));
// Give user the buzz assuming it hasn't been given
const { transactionId } = await createBuzzTransaction({
fromAccountId: 0,
toAccountId: userId,
amount: buzzAmount,
type: TransactionType.Purchase,
externalTransactionId: `PAYPAL_ORDER:${orderId}`,
description: 'Membership bonus',
details: { paypalOrderId: orderId },
});
// Update order status
await fetch(`${PAYPAL_URL}/v2/checkout/orders/${orderId}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Authorization,
},
body: JSON.stringify([
{
op: 'add',
path: `/purchase_units/@reference_id=='${referenceId}'/custom_id`,
value: transactionId,
},
]),
});
return true;
} else {
console.log(response);
throw throwBadRequestError('Order not approved');
}
} else {
console.log(response);
throw new Error('Failed to process PayPal order. Please contact support.');
}
};