mirror of
https://github.com/MAGICGrants/campaign-site.git
synced 2026-01-08 20:08:05 -05:00
Merge pull request #166 from MAGICGrants/coinbase-commerce
feat: coinbase commerce integration
This commit is contained in:
@@ -56,3 +56,6 @@ PRIVACYGUIDES_DISCOURSE_MEMBERSHIP_GROUP_ID=""
|
||||
|
||||
ATTESTATION_PRIVATE_KEY=""
|
||||
NEXT_PUBLIC_ATTESTATION_PUBLIC_KEY=""
|
||||
|
||||
COINBASE_COMMERCE_API_KEY=""
|
||||
COINBASE_COMMERCE_WEBHOOK_SECRET=""
|
||||
39
components/BitcoinLogo.tsx
Normal file
39
components/BitcoinLogo.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { SVGProps } from 'react'
|
||||
|
||||
function BitcoinLogo(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlSpace="preserve"
|
||||
width="100%"
|
||||
height="100%"
|
||||
version="1.1"
|
||||
shape-rendering="geometricPrecision"
|
||||
text-rendering="geometricPrecision"
|
||||
image-rendering="optimizeQuality"
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
viewBox="0 0 4091.27 4091.73"
|
||||
xmlnsXlink="http://www.w3.org/1999/xlink"
|
||||
{...props}
|
||||
>
|
||||
<g id="Layer_x0020_1">
|
||||
<metadata id="CorelCorpID_0Corel-Layer" />
|
||||
<g id="_1421344023328">
|
||||
<path
|
||||
fill="#F7931A"
|
||||
fill-rule="nonzero"
|
||||
d="M4030.06 2540.77c-273.24,1096.01 -1383.32,1763.02 -2479.46,1489.71 -1095.68,-273.24 -1762.69,-1383.39 -1489.33,-2479.31 273.12,-1096.13 1383.2,-1763.19 2479,-1489.95 1096.06,273.24 1763.03,1383.51 1489.76,2479.57l0.02 -0.02z"
|
||||
/>
|
||||
<path
|
||||
fill="white"
|
||||
fill-rule="nonzero"
|
||||
d="M2947.77 1754.38c40.72,-272.26 -166.56,-418.61 -450,-516.24l91.95 -368.8 -224.5 -55.94 -89.51 359.09c-59.02,-14.72 -119.63,-28.59 -179.87,-42.34l90.16 -361.46 -224.36 -55.94 -92 368.68c-48.84,-11.12 -96.81,-22.11 -143.35,-33.69l0.26 -1.16 -309.59 -77.31 -59.72 239.78c0,0 166.56,38.18 163.05,40.53 90.91,22.69 107.35,82.87 104.62,130.57l-104.74 420.15c6.26,1.59 14.38,3.89 23.34,7.49 -7.49,-1.86 -15.46,-3.89 -23.73,-5.87l-146.81 588.57c-11.11,27.62 -39.31,69.07 -102.87,53.33 2.25,3.26 -163.17,-40.72 -163.17,-40.72l-111.46 256.98 292.15 72.83c54.35,13.63 107.61,27.89 160.06,41.3l-92.9 373.03 224.24 55.94 92 -369.07c61.26,16.63 120.71,31.97 178.91,46.43l-91.69 367.33 224.51 55.94 92.89 -372.33c382.82,72.45 670.67,43.24 791.83,-303.02 97.63,-278.78 -4.86,-439.58 -206.26,-544.44 146.69,-33.83 257.18,-130.31 286.64,-329.61l-0.07 -0.05zm-512.93 719.26c-69.38,278.78 -538.76,128.08 -690.94,90.29l123.28 -494.2c152.17,37.99 640.17,113.17 567.67,403.91zm69.43 -723.3c-63.29,253.58 -453.96,124.75 -580.69,93.16l111.77 -448.21c126.73,31.59 534.85,90.55 468.94,355.05l-0.02 0z"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default BitcoinLogo
|
||||
110
components/EvmIcon.tsx
Normal file
110
components/EvmIcon.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { SVGProps } from 'react'
|
||||
|
||||
function EvmIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
width="399.14194mm"
|
||||
height="399.75381mm"
|
||||
viewBox="0 0 399.14194 399.75382"
|
||||
version="1.1"
|
||||
id="svg1"
|
||||
xmlSpace="preserve"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<defs id="defs1" />
|
||||
<g id="layer1" transform="translate(-0.8580624)">
|
||||
<g
|
||||
style={{
|
||||
clipRule: 'evenodd',
|
||||
fillRule: 'evenodd',
|
||||
shapeRendering: 'geometricPrecision',
|
||||
textRendering: 'geometricPrecision',
|
||||
}}
|
||||
id="g2"
|
||||
transform="matrix(0.15265622,0,0,0.15265622,37,0)"
|
||||
>
|
||||
<g id="Layer_x0020_1">
|
||||
<metadata id="CorelCorpID_0Corel-Layer" />
|
||||
<g id="_1421394342400">
|
||||
<g id="g6">
|
||||
<polygon
|
||||
fill="#343434"
|
||||
fill-rule="nonzero"
|
||||
points="383.5,29.11 383.5,873.74 392.07,882.29 784.13,650.54 392.07,0 "
|
||||
id="polygon1"
|
||||
/>
|
||||
<polygon
|
||||
fill="#8c8c8c"
|
||||
fill-rule="nonzero"
|
||||
points="392.07,472.33 392.07,0 0,650.54 392.07,882.29 "
|
||||
id="polygon2"
|
||||
/>
|
||||
<polygon
|
||||
fill="#3c3c3b"
|
||||
fill-rule="nonzero"
|
||||
points="387.24,962.41 387.24,1263.28 392.07,1277.38 784.37,724.89 392.07,956.52 "
|
||||
id="polygon3"
|
||||
/>
|
||||
<polygon
|
||||
fill="#8c8c8c"
|
||||
fill-rule="nonzero"
|
||||
points="392.07,956.52 0,724.89 392.07,1277.38 "
|
||||
id="polygon4"
|
||||
/>
|
||||
<polygon
|
||||
fill="#141414"
|
||||
fill-rule="nonzero"
|
||||
points="784.13,650.54 392.07,472.33 392.07,882.29 "
|
||||
id="polygon5"
|
||||
/>
|
||||
<polygon
|
||||
fill="#393939"
|
||||
fill-rule="nonzero"
|
||||
points="392.07,882.29 392.07,472.33 0,650.54 "
|
||||
id="polygon6"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<g id="g1" transform="matrix(0.0975,0,0,0.0975,205,0)">
|
||||
<path
|
||||
d="m 1000,2000 c 554.17,0 1000,-445.83 1000,-1000 C 2000,445.83 1554.17,0 1000,0 445.83,0 0,445.83 0,1000 0,1554.17 445.83,2000 1000,2000 Z"
|
||||
fill="#2775ca"
|
||||
id="path1"
|
||||
/>
|
||||
<path
|
||||
d="m 1275,1158.33 c 0,-145.83 -87.5,-195.83 -262.5,-216.66 -125,-16.67 -150,-50 -150,-108.34 0,-58.34 41.67,-95.83 125,-95.83 75,0 116.67,25 137.5,87.5 4.17,12.5 16.67,20.83 29.17,20.83 h 66.66 c 16.67,0 29.17,-12.5 29.17,-29.16 V 812.5 C 1233.33,720.83 1158.33,650 1062.5,641.67 v -100 c 0,-16.67 -12.5,-29.17 -33.33,-33.34 h -62.5 c -16.67,0 -29.17,12.5 -33.34,33.34 v 95.83 c -125,16.67 -204.16,100 -204.16,204.17 0,137.5 83.33,191.66 258.33,212.5 116.67,20.83 154.17,45.83 154.17,112.5 0,66.67 -58.34,112.5 -137.5,112.5 -108.34,0 -145.84,-45.84 -158.34,-108.34 -4.16,-16.66 -16.66,-25 -29.16,-25 h -70.84 c -16.66,0 -29.16,12.5 -29.16,29.17 v 4.17 c 16.66,104.16 83.33,179.16 220.83,200 v 100 c 0,16.66 12.5,29.16 33.33,33.33 h 62.5 c 16.67,0 29.17,-12.5 33.34,-33.33 v -100 c 125,-20.84 208.33,-108.34 208.33,-220.84 z"
|
||||
fill="#ffffff"
|
||||
id="path2"
|
||||
/>
|
||||
<path
|
||||
d="m 787.5,1595.83 c -325,-116.66 -491.67,-479.16 -370.83,-800 62.5,-175 200,-308.33 370.83,-370.83 16.67,-8.33 25,-20.83 25,-41.67 V 325 c 0,-16.67 -8.33,-29.17 -25,-33.33 -4.17,0 -12.5,0 -16.67,4.16 -395.83,125 -612.5,545.84 -487.5,941.67 75,233.33 254.17,412.5 487.5,487.5 16.67,8.33 33.34,0 37.5,-16.67 4.17,-4.16 4.17,-8.33 4.17,-16.66 v -58.34 c 0,-12.5 -12.5,-29.16 -25,-37.5 z m 441.67,-1300 c -16.67,-8.33 -33.34,0 -37.5,16.67 -4.17,4.17 -4.17,8.33 -4.17,16.67 v 58.33 c 0,16.67 12.5,33.33 25,41.67 325,116.66 491.67,479.16 370.83,800 -62.5,175 -200,308.33 -370.83,370.83 -16.67,8.33 -25,20.83 -25,41.67 V 1700 c 0,16.67 8.33,29.17 25,33.33 4.17,0 12.5,0 16.67,-4.16 395.83,-125 612.5,-545.84 487.5,-941.67 -75,-237.5 -258.34,-416.67 -487.5,-491.67 z"
|
||||
fill="#ffffff"
|
||||
id="path3"
|
||||
/>
|
||||
</g>
|
||||
<g id="g3" transform="matrix(0.57451135,0,0,0.57451135,0.85486875,229.02585)">
|
||||
<path
|
||||
d="m 62.15,1.45 -61.89,130 a 2.52,2.52 0 0 0 0.54,2.94 l 167.15,160.17 a 2.55,2.55 0 0 0 3.53,0 L 338.63,134.4 a 2.52,2.52 0 0 0 0.54,-2.94 L 277.28,1.46 A 2.5,2.5 0 0 0 275,0 H 64.45 a 2.5,2.5 0 0 0 -2.3,1.45 z"
|
||||
style={{ fill: '#50af95', fillRule: 'evenodd' }}
|
||||
id="path1-3"
|
||||
/>
|
||||
<path
|
||||
d="m 191.19,144.8 v 0 c -1.2,0.09 -7.4,0.46 -21.23,0.46 -11,0 -18.81,-0.33 -21.55,-0.46 v 0 c -42.51,-1.87 -74.24,-9.27 -74.24,-18.13 0,-8.86 31.73,-16.25 74.24,-18.15 v 28.91 c 2.78,0.2 10.74,0.67 21.74,0.67 13.2,0 19.81,-0.55 21,-0.66 v -28.9 c 42.42,1.89 74.08,9.29 74.08,18.13 0,8.84 -31.65,16.24 -74.08,18.12 v 0 z m 0,-39.25 V 79.68 h 59.2 V 40.23 H 89.21 v 39.45 h 59.19 v 25.86 c -48.11,2.21 -84.29,11.74 -84.29,23.16 0,11.42 36.18,20.94 84.29,23.16 v 82.9 h 42.78 v -82.93 c 48,-2.21 84.12,-11.73 84.12,-23.14 0,-11.41 -36.09,-20.93 -84.12,-23.15 v 0 z m 0,0 z"
|
||||
style={{ fill: '#ffffff', fillRule: 'evenodd' }}
|
||||
id="path2-6"
|
||||
/>
|
||||
</g>
|
||||
<path
|
||||
d="M 277.88468,283.25754 259.60002,272.63708 204.746,304.49839 v 63.50367 l 54.85402,31.7518 54.85401,-31.7518 v -98.75914 l 30.43795,-17.62772 30.43796,17.62772 v 35.25547 l -30.43796,17.62777 -18.28467,-10.62045 v 28.24817 l 18.28467,10.62045 54.85402,-31.75184 v -63.50363 l -54.85402,-31.75184 -54.85401,31.75184 v 98.75909 l -30.43795,17.62773 -30.43796,-17.62773 V 318.513 l 30.43796,-17.62773 18.28466,10.62044 z"
|
||||
id="path1-35"
|
||||
style={{ strokeWidth: 1.09489, fill: '#6C00F6' }}
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default EvmIcon
|
||||
21
components/LitecoinLogo.tsx
Normal file
21
components/LitecoinLogo.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { SVGProps } from 'react'
|
||||
|
||||
function LitecoinLogo(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
id="Layer_1"
|
||||
data-name="Layer 1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 82.6 82.6"
|
||||
{...props}
|
||||
>
|
||||
<circle cx="41.3" cy="41.3" r="36.83" style={{ fill: '#fff' }} />
|
||||
<path
|
||||
d="M41.3,0A41.3,41.3,0,1,0,82.6,41.3h0A41.18,41.18,0,0,0,41.54,0ZM42,42.7,37.7,57.2h23a1.16,1.16,0,0,1,1.2,1.12v.38l-2,6.9a1.49,1.49,0,0,1-1.5,1.1H23.2l5.9-20.1-6.6,2L24,44l6.6-2,8.3-28.2a1.51,1.51,0,0,1,1.5-1.1h8.9a1.16,1.16,0,0,1,1.2,1.12v.38L43.5,38l6.6-2-1.4,4.8Z"
|
||||
style={{ fill: '#345d9d' }}
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default LitecoinLogo
|
||||
@@ -3,55 +3,32 @@ import { SVGProps } from 'react'
|
||||
function MoneroLogo(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
width="85.086304mm"
|
||||
height="85.084808mm"
|
||||
viewBox="0 0 85.086304 85.084808"
|
||||
id="svg1"
|
||||
id="Layer_1"
|
||||
data-name="Layer 1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 3756.09 3756.49"
|
||||
{...props}
|
||||
>
|
||||
<defs id="defs1">
|
||||
<clipPath clipPathUnits="userSpaceOnUse" id="clipPath2">
|
||||
<path
|
||||
d="M 0,841.889 H 595.275 V 0 H 0 Z"
|
||||
transform="translate(-193.3003,-554.52051)"
|
||||
id="path2"
|
||||
/>
|
||||
</clipPath>
|
||||
<clipPath clipPathUnits="userSpaceOnUse" id="clipPath4">
|
||||
<path
|
||||
d="M 0,841.889 H 595.275 V 0 H 0 Z"
|
||||
transform="translate(-187.7847,-507.50881)"
|
||||
id="path4"
|
||||
/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<g id="layer1">
|
||||
<path
|
||||
id="path1"
|
||||
d="m 0,0 c -20.377,0 -36.903,-16.524 -36.903,-36.902 0,-4.074 0.66,-7.992 1.88,-11.657 h 11.036 V -17.51 L 0,-41.497 23.987,-17.51 v -31.049 h 11.037 c 1.22,3.665 1.88,7.583 1.88,11.657 C 36.904,-16.524 20.378,0 0,0"
|
||||
style={{
|
||||
fill: '#ff6600',
|
||||
fillOpacity: 1,
|
||||
fillRule: 'nonzero',
|
||||
stroke: 'none',
|
||||
}}
|
||||
transform="matrix(1.1528216,0,0,-1.1528216,42.542576,0)"
|
||||
clipPath="url(#clipPath2)"
|
||||
/>
|
||||
<path
|
||||
id="path3"
|
||||
d="M 0,0 -10.468,10.469 V -9.068 h -4.002 -4.002 -7.546 c 6.478,-10.628 18.178,-17.726 31.533,-17.726 13.355,0 25.056,7.098 31.533,17.726 H 29.501 22.344 21.499 V 10.469 L 11.03,0 5.515,-5.515 Z"
|
||||
style={{
|
||||
fill: '#4c4c4c',
|
||||
fillOpacity: 1,
|
||||
fillRule: 'nonzero',
|
||||
stroke: 'none',
|
||||
}}
|
||||
transform="matrix(1.1528216,0,0,-1.1528216,36.184075,54.196107)"
|
||||
clipPath="url(#clipPath4)"
|
||||
/>
|
||||
</g>
|
||||
<title>monero</title>
|
||||
<path
|
||||
d="M4128,2249.81C4128,3287,3287.26,4127.86,2250,4127.86S372,3287,372,2249.81,1212.76,371.75,2250,371.75,4128,1212.54,4128,2249.81Z"
|
||||
transform="translate(-371.96 -371.75)"
|
||||
style={{ fill: '#fff' }}
|
||||
/>
|
||||
<path
|
||||
id="_149931032"
|
||||
data-name=" 149931032"
|
||||
d="M2250,371.75c-1036.89,0-1879.12,842.06-1877.8,1878,0.26,207.26,33.31,406.63,95.34,593.12h561.88V1263L2250,2483.57,3470.52,1263v1579.9h562c62.12-186.48,95-385.85,95.37-593.12C4129.66,1212.76,3287,372,2250,372Z"
|
||||
transform="translate(-371.96 -371.75)"
|
||||
style={{ fill: '#f26822' }}
|
||||
/>
|
||||
<path
|
||||
id="_149931160"
|
||||
data-name=" 149931160"
|
||||
d="M1969.3,2764.17l-532.67-532.7v994.14H1029.38l-384.29.07c329.63,540.8,925.35,902.56,1604.91,902.56S3525.31,3766.4,3855,3225.6H3063.25V2231.47l-532.7,532.7-280.61,280.61-280.62-280.61h0Z"
|
||||
transform="translate(-371.96 -371.75)"
|
||||
style={{ fill: '#4d4d4d' }}
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
6
env.mjs
6
env.mjs
@@ -54,6 +54,9 @@ export const env = createEnv({
|
||||
PRIVACYGUIDES_DISCOURSE_API_USERNAME: z.string(),
|
||||
PRIVACYGUIDES_DISCOURSE_MEMBERSHIP_GROUP_ID: z.string(),
|
||||
ATTESTATION_PRIVATE_KEY_HEX: z.string().min(1),
|
||||
|
||||
COINBASE_COMMERCE_API_KEY: z.string().min(1),
|
||||
COINBASE_COMMERCE_WEBHOOK_SECRET: z.string().min(1),
|
||||
},
|
||||
/*
|
||||
* Environment variables available on the client (and server).
|
||||
@@ -138,6 +141,9 @@ export const env = createEnv({
|
||||
|
||||
ATTESTATION_PRIVATE_KEY_HEX: process.env.ATTESTATION_PRIVATE_KEY_HEX,
|
||||
NEXT_PUBLIC_ATTESTATION_PUBLIC_KEY_HEX: process.env.NEXT_PUBLIC_ATTESTATION_PUBLIC_KEY_HEX,
|
||||
|
||||
COINBASE_COMMERCE_API_KEY: process.env.COINBASE_COMMERCE_API_KEY,
|
||||
COINBASE_COMMERCE_WEBHOOK_SECRET: process.env.COINBASE_COMMERCE_WEBHOOK_SECRET,
|
||||
},
|
||||
/**
|
||||
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially
|
||||
|
||||
@@ -4,10 +4,7 @@ import Link from 'next/link'
|
||||
import Head from 'next/head'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { faMonero } from '@fortawesome/free-brands-svg-icons'
|
||||
import { faCreditCard } from '@fortawesome/free-solid-svg-icons'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { DollarSign, Info } from 'lucide-react'
|
||||
import { CreditCardIcon, DollarSign, Info } from 'lucide-react'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { FundSlug } from '@prisma/client'
|
||||
import { z } from 'zod'
|
||||
@@ -37,6 +34,9 @@ import MoneroLogo from '../../../components/MoneroLogo'
|
||||
import FiroLogo from '../../../components/FiroLogo'
|
||||
import PrivacyGuidesLogo from '../../../components/PrivacyGuidesLogo'
|
||||
import MagicLogo from '../../../components/MagicLogo'
|
||||
import LitecoinLogo from '../../../components/LitecoinLogo'
|
||||
import BitcoinLogo from '../../../components/BitcoinLogo'
|
||||
import EvmIcon from '../../../components/EvmIcon'
|
||||
|
||||
type QueryParams = { fund?: FundSlug; slug?: string }
|
||||
type Props = { project?: ProjectItem } & QueryParams
|
||||
@@ -48,6 +48,14 @@ const placeholderImages: Record<FundSlug, (props: SVGProps<SVGSVGElement>) => JS
|
||||
general: MagicLogo,
|
||||
}
|
||||
|
||||
const paymentMethodOptions = [
|
||||
{ label: 'Credit Card', icon: CreditCardIcon, value: 'card' },
|
||||
{ label: 'Monero', icon: MoneroLogo, value: 'xmr' },
|
||||
{ label: 'Bitcoin', icon: BitcoinLogo, value: 'btc' },
|
||||
{ label: 'Litecoin', icon: LitecoinLogo, value: 'ltc' },
|
||||
{ label: 'EVMs', icon: EvmIcon, value: 'evm' },
|
||||
] as const
|
||||
|
||||
function DonationPage({ fund: fundSlug, slug, project, ...props }: Props) {
|
||||
const session = useSession()
|
||||
const isAuthed = session.status === 'authenticated'
|
||||
@@ -59,6 +67,7 @@ function DonationPage({ fund: fundSlug, slug, project, ...props }: Props) {
|
||||
name: z.string().optional(),
|
||||
email: z.string().email().optional(),
|
||||
amount: z.coerce.number().min(1).max(MAX_AMOUNT),
|
||||
paymentMethod: z.enum(['card', 'btc', 'xmr', 'ltc', 'evm']),
|
||||
taxDeductible: z.enum(['yes', 'no']),
|
||||
givePointsBack: z.enum(['yes', 'no']),
|
||||
showDonorNameOnLeaderboard: z.enum(['yes', 'no']),
|
||||
@@ -93,55 +102,42 @@ function DonationPage({ fund: fundSlug, slug, project, ...props }: Props) {
|
||||
})
|
||||
|
||||
const amount = form.watch('amount')
|
||||
const paymentMethod = form.watch('paymentMethod')
|
||||
const taxDeductible = form.watch('taxDeductible')
|
||||
const showDonorNameOnLeaderboard = form.watch('showDonorNameOnLeaderboard')
|
||||
|
||||
const donateWithFiatMutation = trpc.donation.donateWithFiat.useMutation()
|
||||
const donateWithCryptoMutation = trpc.donation.donateWithCrypto.useMutation()
|
||||
|
||||
async function handleBtcPay(data: FormInputs) {
|
||||
async function handleSubmit(data: FormInputs) {
|
||||
if (!project) return
|
||||
if (!fundSlug) return
|
||||
|
||||
try {
|
||||
const result = await donateWithCryptoMutation.mutateAsync({
|
||||
email: data.email || null,
|
||||
name: data.name || null,
|
||||
amount: data.amount,
|
||||
projectSlug: project.slug,
|
||||
projectName: project.title,
|
||||
fundSlug,
|
||||
taxDeductible: data.taxDeductible === 'yes',
|
||||
givePointsBack: data.givePointsBack === 'yes',
|
||||
showDonorNameOnLeaderboard: data.showDonorNameOnLeaderboard === 'yes',
|
||||
})
|
||||
|
||||
window.location.assign(result.url)
|
||||
} catch (e) {
|
||||
toast({ title: 'Error', description: 'Sorry, something went wrong.', variant: 'destructive' })
|
||||
const args = {
|
||||
email: data.email || null,
|
||||
name: data.name || null,
|
||||
amount: data.amount,
|
||||
projectSlug: project.slug,
|
||||
projectName: project.title,
|
||||
fundSlug,
|
||||
taxDeductible: data.taxDeductible === 'yes',
|
||||
givePointsBack: data.givePointsBack === 'yes',
|
||||
showDonorNameOnLeaderboard: data.showDonorNameOnLeaderboard === 'yes',
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFiat(data: FormInputs) {
|
||||
if (!project) return
|
||||
if (!fundSlug) return
|
||||
|
||||
try {
|
||||
const result = await donateWithFiatMutation.mutateAsync({
|
||||
email: data.email || null,
|
||||
name: data.name || null,
|
||||
amount: data.amount,
|
||||
projectSlug: project.slug,
|
||||
projectName: project.title,
|
||||
fundSlug,
|
||||
taxDeductible: data.taxDeductible === 'yes',
|
||||
givePointsBack: data.givePointsBack === 'yes',
|
||||
showDonorNameOnLeaderboard: data.showDonorNameOnLeaderboard === 'yes',
|
||||
})
|
||||
if (data.paymentMethod !== 'card') {
|
||||
const result = await donateWithCryptoMutation.mutateAsync({
|
||||
...args,
|
||||
paymentMethod: data.paymentMethod,
|
||||
})
|
||||
window.location.assign(result.url)
|
||||
}
|
||||
|
||||
if (!result.url) throw Error()
|
||||
|
||||
window.location.assign(result.url)
|
||||
if (data.paymentMethod === 'card') {
|
||||
const result = await donateWithFiatMutation.mutateAsync({ ...args })
|
||||
window.location.assign(result.url!)
|
||||
}
|
||||
} catch (e) {
|
||||
toast({ title: 'Error', description: 'Sorry, something went wrong.', variant: 'destructive' })
|
||||
}
|
||||
@@ -257,6 +253,37 @@ function DonationPage({ fund: fundSlug, slug, project, ...props }: Props) {
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="paymentMethod"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Payment Method</FormLabel>
|
||||
<FormControl>
|
||||
<div className="flex flex-row gap-2 items-center flex-wrap ">
|
||||
{paymentMethodOptions.map((option, index) => {
|
||||
const Icon = option.icon
|
||||
return (
|
||||
<Button
|
||||
key={`amount-button-${index}`}
|
||||
variant={option.value === paymentMethod ? 'default' : 'light'}
|
||||
size="sm"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
form.setValue('paymentMethod', option.value, { shouldValidate: true })
|
||||
}
|
||||
>
|
||||
<Icon className="w-5 h-5" /> {option.label}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="taxDeductible"
|
||||
@@ -383,35 +410,15 @@ function DonationPage({ fund: fundSlug, slug, project, ...props }: Props) {
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex flex-col sm:flex-row space-y-2 sm:space-x-2 sm:space-y-0">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={form.handleSubmit(handleBtcPay)}
|
||||
disabled={!form.formState.isValid || form.formState.isSubmitting}
|
||||
className="grow basis-0"
|
||||
>
|
||||
{donateWithCryptoMutation.isPending ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faMonero} className="h-5 w-5" />
|
||||
)}
|
||||
Donate with Crypto
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
onClick={form.handleSubmit(handleFiat)}
|
||||
disabled={!form.formState.isValid || form.formState.isSubmitting}
|
||||
className="grow basis-0 bg-indigo-500 hover:bg-indigo-700"
|
||||
>
|
||||
{donateWithFiatMutation.isPending ? (
|
||||
<Spinner className="fill-indigo-500" />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faCreditCard} className="h-5 w-5" />
|
||||
)}
|
||||
Donate with Card
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={form.handleSubmit(handleSubmit)}
|
||||
disabled={!form.formState.isValid || form.formState.isSubmitting}
|
||||
className="grow basis-0"
|
||||
>
|
||||
{donateWithCryptoMutation.isPending && <Spinner />}
|
||||
Donate
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
|
||||
@@ -2,13 +2,10 @@ import { useEffect } from 'react'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { faMonero } from '@fortawesome/free-brands-svg-icons'
|
||||
import { faCreditCard } from '@fortawesome/free-solid-svg-icons'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { DollarSign } from 'lucide-react'
|
||||
import { CreditCardIcon, DollarSign } from 'lucide-react'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { FundSlug } from '@prisma/client'
|
||||
import { GetServerSidePropsContext, GetStaticPropsContext } from 'next'
|
||||
import { GetStaticPropsContext } from 'next'
|
||||
import Image from 'next/image'
|
||||
import Head from 'next/head'
|
||||
import { z } from 'zod'
|
||||
@@ -43,10 +40,22 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '../../components/ui/select'
|
||||
import MoneroLogo from '../../components/MoneroLogo'
|
||||
import BitcoinLogo from '../../components/BitcoinLogo'
|
||||
import LitecoinLogo from '../../components/LitecoinLogo'
|
||||
import EvmIcon from '../../components/EvmIcon'
|
||||
|
||||
type QueryParams = { fund: FundSlug; slug: string }
|
||||
type Props = { project: ProjectItem } & QueryParams
|
||||
|
||||
const paymentMethodOptions = [
|
||||
{ label: 'Credit Card', icon: CreditCardIcon, value: 'card' },
|
||||
{ label: 'Monero', icon: MoneroLogo, value: 'xmr' },
|
||||
{ label: 'Bitcoin', icon: BitcoinLogo, value: 'btc' },
|
||||
{ label: 'Litecoin', icon: LitecoinLogo, value: 'ltc' },
|
||||
{ label: 'EVMs', icon: EvmIcon, value: 'evm' },
|
||||
] as const
|
||||
|
||||
function MembershipPage({ fund: fundSlug, project }: Props) {
|
||||
const session = useSession()
|
||||
const router = useRouter()
|
||||
@@ -54,6 +63,7 @@ function MembershipPage({ fund: fundSlug, project }: Props) {
|
||||
const schema = z
|
||||
.object({
|
||||
amount: z.coerce.number(),
|
||||
paymentMethod: z.enum(['card', 'btc', 'xmr', 'ltc', 'evm']),
|
||||
term: z.enum(['monthly', 'annually']),
|
||||
taxDeductible: z.enum(['yes', 'no']),
|
||||
recurring: z.enum(['yes', 'no']),
|
||||
@@ -64,7 +74,7 @@ function MembershipPage({ fund: fundSlug, project }: Props) {
|
||||
ctx.addIssue({
|
||||
path: ['amount'],
|
||||
code: 'custom',
|
||||
message: `Min. monthly amount is $${MONTHLY_MEMBERSHIP_MIN_PRICE_USD}.`,
|
||||
message: `Min. amount is $${MONTHLY_MEMBERSHIP_MIN_PRICE_USD}.`,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -72,7 +82,7 @@ function MembershipPage({ fund: fundSlug, project }: Props) {
|
||||
ctx.addIssue({
|
||||
path: ['amount'],
|
||||
code: 'custom',
|
||||
message: `Min. anually amount is $${ANNUALLY_MEMBERSHIP_MIN_PRICE_USD}.`,
|
||||
message: `Min. amount is $${ANNUALLY_MEMBERSHIP_MIN_PRICE_USD}.`,
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -107,42 +117,34 @@ function MembershipPage({ fund: fundSlug, project }: Props) {
|
||||
}
|
||||
}, [session.status])
|
||||
|
||||
async function handleBtcPay(data: FormInputs) {
|
||||
async function handleSubmit(data: FormInputs) {
|
||||
if (!project) return
|
||||
if (!fundSlug) return
|
||||
|
||||
try {
|
||||
const result = await payMembershipWithCryptoMutation.mutateAsync({
|
||||
fundSlug,
|
||||
amount: data.amount,
|
||||
term: data.term,
|
||||
taxDeductible: data.taxDeductible === 'yes',
|
||||
givePointsBack: data.givePointsBack === 'yes',
|
||||
})
|
||||
|
||||
window.location.assign(result.url)
|
||||
} catch (e) {
|
||||
toast({ title: 'Error', description: 'Sorry, something went wrong.', variant: 'destructive' })
|
||||
const args = {
|
||||
fundSlug,
|
||||
amount: data.amount,
|
||||
term: data.term,
|
||||
taxDeductible: data.taxDeductible === 'yes',
|
||||
givePointsBack: data.givePointsBack === 'yes',
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFiat(data: FormInputs) {
|
||||
if (!project) return
|
||||
if (!fundSlug) return
|
||||
|
||||
try {
|
||||
const result = await payMembershipWithFiatMutation.mutateAsync({
|
||||
fundSlug,
|
||||
amount: data.amount,
|
||||
term: data.term,
|
||||
recurring: data.recurring === 'yes',
|
||||
taxDeductible: data.taxDeductible === 'yes',
|
||||
givePointsBack: data.givePointsBack === 'yes',
|
||||
})
|
||||
if (data.paymentMethod !== 'card') {
|
||||
const result = await payMembershipWithCryptoMutation.mutateAsync({
|
||||
...args,
|
||||
paymentMethod: data.paymentMethod,
|
||||
})
|
||||
window.location.assign(result.url)
|
||||
}
|
||||
|
||||
if (!result.url) throw new Error()
|
||||
|
||||
window.location.assign(result.url)
|
||||
if (data.paymentMethod === 'card') {
|
||||
const result = await payMembershipWithFiatMutation.mutateAsync({
|
||||
...args,
|
||||
recurring: data.recurring === 'yes',
|
||||
})
|
||||
window.location.assign(result.url!)
|
||||
}
|
||||
} catch (e) {
|
||||
toast({ title: 'Error', description: 'Sorry, something went wrong.', variant: 'destructive' })
|
||||
}
|
||||
@@ -161,6 +163,7 @@ function MembershipPage({ fund: fundSlug, project }: Props) {
|
||||
}, [session, userHasMembershipQuery.data])
|
||||
|
||||
const amount = form.watch('amount')
|
||||
const paymentMethod = form.watch('paymentMethod')
|
||||
const term = form.watch('term')
|
||||
const annualTermSavePerc =
|
||||
amount < 100 && term === 'monthly'
|
||||
@@ -203,7 +206,7 @@ function MembershipPage({ fund: fundSlug, project }: Props) {
|
||||
</div>
|
||||
|
||||
<Form {...form}>
|
||||
<form className="flex flex-col gap-6">
|
||||
<form onSubmit={form.handleSubmit(handleSubmit)} className="flex flex-col gap-6">
|
||||
<div className="flex flex-col sm:flex-row sm:space-x-2 space-y-2 sm:space-y-0">
|
||||
<FormField
|
||||
control={form.control}
|
||||
@@ -259,6 +262,39 @@ function MembershipPage({ fund: fundSlug, project }: Props) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="paymentMethod"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Payment Method</FormLabel>
|
||||
<FormControl>
|
||||
<div className="flex flex-row gap-2 items-center flex-wrap ">
|
||||
{paymentMethodOptions.map((option, index) => {
|
||||
const Icon = option.icon
|
||||
return (
|
||||
<Button
|
||||
key={`amount-button-${index}`}
|
||||
variant={option.value === paymentMethod ? 'default' : 'light'}
|
||||
size="sm"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
form.setValue('paymentMethod', option.value, {
|
||||
shouldValidate: true,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Icon className="w-5 h-5" /> {option.label}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="taxDeductible"
|
||||
@@ -369,35 +405,13 @@ function MembershipPage({ fund: fundSlug, project }: Props) {
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="mt-4 flex flex-col sm:flex-row space-y-2 sm:space-x-2 sm:space-y-0">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={form.handleSubmit(handleBtcPay)}
|
||||
disabled={!form.formState.isValid || form.formState.isSubmitting}
|
||||
className="grow basis-0"
|
||||
>
|
||||
{payMembershipWithCryptoMutation.isPending ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faMonero} className="h-5 w-5" />
|
||||
)}
|
||||
Pay with Crypto
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
onClick={form.handleSubmit(handleFiat)}
|
||||
disabled={!form.formState.isValid || form.formState.isSubmitting}
|
||||
className="grow basis-0 bg-indigo-500 hover:bg-indigo-700"
|
||||
>
|
||||
{payMembershipWithFiatMutation.isPending ? (
|
||||
<Spinner className="fill-indigo-500" />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faCreditCard} className="h-5 w-5" />
|
||||
)}
|
||||
Pay with Card
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
disabled={!form.formState.isValid || form.formState.isSubmitting}
|
||||
className="grow basis-0"
|
||||
>
|
||||
{payMembershipWithCryptoMutation.isPending && <Spinner />}
|
||||
Get Membership
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
@@ -46,8 +46,6 @@ const Project: NextPage<SingleProjectPageProps> = ({ project, donationStats }) =
|
||||
|
||||
const { slug, title, summary, coverImage, content, nym, website, goal, isFunded } = project
|
||||
|
||||
const PlaceholderImage = placeholderImages[project.fund]
|
||||
|
||||
if (!router.isFallback && !slug) {
|
||||
return <ErrorPage statusCode={404} />
|
||||
}
|
||||
@@ -57,6 +55,21 @@ const Project: NextPage<SingleProjectPageProps> = ({ project, donationStats }) =
|
||||
projectSlug: project.slug,
|
||||
})
|
||||
|
||||
const totalFiatAmount =
|
||||
donationStats.xmr.fiatAmount +
|
||||
donationStats.btc.fiatAmount +
|
||||
donationStats.ltc.fiatAmount +
|
||||
donationStats.evm.fiatAmount +
|
||||
donationStats.usd.fiatAmount
|
||||
|
||||
const totalDonationCount =
|
||||
donationStats.xmr.count +
|
||||
donationStats.btc.count +
|
||||
donationStats.ltc.count +
|
||||
donationStats.evm.count +
|
||||
donationStats.manual.count +
|
||||
donationStats.usd.count
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
@@ -87,25 +100,13 @@ const Project: NextPage<SingleProjectPageProps> = ({ project, donationStats }) =
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Progress
|
||||
current={
|
||||
donationStats.xmr.fiatAmount +
|
||||
donationStats.btc.fiatAmount +
|
||||
donationStats.usd.fiatAmount
|
||||
}
|
||||
goal={goal}
|
||||
/>
|
||||
<Progress current={totalFiatAmount} goal={goal} />
|
||||
|
||||
<ul className="font-semibold">
|
||||
<li className="flex items-center space-x-1">
|
||||
<span className="text-green-500 text-xl">{`${formatUsd(donationStats.xmr.fiatAmount + donationStats.btc.fiatAmount + donationStats.ltc.fiatAmount + donationStats.manual.fiatAmount + donationStats.usd.fiatAmount)}`}</span>{' '}
|
||||
<span className="text-green-500 text-xl">{`${formatUsd(totalFiatAmount)}`}</span>{' '}
|
||||
<span className="font-normal text-sm text-gray">
|
||||
in{' '}
|
||||
{donationStats.xmr.count +
|
||||
donationStats.btc.count +
|
||||
donationStats.manual.count +
|
||||
donationStats.usd.count}{' '}
|
||||
donations total
|
||||
in {totalDonationCount} donations total
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
@@ -127,7 +128,13 @@ const Project: NextPage<SingleProjectPageProps> = ({ project, donationStats }) =
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
{`${formatUsd(donationStats.usd.amount + donationStats.manual.fiatAmount)}`}{' '}
|
||||
{formatUsd(donationStats.evm.amount)}{' '}
|
||||
<span className="font-normal text-sm text-gray">
|
||||
in {donationStats.evm.count} EVM token donations
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
{formatUsd(donationStats.usd.amount + donationStats.manual.fiatAmount)}{' '}
|
||||
<span className="font-normal text-sm text-gray">
|
||||
in {donationStats.usd.count + donationStats.manual.count} donations
|
||||
</span>
|
||||
@@ -206,6 +213,11 @@ export async function getServerSideProps({ params, resolvedUrl }: GetServerSideP
|
||||
amount: project.isFunded ? project.totalDonationsLTC : 0,
|
||||
fiatAmount: project.isFunded ? project.totalDonationsLTCInFiat : 0,
|
||||
},
|
||||
evm: {
|
||||
count: project.isFunded ? project.numDonationsEVM : 0,
|
||||
amount: project.isFunded ? project.totalDonationsEVM : 0,
|
||||
fiatAmount: project.isFunded ? project.totalDonationsEVMInFiat : 0,
|
||||
},
|
||||
manual: {
|
||||
count: project.isFunded ? project.numDonationsManual : 0,
|
||||
amount: project.isFunded ? project.totalDonationsManual : 0,
|
||||
@@ -227,16 +239,23 @@ export async function getServerSideProps({ params, resolvedUrl }: GetServerSideP
|
||||
BTC: donationStats.btc,
|
||||
XMR: donationStats.xmr,
|
||||
LTC: donationStats.ltc,
|
||||
EVM: donationStats.evm,
|
||||
MANUAL: donationStats.manual,
|
||||
} as const
|
||||
|
||||
donations.forEach((donation) => {
|
||||
;(donation.cryptoPayments as DonationCryptoPayments | null)?.forEach((payment) => {
|
||||
const stats = cryptoCodeToStats[payment.cryptoCode]
|
||||
if (payment.cryptoCode in cryptoCodeToStats) {
|
||||
const stats = cryptoCodeToStats[payment.cryptoCode as keyof typeof cryptoCodeToStats]
|
||||
|
||||
stats.count += 1
|
||||
stats.amount += payment.netAmount
|
||||
stats.fiatAmount += payment.netAmount * payment.rate
|
||||
stats.count += 1
|
||||
stats.amount += payment.netAmount
|
||||
stats.fiatAmount += payment.netAmount * payment.rate
|
||||
} else if (donation.coinbaseChargeId) {
|
||||
cryptoCodeToStats.EVM.count += 1
|
||||
cryptoCodeToStats.EVM.amount += payment.netAmount
|
||||
cryptoCodeToStats.EVM.fiatAmount += payment.netAmount * payment.rate
|
||||
}
|
||||
})
|
||||
|
||||
if (!donation.cryptoPayments) {
|
||||
|
||||
@@ -202,7 +202,7 @@ async function handleDonationOrMembership(body: WebhookBody) {
|
||||
} catch (error) {
|
||||
log(
|
||||
'error',
|
||||
`[Stripe webhook] Failed to give points for invoice ${body.invoiceId}. Rolling back.`
|
||||
`[BTCPay webhook] Failed to give points for invoice ${body.invoiceId}. Rolling back.`
|
||||
)
|
||||
await prisma.donation.delete({ where: { id: donation.id } })
|
||||
throw error
|
||||
|
||||
233
pages/api/coinbasecommerce/webhook.ts
Normal file
233
pages/api/coinbasecommerce/webhook.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
import getRawBody from 'raw-body'
|
||||
import crypto from 'crypto'
|
||||
|
||||
import { env } from '../../../env.mjs'
|
||||
import { DonationCryptoPayments, DonationMetadata } from '../../../server/types'
|
||||
import { prisma } from '../../../server/services'
|
||||
import { log } from '../../../utils/logging'
|
||||
import dayjs from 'dayjs'
|
||||
import { NET_DONATION_AMOUNT_WITH_POINTS_RATE, POINTS_PER_USD } from '../../../config'
|
||||
import { givePointsToUser } from '../../../server/utils/perks'
|
||||
import { addUserToPgMembersGroup } from '../../../utils/pg-forum-connection'
|
||||
import { getDonationAttestation, getMembershipAttestation } from '../../../server/utils/attestation'
|
||||
import { sendDonationConfirmationEmail } from '../../../server/utils/mailing'
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: false,
|
||||
},
|
||||
}
|
||||
|
||||
type WebhookBody = {
|
||||
id: string
|
||||
scheduled_for: string
|
||||
attempt_number: number
|
||||
event: {
|
||||
id: string
|
||||
resource: string
|
||||
type: string
|
||||
api_version: string
|
||||
created_at: string
|
||||
data: {
|
||||
code: string
|
||||
id: string
|
||||
resource: string
|
||||
name: string
|
||||
description: string
|
||||
hosted_url: string
|
||||
created_at: string
|
||||
expires_at: string
|
||||
support_email: string
|
||||
pricing_type: string
|
||||
pricing: {
|
||||
local: {
|
||||
amount: string
|
||||
currency: string
|
||||
}
|
||||
settlement: {
|
||||
amount: string
|
||||
currency: string
|
||||
}
|
||||
}
|
||||
pwcb_only: boolean
|
||||
offchain_eligible: boolean
|
||||
coinbase_managed_merchant: boolean
|
||||
collected_email: boolean
|
||||
fee_rate: number
|
||||
metadata: DonationMetadata
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDonationOrMembership(body: WebhookBody) {
|
||||
if (!body.event.data.metadata || JSON.stringify(body.event.data.metadata) === '{}') return
|
||||
|
||||
const metadata: DonationMetadata = body.event.data.metadata
|
||||
const chargeId = body.event.data.id
|
||||
|
||||
const existingDonation = await prisma.donation.findFirst({
|
||||
where: { coinbaseChargeId: chargeId },
|
||||
})
|
||||
|
||||
if (existingDonation) {
|
||||
log('warn', `[Coinbase webhook] Attempted to process already processed charge ${chargeId}.`)
|
||||
return
|
||||
}
|
||||
|
||||
const termToMembershipExpiresAt = {
|
||||
monthly: dayjs().add(1, 'month').toDate(),
|
||||
annually: dayjs().add(1, 'year').toDate(),
|
||||
} as const
|
||||
|
||||
let membershipExpiresAt = null
|
||||
|
||||
if (metadata.isMembership === 'true' && metadata.membershipTerm) {
|
||||
membershipExpiresAt = termToMembershipExpiresAt[metadata.membershipTerm]
|
||||
}
|
||||
|
||||
const shouldGivePointsBack = metadata.givePointsBack === 'true'
|
||||
|
||||
const grossFiatAmount = Number(body.event.data.pricing.local.amount)
|
||||
|
||||
const netFiatAmount = shouldGivePointsBack
|
||||
? grossFiatAmount * NET_DONATION_AMOUNT_WITH_POINTS_RATE
|
||||
: grossFiatAmount
|
||||
|
||||
const pointsToGive = shouldGivePointsBack ? Math.floor(grossFiatAmount / POINTS_PER_USD) : 0
|
||||
|
||||
const cryptoPayments: DonationCryptoPayments = [
|
||||
{
|
||||
cryptoCode: body.event.data.pricing.settlement.currency,
|
||||
grossAmount: Number(body.event.data.pricing.settlement.amount),
|
||||
netAmount:
|
||||
Number(body.event.data.pricing.settlement.amount) * NET_DONATION_AMOUNT_WITH_POINTS_RATE,
|
||||
rate: Number(body.event.data.pricing.settlement.amount) / grossFiatAmount,
|
||||
},
|
||||
]
|
||||
|
||||
const donation = await prisma.donation.create({
|
||||
data: {
|
||||
userId: metadata.userId,
|
||||
coinbaseChargeId: chargeId,
|
||||
projectName: metadata.projectName,
|
||||
projectSlug: metadata.projectSlug,
|
||||
fundSlug: metadata.fundSlug,
|
||||
cryptoPayments,
|
||||
grossFiatAmount: Number(grossFiatAmount.toFixed(2)),
|
||||
netFiatAmount: Number(netFiatAmount.toFixed(2)),
|
||||
pointsAdded: pointsToGive,
|
||||
membershipExpiresAt,
|
||||
membershipTerm: metadata.membershipTerm || null,
|
||||
showDonorNameOnLeaderboard: metadata.showDonorNameOnLeaderboard === 'true',
|
||||
donorName: metadata.donorName,
|
||||
},
|
||||
})
|
||||
|
||||
// Add points
|
||||
if (shouldGivePointsBack && metadata.userId) {
|
||||
try {
|
||||
await givePointsToUser({ pointsToGive, donation })
|
||||
} catch (error) {
|
||||
log('error', `[Coinbase webhook] Failed to give points for charge ${chargeId}. Rolling back.`)
|
||||
await prisma.donation.delete({ where: { id: donation.id } })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Add PG forum user to membership group
|
||||
if (metadata.isMembership && metadata.fundSlug === 'privacyguides' && metadata.userId) {
|
||||
try {
|
||||
await addUserToPgMembersGroup(metadata.userId)
|
||||
} catch (error) {
|
||||
log(
|
||||
'warn',
|
||||
`[Coinbase webhook] Could not add user ${metadata.userId} to PG forum members group. Charge: ${chargeId}. NOT rolling back. Continuing... Cause:`
|
||||
)
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
if (metadata.donorEmail && metadata.donorName) {
|
||||
let attestationMessage = ''
|
||||
let attestationSignature = ''
|
||||
|
||||
if (metadata.isMembership === 'true' && metadata.membershipTerm) {
|
||||
const attestation = await getMembershipAttestation({
|
||||
donorName: metadata.donorName,
|
||||
donorEmail: metadata.donorEmail,
|
||||
totalAmountToDate: grossFiatAmount,
|
||||
donation,
|
||||
})
|
||||
|
||||
attestationMessage = attestation.message
|
||||
attestationSignature = attestation.signature
|
||||
}
|
||||
|
||||
if (metadata.isMembership === 'false') {
|
||||
const attestation = await getDonationAttestation({
|
||||
donorName: metadata.donorName,
|
||||
donorEmail: metadata.donorEmail,
|
||||
donation,
|
||||
})
|
||||
|
||||
attestationMessage = attestation.message
|
||||
attestationSignature = attestation.signature
|
||||
}
|
||||
|
||||
try {
|
||||
await sendDonationConfirmationEmail({
|
||||
to: metadata.donorEmail,
|
||||
donorName: metadata.donorName,
|
||||
donation,
|
||||
attestationMessage,
|
||||
attestationSignature,
|
||||
})
|
||||
} catch (error) {
|
||||
log(
|
||||
'warn',
|
||||
`[Coinbase webhook] Failed to send donation confirmation email for charge ${chargeId}. NOT rolling back. Cause:`
|
||||
)
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
log('info', `[Coinbase webhook] Successfully processed charge ${chargeId}!`)
|
||||
}
|
||||
|
||||
async function handleCoinbaseCommerceWebhook(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method !== 'POST') {
|
||||
res.setHeader('Allow', ['POST'])
|
||||
res.status(405).end(`Method ${req.method} Not Allowed`)
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof req.headers['x-cc-webhook-signature'] !== 'string') {
|
||||
res.status(400).json({ success: false })
|
||||
return
|
||||
}
|
||||
|
||||
const rawBody = await getRawBody(req)
|
||||
const body: WebhookBody = JSON.parse(Buffer.from(rawBody).toString('utf8'))
|
||||
|
||||
const expectedSigHash = crypto
|
||||
.createHmac('sha256', env.COINBASE_COMMERCE_WEBHOOK_SECRET)
|
||||
.update(rawBody)
|
||||
.digest('hex')
|
||||
|
||||
const incomingSigHash = req.headers['x-cc-webhook-signature'] as string
|
||||
|
||||
if (expectedSigHash !== incomingSigHash) {
|
||||
console.error('Invalid signature')
|
||||
res.status(401).json({ success: false })
|
||||
return
|
||||
}
|
||||
|
||||
if (body.event.type === 'charge:confirmed') {
|
||||
await handleDonationOrMembership(body)
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true })
|
||||
}
|
||||
|
||||
export default handleCoinbaseCommerceWebhook
|
||||
@@ -0,0 +1,11 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- A unique constraint covering the columns `[coinbaseChargeId]` on the table `Donation` will be added. If there are existing duplicate values, this will fail.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "Donation" ADD COLUMN "coinbaseChargeId" TEXT;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Donation_coinbaseChargeId_key" ON "Donation"("coinbaseChargeId");
|
||||
@@ -41,6 +41,7 @@ model Donation {
|
||||
stripePaymentIntentId String? @unique // For donations and non-recurring memberships
|
||||
stripeInvoiceId String? @unique // For recurring memberships
|
||||
stripeSubscriptionId String? // For recurring memberships
|
||||
coinbaseChargeId String? @unique
|
||||
projectSlug String
|
||||
projectName String
|
||||
fundSlug FundSlug
|
||||
|
||||
@@ -19,6 +19,7 @@ import { BtcPayCreateInvoiceRes, DonationMetadata } from '../types'
|
||||
import { funds, fundSlugs } from '../../utils/funds'
|
||||
import { fundSlugToCustomerIdAttr } from '../utils/funds'
|
||||
import { getDonationAttestation, getMembershipAttestation } from '../utils/attestation'
|
||||
import { createCoinbaseCharge } from '../utils/coinbase-commerce'
|
||||
|
||||
export const donationRouter = router({
|
||||
donateWithFiat: publicProcedure
|
||||
@@ -136,6 +137,7 @@ export const donationRouter = router({
|
||||
donateWithCrypto: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
paymentMethod: z.enum(['btc', 'xmr', 'ltc', 'evm']),
|
||||
name: z.string().trim().min(1).nullable(),
|
||||
email: z.string().trim().email().nullable(),
|
||||
projectName: z.string().min(1),
|
||||
@@ -176,14 +178,31 @@ export const donationRouter = router({
|
||||
showDonorNameOnLeaderboard: input.showDonorNameOnLeaderboard ? 'true' : 'false',
|
||||
}
|
||||
|
||||
const { data: invoice } = await btcpayApi.post<BtcPayCreateInvoiceRes>(`/invoices`, {
|
||||
amount: input.amount,
|
||||
currency: CURRENCY,
|
||||
metadata,
|
||||
checkout: { redirectURL: `${env.APP_URL}/${input.fundSlug}/thankyou` },
|
||||
})
|
||||
let url = ''
|
||||
|
||||
const url = invoice.checkoutLink.replace(/^(https?:\/\/)([^\/]+)/, env.BTCPAY_EXTERNAL_URL)
|
||||
if (input.paymentMethod !== 'evm') {
|
||||
const { data: invoice } = await btcpayApi.post<BtcPayCreateInvoiceRes>(`/invoices`, {
|
||||
amount: input.amount,
|
||||
currency: CURRENCY,
|
||||
metadata,
|
||||
checkout: {
|
||||
redirectURL: `${env.APP_URL}/${input.fundSlug}/thankyou`,
|
||||
defaultPaymentMethod: input.paymentMethod.toUpperCase(),
|
||||
},
|
||||
})
|
||||
|
||||
url = invoice.checkoutLink.replace(/^(https?:\/\/)([^\/]+)/, env.BTCPAY_EXTERNAL_URL)
|
||||
}
|
||||
|
||||
if (input.paymentMethod === 'evm') {
|
||||
const charge = await createCoinbaseCharge({
|
||||
amountUsd: 0.1,
|
||||
fundSlug: input.fundSlug,
|
||||
metadata,
|
||||
})
|
||||
|
||||
url = charge.hosted_url
|
||||
}
|
||||
|
||||
return { url }
|
||||
}),
|
||||
@@ -329,6 +348,7 @@ export const donationRouter = router({
|
||||
payMembershipWithCrypto: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
paymentMethod: z.enum(['btc', 'xmr', 'ltc', 'evm']),
|
||||
fundSlug: z.enum(fundSlugs),
|
||||
amount: z.number(),
|
||||
term: z.enum(['monthly', 'annually']),
|
||||
@@ -390,14 +410,33 @@ export const donationRouter = router({
|
||||
showDonorNameOnLeaderboard: 'false',
|
||||
}
|
||||
|
||||
const { data: invoice } = await btcpayApi.post<BtcPayCreateInvoiceRes>(`/invoices`, {
|
||||
amount: input.amount,
|
||||
currency: CURRENCY,
|
||||
metadata,
|
||||
checkout: { redirectURL: `${env.APP_URL}/${input.fundSlug}/thankyou` },
|
||||
})
|
||||
let url = ''
|
||||
|
||||
return { url: invoice.checkoutLink }
|
||||
if (input.paymentMethod !== 'evm') {
|
||||
const { data: invoice } = await btcpayApi.post<BtcPayCreateInvoiceRes>(`/invoices`, {
|
||||
amount: input.amount,
|
||||
currency: CURRENCY,
|
||||
metadata,
|
||||
checkout: {
|
||||
redirectURL: `${env.APP_URL}/${input.fundSlug}/thankyou`,
|
||||
defaultPaymentMethod: input.paymentMethod.toUpperCase(),
|
||||
},
|
||||
})
|
||||
|
||||
url = invoice.checkoutLink.replace(/^(https?:\/\/)([^\/]+)/, env.BTCPAY_EXTERNAL_URL)
|
||||
}
|
||||
|
||||
if (input.paymentMethod === 'evm') {
|
||||
const charge = await createCoinbaseCharge({
|
||||
amountUsd: input.amount,
|
||||
fundSlug: input.fundSlug,
|
||||
metadata,
|
||||
})
|
||||
|
||||
url = charge.hosted_url
|
||||
}
|
||||
|
||||
return { url }
|
||||
}),
|
||||
|
||||
donationList: protectedProcedure
|
||||
|
||||
@@ -60,6 +60,11 @@ const privacyGuidesDiscourseApi = axios.create({
|
||||
},
|
||||
})
|
||||
|
||||
const coinbaseCommerceApi = axios.create({
|
||||
baseURL: 'https://api.commerce.coinbase.com',
|
||||
headers: { 'X-CC-Api-Key': env.COINBASE_COMMERCE_API_KEY },
|
||||
})
|
||||
|
||||
export {
|
||||
prisma,
|
||||
keycloak,
|
||||
@@ -69,4 +74,5 @@ export {
|
||||
printfulApi,
|
||||
stripe,
|
||||
privacyGuidesDiscourseApi,
|
||||
coinbaseCommerceApi,
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ export type DonationMetadata = {
|
||||
}
|
||||
|
||||
export type DonationCryptoPayments = {
|
||||
cryptoCode: 'BTC' | 'XMR' | 'LTC' | 'MANUAL'
|
||||
cryptoCode: 'BTC' | 'XMR' | 'LTC' | 'MANUAL' | string
|
||||
grossAmount: number
|
||||
netAmount: number
|
||||
rate: number
|
||||
|
||||
46
server/utils/coinbase-commerce.ts
Normal file
46
server/utils/coinbase-commerce.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { FundSlug } from '@prisma/client'
|
||||
import { DonationMetadata } from '../types'
|
||||
import { coinbaseCommerceApi } from '../services'
|
||||
import { env } from '../../env.mjs'
|
||||
import { AxiosResponse } from 'axios'
|
||||
|
||||
type CreateCoinbaseChargeFunParams = {
|
||||
amountUsd: number
|
||||
metadata: DonationMetadata
|
||||
fundSlug: FundSlug
|
||||
}
|
||||
|
||||
type CreateCoinbaseChargeBody = {
|
||||
cancel_url?: string
|
||||
redirect_url?: string
|
||||
local_price: {
|
||||
amount: string
|
||||
currency: string
|
||||
}
|
||||
pricing_type: 'fixed_price' | 'no_price'
|
||||
metadata: DonationMetadata
|
||||
}
|
||||
|
||||
type CreateCoinbaseChargeRes = {
|
||||
data: {
|
||||
hosted_url: string
|
||||
}
|
||||
}
|
||||
|
||||
export async function createCoinbaseCharge({ amountUsd, metadata }: CreateCoinbaseChargeFunParams) {
|
||||
const {
|
||||
data: { data },
|
||||
} = await coinbaseCommerceApi.post<
|
||||
any,
|
||||
AxiosResponse<CreateCoinbaseChargeRes>,
|
||||
CreateCoinbaseChargeBody
|
||||
>('/charges', {
|
||||
local_price: { amount: amountUsd.toString(), currency: 'usd' },
|
||||
pricing_type: 'fixed_price',
|
||||
metadata,
|
||||
redirect_url: `${env.APP_URL}/${metadata.fundSlug}/thankyou`,
|
||||
cancel_url: `${env.APP_URL}/${metadata.fundSlug}`,
|
||||
})
|
||||
|
||||
return data
|
||||
}
|
||||
@@ -19,15 +19,18 @@ export const funds: Record<FundSlug, ProjectItem & { slug: FundSlug }> = {
|
||||
numDonationsBTC: 0,
|
||||
numDonationsXMR: 0,
|
||||
numDonationsLTC: 0,
|
||||
numDonationsEVM: 0,
|
||||
numDonationsFiat: 0,
|
||||
numDonationsManual: 0,
|
||||
totalDonationsBTC: 0,
|
||||
totalDonationsXMR: 0,
|
||||
totalDonationsLTC: 0,
|
||||
totalDonationsEVM: 0,
|
||||
totalDonationsFiat: 0,
|
||||
totalDonationsBTCInFiat: 0,
|
||||
totalDonationsXMRInFiat: 0,
|
||||
totalDonationsLTCInFiat: 0,
|
||||
totalDonationsEVMInFiat: 0,
|
||||
totalDonationsManual: 0,
|
||||
},
|
||||
firo: {
|
||||
@@ -46,15 +49,18 @@ export const funds: Record<FundSlug, ProjectItem & { slug: FundSlug }> = {
|
||||
numDonationsBTC: 0,
|
||||
numDonationsXMR: 0,
|
||||
numDonationsLTC: 0,
|
||||
numDonationsEVM: 0,
|
||||
numDonationsFiat: 0,
|
||||
numDonationsManual: 0,
|
||||
totalDonationsBTC: 0,
|
||||
totalDonationsXMR: 0,
|
||||
totalDonationsLTC: 0,
|
||||
totalDonationsEVM: 0,
|
||||
totalDonationsFiat: 0,
|
||||
totalDonationsBTCInFiat: 0,
|
||||
totalDonationsXMRInFiat: 0,
|
||||
totalDonationsLTCInFiat: 0,
|
||||
totalDonationsEVMInFiat: 0,
|
||||
totalDonationsManual: 0,
|
||||
},
|
||||
privacyguides: {
|
||||
@@ -78,15 +84,18 @@ export const funds: Record<FundSlug, ProjectItem & { slug: FundSlug }> = {
|
||||
numDonationsBTC: 0,
|
||||
numDonationsXMR: 0,
|
||||
numDonationsLTC: 0,
|
||||
numDonationsEVM: 0,
|
||||
numDonationsFiat: 0,
|
||||
numDonationsManual: 0,
|
||||
totalDonationsBTC: 0,
|
||||
totalDonationsXMR: 0,
|
||||
totalDonationsLTC: 0,
|
||||
totalDonationsEVM: 0,
|
||||
totalDonationsFiat: 0,
|
||||
totalDonationsBTCInFiat: 0,
|
||||
totalDonationsXMRInFiat: 0,
|
||||
totalDonationsLTCInFiat: 0,
|
||||
totalDonationsEVMInFiat: 0,
|
||||
totalDonationsManual: 0,
|
||||
},
|
||||
general: {
|
||||
@@ -109,15 +118,18 @@ export const funds: Record<FundSlug, ProjectItem & { slug: FundSlug }> = {
|
||||
numDonationsBTC: 0,
|
||||
numDonationsXMR: 0,
|
||||
numDonationsLTC: 0,
|
||||
numDonationsEVM: 0,
|
||||
numDonationsFiat: 0,
|
||||
numDonationsManual: 0,
|
||||
totalDonationsBTC: 0,
|
||||
totalDonationsXMR: 0,
|
||||
totalDonationsLTC: 0,
|
||||
totalDonationsEVM: 0,
|
||||
totalDonationsFiat: 0,
|
||||
totalDonationsBTCInFiat: 0,
|
||||
totalDonationsXMRInFiat: 0,
|
||||
totalDonationsLTCInFiat: 0,
|
||||
totalDonationsEVMInFiat: 0,
|
||||
totalDonationsManual: 0,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -63,16 +63,19 @@ export function getProjectBySlug(slug: string, fundSlug: FundSlug) {
|
||||
numDonationsBTC: data.numDonationsBTC || 0,
|
||||
numDonationsXMR: data.numDonationsXMR || 0,
|
||||
numDonationsLTC: data.numDonationsLTC || 0,
|
||||
numDonationsEVM: data.numDonationsEVM || 0,
|
||||
numDonationsFiat: data.numDonationsFiat || 0,
|
||||
numDonationsManual: data.numDonationsManual || 0,
|
||||
totalDonationsBTC: data.totalDonationsBTC || 0,
|
||||
totalDonationsXMR: data.totalDonationsXMR || 0,
|
||||
totalDonationsLTC: data.totalDonationsLTC || 0,
|
||||
totalDonationsEVM: data.totalDonationsEVM || 0,
|
||||
totalDonationsFiat: data.totalDonationsFiat || 0,
|
||||
totalDonationsManual: data.totalDonationsManual || 0,
|
||||
totalDonationsBTCInFiat: data.totalDonationsBTCInFiat || 0,
|
||||
totalDonationsXMRInFiat: data.totalDonationsXMRInFiat || 0,
|
||||
totalDonationsLTCInFiat: data.totalDonationsLTCInFiat || 0,
|
||||
totalDonationsEVMInFiat: data.totalDonationsEVMInFiat || 0,
|
||||
}
|
||||
|
||||
return project
|
||||
|
||||
@@ -17,16 +17,19 @@ export type ProjectItem = {
|
||||
numDonationsBTC: number
|
||||
numDonationsXMR: number
|
||||
numDonationsLTC: number
|
||||
numDonationsEVM: number
|
||||
numDonationsFiat: number
|
||||
numDonationsManual: number
|
||||
totalDonationsBTC: number
|
||||
totalDonationsXMR: number
|
||||
totalDonationsLTC: number
|
||||
totalDonationsEVM: number
|
||||
totalDonationsFiat: number
|
||||
totalDonationsManual: number
|
||||
totalDonationsBTCInFiat: number
|
||||
totalDonationsXMRInFiat: number
|
||||
totalDonationsLTCInFiat: number
|
||||
totalDonationsEVMInFiat: number
|
||||
}
|
||||
|
||||
export type PayReq = {
|
||||
@@ -53,6 +56,11 @@ export type ProjectDonationStats = {
|
||||
amount: number
|
||||
fiatAmount: number
|
||||
}
|
||||
evm: {
|
||||
count: number
|
||||
amount: number
|
||||
fiatAmount: number
|
||||
}
|
||||
manual: {
|
||||
count: number
|
||||
amount: number
|
||||
|
||||
Reference in New Issue
Block a user