mirror of
https://github.com/MAGICGrants/campaign-site.git
synced 2026-01-08 20:08:05 -05:00
Merge pull request #159 from MAGICGrants/general-refactor
General refactor
This commit is contained in:
@@ -1,27 +1,45 @@
|
||||
import { networkFor, SocialIcon } from 'react-social-icons'
|
||||
import { ReactNode } from 'react'
|
||||
import { ReactNode, SVGProps } from 'react'
|
||||
import { FundSlug } from '@prisma/client'
|
||||
import Image from 'next/image'
|
||||
|
||||
import { ProjectItem } from '../utils/types'
|
||||
import CustomLink from './CustomLink'
|
||||
import WebIcon from './WebIcon'
|
||||
import MagicLogo from './MagicLogo'
|
||||
import MoneroLogo from './MoneroLogo'
|
||||
import FiroLogo from './FiroLogo'
|
||||
import PrivacyGuidesLogo from './PrivacyGuidesLogo'
|
||||
|
||||
interface Props {
|
||||
project: ProjectItem
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
const placeholderImages: Record<FundSlug, (props: SVGProps<SVGSVGElement>) => JSX.Element> = {
|
||||
monero: MoneroLogo,
|
||||
firo: FiroLogo,
|
||||
privacyguides: PrivacyGuidesLogo,
|
||||
general: MagicLogo,
|
||||
}
|
||||
|
||||
export default function PageHeading({ project, children }: Props) {
|
||||
const PlaceholderImage = placeholderImages[project.fund]
|
||||
|
||||
return (
|
||||
<div className="divide-y divide-gray-200">
|
||||
<div className="items-start space-y-2 pb-8 pt-6 md:space-y-5 xl:grid xl:grid-cols-3 xl:gap-x-8">
|
||||
<Image
|
||||
src={project.coverImage}
|
||||
alt="avatar"
|
||||
width={300}
|
||||
height={300}
|
||||
className="h-60 w-60 mx-auto my-auto object-contain row-span-3 hidden xl:block"
|
||||
/>
|
||||
{project.coverImage ? (
|
||||
<Image
|
||||
src={project.coverImage}
|
||||
alt="avatar"
|
||||
width={300}
|
||||
height={300}
|
||||
className="h-60 w-60 mx-auto my-auto object-contain row-span-3 hidden xl:block"
|
||||
/>
|
||||
) : (
|
||||
<PlaceholderImage className="w-60 h-60 mx-auto my-auto object-contain row-span-3 hidden xl:block" />
|
||||
)}
|
||||
|
||||
<h1 className="text-3xl font-extrabold leading-9 tracking-tight text-gray-900 sm:text-4xl sm:leading-10 md:text-6xl md:leading-14 xl:col-span-2">
|
||||
{!!project.website && (
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
type ProgressProps = { current: number; goal: number }
|
||||
import { formatUsd } from '../utils/money-formating'
|
||||
|
||||
const Progress = ({ current, goal }: ProgressProps) => {
|
||||
type ProgressProps = { current: number; goal: number; percentOnly?: boolean }
|
||||
|
||||
const numberFormat = Intl.NumberFormat('en', { notation: 'compact', compactDisplay: 'short' })
|
||||
|
||||
const Progress = ({ current, goal, percentOnly }: ProgressProps) => {
|
||||
const percent = Math.floor((current / goal) * 100)
|
||||
|
||||
return (
|
||||
@@ -12,7 +16,14 @@ const Progress = ({ current, goal }: ProgressProps) => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span className="text-sm font-semibold">{percent < 100 ? percent : 100}%</span>
|
||||
<span className="text-sm">
|
||||
Raised <strong>{percent < 100 ? percent : 100}%</strong>{' '}
|
||||
{!percentOnly && (
|
||||
<>
|
||||
of <strong className="text-green-500">${numberFormat.format(goal)}</strong> Goal
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, SVGProps } from 'react'
|
||||
import { FundSlug } from '@prisma/client'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
|
||||
import { ProjectItem } from '../utils/types'
|
||||
import { cn } from '../utils/cn'
|
||||
import Progress from './Progress'
|
||||
import MoneroLogo from './MoneroLogo'
|
||||
import FiroLogo from './FiroLogo'
|
||||
import PrivacyGuidesLogo from './PrivacyGuidesLogo'
|
||||
import MagicLogo from './MagicLogo'
|
||||
|
||||
const numberFormat = Intl.NumberFormat('en', { notation: 'compact', compactDisplay: 'short' })
|
||||
|
||||
@@ -13,21 +18,15 @@ export type ProjectCardProps = {
|
||||
customImageStyles?: React.CSSProperties
|
||||
}
|
||||
|
||||
const placeholderImages: Record<FundSlug, (props: SVGProps<SVGSVGElement>) => JSX.Element> = {
|
||||
monero: MoneroLogo,
|
||||
firo: FiroLogo,
|
||||
privacyguides: PrivacyGuidesLogo,
|
||||
general: MagicLogo,
|
||||
}
|
||||
|
||||
const ProjectCard: React.FC<ProjectCardProps> = ({ project, customImageStyles }) => {
|
||||
const [isHorizontal, setIsHorizontal] = useState<boolean | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const img = document.createElement('img')
|
||||
img.src = project.coverImage
|
||||
|
||||
// check if image is horizontal - added additional 10% to height to ensure only true
|
||||
// horizontals get flagged.
|
||||
img.onload = () => {
|
||||
const { naturalWidth, naturalHeight } = img
|
||||
const isHorizontal = naturalWidth >= naturalHeight * 1.1
|
||||
setIsHorizontal(isHorizontal)
|
||||
}
|
||||
}, [project.coverImage])
|
||||
const PlaceholderImage = placeholderImages[project.fund]
|
||||
|
||||
return (
|
||||
<Link href={`/${project.fund}/projects/${project.slug}`} passHref target="_blank">
|
||||
@@ -40,16 +39,20 @@ const ProjectCard: React.FC<ProjectCardProps> = ({ project, customImageStyles })
|
||||
project.fund === 'general' && 'border-primary'
|
||||
)}
|
||||
>
|
||||
<div className="flex h-36 w-full sm:h-52">
|
||||
<Image
|
||||
alt={project.title}
|
||||
src={project.coverImage}
|
||||
width={1200}
|
||||
height={1200}
|
||||
style={{ objectFit: 'contain', ...customImageStyles }}
|
||||
priority={true}
|
||||
className="cursor-pointer rounded-t-xl bg-white"
|
||||
/>
|
||||
<div className="flex h-48 w-full sm:h-52">
|
||||
{project.coverImage ? (
|
||||
<Image
|
||||
alt={project.title}
|
||||
src={project.coverImage}
|
||||
width={700}
|
||||
height={700}
|
||||
style={{ objectFit: 'contain', ...customImageStyles }}
|
||||
priority={true}
|
||||
className="cursor-pointer rounded-t-xl bg-white"
|
||||
/>
|
||||
) : (
|
||||
<PlaceholderImage className="w-1/2 h-full max-h-full m-auto cursor-pointer rounded-t-xl bg-white" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<figcaption className="p-5 flex flex-col grow space-y-4 justify-between">
|
||||
@@ -73,6 +76,7 @@ const ProjectCard: React.FC<ProjectCardProps> = ({ project, customImageStyles })
|
||||
project.totalDonationsFiat
|
||||
}
|
||||
goal={project.goal}
|
||||
percentOnly
|
||||
/>
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
@@ -6,3 +6,4 @@ export const MONTHLY_MEMBERSHIP_MIN_PRICE_USD = 10
|
||||
export const ANNUALLY_MEMBERSHIP_MIN_PRICE_USD = 100
|
||||
export const POINTS_PER_USD = 1
|
||||
export const POINTS_REDEEM_PRICE_USD = 0.1
|
||||
export const NET_DONATION_AMOUNT_WITH_POINTS_RATE = 0.9
|
||||
|
||||
@@ -7,7 +7,7 @@ services:
|
||||
magic-btcpayserver:
|
||||
restart: unless-stopped
|
||||
container_name: magic-btcpayserver
|
||||
image: ${BTCPAY_IMAGE:-btcpayserver/btcpayserver:1.13.3-altcoins}
|
||||
image: ${BTCPAY_IMAGE:-btcpayserver/btcpayserver:2.0.8}
|
||||
expose:
|
||||
- '49392'
|
||||
environment:
|
||||
@@ -25,7 +25,7 @@ services:
|
||||
BTCPAY_DOCKERDEPLOYMENT: 'true'
|
||||
BTCPAY_CHAINS: 'xmr'
|
||||
BTCPAY_XMR_DAEMON_URI: http://xmr-node.cakewallet.com:18081
|
||||
BTCPAY_XMR_WALLET_DAEMON_URI: http://monerod_wallet:18082
|
||||
BTCPAY_XMR_WALLET_DAEMON_URI: http://magic-monerod-wallet:18088
|
||||
BTCPAY_XMR_WALLET_DAEMON_WALLETDIR: /root/xmr_wallet
|
||||
labels:
|
||||
traefik.enable: 'true'
|
||||
@@ -47,11 +47,9 @@ services:
|
||||
restart: unless-stopped
|
||||
container_name: magic-monerod-wallet
|
||||
image: btcpayserver/monero:0.18.3.3
|
||||
entrypoint: monero-wallet-rpc --rpc-bind-ip=0.0.0.0 --disable-rpc-login --confirm-external-bind --rpc-bind-port=18082 --non-interactive --trusted-daemon --daemon-address=xmr-node.cakewallet.com:18081 --wallet-file=/wallet/wallet --password-file /wallet/password --tx-notify="/bin/sh ./scripts/notifier.sh -X GET http://btcpayserver:49392/monerolikedaemoncallback/tx?cryptoCode=xmr&hash=%s"
|
||||
entrypoint: monero-wallet-rpc --rpc-bind-ip=0.0.0.0 --disable-rpc-login --confirm-external-bind --rpc-bind-port=18088 --non-interactive --trusted-daemon --daemon-address=xmr-node.cakewallet.com:18081 --wallet-file=/wallet/wallet --password-file /wallet/password --tx-notify="/bin/sh ./scripts/notifier.sh -X GET http://magic-btcpayserver:49392/monerolikedaemoncallback/tx?cryptoCode=xmr&hash=%s"
|
||||
expose:
|
||||
- '18082'
|
||||
ports:
|
||||
- 18082:18082
|
||||
- '18088'
|
||||
volumes:
|
||||
- 'xmr_wallet:/wallet'
|
||||
|
||||
|
||||
234
package-lock.json
generated
234
package-lock.json
generated
@@ -139,13 +139,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.24.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.24.7.tgz",
|
||||
"integrity": "sha512-BcYH1CVJBO9tvyIZ2jVeXgSIMvGZ2FDRvDdOIVQyuklNKSsx+eppDEBq/g47Ayw+RqNFE+URvOShmf+f/qwAlA==",
|
||||
"version": "7.26.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz",
|
||||
"integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/highlight": "^7.24.7",
|
||||
"@babel/helper-validator-identifier": "^7.25.9",
|
||||
"js-tokens": "^4.0.0",
|
||||
"picocolors": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
@@ -445,9 +446,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-string-parser": {
|
||||
"version": "7.24.8",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.24.8.tgz",
|
||||
"integrity": "sha512-pO9KhhRcuUyGnJWwyEgnRJTSIZHiT+vMD0kPeD+so0l7mxkMT19g3pjY9GTnHySck/hDzq+dtW/4VgnMkippsQ==",
|
||||
"version": "7.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz",
|
||||
"integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -455,9 +456,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-validator-identifier": {
|
||||
"version": "7.24.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.7.tgz",
|
||||
"integrity": "sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w==",
|
||||
"version": "7.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz",
|
||||
"integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -490,43 +491,27 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helpers": {
|
||||
"version": "7.25.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.25.6.tgz",
|
||||
"integrity": "sha512-Xg0tn4HcfTijTwfDwYlvVCl43V6h4KyVVX2aEm4qdO/PC6L2YvzLHFdmxhoeSA3eslcE6+ZVXHgWwopXYLNq4Q==",
|
||||
"version": "7.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.0.tgz",
|
||||
"integrity": "sha512-U5eyP/CTFPuNE3qk+WZMxFkp/4zUzdceQlfzf7DdGdhp+Fezd7HD+i8Y24ZuTMKX3wQBld449jijbGq6OdGNQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/template": "^7.25.0",
|
||||
"@babel/types": "^7.25.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/highlight": {
|
||||
"version": "7.24.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.24.7.tgz",
|
||||
"integrity": "sha512-EStJpq4OuY8xYfhGVXngigBJRWxftKX9ksiGDnmlY3o7B/V7KIAc9X4oiK87uPJSc/vs5L869bem5fhZa8caZw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-validator-identifier": "^7.24.7",
|
||||
"chalk": "^2.4.2",
|
||||
"js-tokens": "^4.0.0",
|
||||
"picocolors": "^1.0.0"
|
||||
"@babel/template": "^7.27.0",
|
||||
"@babel/types": "^7.27.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/parser": {
|
||||
"version": "7.25.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.25.6.tgz",
|
||||
"integrity": "sha512-trGdfBdbD0l1ZPmcJ83eNxB9rbEax4ALFTF7fN386TMYbeCQbyme5cOEXQhbGXKebwGaB/J52w1mrklMcbgy6Q==",
|
||||
"version": "7.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.0.tgz",
|
||||
"integrity": "sha512-iaepho73/2Pz7w2eMS0Q5f83+0RKI7i4xmiYeBmDzfRVbQtTOG7Ts0S4HzJVsTMGI9keU8rNfuZr8DKfSt7Yyg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.25.6"
|
||||
"@babel/types": "^7.27.0"
|
||||
},
|
||||
"bin": {
|
||||
"parser": "bin/babel-parser.js"
|
||||
@@ -2018,9 +2003,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.25.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.25.6.tgz",
|
||||
"integrity": "sha512-VBj9MYyDb9tuLq7yzqjgzt6Q+IBQLrGZfdjOekyEirZPHxXWoTSGUTMrpsfi58Up73d13NfYLv8HT9vmznjzhQ==",
|
||||
"version": "7.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz",
|
||||
"integrity": "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"regenerator-runtime": "^0.14.0"
|
||||
@@ -2030,15 +2015,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/template": {
|
||||
"version": "7.25.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.0.tgz",
|
||||
"integrity": "sha512-aOOgh1/5XzKvg1jvVz7AVrx2piJ2XBi227DHmbY6y+bM9H2FlN+IfecYu4Xl0cNiiVejlsCri89LUsbj8vJD9Q==",
|
||||
"version": "7.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.0.tgz",
|
||||
"integrity": "sha512-2ncevenBqXI6qRMukPlXwHKHchC7RyMuu4xv5JBXRfOGVcTy1mXCD12qrp7Jsoxll1EV3+9sE4GugBVRjT2jFA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.24.7",
|
||||
"@babel/parser": "^7.25.0",
|
||||
"@babel/types": "^7.25.0"
|
||||
"@babel/code-frame": "^7.26.2",
|
||||
"@babel/parser": "^7.27.0",
|
||||
"@babel/types": "^7.27.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -2064,15 +2049,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/types": {
|
||||
"version": "7.25.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.25.6.tgz",
|
||||
"integrity": "sha512-/l42B1qxpG6RdfYf343Uw1vmDjeNhneUXtzhojE7pDgfpEypmRhI6j1kr17XCVv4Cgl9HdAiQY2x0GwKm7rWCw==",
|
||||
"version": "7.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.0.tgz",
|
||||
"integrity": "sha512-H45s8fVLYjbhFH62dIJ3WtmJ6RSPt/3DRO0ZcT2SUiYiQyz3BLVb9ADEnLl91m74aQPS3AzzeajZHYOalWe3bg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-string-parser": "^7.24.8",
|
||||
"@babel/helper-validator-identifier": "^7.24.7",
|
||||
"to-fast-properties": "^2.0.0"
|
||||
"@babel/helper-string-parser": "^7.25.9",
|
||||
"@babel/helper-validator-identifier": "^7.25.9"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -2869,9 +2853,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@next/env": {
|
||||
"version": "14.2.23",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.23.tgz",
|
||||
"integrity": "sha512-CysUC9IO+2Bh0omJ3qrb47S8DtsTKbFidGm6ow4gXIG6reZybqxbkH2nhdEm1tC8SmgzDdpq3BIML0PWsmyUYA==",
|
||||
"version": "14.2.26",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.26.tgz",
|
||||
"integrity": "sha512-vO//GJ/YBco+H7xdQhzJxF7ub3SUwft76jwaeOyVVQFHCi5DCnkP16WHB+JBylo4vOKPoZBlR94Z8xBxNBdNJA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@next/eslint-plugin-next": {
|
||||
@@ -2885,9 +2869,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-arm64": {
|
||||
"version": "14.2.23",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.23.tgz",
|
||||
"integrity": "sha512-WhtEntt6NcbABA8ypEoFd3uzq5iAnrl9AnZt9dXdO+PZLACE32z3a3qA5OoV20JrbJfSJ6Sd6EqGZTrlRnGxQQ==",
|
||||
"version": "14.2.26",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.26.tgz",
|
||||
"integrity": "sha512-zDJY8gsKEseGAxG+C2hTMT0w9Nk9N1Sk1qV7vXYz9MEiyRoF5ogQX2+vplyUMIfygnjn9/A04I6yrUTRTuRiyQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2901,9 +2885,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-x64": {
|
||||
"version": "14.2.23",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.23.tgz",
|
||||
"integrity": "sha512-vwLw0HN2gVclT/ikO6EcE+LcIN+0mddJ53yG4eZd0rXkuEr/RnOaMH8wg/sYl5iz5AYYRo/l6XX7FIo6kwbw1Q==",
|
||||
"version": "14.2.26",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.26.tgz",
|
||||
"integrity": "sha512-U0adH5ryLfmTDkahLwG9sUQG2L0a9rYux8crQeC92rPhi3jGQEY47nByQHrVrt3prZigadwj/2HZ1LUUimuSbg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2917,9 +2901,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-gnu": {
|
||||
"version": "14.2.23",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.23.tgz",
|
||||
"integrity": "sha512-uuAYwD3At2fu5CH1wD7FpP87mnjAv4+DNvLaR9kiIi8DLStWSW304kF09p1EQfhcbUI1Py2vZlBO2VaVqMRtpg==",
|
||||
"version": "14.2.26",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.26.tgz",
|
||||
"integrity": "sha512-SINMl1I7UhfHGM7SoRiw0AbwnLEMUnJ/3XXVmhyptzriHbWvPPbbm0OEVG24uUKhuS1t0nvN/DBvm5kz6ZIqpg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2933,9 +2917,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-musl": {
|
||||
"version": "14.2.23",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.23.tgz",
|
||||
"integrity": "sha512-Mm5KHd7nGgeJ4EETvVgFuqKOyDh+UMXHXxye6wRRFDr4FdVRI6YTxajoV2aHE8jqC14xeAMVZvLqYqS7isHL+g==",
|
||||
"version": "14.2.26",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.26.tgz",
|
||||
"integrity": "sha512-s6JaezoyJK2DxrwHWxLWtJKlqKqTdi/zaYigDXUJ/gmx/72CrzdVZfMvUc6VqnZ7YEvRijvYo+0o4Z9DencduA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2949,9 +2933,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-gnu": {
|
||||
"version": "14.2.23",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.23.tgz",
|
||||
"integrity": "sha512-Ybfqlyzm4sMSEQO6lDksggAIxnvWSG2cDWnG2jgd+MLbHYn2pvFA8DQ4pT2Vjk3Cwrv+HIg7vXJ8lCiLz79qoQ==",
|
||||
"version": "14.2.26",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.26.tgz",
|
||||
"integrity": "sha512-FEXeUQi8/pLr/XI0hKbe0tgbLmHFRhgXOUiPScz2hk0hSmbGiU8aUqVslj/6C6KA38RzXnWoJXo4FMo6aBxjzg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2965,9 +2949,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-musl": {
|
||||
"version": "14.2.23",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.23.tgz",
|
||||
"integrity": "sha512-OSQX94sxd1gOUz3jhhdocnKsy4/peG8zV1HVaW6DLEbEmRRtUCUQZcKxUD9atLYa3RZA+YJx+WZdOnTkDuNDNA==",
|
||||
"version": "14.2.26",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.26.tgz",
|
||||
"integrity": "sha512-BUsomaO4d2DuXhXhgQCVt2jjX4B4/Thts8nDoIruEJkhE5ifeQFtvW5c9JkdOtYvE5p2G0hcwQ0UbRaQmQwaVg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2981,9 +2965,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-arm64-msvc": {
|
||||
"version": "14.2.23",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.23.tgz",
|
||||
"integrity": "sha512-ezmbgZy++XpIMTcTNd0L4k7+cNI4ET5vMv/oqNfTuSXkZtSA9BURElPFyarjjGtRgZ9/zuKDHoMdZwDZIY3ehQ==",
|
||||
"version": "14.2.26",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.26.tgz",
|
||||
"integrity": "sha512-5auwsMVzT7wbB2CZXQxDctpWbdEnEW/e66DyXO1DcgHxIyhP06awu+rHKshZE+lPLIGiwtjo7bsyeuubewwxMw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2997,9 +2981,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-ia32-msvc": {
|
||||
"version": "14.2.23",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.23.tgz",
|
||||
"integrity": "sha512-zfHZOGguFCqAJ7zldTKg4tJHPJyJCOFhpoJcVxKL9BSUHScVDnMdDuOU1zPPGdOzr/GWxbhYTjyiEgLEpAoFPA==",
|
||||
"version": "14.2.26",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.26.tgz",
|
||||
"integrity": "sha512-GQWg/Vbz9zUGi9X80lOeGsz1rMH/MtFO/XqigDznhhhTfDlDoynCM6982mPCbSlxJ/aveZcKtTlwfAjwhyxDpg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -3013,9 +2997,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-x64-msvc": {
|
||||
"version": "14.2.23",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.23.tgz",
|
||||
"integrity": "sha512-xCtq5BD553SzOgSZ7UH5LH+OATQihydObTrCTvVzOro8QiWYKdBVwcB2Mn2MLMo6DGW9yH1LSPw7jS7HhgJgjw==",
|
||||
"version": "14.2.26",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.26.tgz",
|
||||
"integrity": "sha512-2rdB3T1/Gp7bv1eQTTm9d1Y1sv9UuJ2LAwOE0Pe2prHKe32UNscj7YS13fRB37d0GAiGNR+Y7ZcW8YjDI8Ns0w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -5342,19 +5326,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-styles": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
|
||||
"integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^1.9.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/any-promise": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
|
||||
@@ -5661,9 +5632,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.7.7",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.7.7.tgz",
|
||||
"integrity": "sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==",
|
||||
"version": "1.8.4",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.8.4.tgz",
|
||||
"integrity": "sha512-eBSYY4Y68NNlHbHBMdeDmKNtDgXWhQsJcGqzO3iLUM0GraQFSS9cVgPX5I9b3lbdFKyYoAEGAZF1DwhTaljNAw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.6",
|
||||
@@ -5976,21 +5947,6 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/chalk": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
|
||||
"integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^3.2.1",
|
||||
"escape-string-regexp": "^1.0.5",
|
||||
"supports-color": "^5.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/character-entities": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz",
|
||||
@@ -6499,16 +6455,6 @@
|
||||
"node": ">=12.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "1.9.3",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
|
||||
"integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "1.1.3"
|
||||
}
|
||||
},
|
||||
"node_modules/color-name": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
|
||||
@@ -7454,16 +7400,6 @@
|
||||
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/escape-string-regexp": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
|
||||
"integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint": {
|
||||
"version": "8.57.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz",
|
||||
@@ -10815,12 +10751,12 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/next": {
|
||||
"version": "14.2.23",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-14.2.23.tgz",
|
||||
"integrity": "sha512-mjN3fE6u/tynneLiEg56XnthzuYw+kD7mCujgVqioxyPqbmiotUCGJpIZGS/VaPg3ZDT1tvWxiVyRzeqJFm/kw==",
|
||||
"version": "14.2.26",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-14.2.26.tgz",
|
||||
"integrity": "sha512-b81XSLihMwCfwiUVRRja3LphLo4uBBMZEzBBWMaISbKTwOmq3wPknIETy/8000tr7Gq4WmbuFYPS7jOYIf+ZJw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@next/env": "14.2.23",
|
||||
"@next/env": "14.2.26",
|
||||
"@swc/helpers": "0.5.5",
|
||||
"busboy": "1.6.0",
|
||||
"caniuse-lite": "^1.0.30001579",
|
||||
@@ -10835,15 +10771,15 @@
|
||||
"node": ">=18.17.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@next/swc-darwin-arm64": "14.2.23",
|
||||
"@next/swc-darwin-x64": "14.2.23",
|
||||
"@next/swc-linux-arm64-gnu": "14.2.23",
|
||||
"@next/swc-linux-arm64-musl": "14.2.23",
|
||||
"@next/swc-linux-x64-gnu": "14.2.23",
|
||||
"@next/swc-linux-x64-musl": "14.2.23",
|
||||
"@next/swc-win32-arm64-msvc": "14.2.23",
|
||||
"@next/swc-win32-ia32-msvc": "14.2.23",
|
||||
"@next/swc-win32-x64-msvc": "14.2.23"
|
||||
"@next/swc-darwin-arm64": "14.2.26",
|
||||
"@next/swc-darwin-x64": "14.2.26",
|
||||
"@next/swc-linux-arm64-gnu": "14.2.26",
|
||||
"@next/swc-linux-arm64-musl": "14.2.26",
|
||||
"@next/swc-linux-x64-gnu": "14.2.26",
|
||||
"@next/swc-linux-x64-musl": "14.2.26",
|
||||
"@next/swc-win32-arm64-msvc": "14.2.26",
|
||||
"@next/swc-win32-ia32-msvc": "14.2.26",
|
||||
"@next/swc-win32-x64-msvc": "14.2.26"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": "^1.1.0",
|
||||
@@ -13271,16 +13207,6 @@
|
||||
"readable-stream": "3"
|
||||
}
|
||||
},
|
||||
"node_modules/to-fast-properties": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz",
|
||||
"integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/to-regex-range": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { GetStaticProps, GetStaticPropsContext } from 'next'
|
||||
import { SVGProps, useEffect, useRef, useState } from 'react'
|
||||
import { GetStaticPropsContext } from 'next'
|
||||
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'
|
||||
@@ -12,7 +14,6 @@ import { z } from 'zod'
|
||||
import Image from 'next/image'
|
||||
|
||||
import { MAX_AMOUNT } from '../../../config'
|
||||
import { useFundSlug } from '../../../utils/use-fund-slug'
|
||||
import { trpc } from '../../../utils/trpc'
|
||||
import Spinner from '../../../components/Spinner'
|
||||
import { useToast } from '../../../components/ui/use-toast'
|
||||
@@ -32,15 +33,25 @@ import CustomLink from '../../../components/CustomLink'
|
||||
import { getProjectBySlug, getProjects } from '../../../utils/md'
|
||||
import { funds, fundSlugs } from '../../../utils/funds'
|
||||
import { ProjectItem } from '../../../utils/types'
|
||||
import Link from 'next/link'
|
||||
import Head from 'next/head'
|
||||
import MoneroLogo from '../../../components/MoneroLogo'
|
||||
import FiroLogo from '../../../components/FiroLogo'
|
||||
import PrivacyGuidesLogo from '../../../components/PrivacyGuidesLogo'
|
||||
import MagicLogo from '../../../components/MagicLogo'
|
||||
|
||||
type QueryParams = { fund: FundSlug; slug: string }
|
||||
type Props = { project: ProjectItem } & QueryParams
|
||||
|
||||
const placeholderImages: Record<FundSlug, (props: SVGProps<SVGSVGElement>) => JSX.Element> = {
|
||||
monero: MoneroLogo,
|
||||
firo: FiroLogo,
|
||||
privacyguides: PrivacyGuidesLogo,
|
||||
general: MagicLogo,
|
||||
}
|
||||
|
||||
function DonationPage({ fund: fundSlug, slug, project }: Props) {
|
||||
const session = useSession()
|
||||
const isAuthed = session.status === 'authenticated'
|
||||
const PlaceholderImage = placeholderImages[project.fund]
|
||||
|
||||
const schema = z
|
||||
.object({
|
||||
@@ -153,14 +164,21 @@ function DonationPage({ fund: fundSlug, slug, project }: Props) {
|
||||
<div className="max-w-[540px] mx-auto p-6 space-y-6 rounded-lg bg-white">
|
||||
<div className="py-4 flex flex-col space-y-6">
|
||||
<div className="flex flex-col items-center sm:space-x-4 sm:flex-row">
|
||||
<Image
|
||||
alt={project.title}
|
||||
src={project.coverImage}
|
||||
width={200}
|
||||
height={96}
|
||||
objectFit="cover"
|
||||
className="w-36 rounded-lg"
|
||||
/>
|
||||
{project.coverImage ? (
|
||||
<Image
|
||||
alt={project.title}
|
||||
src={project.coverImage}
|
||||
width={200}
|
||||
height={96}
|
||||
objectFit="cover"
|
||||
className="w-36 rounded-lg"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-52">
|
||||
<PlaceholderImage className="w-20 h-20 m-auto" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col justify-center">
|
||||
<h2 className="text-center sm:text-left font-semibold">Donate to {project.title}</h2>
|
||||
<h3 className="text-gray-500">Pledge your support</h3>
|
||||
|
||||
@@ -189,7 +189,7 @@ function MembershipPage({ fund: fundSlug, project }: Props) {
|
||||
<div className="flex flex-col items-center sm:space-x-4 sm:flex-row">
|
||||
<Image
|
||||
alt={project.title}
|
||||
src={project.coverImage}
|
||||
src={project.coverImage!}
|
||||
width={200}
|
||||
height={96}
|
||||
objectFit="cover"
|
||||
|
||||
@@ -65,7 +65,7 @@ import { trpc } from '../../../utils/trpc'
|
||||
import { cn } from '../../../utils/cn'
|
||||
import { strapiApi } from '../../../server/services'
|
||||
import { GetServerSidePropsContext } from 'next'
|
||||
import { getUserPointBalance } from '../../../server/utils/perks'
|
||||
import { getPointsBalance } from '../../../server/utils/perks'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '../../api/auth/[...nextauth]'
|
||||
|
||||
@@ -75,26 +75,28 @@ const pointFormat = Intl.NumberFormat('en', { notation: 'standard', compactDispl
|
||||
|
||||
const schema = z
|
||||
.object({
|
||||
shippingAddressLine1: z.string().min(1),
|
||||
shippingAddressLine2: z.string(),
|
||||
shippingCity: z.string().min(1),
|
||||
shippingState: z.string(),
|
||||
shippingCountry: z.string().min(1),
|
||||
shippingZip: z.string().min(1),
|
||||
shippingPhone: z
|
||||
.string()
|
||||
.min(1)
|
||||
.regex(/^\+?\d{6,15}$/, 'Invalid phone number.'),
|
||||
shippingTaxNumber: z.string(),
|
||||
printfulSyncVariantId: z.string().optional(),
|
||||
_shippingStateOptionsLength: z.number(),
|
||||
_useAccountMailingAddress: z.boolean(),
|
||||
shipping: z.object({
|
||||
addressLine1: z.string().min(1),
|
||||
addressLine2: z.string(),
|
||||
city: z.string().min(1),
|
||||
stateCode: z.string(),
|
||||
countryCode: z.string().min(1),
|
||||
zip: z.string().min(1),
|
||||
phone: z
|
||||
.string()
|
||||
.min(1)
|
||||
.regex(/^\+?\d{6,15}$/, 'Invalid phone number.'),
|
||||
taxNumber: z.string(),
|
||||
}),
|
||||
printfulSyncVariantId: z.string().optional(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
const cpfRegex = /(^\d{3}\.\d{3}\.\d{3}\-\d{2}$)|(^\d{2}\.\d{3}\.\d{3}\/\d{4}\-\d{2}$)/
|
||||
|
||||
if (data.shippingCountry === 'BR') {
|
||||
if (data.shippingTaxNumber.length < 1) {
|
||||
if (data.shipping.countryCode === 'BR') {
|
||||
if (data.shipping.taxNumber.length < 1) {
|
||||
ctx.addIssue({
|
||||
path: ['shippingTaxNumber'],
|
||||
code: 'custom',
|
||||
@@ -103,7 +105,7 @@ const schema = z
|
||||
return
|
||||
}
|
||||
|
||||
if (!cpfRegex.test(data.shippingTaxNumber)) {
|
||||
if (!cpfRegex.test(data.shipping.taxNumber)) {
|
||||
ctx.addIssue({
|
||||
path: ['shippingTaxNumber'],
|
||||
code: 'custom',
|
||||
@@ -114,7 +116,7 @@ const schema = z
|
||||
}
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (!data.shippingState && data._shippingStateOptionsLength) {
|
||||
if (!data.shipping.stateCode && data._shippingStateOptionsLength) {
|
||||
ctx.addIssue({
|
||||
path: ['shippingState'],
|
||||
code: 'custom',
|
||||
@@ -147,14 +149,16 @@ function Perk({ perk, balance }: Props) {
|
||||
defaultValues: {
|
||||
_shippingStateOptionsLength: 0,
|
||||
_useAccountMailingAddress: false,
|
||||
shippingAddressLine1: '',
|
||||
shippingAddressLine2: '',
|
||||
shippingCity: '',
|
||||
shippingState: '',
|
||||
shippingCountry: '',
|
||||
shippingZip: '',
|
||||
shippingPhone: '',
|
||||
shippingTaxNumber: '',
|
||||
shipping: {
|
||||
addressLine1: '',
|
||||
addressLine2: '',
|
||||
city: '',
|
||||
stateCode: '',
|
||||
countryCode: '',
|
||||
zip: '',
|
||||
phone: '',
|
||||
taxNumber: '',
|
||||
},
|
||||
},
|
||||
shouldFocusError: false,
|
||||
})
|
||||
@@ -170,8 +174,8 @@ function Perk({ perk, balance }: Props) {
|
||||
value: country.code,
|
||||
}))
|
||||
|
||||
const shippingCountry = form.watch('shippingCountry')
|
||||
const shippingState = form.watch('shippingState')
|
||||
const shippingCountry = form.watch('shipping.countryCode')
|
||||
const shippingState = form.watch('shipping.stateCode')
|
||||
const printfulSyncVariantId = form.watch('printfulSyncVariantId')
|
||||
const useAccountMailingAddress = form.watch('_useAccountMailingAddress')
|
||||
|
||||
@@ -190,8 +194,8 @@ function Perk({ perk, balance }: Props) {
|
||||
}, [shippingCountry])
|
||||
|
||||
useEffect(() => {
|
||||
form.setValue('shippingState', '')
|
||||
form.setValue('shippingTaxNumber', '')
|
||||
form.setValue('shipping.stateCode', '')
|
||||
form.setValue('shipping.taxNumber', '')
|
||||
}, [shippingCountry])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -202,19 +206,22 @@ function Perk({ perk, balance }: Props) {
|
||||
if (!getUserAttributesQuery.data) return
|
||||
|
||||
if (useAccountMailingAddress) {
|
||||
form.setValue('shippingAddressLine1', getUserAttributesQuery.data.addressLine1)
|
||||
form.setValue('shippingAddressLine2', getUserAttributesQuery.data.addressLine2)
|
||||
form.setValue('shippingCountry', getUserAttributesQuery.data.addressCountry)
|
||||
form.setValue('shippingCity', getUserAttributesQuery.data.addressCity)
|
||||
form.setValue('shippingZip', getUserAttributesQuery.data.addressZip)
|
||||
setTimeout(() => form.setValue('shippingState', getUserAttributesQuery.data.addressState), 20)
|
||||
form.setValue('shipping.addressLine1', getUserAttributesQuery.data.addressLine1)
|
||||
form.setValue('shipping.addressLine2', getUserAttributesQuery.data.addressLine2)
|
||||
form.setValue('shipping.countryCode', getUserAttributesQuery.data.addressCountry)
|
||||
form.setValue('shipping.city', getUserAttributesQuery.data.addressCity)
|
||||
form.setValue('shipping.zip', getUserAttributesQuery.data.addressZip)
|
||||
setTimeout(
|
||||
() => form.setValue('shipping.stateCode', getUserAttributesQuery.data.addressState),
|
||||
100
|
||||
)
|
||||
} else {
|
||||
form.setValue('shippingAddressLine1', '')
|
||||
form.setValue('shippingAddressLine2', '')
|
||||
form.setValue('shippingCountry', '')
|
||||
form.setValue('shippingState', '')
|
||||
form.setValue('shippingCity', '')
|
||||
form.setValue('shippingZip', '')
|
||||
form.setValue('shipping.addressLine1', '')
|
||||
form.setValue('shipping.addressLine2', '')
|
||||
form.setValue('shipping.countryCode', '')
|
||||
form.setValue('shipping.stateCode', '')
|
||||
form.setValue('shipping.city', '')
|
||||
form.setValue('shipping.zip', '')
|
||||
}
|
||||
}, [useAccountMailingAddress])
|
||||
|
||||
@@ -226,7 +233,6 @@ function Perk({ perk, balance }: Props) {
|
||||
try {
|
||||
const _costEstimate = await estimatePrintfulOrderCosts.mutateAsync({
|
||||
...data,
|
||||
|
||||
printfulSyncVariantId: Number(data.printfulSyncVariantId),
|
||||
})
|
||||
|
||||
@@ -424,7 +430,7 @@ function Perk({ perk, balance }: Props) {
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="shippingAddressLine1"
|
||||
name="shipping.addressLine1"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Address line 1 *</FormLabel>
|
||||
@@ -438,7 +444,7 @@ function Perk({ perk, balance }: Props) {
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="shippingAddressLine2"
|
||||
name="shipping.addressLine2"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Address line 2</FormLabel>
|
||||
@@ -452,7 +458,7 @@ function Perk({ perk, balance }: Props) {
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="shippingCountry"
|
||||
name="shipping.countryCode"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col">
|
||||
<FormLabel>Country *</FormLabel>
|
||||
@@ -493,7 +499,7 @@ function Perk({ perk, balance }: Props) {
|
||||
value={country.label}
|
||||
key={country.value}
|
||||
onSelect={() => (
|
||||
form.setValue('shippingCountry', country.value, {
|
||||
form.setValue('shipping.countryCode', country.value, {
|
||||
shouldValidate: true,
|
||||
}),
|
||||
setCountrySelectOpen(false)
|
||||
@@ -523,7 +529,7 @@ function Perk({ perk, balance }: Props) {
|
||||
{!!shippingStateOptions.length && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="shippingState"
|
||||
name="shipping.stateCode"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col">
|
||||
<FormLabel>State *</FormLabel>
|
||||
@@ -560,7 +566,7 @@ function Perk({ perk, balance }: Props) {
|
||||
value={state.label}
|
||||
key={state.value}
|
||||
onSelect={() => (
|
||||
form.setValue('shippingState', state.value, {
|
||||
form.setValue('shipping.stateCode', state.value, {
|
||||
shouldValidate: true,
|
||||
}),
|
||||
setStateSelectOpen(false)
|
||||
@@ -590,7 +596,7 @@ function Perk({ perk, balance }: Props) {
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="shippingCity"
|
||||
name="shipping.city"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>City *</FormLabel>
|
||||
@@ -604,7 +610,7 @@ function Perk({ perk, balance }: Props) {
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="shippingZip"
|
||||
name="shipping.zip"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Postal code *</FormLabel>
|
||||
@@ -618,7 +624,7 @@ function Perk({ perk, balance }: Props) {
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="shippingPhone"
|
||||
name="shipping.phone"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Phone number *</FormLabel>
|
||||
@@ -633,7 +639,7 @@ function Perk({ perk, balance }: Props) {
|
||||
{shippingCountry === 'BR' && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="shippingTaxNumber"
|
||||
name="shipping.taxNumber"
|
||||
render={({ field }) => (
|
||||
<FormItem className="space-y-0">
|
||||
<FormLabel>Tax number (Brazilian CPF/CNPJ) *</FormLabel>
|
||||
@@ -746,7 +752,7 @@ export async function getServerSideProps({ params, req, res }: GetServerSideProp
|
||||
data: { data: perk },
|
||||
},
|
||||
] = await Promise.all([
|
||||
getUserPointBalance(session.user.sub),
|
||||
getPointsBalance(session.user.sub),
|
||||
strapiApi.get<StrapiGetPerkPopulatedRes>(
|
||||
`/perks/${params?.id!}?populate[images][fields]=formats`
|
||||
),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { SVGProps } from 'react'
|
||||
import { FundSlug } from '@prisma/client'
|
||||
import { useRouter } from 'next/router'
|
||||
import { GetServerSidePropsContext, NextPage } from 'next/types'
|
||||
import Head from 'next/head'
|
||||
@@ -15,12 +15,17 @@ import PageHeading from '../../../components/PageHeading'
|
||||
import Progress from '../../../components/Progress'
|
||||
import { prisma } from '../../../server/services'
|
||||
import { Button } from '../../../components/ui/button'
|
||||
import CustomLink from '../../../components/CustomLink'
|
||||
import { trpc } from '../../../utils/trpc'
|
||||
import { getFundSlugFromUrlPath } from '../../../utils/funds'
|
||||
import { funds, getFundSlugFromUrlPath } from '../../../utils/funds'
|
||||
import { useFundSlug } from '../../../utils/use-fund-slug'
|
||||
import { Table, TableBody, TableCell, TableRow } from '../../../components/ui/table'
|
||||
import { cn } from '../../../utils/cn'
|
||||
import { DonationCryptoPayments } from '../../../server/types'
|
||||
import { formatBtc, formatUsd } from '../../../utils/money-formating'
|
||||
import MagicLogo from '../../../components/MagicLogo'
|
||||
import MoneroLogo from '../../../components/MoneroLogo'
|
||||
import FiroLogo from '../../../components/FiroLogo'
|
||||
import PrivacyGuidesLogo from '../../../components/PrivacyGuidesLogo'
|
||||
|
||||
type SingleProjectPageProps = {
|
||||
project: ProjectItem
|
||||
@@ -28,33 +33,20 @@ type SingleProjectPageProps = {
|
||||
donationStats: ProjectDonationStats
|
||||
}
|
||||
|
||||
const placeholderImages: Record<FundSlug, (props: SVGProps<SVGSVGElement>) => JSX.Element> = {
|
||||
monero: MoneroLogo,
|
||||
firo: FiroLogo,
|
||||
privacyguides: PrivacyGuidesLogo,
|
||||
general: MagicLogo,
|
||||
}
|
||||
|
||||
const Project: NextPage<SingleProjectPageProps> = ({ project, donationStats }) => {
|
||||
const router = useRouter()
|
||||
const [registerIsOpen, setRegisterIsOpen] = useState(false)
|
||||
const [loginIsOpen, setLoginIsOpen] = useState(false)
|
||||
const [passwordResetIsOpen, setPasswordResetIsOpen] = useState(false)
|
||||
const session = useSession()
|
||||
const fundSlug = useFundSlug()
|
||||
|
||||
const { slug, title, summary, coverImage, content, nym, website, goal, isFunded } = project
|
||||
|
||||
function formatBtc(bitcoin: number) {
|
||||
if (bitcoin > 0.1) {
|
||||
return `${bitcoin.toFixed(3) || 0.0} BTC`
|
||||
} else {
|
||||
return `${Math.floor(bitcoin * 100000000).toLocaleString()} sats`
|
||||
}
|
||||
}
|
||||
|
||||
function formatUsd(dollars: number): string {
|
||||
if (dollars == 0) {
|
||||
return '$0'
|
||||
} else if (dollars / 1000 > 1) {
|
||||
return `$${Math.round(dollars / 1000)}k+`
|
||||
} else {
|
||||
return `$${dollars.toFixed(0)}`
|
||||
}
|
||||
}
|
||||
const PlaceholderImage = placeholderImages[project.fund]
|
||||
|
||||
if (!router.isFallback && !slug) {
|
||||
return <ErrorPage statusCode={404} />
|
||||
@@ -68,21 +60,27 @@ const Project: NextPage<SingleProjectPageProps> = ({ project, donationStats }) =
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Monero Fund | {project.title}</title>
|
||||
<title>
|
||||
{project.title} - {funds[project.fund].title}
|
||||
</title>
|
||||
</Head>
|
||||
|
||||
<div className="divide-y divide-gray-200">
|
||||
<PageHeading project={project}>
|
||||
<div className="w-full flex flex-col items-center gap-4 xl:flex">
|
||||
<Image
|
||||
src={coverImage}
|
||||
alt="avatar"
|
||||
width={700}
|
||||
height={700}
|
||||
className="w-full max-w-[700px] mx-auto object-contain xl:hidden"
|
||||
/>
|
||||
{coverImage ? (
|
||||
<Image
|
||||
src={coverImage}
|
||||
alt="avatar"
|
||||
width={700}
|
||||
height={700}
|
||||
className="w-full max-w-[700px] mx-auto object-contain xl:hidden"
|
||||
/>
|
||||
) : (
|
||||
<PlaceholderImage className="w-full max-w-[700px] mx-auto object-contain xl:hidden" />
|
||||
)}
|
||||
|
||||
<div className="w-full max-w-96 space-y-8 p-6 bg-white rounded-lg">
|
||||
<div className="w-full max-w-96 space-y-6 p-6 bg-white rounded-lg">
|
||||
{!project.isFunded && (
|
||||
<div className="w-full">
|
||||
<Link href={`/${fundSlug}/donate/${project.slug}`}>
|
||||
@@ -91,47 +89,52 @@ const Project: NextPage<SingleProjectPageProps> = ({ project, donationStats }) =
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="w-full">
|
||||
<h1 className="mb-4 font-bold">Raised</h1>
|
||||
<Progress
|
||||
current={
|
||||
donationStats.xmr.fiatAmount +
|
||||
donationStats.btc.fiatAmount +
|
||||
donationStats.usd.fiatAmount
|
||||
}
|
||||
goal={goal}
|
||||
/>
|
||||
|
||||
<Progress
|
||||
current={
|
||||
donationStats.xmr.fiatAmount +
|
||||
donationStats.btc.fiatAmount +
|
||||
donationStats.usd.fiatAmount
|
||||
}
|
||||
goal={goal}
|
||||
/>
|
||||
|
||||
<ul className="font-semibold space-y-1">
|
||||
<li className="flex items-center space-x-1">
|
||||
<span className="text-green-500 text-xl">{`${formatUsd(donationStats.xmr.fiatAmount + donationStats.btc.fiatAmount + donationStats.usd.fiatAmount)}`}</span>{' '}
|
||||
<span className="font-normal text-sm text-gray">
|
||||
in{' '}
|
||||
{donationStats.xmr.count + donationStats.btc.count + donationStats.usd.count}{' '}
|
||||
donations total
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
{donationStats.xmr.amount} XMR{' '}
|
||||
<span className="font-normal text-sm text-gray">
|
||||
in {donationStats.xmr.count} donations
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
{formatBtc(donationStats.btc.amount)}{' '}
|
||||
<span className="font-normal text-sm text-gray">
|
||||
in {donationStats.btc.count} donations
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
{`${formatUsd(donationStats.usd.amount)}`} Fiat{' '}
|
||||
<span className="font-normal text-sm text-gray">
|
||||
in {donationStats.usd.count} donations
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<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="font-normal text-sm text-gray">
|
||||
in{' '}
|
||||
{donationStats.xmr.count +
|
||||
donationStats.btc.count +
|
||||
donationStats.manual.count +
|
||||
donationStats.usd.count}{' '}
|
||||
donations total
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
{donationStats.xmr.amount.toFixed(2)} XMR{' '}
|
||||
<span className="font-normal text-sm text-gray">
|
||||
in {donationStats.xmr.count} donations
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
{formatBtc(donationStats.btc.amount)}{' '}
|
||||
<span className="font-normal text-sm text-gray">
|
||||
in {donationStats.btc.count} donations
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
{donationStats.ltc.amount.toFixed(2)} LTC{' '}
|
||||
<span className="font-normal text-sm text-gray">
|
||||
in {donationStats.ltc.count} 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>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-96 min-h-72 space-y-4 p-6 bg-white rounded-lg">
|
||||
@@ -200,6 +203,16 @@ export async function getServerSideProps({ params, resolvedUrl }: GetServerSideP
|
||||
amount: project.isFunded ? project.totalDonationsBTC : 0,
|
||||
fiatAmount: project.isFunded ? project.totalDonationsBTCInFiat : 0,
|
||||
},
|
||||
ltc: {
|
||||
count: project.isFunded ? project.numDonationsLTC : 0,
|
||||
amount: project.isFunded ? project.totalDonationsLTC : 0,
|
||||
fiatAmount: project.isFunded ? project.totalDonationsLTCInFiat : 0,
|
||||
},
|
||||
manual: {
|
||||
count: project.isFunded ? project.numDonationsManual : 0,
|
||||
amount: project.isFunded ? project.totalDonationsManual : 0,
|
||||
fiatAmount: project.isFunded ? project.totalDonationsManual : 0,
|
||||
},
|
||||
usd: {
|
||||
count: project.isFunded ? project.numDonationsFiat : 0,
|
||||
amount: project.isFunded ? project.totalDonationsFiat : 0,
|
||||
@@ -212,20 +225,23 @@ export async function getServerSideProps({ params, resolvedUrl }: GetServerSideP
|
||||
where: { projectSlug: params.slug as string, fundSlug },
|
||||
})
|
||||
|
||||
const cryptoCodeToStats = {
|
||||
BTC: donationStats.btc,
|
||||
XMR: donationStats.xmr,
|
||||
LTC: donationStats.ltc,
|
||||
MANUAL: donationStats.manual,
|
||||
} as const
|
||||
|
||||
donations.forEach((donation) => {
|
||||
if (donation.cryptoCode === 'XMR') {
|
||||
donationStats.xmr.count += 1
|
||||
donationStats.xmr.amount += donation.netCryptoAmount || 0
|
||||
donationStats.xmr.fiatAmount += donation.netFiatAmount
|
||||
}
|
||||
;(donation.cryptoPayments as DonationCryptoPayments | null)?.forEach((payment) => {
|
||||
const stats = cryptoCodeToStats[payment.cryptoCode]
|
||||
|
||||
if (donation.cryptoCode === 'BTC') {
|
||||
donationStats.btc.count += 1
|
||||
donationStats.btc.amount += donation.netCryptoAmount || 0
|
||||
donationStats.btc.fiatAmount += donation.netFiatAmount
|
||||
}
|
||||
stats.count += 1
|
||||
stats.amount += payment.netAmount
|
||||
stats.fiatAmount += payment.netAmount * payment.rate
|
||||
})
|
||||
|
||||
if (donation.cryptoCode === null) {
|
||||
if (!donation.cryptoPayments) {
|
||||
donationStats.usd.count += 1
|
||||
donationStats.usd.amount += donation.netFiatAmount
|
||||
donationStats.usd.fiatAmount += donation.netFiatAmount
|
||||
|
||||
@@ -7,22 +7,20 @@ import {
|
||||
BtcPayGetRatesRes,
|
||||
BtcPayGetPaymentMethodsRes,
|
||||
DonationMetadata,
|
||||
StrapiCreatePointBody,
|
||||
DonationCryptoPayments,
|
||||
} from '../../../server/types'
|
||||
import {
|
||||
btcpayApi as _btcpayApi,
|
||||
btcpayApi,
|
||||
prisma,
|
||||
privacyGuidesDiscourseApi,
|
||||
strapiApi,
|
||||
} from '../../../server/services'
|
||||
import { btcpayApi as _btcpayApi, btcpayApi, prisma } from '../../../server/services'
|
||||
import { env } from '../../../env.mjs'
|
||||
import { getUserPointBalance } from '../../../server/utils/perks'
|
||||
import { givePointsToUser } from '../../../server/utils/perks'
|
||||
import { sendDonationConfirmationEmail } from '../../../server/utils/mailing'
|
||||
import { POINTS_PER_USD } from '../../../config'
|
||||
import { NET_DONATION_AMOUNT_WITH_POINTS_RATE, POINTS_PER_USD } from '../../../config'
|
||||
import { getDonationAttestation, getMembershipAttestation } from '../../../server/utils/attestation'
|
||||
import { funds } from '../../../utils/funds'
|
||||
import { addUserToPgMembersGroup } from '../../../utils/pg-forum-connection'
|
||||
import { log } from '../../../utils/logging'
|
||||
import {
|
||||
getBtcPayInvoice,
|
||||
getBtcPayInvoicePaymentMethods,
|
||||
} from '../../../server/utils/btcpayserver'
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
@@ -30,7 +28,8 @@ export const config = {
|
||||
},
|
||||
}
|
||||
|
||||
type BtcpayBody = Record<string, any> & {
|
||||
type WebhookBody = Record<string, any> & {
|
||||
manuallyMarked: boolean
|
||||
deliveryId: string
|
||||
webhookId: string
|
||||
originalDeliveryId: string
|
||||
@@ -43,6 +42,237 @@ type BtcpayBody = Record<string, any> & {
|
||||
paymentMethod: string
|
||||
}
|
||||
|
||||
async function handleFundingRequiredApiDonation(body: WebhookBody) {
|
||||
if (!body.metadata || JSON.stringify(body.metadata) === '{}') return
|
||||
|
||||
const existingDonation = await prisma.donation.findFirst({
|
||||
where: { btcPayInvoiceId: body.invoiceId },
|
||||
})
|
||||
|
||||
if (existingDonation) {
|
||||
log(
|
||||
'warn',
|
||||
`[BTCPay webhook] Attempted to process already processed invoice ${body.invoiceId}.`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Handle payment methods like "BTC-LightningNetwork" if added in the future
|
||||
const cryptoCode = body.paymentMethod.includes('-')
|
||||
? body.paymentMethod.split('-')[0]
|
||||
: body.paymentMethod
|
||||
|
||||
const { data: rates } = await btcpayApi.get<BtcPayGetRatesRes>(
|
||||
`/rates?currencyPair=${cryptoCode}_USD`
|
||||
)
|
||||
|
||||
const cryptoRate = Number(rates[0].rate)
|
||||
const cryptoAmount = Number(body.payment.value)
|
||||
const fiatAmount = Number((cryptoAmount * cryptoRate).toFixed(2))
|
||||
|
||||
await prisma.donation.create({
|
||||
data: {
|
||||
userId: null,
|
||||
btcPayInvoiceId: body.invoiceId,
|
||||
projectName: body.metadata.projectName,
|
||||
projectSlug: body.metadata.projectSlug,
|
||||
fundSlug: body.metadata.fundSlug,
|
||||
grossFiatAmount: fiatAmount,
|
||||
netFiatAmount: fiatAmount,
|
||||
showDonorNameOnLeaderboard: body.metadata.showDonorNameOnLeaderboard === 'true',
|
||||
donorName: body.metadata.donorName,
|
||||
},
|
||||
})
|
||||
|
||||
log('info', `[BTCPay webhook] Successfully processed invoice ${body.invoiceId}!`)
|
||||
}
|
||||
|
||||
// This handles both donations and memberships.
|
||||
async function handleDonationOrMembership(body: WebhookBody) {
|
||||
if (!body.metadata || JSON.stringify(body.metadata) === '{}') return
|
||||
|
||||
const existingDonation = await prisma.donation.findFirst({
|
||||
where: { btcPayInvoiceId: body.invoiceId },
|
||||
})
|
||||
|
||||
if (existingDonation) {
|
||||
log(
|
||||
'warn',
|
||||
`[BTCPay webhook] Attempted to process already processed invoice ${body.invoiceId}.`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const termToMembershipExpiresAt = {
|
||||
monthly: dayjs().add(1, 'month').toDate(),
|
||||
annually: dayjs().add(1, 'year').toDate(),
|
||||
} as const
|
||||
|
||||
let membershipExpiresAt = null
|
||||
|
||||
if (body.metadata.isMembership === 'true' && body.metadata.membershipTerm) {
|
||||
membershipExpiresAt = termToMembershipExpiresAt[body.metadata.membershipTerm]
|
||||
}
|
||||
|
||||
const cryptoPayments: DonationCryptoPayments = []
|
||||
const paymentMethods = await getBtcPayInvoicePaymentMethods(body.invoiceId)
|
||||
const shouldGivePointsBack = body.metadata.givePointsBack === 'true'
|
||||
|
||||
// Get how much was paid for each crypto
|
||||
paymentMethods.forEach((paymentMethod) => {
|
||||
if (!body.metadata) return
|
||||
|
||||
const cryptoRate = Number(paymentMethod.rate)
|
||||
const grossCryptoAmount = Number(paymentMethod.paymentMethodPaid)
|
||||
|
||||
// Deduct 10% of amount if donator wants points
|
||||
const netCryptoAmount = shouldGivePointsBack
|
||||
? grossCryptoAmount * NET_DONATION_AMOUNT_WITH_POINTS_RATE
|
||||
: grossCryptoAmount
|
||||
|
||||
// Move on if amound paid with current method is 0
|
||||
if (!grossCryptoAmount) return
|
||||
|
||||
cryptoPayments.push({
|
||||
cryptoCode: paymentMethod.currency,
|
||||
grossAmount: grossCryptoAmount,
|
||||
netAmount: netCryptoAmount,
|
||||
rate: cryptoRate,
|
||||
})
|
||||
})
|
||||
|
||||
// Handle marked paid invoice
|
||||
if (body.manuallyMarked) {
|
||||
const invoice = await getBtcPayInvoice(body.invoiceId)
|
||||
|
||||
const amountPaidFiat = cryptoPayments.reduce(
|
||||
(total, paymentMethod) => total + paymentMethod.grossAmount * paymentMethod.rate,
|
||||
0
|
||||
)
|
||||
|
||||
const invoiceAmountFiat = Number(invoice.amount)
|
||||
const amountDueFiat = invoiceAmountFiat - amountPaidFiat
|
||||
|
||||
if (amountDueFiat > 0) {
|
||||
cryptoPayments.push({
|
||||
cryptoCode: 'MANUAL',
|
||||
grossAmount: amountDueFiat,
|
||||
netAmount: shouldGivePointsBack
|
||||
? amountDueFiat * NET_DONATION_AMOUNT_WITH_POINTS_RATE
|
||||
: amountDueFiat,
|
||||
rate: 1,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const grossFiatAmount = cryptoPayments.reduce(
|
||||
(total, paymentMethod) => total + paymentMethod.grossAmount * paymentMethod.rate,
|
||||
0
|
||||
)
|
||||
|
||||
const netFiatAmount = cryptoPayments.reduce(
|
||||
(total, paymentMethod) => total + paymentMethod.netAmount * paymentMethod.rate,
|
||||
0
|
||||
)
|
||||
|
||||
const pointsToGive = shouldGivePointsBack ? Math.floor(grossFiatAmount / POINTS_PER_USD) : 0
|
||||
|
||||
const donation = await prisma.donation.create({
|
||||
data: {
|
||||
userId: body.metadata.userId,
|
||||
btcPayInvoiceId: body.invoiceId,
|
||||
projectName: body.metadata.projectName,
|
||||
projectSlug: body.metadata.projectSlug,
|
||||
fundSlug: body.metadata.fundSlug,
|
||||
cryptoPayments,
|
||||
grossFiatAmount: Number(grossFiatAmount.toFixed(2)),
|
||||
netFiatAmount: Number(netFiatAmount.toFixed(2)),
|
||||
pointsAdded: pointsToGive,
|
||||
membershipExpiresAt,
|
||||
membershipTerm: body.metadata.membershipTerm || null,
|
||||
showDonorNameOnLeaderboard: body.metadata.showDonorNameOnLeaderboard === 'true',
|
||||
donorName: body.metadata.donorName,
|
||||
},
|
||||
})
|
||||
|
||||
// Add points
|
||||
if (shouldGivePointsBack && body.metadata.userId) {
|
||||
try {
|
||||
await givePointsToUser({ pointsToGive, donation })
|
||||
} catch (error) {
|
||||
log(
|
||||
'error',
|
||||
`[Stripe webhook] Failed to give points for invoice ${body.invoiceId}. Rolling back.`
|
||||
)
|
||||
await prisma.donation.delete({ where: { id: donation.id } })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Add PG forum user to membership group
|
||||
if (
|
||||
body.metadata.isMembership &&
|
||||
body.metadata.fundSlug === 'privacyguides' &&
|
||||
body.metadata.userId
|
||||
) {
|
||||
try {
|
||||
await addUserToPgMembersGroup(body.metadata.userId)
|
||||
} catch (error) {
|
||||
log(
|
||||
'warn',
|
||||
`[BTCPay webhook] Could not add user ${body.metadata.userId} to PG forum members group. Invoice: ${body.invoiceId}. NOT rolling back. Continuing... Cause:`
|
||||
)
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
if (body.metadata.donorEmail && body.metadata.donorName) {
|
||||
let attestationMessage = ''
|
||||
let attestationSignature = ''
|
||||
|
||||
if (body.metadata.isMembership === 'true' && body.metadata.membershipTerm) {
|
||||
const attestation = await getMembershipAttestation({
|
||||
donorName: body.metadata.donorName,
|
||||
donorEmail: body.metadata.donorEmail,
|
||||
totalAmountToDate: grossFiatAmount,
|
||||
donation,
|
||||
})
|
||||
|
||||
attestationMessage = attestation.message
|
||||
attestationSignature = attestation.signature
|
||||
}
|
||||
|
||||
if (body.metadata.isMembership === 'false') {
|
||||
const attestation = await getDonationAttestation({
|
||||
donorName: body.metadata.donorName,
|
||||
donorEmail: body.metadata.donorEmail,
|
||||
donation,
|
||||
})
|
||||
|
||||
attestationMessage = attestation.message
|
||||
attestationSignature = attestation.signature
|
||||
}
|
||||
|
||||
try {
|
||||
await sendDonationConfirmationEmail({
|
||||
to: body.metadata.donorEmail,
|
||||
donorName: body.metadata.donorName,
|
||||
donation,
|
||||
attestationMessage,
|
||||
attestationSignature,
|
||||
})
|
||||
} catch (error) {
|
||||
log(
|
||||
'warn',
|
||||
`[BTCPay webhook] Failed to send donation confirmation email for invoice ${body.invoiceId}. NOT rolling back. Cause:`
|
||||
)
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
log('info', `[BTCPay webhook] Successfully processed invoice ${body.invoiceId}!`)
|
||||
}
|
||||
|
||||
async function handleBtcpayWebhook(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method !== 'POST') {
|
||||
res.setHeader('Allow', ['POST'])
|
||||
@@ -56,7 +286,7 @@ async function handleBtcpayWebhook(req: NextApiRequest, res: NextApiResponse) {
|
||||
}
|
||||
|
||||
const rawBody = await getRawBody(req)
|
||||
const body: BtcpayBody = JSON.parse(Buffer.from(rawBody).toString('utf8'))
|
||||
const body: WebhookBody = JSON.parse(Buffer.from(rawBody).toString('utf8'))
|
||||
|
||||
const expectedSigHash = crypto
|
||||
.createHmac('sha256', env.BTCPAY_WEBHOOK_SECRET)
|
||||
@@ -67,7 +297,7 @@ async function handleBtcpayWebhook(req: NextApiRequest, res: NextApiResponse) {
|
||||
|
||||
if (expectedSigHash !== incomingSigHash) {
|
||||
console.error('Invalid signature')
|
||||
res.status(400).json({ success: false })
|
||||
res.status(401).json({ success: false })
|
||||
return
|
||||
}
|
||||
|
||||
@@ -81,35 +311,7 @@ async function handleBtcpayWebhook(req: NextApiRequest, res: NextApiResponse) {
|
||||
return res.status(200).json({ success: true })
|
||||
}
|
||||
|
||||
// Handle payment methods like "BTC-LightningNetwork" if added in the future
|
||||
const cryptoCode = body.paymentMethod.includes('-')
|
||||
? body.paymentMethod.split('-')[0]
|
||||
: body.paymentMethod
|
||||
|
||||
const { data: rates } = await btcpayApi.get<BtcPayGetRatesRes>(
|
||||
`/rates?currencyPair=${cryptoCode}_USD`
|
||||
)
|
||||
|
||||
const cryptoRate = Number(rates[0].rate)
|
||||
const cryptoAmount = Number(body.payment.value)
|
||||
const fiatAmount = Number((cryptoAmount * cryptoRate).toFixed(2))
|
||||
|
||||
await prisma.donation.create({
|
||||
data: {
|
||||
userId: null,
|
||||
btcPayInvoiceId: body.invoiceId,
|
||||
projectName: body.metadata.projectName,
|
||||
projectSlug: body.metadata.projectSlug,
|
||||
fundSlug: body.metadata.fundSlug,
|
||||
cryptoCode,
|
||||
grossCryptoAmount: cryptoAmount,
|
||||
grossFiatAmount: fiatAmount,
|
||||
netCryptoAmount: cryptoAmount,
|
||||
netFiatAmount: fiatAmount,
|
||||
showDonorNameOnLeaderboard: body.metadata.showDonorNameOnLeaderboard === 'true',
|
||||
donorName: body.metadata.donorName,
|
||||
},
|
||||
})
|
||||
await handleFundingRequiredApiDonation(body)
|
||||
}
|
||||
|
||||
if (body.type === 'InvoiceSettled') {
|
||||
@@ -118,143 +320,7 @@ async function handleBtcpayWebhook(req: NextApiRequest, res: NextApiResponse) {
|
||||
return res.status(200).json({ success: true })
|
||||
}
|
||||
|
||||
const { data: paymentMethods } = await btcpayApi.get<BtcPayGetPaymentMethodsRes>(
|
||||
`/invoices/${body.invoiceId}/payment-methods`
|
||||
)
|
||||
|
||||
let membershipExpiresAt = null
|
||||
|
||||
if (body.metadata.isMembership === 'true' && body.metadata.membershipTerm === 'monthly') {
|
||||
membershipExpiresAt = dayjs().add(1, 'month').toDate()
|
||||
}
|
||||
|
||||
if (body.metadata.isMembership === 'true' && body.metadata.membershipTerm === 'annually') {
|
||||
membershipExpiresAt = dayjs().add(1, 'year').toDate()
|
||||
}
|
||||
|
||||
// Create one donation and one point history for each invoice payment method
|
||||
await Promise.all(
|
||||
paymentMethods.map(async (paymentMethod) => {
|
||||
if (!body.metadata) return
|
||||
const shouldGivePointsBack = body.metadata.givePointsBack === 'true'
|
||||
const cryptoRate = Number(paymentMethod.rate)
|
||||
const grossCryptoAmount = Number(paymentMethod.paymentMethodPaid)
|
||||
const grossFiatAmount = grossCryptoAmount * cryptoRate
|
||||
// Deduct 10% of amount if donator wants points
|
||||
const netCryptoAmount = shouldGivePointsBack ? grossCryptoAmount * 0.9 : grossCryptoAmount
|
||||
const netFiatAmount = netCryptoAmount * cryptoRate
|
||||
|
||||
// Move on if amound paid with current method is 0
|
||||
if (!grossCryptoAmount) return
|
||||
|
||||
const pointsAdded = shouldGivePointsBack ? Math.floor(grossFiatAmount / POINTS_PER_USD) : 0
|
||||
|
||||
// Add PG forum user to membership group
|
||||
if (
|
||||
body.metadata.isMembership &&
|
||||
body.metadata.fundSlug === 'privacyguides' &&
|
||||
body.metadata.userId
|
||||
) {
|
||||
await addUserToPgMembersGroup(body.metadata.userId)
|
||||
}
|
||||
|
||||
const donation = await prisma.donation.create({
|
||||
data: {
|
||||
userId: body.metadata.userId,
|
||||
btcPayInvoiceId: body.invoiceId,
|
||||
projectName: body.metadata.projectName,
|
||||
projectSlug: body.metadata.projectSlug,
|
||||
fundSlug: body.metadata.fundSlug,
|
||||
cryptoCode: paymentMethod.currency,
|
||||
grossCryptoAmount: Number(grossCryptoAmount.toFixed(2)),
|
||||
grossFiatAmount: Number(grossFiatAmount.toFixed(2)),
|
||||
netCryptoAmount: Number(netCryptoAmount.toFixed(2)),
|
||||
netFiatAmount: Number(netFiatAmount.toFixed(2)),
|
||||
pointsAdded,
|
||||
membershipExpiresAt,
|
||||
membershipTerm: body.metadata.membershipTerm || null,
|
||||
showDonorNameOnLeaderboard: body.metadata.showDonorNameOnLeaderboard === 'true',
|
||||
donorName: body.metadata.donorName,
|
||||
},
|
||||
})
|
||||
|
||||
// Add points
|
||||
if (shouldGivePointsBack && body.metadata.userId) {
|
||||
// Get balance for project/fund by finding user's last point history
|
||||
const currentBalance = await getUserPointBalance(body.metadata.userId)
|
||||
|
||||
try {
|
||||
await strapiApi.post<any, any, StrapiCreatePointBody>('/points', {
|
||||
data: {
|
||||
balanceChange: pointsAdded.toString(),
|
||||
balance: (currentBalance + pointsAdded).toString(),
|
||||
userId: body.metadata.userId,
|
||||
donationId: donation.id,
|
||||
donationProjectName: donation.projectName,
|
||||
donationProjectSlug: donation.projectSlug,
|
||||
donationFundSlug: donation.fundSlug,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.log((error as any).data.error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
if (body.metadata.donorEmail && body.metadata.donorName) {
|
||||
let attestationMessage = ''
|
||||
let attestationSignature = ''
|
||||
|
||||
if (body.metadata.isMembership === 'true' && body.metadata.membershipTerm) {
|
||||
const attestation = await getMembershipAttestation({
|
||||
donorName: body.metadata.donorName,
|
||||
donorEmail: body.metadata.donorEmail,
|
||||
amount: Number(grossFiatAmount.toFixed(2)),
|
||||
term: body.metadata.membershipTerm,
|
||||
method: paymentMethod.currency,
|
||||
fundName: funds[body.metadata.fundSlug].title,
|
||||
fundSlug: body.metadata.fundSlug,
|
||||
periodStart: new Date(),
|
||||
periodEnd: membershipExpiresAt!,
|
||||
})
|
||||
|
||||
attestationMessage = attestation.message
|
||||
attestationSignature = attestation.signature
|
||||
}
|
||||
|
||||
if (body.metadata.isMembership === 'false') {
|
||||
const attestation = await getDonationAttestation({
|
||||
donorName: body.metadata.donorName,
|
||||
donorEmail: body.metadata.donorEmail,
|
||||
amount: Number(grossFiatAmount.toFixed(2)),
|
||||
method: paymentMethod.currency,
|
||||
fundName: funds[body.metadata.fundSlug].title,
|
||||
fundSlug: body.metadata.fundSlug,
|
||||
projectName: body.metadata.projectName,
|
||||
date: new Date(),
|
||||
donationId: donation.id,
|
||||
})
|
||||
|
||||
attestationMessage = attestation.message
|
||||
attestationSignature = attestation.signature
|
||||
}
|
||||
|
||||
sendDonationConfirmationEmail({
|
||||
to: body.metadata.donorEmail,
|
||||
donorName: body.metadata.donorName,
|
||||
fundSlug: body.metadata.fundSlug,
|
||||
projectName: body.metadata.projectName,
|
||||
isMembership: body.metadata.isMembership === 'true',
|
||||
isSubscription: false,
|
||||
pointsReceived: pointsAdded,
|
||||
btcpayAsset: paymentMethod.currency,
|
||||
btcpayCryptoAmount: grossCryptoAmount,
|
||||
attestationMessage,
|
||||
attestationSignature,
|
||||
})
|
||||
}
|
||||
})
|
||||
)
|
||||
await handleDonationOrMembership(body)
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true })
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- A unique constraint covering the columns `[btcPayInvoiceId]` on the table `Donation` will be added. If there are existing duplicate values, this will fail.
|
||||
- A unique constraint covering the columns `[stripePaymentIntentId]` on the table `Donation` will be added. If there are existing duplicate values, this will fail.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "Donation" ADD COLUMN "cryptoPayments" JSONB,
|
||||
ALTER COLUMN "grossCryptoAmount" SET DATA TYPE TEXT,
|
||||
ALTER COLUMN "netCryptoAmount" SET DATA TYPE TEXT;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Donation_btcPayInvoiceId_key" ON "Donation"("btcPayInvoiceId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Donation_stripePaymentIntentId_key" ON "Donation"("stripePaymentIntentId");
|
||||
@@ -37,16 +37,14 @@ model Donation {
|
||||
|
||||
userId String?
|
||||
donorName String?
|
||||
btcPayInvoiceId String?
|
||||
stripePaymentIntentId String? // For donations and non-recurring memberships
|
||||
btcPayInvoiceId String? @unique
|
||||
stripePaymentIntentId String? @unique // For donations and non-recurring memberships
|
||||
stripeInvoiceId String? @unique // For recurring memberships
|
||||
stripeSubscriptionId String? // For recurring memberships
|
||||
projectSlug String
|
||||
projectName String
|
||||
fundSlug FundSlug
|
||||
cryptoCode String?
|
||||
grossCryptoAmount Float?
|
||||
netCryptoAmount Float?
|
||||
cryptoPayments Json?
|
||||
grossFiatAmount Float
|
||||
netFiatAmount Float
|
||||
pointsAdded Int @default(0)
|
||||
@@ -54,6 +52,10 @@ model Donation {
|
||||
membershipTerm MembershipTerm?
|
||||
showDonorNameOnLeaderboard Boolean? @default(false)
|
||||
|
||||
cryptoCode String?
|
||||
grossCryptoAmount String?
|
||||
netCryptoAmount String?
|
||||
|
||||
@@index([btcPayInvoiceId])
|
||||
@@index([stripePaymentIntentId])
|
||||
@@index([stripeSubscriptionId])
|
||||
|
||||
@@ -4,6 +4,7 @@ import { redisConnection as connection } from '../config/redis'
|
||||
|
||||
import './workers/perk'
|
||||
import './workers/membership-check'
|
||||
import './workers/donation-migration'
|
||||
|
||||
export const perkPurchaseQueue = new Queue<PerkPurchaseWorkerData>('PerkPurchase', {
|
||||
connection,
|
||||
@@ -11,8 +12,16 @@ export const perkPurchaseQueue = new Queue<PerkPurchaseWorkerData>('PerkPurchase
|
||||
|
||||
export const membershipCheckQueue = new Queue('MembershipCheck', { connection })
|
||||
|
||||
export const donationMigration = new Queue('DonationMigration', { connection })
|
||||
|
||||
membershipCheckQueue.upsertJobScheduler(
|
||||
'MembershipCheckScheduler',
|
||||
{ pattern: '0 * * * *' },
|
||||
{ name: 'MembershipCheck' }
|
||||
)
|
||||
|
||||
donationMigration.upsertJobScheduler(
|
||||
'DonationMigrationScheduler',
|
||||
{ pattern: '* * * * *' },
|
||||
{ name: 'DonationMigration' }
|
||||
)
|
||||
|
||||
@@ -503,13 +503,7 @@ export const donationRouter = router({
|
||||
const { message, signature } = await getDonationAttestation({
|
||||
donorName: user.attributes?.name,
|
||||
donorEmail: ctx.session.user.email,
|
||||
amount: donation.grossFiatAmount,
|
||||
method: donation.cryptoCode ? donation.cryptoCode : 'Fiat',
|
||||
fundSlug: donation.fundSlug,
|
||||
fundName: funds[donation.fundSlug].title,
|
||||
projectName: donation.projectName,
|
||||
date: donation.createdAt,
|
||||
donationId: donation.id,
|
||||
donation,
|
||||
})
|
||||
|
||||
return { message, signature }
|
||||
@@ -556,15 +550,9 @@ export const donationRouter = router({
|
||||
const { message, signature } = await getMembershipAttestation({
|
||||
donorName: user.attributes?.name,
|
||||
donorEmail: ctx.session.user.email,
|
||||
// For membership donations, a null membership term means that membership is an annual one,
|
||||
// since it was started before monthly memberships were introduced.
|
||||
term: donations[0].membershipTerm || 'annually',
|
||||
amount: membershipValue,
|
||||
method: donations[0].cryptoCode ? donations[0].cryptoCode : 'Fiat',
|
||||
fundSlug: donations[0].fundSlug,
|
||||
fundName: funds[donations[0].fundSlug].title,
|
||||
donation: donations[0],
|
||||
periodStart: membershipStart,
|
||||
periodEnd: membershipEnd,
|
||||
totalAmountToDate: membershipValue,
|
||||
})
|
||||
|
||||
return { message, signature }
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
StrapiGetPointsPopulatedRes,
|
||||
} from '../types'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { estimatePrintfulOrderCost, getUserPointBalance } from '../utils/perks'
|
||||
import { estimatePrintfulOrderCost, getPointsBalance } from '../utils/perks'
|
||||
import { POINTS_REDEEM_PRICE_USD } from '../../config'
|
||||
import { authenticateKeycloakClient } from '../utils/keycloak'
|
||||
import { perkPurchaseQueue } from '../queues'
|
||||
@@ -20,7 +20,7 @@ import { redisConnection } from '../../config/redis'
|
||||
export const perkRouter = router({
|
||||
getBalance: protectedProcedure.query(async ({ ctx }) => {
|
||||
const userId = ctx.session.user.sub
|
||||
const balance = getUserPointBalance(userId)
|
||||
const balance = getPointsBalance(userId)
|
||||
return balance
|
||||
}),
|
||||
|
||||
@@ -71,14 +71,16 @@ export const perkRouter = router({
|
||||
.input(
|
||||
z.object({
|
||||
printfulSyncVariantId: z.number(),
|
||||
shippingAddressLine1: z.string().min(1),
|
||||
shippingAddressLine2: z.string(),
|
||||
shippingCity: z.string().min(1),
|
||||
shippingState: z.string(),
|
||||
shippingCountry: z.string().min(1),
|
||||
shippingZip: z.string().min(1),
|
||||
shippingPhone: z.string().min(1),
|
||||
shippingTaxNumber: z.string(),
|
||||
shipping: z.object({
|
||||
addressLine1: z.string().min(1),
|
||||
addressLine2: z.string().optional(),
|
||||
city: z.string().min(1),
|
||||
stateCode: z.string().min(1),
|
||||
countryCode: z.string().min(1),
|
||||
zip: z.string().min(1),
|
||||
phone: z.string().min(1),
|
||||
taxNumber: z.string().optional(),
|
||||
}),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
@@ -94,16 +96,9 @@ export const perkRouter = router({
|
||||
})
|
||||
|
||||
const costEstimate = await estimatePrintfulOrderCost({
|
||||
address1: input.shippingAddressLine1!,
|
||||
address2: input.shippingAddressLine2 || '',
|
||||
city: input.shippingCity!,
|
||||
stateCode: input.shippingState!,
|
||||
countryCode: input.shippingCountry!,
|
||||
zip: input.shippingZip!,
|
||||
name: user.attributes?.name?.[0],
|
||||
phone: input.shippingPhone!,
|
||||
shipping: input.shipping,
|
||||
email: user.email,
|
||||
tax_number: input.shippingTaxNumber,
|
||||
name: user.attributes?.name?.[0],
|
||||
printfulSyncVariantId: input.printfulSyncVariantId,
|
||||
})
|
||||
|
||||
@@ -121,14 +116,18 @@ export const perkRouter = router({
|
||||
perkId: z.string(),
|
||||
perkPrintfulSyncVariantId: z.number().optional(),
|
||||
fundSlug: z.enum(fundSlugs),
|
||||
shippingAddressLine1: z.string().optional(),
|
||||
shippingAddressLine2: z.string().optional(),
|
||||
shippingCity: z.string().optional(),
|
||||
shippingState: z.string().optional(),
|
||||
shippingCountry: z.string().optional(),
|
||||
shippingZip: z.string().optional(),
|
||||
shippingPhone: z.string().optional(),
|
||||
shippingTaxNumber: z.string().optional(),
|
||||
shipping: z
|
||||
.object({
|
||||
addressLine1: z.string().min(1),
|
||||
addressLine2: z.string().optional(),
|
||||
city: z.string().min(1),
|
||||
stateCode: z.string().min(1),
|
||||
countryCode: z.string().min(1),
|
||||
zip: z.string().min(1),
|
||||
phone: z.string().min(1),
|
||||
taxNumber: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
@@ -150,20 +149,8 @@ export const perkRouter = router({
|
||||
if (!perk) throw new TRPCError({ code: 'BAD_REQUEST', message: 'Perk not found.' })
|
||||
|
||||
// Check if shipping data is present if required
|
||||
if (perk.needsShippingAddress) {
|
||||
const shippingDataIsMissing =
|
||||
[
|
||||
input.shippingAddressLine1,
|
||||
input.shippingCity,
|
||||
input.shippingState,
|
||||
input.shippingCountry,
|
||||
input.shippingZip,
|
||||
input.shippingPhone,
|
||||
].filter((data) => !data).length > 0
|
||||
|
||||
if (shippingDataIsMissing) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Shipping data is missing.' })
|
||||
}
|
||||
if (perk.needsShippingAddress && !input.shipping) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Shipping data is missing.' })
|
||||
}
|
||||
|
||||
// Check if perk is available in the fund
|
||||
@@ -176,16 +163,9 @@ export const perkRouter = router({
|
||||
|
||||
if (perk.printfulProductId && input.perkPrintfulSyncVariantId) {
|
||||
const printfulCostEstimate = await estimatePrintfulOrderCost({
|
||||
address1: input.shippingAddressLine1!,
|
||||
address2: input.shippingAddressLine2 || '',
|
||||
city: input.shippingCity!,
|
||||
stateCode: input.shippingState!,
|
||||
countryCode: input.shippingCountry!,
|
||||
zip: input.shippingZip!,
|
||||
name: user.attributes?.name?.[0],
|
||||
phone: input.shippingPhone!,
|
||||
shipping: input.shipping!,
|
||||
email: user.email,
|
||||
tax_number: input.shippingTaxNumber,
|
||||
name: user.attributes?.name?.[0],
|
||||
printfulSyncVariantId: input.perkPrintfulSyncVariantId,
|
||||
})
|
||||
|
||||
@@ -194,7 +174,7 @@ export const perkRouter = router({
|
||||
deductionAmount = perk.price
|
||||
}
|
||||
|
||||
const currentBalance = await getUserPointBalance(userId)
|
||||
const currentBalance = await getPointsBalance(userId)
|
||||
const balanceAfterPurchase = currentBalance - deductionAmount
|
||||
|
||||
if (balanceAfterPurchase < 0) {
|
||||
@@ -204,14 +184,7 @@ export const perkRouter = router({
|
||||
const purchaseJob = await perkPurchaseQueue.add('purchase', {
|
||||
perk,
|
||||
perkPrintfulSyncVariantId: input.perkPrintfulSyncVariantId,
|
||||
shippingAddressLine1: input.shippingAddressLine1,
|
||||
shippingAddressLine2: input.shippingAddressLine2,
|
||||
shippingCountry: input.shippingCountry,
|
||||
shippingState: input.shippingState,
|
||||
shippingCity: input.shippingCity,
|
||||
shippingZip: input.shippingZip,
|
||||
shippingPhone: input.shippingPhone,
|
||||
shippingTaxNumber: input.shippingTaxNumber,
|
||||
shipping: input.shipping,
|
||||
userId: user.id,
|
||||
userEmail: user.email,
|
||||
userFullname: user?.attributes?.name?.[0],
|
||||
|
||||
@@ -29,6 +29,13 @@ export type DonationMetadata = {
|
||||
showDonorNameOnLeaderboard: 'true' | 'false'
|
||||
}
|
||||
|
||||
export type DonationCryptoPayments = {
|
||||
cryptoCode: 'BTC' | 'XMR' | 'LTC' | 'MANUAL'
|
||||
grossAmount: number
|
||||
netAmount: number
|
||||
rate: number
|
||||
}[]
|
||||
|
||||
export type BtcPayGetRatesRes = [
|
||||
{
|
||||
currencyPair: string
|
||||
@@ -37,6 +44,11 @@ export type BtcPayGetRatesRes = [
|
||||
},
|
||||
]
|
||||
|
||||
export type BtcPayGetInvoiceRes = {
|
||||
id: string
|
||||
amount: string
|
||||
}
|
||||
|
||||
export type BtcPayGetPaymentMethodsRes = {
|
||||
rate: string
|
||||
amount: string
|
||||
@@ -123,7 +135,7 @@ export type StrapiGetPerkRes = {
|
||||
|
||||
// Strapi Order
|
||||
|
||||
type StrapiOrder = {
|
||||
export type StrapiOrder = {
|
||||
id: number
|
||||
documentId: string
|
||||
createdAt: string
|
||||
@@ -339,6 +351,7 @@ export type PrintfulCreateOrderReq = {
|
||||
}
|
||||
|
||||
export type PrintfulCreateOrderRes = {
|
||||
externalId: string
|
||||
costs: {
|
||||
currency: 'USD'
|
||||
subtotal: string
|
||||
|
||||
@@ -1,44 +1,33 @@
|
||||
import * as ed from '@noble/ed25519'
|
||||
import { FundSlug, MembershipTerm } from '@prisma/client'
|
||||
import { Donation, FundSlug, MembershipTerm } from '@prisma/client'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
import { env } from '../../env.mjs'
|
||||
import { funds } from '../../utils/funds'
|
||||
|
||||
type GetDonationAttestationParams = {
|
||||
donorName: string
|
||||
donorEmail: string
|
||||
donationId: string
|
||||
amount: number
|
||||
method: string
|
||||
fundSlug: FundSlug
|
||||
fundName: string
|
||||
projectName: string
|
||||
date: Date
|
||||
donation: Donation
|
||||
}
|
||||
|
||||
export async function getDonationAttestation({
|
||||
donorName,
|
||||
donorEmail,
|
||||
donationId,
|
||||
amount,
|
||||
method,
|
||||
fundSlug,
|
||||
fundName,
|
||||
projectName,
|
||||
date,
|
||||
donation,
|
||||
}: GetDonationAttestationParams) {
|
||||
const message = `MAGIC Grants Donation Attestation
|
||||
|
||||
Name: ${donorName}
|
||||
Email: ${donorEmail}
|
||||
Donation ID: ${donationId}
|
||||
Amount: $${amount.toFixed(2)}
|
||||
Method: ${method}
|
||||
Fund: ${fundName}
|
||||
Project: ${projectName}
|
||||
Date: ${dayjs(date).format('YYYY-M-D')}
|
||||
Donation ID: ${donation.id}
|
||||
Amount: $${donation.grossFiatAmount.toFixed(2)}
|
||||
Method: ${donation.cryptoPayments ? 'Crypto' : 'Fiat'}
|
||||
Fund: ${funds[donation.fundSlug].title}
|
||||
Project: ${donation.projectName}
|
||||
Date: ${dayjs(donation.createdAt).format('YYYY-M-D')}
|
||||
|
||||
Verify this attestation at donate.magicgrants.org/${fundSlug}/verify-attestation`
|
||||
Verify this attestation at donate.magicgrants.org/${donation.fundSlug}/verify-attestation`
|
||||
|
||||
const signature = await ed.signAsync(
|
||||
Buffer.from(message, 'utf-8').toString('hex'),
|
||||
@@ -53,38 +42,30 @@ Verify this attestation at donate.magicgrants.org/${fundSlug}/verify-attestation
|
||||
type GetMembershipAttestation = {
|
||||
donorName: string
|
||||
donorEmail: string
|
||||
term: MembershipTerm
|
||||
amount: number
|
||||
method: string
|
||||
fundName: string
|
||||
fundSlug: FundSlug
|
||||
periodStart: Date
|
||||
periodEnd: Date
|
||||
donation: Donation
|
||||
totalAmountToDate?: number
|
||||
periodStart?: Date
|
||||
}
|
||||
|
||||
export async function getMembershipAttestation({
|
||||
donorName,
|
||||
donorEmail,
|
||||
term,
|
||||
amount,
|
||||
method,
|
||||
fundName,
|
||||
fundSlug,
|
||||
totalAmountToDate,
|
||||
donation,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
}: GetMembershipAttestation) {
|
||||
const message = `MAGIC Grants Membership Attestation
|
||||
|
||||
Name: ${donorName}
|
||||
Email: ${donorEmail}
|
||||
Term: ${term.charAt(0).toUpperCase() + term.slice(1)}
|
||||
Total amount to date: $${amount.toFixed(2)}
|
||||
Method: ${method}
|
||||
Fund: ${fundName}
|
||||
Period start: ${dayjs(periodStart).format('YYYY-M-D')}
|
||||
Period end: ${dayjs(periodEnd).format('YYYY-M-D')}
|
||||
Term: ${donation.membershipTerm!.charAt(0).toUpperCase() + donation.membershipTerm!.slice(1)}
|
||||
Total amount to date: $${(totalAmountToDate || donation.grossFiatAmount).toFixed(2)}
|
||||
Method: ${donation.cryptoPayments ? 'Crypto' : 'Fiat'}
|
||||
Fund: ${funds[donation.fundSlug].title}
|
||||
Period start: ${dayjs(periodStart || donation.createdAt).format('YYYY-M-D')}
|
||||
Period end: ${dayjs(donation.membershipExpiresAt).format('YYYY-M-D')}
|
||||
|
||||
Verify this attestation at donate.magicgrants.org/${fundSlug}/verify-attestation`
|
||||
Verify this attestation at donate.magicgrants.org/${donation.fundSlug}/verify-attestation`
|
||||
|
||||
const signature = await ed.signAsync(
|
||||
Buffer.from(message, 'utf-8').toString('hex'),
|
||||
|
||||
26
server/utils/btcpayserver.ts
Normal file
26
server/utils/btcpayserver.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { log } from '../../utils/logging'
|
||||
import { btcpayApi } from '../services'
|
||||
import { BtcPayGetInvoiceRes, BtcPayGetPaymentMethodsRes } from '../types'
|
||||
|
||||
export async function getBtcPayInvoice(id: string) {
|
||||
try {
|
||||
const { data: invoice } = await btcpayApi.get<BtcPayGetInvoiceRes>(`/invoices/${id}`)
|
||||
return invoice
|
||||
} catch (error) {
|
||||
log('error', `Failed to get BTCPayServer invoice ${id}.`)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function getBtcPayInvoicePaymentMethods(invoiceId: string) {
|
||||
try {
|
||||
const { data: paymentMethods } = await btcpayApi.get<BtcPayGetPaymentMethodsRes>(
|
||||
`/invoices/${invoiceId}/payment-methods`
|
||||
)
|
||||
|
||||
return paymentMethods
|
||||
} catch (error) {
|
||||
log('error', `Failed to get BTCPayServer payment methods for invoice ${invoiceId}.`)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FundSlug } from '@prisma/client'
|
||||
import { Donation, FundSlug } from '@prisma/client'
|
||||
import localizedFormat from 'dayjs/plugin/localizedFormat'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
@@ -7,6 +7,7 @@ import { transporter } from '../services'
|
||||
import { funds } from '../../utils/funds'
|
||||
import { POINTS_REDEEM_PRICE_USD } from '../../config'
|
||||
import markdownToHtml from '../../utils/markdownToHtml'
|
||||
import { DonationCryptoPayments } from '../types'
|
||||
|
||||
dayjs.extend(localizedFormat)
|
||||
|
||||
@@ -15,14 +16,7 @@ const pointsFormat = Intl.NumberFormat('en', { notation: 'standard', compactDisp
|
||||
type SendDonationConfirmationEmailParams = {
|
||||
to: string
|
||||
donorName: string
|
||||
fundSlug: FundSlug
|
||||
projectName?: string
|
||||
isMembership: boolean
|
||||
isSubscription: boolean
|
||||
stripeUsdAmount?: number
|
||||
btcpayCryptoAmount?: number
|
||||
btcpayAsset?: 'BTC' | 'XMR'
|
||||
pointsReceived: number
|
||||
donation: Donation
|
||||
attestationMessage: string
|
||||
attestationSignature: string
|
||||
}
|
||||
@@ -30,27 +24,26 @@ type SendDonationConfirmationEmailParams = {
|
||||
export async function sendDonationConfirmationEmail({
|
||||
to,
|
||||
donorName,
|
||||
fundSlug,
|
||||
projectName,
|
||||
isMembership,
|
||||
isSubscription,
|
||||
stripeUsdAmount,
|
||||
btcpayCryptoAmount,
|
||||
btcpayAsset,
|
||||
pointsReceived,
|
||||
donation,
|
||||
attestationMessage,
|
||||
attestationSignature,
|
||||
}: SendDonationConfirmationEmailParams) {
|
||||
const dateStr = dayjs().format('YYYY-M-D')
|
||||
const fundName = funds[fundSlug].title
|
||||
const fundName = funds[donation.fundSlug].title
|
||||
const isMembership = !donation.membershipExpiresAt
|
||||
const isSubscription = donation.stripeSubscriptionId
|
||||
const isPaidWithCrypto = (donation.cryptoPayments as DonationCryptoPayments | null)?.length
|
||||
const cryptoDonationDescription = (donation.cryptoPayments as DonationCryptoPayments | null)
|
||||
?.map((payment) => `${payment.grossAmount} ${payment.cryptoCode}`)
|
||||
.join(', ')
|
||||
|
||||
const markdown = `# Donation receipt
|
||||
|
||||
Thank you for your donation to MAGIC Grants! Your donation supports our charitable mission.
|
||||
|
||||
${!isMembership ? `You donated to: ${fundName}` : ''}
|
||||
${isMembership ? `You donated to: ${fundName}` : ''}
|
||||
|
||||
${projectName ? `You supported this campaign: ${projectName}` : ''}
|
||||
${donation.projectName ? `You supported this campaign: ${donation.projectName}` : ''}
|
||||
|
||||
${
|
||||
isMembership
|
||||
@@ -69,17 +62,17 @@ export async function sendDonationConfirmationEmail({
|
||||
${donorName}
|
||||
|
||||
MAGIC Grants acknowledges and expresses appreciation for the following contribution:
|
||||
- [${stripeUsdAmount ? '☑️' : '⬜'}] Cash or bank transfer donation amount: ${stripeUsdAmount ? stripeUsdAmount.toFixed(2) : 'N/A'}
|
||||
- [${btcpayCryptoAmount ? '☑️' : '⬜'}] In-kind (non-fiat) donation description: ${btcpayCryptoAmount && btcpayAsset ? `${btcpayCryptoAmount} ${btcpayAsset}` : '-'}
|
||||
- ${!isPaidWithCrypto ? '☑️' : '⬜'} Cash or bank transfer donation amount: ${!isPaidWithCrypto ? `$${donation.grossFiatAmount}` : '$0.00'}
|
||||
- ${isPaidWithCrypto ? '☑️' : '⬜'} In-kind (non-fiat) donation description: ${cryptoDonationDescription ? cryptoDonationDescription : '-'}
|
||||
|
||||
Description and/or restrictions: ${fundSlug === 'general' ? 'None' : `Donation to the ${fundName}`}
|
||||
Description and/or restrictions: ${donation.fundSlug === 'general' ? 'None' : `Donation to the ${fundName}`}
|
||||
|
||||
The following describes the context of your donation:
|
||||
|
||||
- [${!pointsReceived ? '☑️' : '⬜'}] No goods or services were received in exchange for your generous donation.
|
||||
- [${pointsReceived ? '☑️' : '⬜'}] In connection with your generous donation, you received ${pointsFormat.format(pointsReceived)} points, valued at approximately $${(pointsReceived * POINTS_REDEEM_PRICE_USD).toFixed(2)}.
|
||||
- ${!donation.pointsAdded ? '☑️' : '⬜'} No goods or services were received in exchange for your generous donation.
|
||||
- ${donation.pointsAdded ? '☑️' : '⬜'} In connection with your generous donation, you received ${pointsFormat.format(donation.pointsAdded)} points, valued at approximately $${(donation.pointsAdded * POINTS_REDEEM_PRICE_USD).toFixed(2)}.
|
||||
|
||||
${btcpayCryptoAmount ? 'If you wish to receive a tax deduction for a cryptocurrency donation over $500, you MUST complete [Form 8283](https://www.irs.gov/pub/irs-pdf/f8283.pdf) and send the completed form to [info@magicgrants.org](mailto:info@magicgrants.org) to qualify for a deduction.' : ''}
|
||||
${isPaidWithCrypto ? 'If you wish to receive a tax deduction for a cryptocurrency donation over $500, you MUST complete [Form 8283](https://www.irs.gov/pub/irs-pdf/f8283.pdf) and send the completed form to [info@magicgrants.org](mailto:info@magicgrants.org) to qualify for a deduction.' : ''}
|
||||
|
||||
### Signed attestation
|
||||
|
||||
@@ -98,7 +91,7 @@ export async function sendDonationConfirmationEmail({
|
||||
${env.NEXT_PUBLIC_ATTESTATION_PUBLIC_KEY_HEX}
|
||||
\`\`\`
|
||||
|
||||
This attestation can be verified at [donate.magicgrants.org/${fundSlug}/verify-attestation](https://donate.magicgrants.org/${fundSlug}/verify-attestation).
|
||||
This attestation can be verified at [donate.magicgrants.org/${donation.fundSlug}/verify-attestation](https://donate.magicgrants.org/${donation.fundSlug}/verify-attestation).
|
||||
|
||||
MAGIC Grants
|
||||
1942 Broadway St., STE 314C
|
||||
@@ -146,11 +139,11 @@ type SendPerkPurchaseConfirmationEmailParams = {
|
||||
to: string
|
||||
perkName: string
|
||||
address?: {
|
||||
address1: string
|
||||
address2?: string
|
||||
addressLine1: string
|
||||
addressLine2?: string
|
||||
city: string
|
||||
state?: string
|
||||
country: string
|
||||
stateCode: string
|
||||
countryCode: string
|
||||
zip: string
|
||||
}
|
||||
pointsRedeemed: number
|
||||
@@ -171,11 +164,11 @@ export async function sendPerkPurchaseConfirmationEmail({
|
||||
address
|
||||
? `Mailing address
|
||||
|
||||
Address line 1: ${address.address1}
|
||||
Address line 2: ${address.address2 ? address.address2 : '-'}
|
||||
Address line 1: ${address.addressLine1}
|
||||
Address line 2: ${address.addressLine2 || '-'}
|
||||
City: ${address.city}
|
||||
State: ${address.state}
|
||||
Country: ${address.country}
|
||||
State: ${address.stateCode}
|
||||
Country: ${address.countryCode}
|
||||
Zip: ${address.zip}`
|
||||
: ''
|
||||
}
|
||||
|
||||
@@ -1,12 +1,29 @@
|
||||
import { AxiosResponse } from 'axios'
|
||||
import { printfulApi, prisma, strapiApi } from '../services'
|
||||
import {
|
||||
PrintfulCreateOrderReq,
|
||||
PrintfulCreateOrderRes,
|
||||
PrintfulEstimateOrderReq,
|
||||
PrintfulEstimateOrderRes,
|
||||
StrapiCreateOrderBody,
|
||||
StrapiCreateOrderRes,
|
||||
StrapiCreatePointBody,
|
||||
StrapiGetPointsPopulatedRes,
|
||||
} from '../types'
|
||||
import { Donation } from '@prisma/client'
|
||||
|
||||
export async function getUserPointBalance(userId: string): Promise<number> {
|
||||
type Shipping = {
|
||||
addressLine1: string
|
||||
addressLine2?: string
|
||||
city: string
|
||||
stateCode: string
|
||||
countryCode: string
|
||||
zip: string
|
||||
phone: string
|
||||
taxNumber?: string
|
||||
}
|
||||
|
||||
export async function getPointsBalance(userId: string): Promise<number> {
|
||||
const {
|
||||
data: { data: pointHistory },
|
||||
} = await strapiApi.get<StrapiGetPointsPopulatedRes>(
|
||||
@@ -19,32 +36,111 @@ export async function getUserPointBalance(userId: string): Promise<number> {
|
||||
return currentBalance
|
||||
}
|
||||
|
||||
type GivePointsToUserParams = { pointsToGive: number; donation: Donation }
|
||||
|
||||
export async function givePointsToUser({ pointsToGive, donation }: GivePointsToUserParams) {
|
||||
if (!donation.userId) {
|
||||
console.error(
|
||||
'Could not give points using donation with null userId. Donation ID:',
|
||||
donation.userId
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const pointsBalance = await getPointsBalance(donation.userId)
|
||||
|
||||
await strapiApi.post<any, any, StrapiCreatePointBody>('/points', {
|
||||
data: {
|
||||
balanceChange: pointsToGive.toString(),
|
||||
balance: (pointsBalance + pointsToGive).toString(),
|
||||
userId: donation.userId,
|
||||
donationId: donation.id,
|
||||
donationProjectName: donation.projectName,
|
||||
donationProjectSlug: donation.projectSlug,
|
||||
donationFundSlug: donation.fundSlug,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
type DeductPointsFromUserParams = {
|
||||
deductionAmount: number
|
||||
userId: string
|
||||
perkId: string
|
||||
orderId: string
|
||||
}
|
||||
|
||||
export async function deductPointsFromUser({
|
||||
deductionAmount,
|
||||
userId,
|
||||
perkId,
|
||||
orderId,
|
||||
}: DeductPointsFromUserParams) {
|
||||
const pointsBalance = await getPointsBalance(userId)
|
||||
const newPointsBalance = pointsBalance - deductionAmount
|
||||
|
||||
await strapiApi.post<any, any, StrapiCreatePointBody>('/points', {
|
||||
data: {
|
||||
balanceChange: (-deductionAmount).toString(),
|
||||
balance: newPointsBalance.toString(),
|
||||
userId: userId,
|
||||
perk: perkId,
|
||||
order: orderId,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export type CreateStrapiOrderParams = {
|
||||
perkId: string
|
||||
userId: string
|
||||
userEmail: string
|
||||
shipping?: Shipping
|
||||
}
|
||||
|
||||
export async function createStrapiOrder({
|
||||
perkId,
|
||||
userId,
|
||||
userEmail,
|
||||
shipping,
|
||||
}: CreateStrapiOrderParams) {
|
||||
const {
|
||||
data: { data: order },
|
||||
} = await strapiApi.post<any, AxiosResponse<StrapiCreateOrderRes>, StrapiCreateOrderBody>(
|
||||
'/orders',
|
||||
{
|
||||
data: {
|
||||
perk: perkId,
|
||||
userId: userId,
|
||||
userEmail: userEmail,
|
||||
shippingAddressLine1: shipping?.addressLine1,
|
||||
shippingAddressLine2: shipping?.addressLine2,
|
||||
shippingCity: shipping?.city,
|
||||
shippingState: shipping?.stateCode,
|
||||
shippingCountry: shipping?.countryCode,
|
||||
shippingZip: shipping?.zip,
|
||||
shippingPhone: shipping?.phone,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
return order
|
||||
}
|
||||
|
||||
export async function deleteStrapiOrder(orderId: string) {
|
||||
await strapiApi.delete(`/orders/${orderId}`)
|
||||
}
|
||||
|
||||
type EstimatePrintfulOrderCostParams = {
|
||||
printfulSyncVariantId: number
|
||||
address1: string
|
||||
address2: string
|
||||
city: string
|
||||
stateCode: string
|
||||
countryCode: string
|
||||
zip: string
|
||||
name: string
|
||||
phone: string
|
||||
email: string
|
||||
tax_number?: string
|
||||
name: string
|
||||
printfulSyncVariantId: number
|
||||
shipping: Shipping
|
||||
}
|
||||
|
||||
export async function estimatePrintfulOrderCost({
|
||||
printfulSyncVariantId,
|
||||
address1,
|
||||
address2,
|
||||
city,
|
||||
stateCode,
|
||||
countryCode,
|
||||
zip,
|
||||
name,
|
||||
phone,
|
||||
email,
|
||||
tax_number,
|
||||
name,
|
||||
shipping,
|
||||
}: EstimatePrintfulOrderCostParams) {
|
||||
const {
|
||||
data: { result: costEstimate },
|
||||
@@ -52,16 +148,16 @@ export async function estimatePrintfulOrderCost({
|
||||
`/orders/estimate-costs`,
|
||||
{
|
||||
recipient: {
|
||||
address1,
|
||||
address2,
|
||||
city,
|
||||
state_code: stateCode,
|
||||
country_code: countryCode,
|
||||
zip,
|
||||
name,
|
||||
phone,
|
||||
address1: shipping.addressLine1,
|
||||
address2: shipping.addressLine2 || '',
|
||||
city: shipping.city,
|
||||
state_code: shipping.stateCode,
|
||||
country_code: shipping.countryCode,
|
||||
zip: shipping.zip,
|
||||
phone: shipping.phone,
|
||||
tax_number: shipping.taxNumber,
|
||||
email,
|
||||
tax_number,
|
||||
name,
|
||||
},
|
||||
items: [{ quantity: 1, sync_variant_id: printfulSyncVariantId }],
|
||||
}
|
||||
@@ -69,3 +165,43 @@ export async function estimatePrintfulOrderCost({
|
||||
|
||||
return costEstimate
|
||||
}
|
||||
|
||||
type CreatePrintfulOrderParams = {
|
||||
email: string
|
||||
name: string
|
||||
printfulSyncVariantId: number
|
||||
shipping: Shipping
|
||||
}
|
||||
|
||||
export async function createPrintfulOrder({
|
||||
printfulSyncVariantId,
|
||||
email,
|
||||
name,
|
||||
shipping,
|
||||
}: CreatePrintfulOrderParams) {
|
||||
const { data } = await printfulApi.post<
|
||||
{},
|
||||
AxiosResponse<PrintfulCreateOrderRes>,
|
||||
PrintfulCreateOrderReq
|
||||
>(process.env.NODE_ENV === 'production' ? '/orders?confirm=true' : '/orders', {
|
||||
recipient: {
|
||||
name,
|
||||
email,
|
||||
address1: shipping.addressLine1,
|
||||
address2: shipping?.addressLine2 || '',
|
||||
city: shipping.city,
|
||||
state_code: shipping.stateCode,
|
||||
country_code: shipping.countryCode!,
|
||||
zip: shipping.zip,
|
||||
phone: shipping.phone,
|
||||
tax_number: shipping.taxNumber,
|
||||
},
|
||||
items: [{ quantity: 1, sync_variant_id: printfulSyncVariantId }],
|
||||
})
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export async function cancelPrintfulOrder(id: string) {
|
||||
await printfulApi.delete(`/orders/${id}`)
|
||||
}
|
||||
|
||||
@@ -9,16 +9,253 @@ import {
|
||||
prisma,
|
||||
stripe as _stripe,
|
||||
strapiApi,
|
||||
privacyGuidesDiscourseApi,
|
||||
} from '../../server/services'
|
||||
import { DonationMetadata, StrapiCreatePointBody } from '../../server/types'
|
||||
import { DonationMetadata } from '../../server/types'
|
||||
import { sendDonationConfirmationEmail } from './mailing'
|
||||
import { getUserPointBalance } from './perks'
|
||||
import { POINTS_PER_USD } from '../../config'
|
||||
import { env } from '../../env.mjs'
|
||||
import { getPointsBalance, givePointsToUser } from './perks'
|
||||
import { NET_DONATION_AMOUNT_WITH_POINTS_RATE, POINTS_PER_USD } from '../../config'
|
||||
import { getDonationAttestation, getMembershipAttestation } from './attestation'
|
||||
import { funds } from '../../utils/funds'
|
||||
import { addUserToPgMembersGroup } from '../../utils/pg-forum-connection'
|
||||
import { log } from '../../utils/logging'
|
||||
|
||||
async function handleDonationOrNonRecurringMembership(paymentIntent: Stripe.PaymentIntent) {
|
||||
const metadata = paymentIntent.metadata as DonationMetadata
|
||||
|
||||
// Payment intents for subscriptions will not have metadata
|
||||
if (!metadata) return
|
||||
if (JSON.stringify(metadata) === '{}') return
|
||||
if (metadata.isSubscription === 'true') return
|
||||
|
||||
const existingDonation = await prisma.donation.findFirst({
|
||||
where: { stripePaymentIntentId: paymentIntent.id },
|
||||
})
|
||||
|
||||
if (existingDonation) {
|
||||
log(
|
||||
'warn',
|
||||
`[Stripe webhook] Attempted to process already processed payment intent ${paymentIntent.id}.`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Skip this event if intent is still not fully paid
|
||||
if (paymentIntent.amount_received !== paymentIntent.amount) return
|
||||
|
||||
const shouldGivePointsBack = metadata.givePointsBack === 'true'
|
||||
const grossFiatAmount = paymentIntent.amount_received / 100
|
||||
const netFiatAmount = shouldGivePointsBack
|
||||
? Number((grossFiatAmount * NET_DONATION_AMOUNT_WITH_POINTS_RATE).toFixed(2))
|
||||
: grossFiatAmount
|
||||
const pointsToGive = shouldGivePointsBack ? Math.floor(grossFiatAmount / POINTS_PER_USD) : 0
|
||||
let membershipExpiresAt = null
|
||||
|
||||
const termToMembershipExpiresAt = {
|
||||
monthly: dayjs().add(1, 'month').toDate(),
|
||||
annually: dayjs().add(1, 'year').toDate(),
|
||||
} as const
|
||||
|
||||
if (paymentIntent.metadata.isMembership === 'true' && metadata.membershipTerm) {
|
||||
membershipExpiresAt = termToMembershipExpiresAt[metadata.membershipTerm]
|
||||
}
|
||||
|
||||
// Add PG forum user to membership group
|
||||
if (metadata.isMembership && metadata.fundSlug === 'privacyguides' && metadata.userId) {
|
||||
await addUserToPgMembersGroup(metadata.userId)
|
||||
}
|
||||
|
||||
const donation = await prisma.donation.create({
|
||||
data: {
|
||||
userId: metadata.userId,
|
||||
stripePaymentIntentId: paymentIntent.id,
|
||||
projectName: metadata.projectName,
|
||||
projectSlug: metadata.projectSlug,
|
||||
fundSlug: metadata.fundSlug,
|
||||
grossFiatAmount,
|
||||
netFiatAmount,
|
||||
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',
|
||||
`[Stripe webhook] Failed to give points for payment intent ${paymentIntent.id}. Rolling back.`
|
||||
)
|
||||
await prisma.donation.delete({ where: { id: donation.id } })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Get attestation and send confirmation email
|
||||
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,
|
||||
donation,
|
||||
totalAmountToDate: grossFiatAmount,
|
||||
})
|
||||
|
||||
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',
|
||||
`[Stripe webhook] Failed to send donation confirmation email for payment intent ${paymentIntent.id}. NOT rolling back. Cause:`
|
||||
)
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
log('info', `[Stripe webhook] Successfully processed payment intent ${paymentIntent.id}!`)
|
||||
}
|
||||
|
||||
async function handleRecurringMembership(invoice: Stripe.Invoice) {
|
||||
if (!invoice.subscription) return
|
||||
|
||||
const metadata = invoice.subscription_details?.metadata as DonationMetadata
|
||||
const invoiceLine = invoice.lines.data.find((line) => line.invoice === invoice.id)
|
||||
|
||||
if (!invoiceLine) {
|
||||
log(
|
||||
'warn',
|
||||
`[/api/stripe/${metadata.fundSlug}-webhook] Line not fund for invoice ${invoice.id}. Skipping.`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const existingDonation = await prisma.donation.findFirst({
|
||||
where: { stripeInvoiceId: invoice.id },
|
||||
})
|
||||
|
||||
if (existingDonation) {
|
||||
log('warn', `[Stripe webhook] Attempted to process already processed invoice ${invoice.id}.`)
|
||||
return
|
||||
}
|
||||
|
||||
const shouldGivePointsBack = metadata.givePointsBack === 'true'
|
||||
const grossFiatAmount = invoice.total / 100
|
||||
const netFiatAmount = shouldGivePointsBack
|
||||
? Number((grossFiatAmount * NET_DONATION_AMOUNT_WITH_POINTS_RATE).toFixed(2))
|
||||
: grossFiatAmount
|
||||
const pointsToGive = shouldGivePointsBack ? parseInt(String(grossFiatAmount * 100)) : 0
|
||||
const membershipExpiresAt = new Date(invoiceLine.period.end * 1000)
|
||||
|
||||
// Add PG forum user to membership group
|
||||
if (metadata.isMembership && metadata.fundSlug === 'privacyguides' && metadata.userId) {
|
||||
await addUserToPgMembersGroup(metadata.userId)
|
||||
}
|
||||
|
||||
const donation = await prisma.donation.create({
|
||||
data: {
|
||||
userId: metadata.userId as string,
|
||||
stripeInvoiceId: invoice.id,
|
||||
stripeSubscriptionId: invoice.subscription.toString(),
|
||||
projectName: metadata.projectName,
|
||||
projectSlug: metadata.projectSlug,
|
||||
fundSlug: metadata.fundSlug,
|
||||
grossFiatAmount,
|
||||
netFiatAmount,
|
||||
pointsAdded: pointsToGive,
|
||||
membershipExpiresAt,
|
||||
membershipTerm: metadata.membershipTerm || null,
|
||||
showDonorNameOnLeaderboard: metadata.showDonorNameOnLeaderboard === 'true',
|
||||
donorName: metadata.donorName,
|
||||
},
|
||||
})
|
||||
|
||||
// Add points
|
||||
if (shouldGivePointsBack && metadata.userId) {
|
||||
// Get balance for project/fund by finding user's last point history
|
||||
const currentBalance = await getPointsBalance(metadata.userId)
|
||||
|
||||
try {
|
||||
await givePointsToUser({ donation, pointsToGive })
|
||||
} catch (error) {
|
||||
log(
|
||||
'error',
|
||||
`[BTCPay webhook] Failed to give points for invoice ${invoice.id}. Rolling back.`
|
||||
)
|
||||
await prisma.donation.delete({ where: { id: donation.id } })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
if (metadata.donorEmail && metadata.donorName && metadata.membershipTerm) {
|
||||
const donations = await prisma.donation.findMany({
|
||||
where: {
|
||||
stripeSubscriptionId: invoice.subscription.toString(),
|
||||
membershipExpiresAt: { not: null },
|
||||
},
|
||||
orderBy: { membershipExpiresAt: 'desc' },
|
||||
})
|
||||
|
||||
const membershipStart = donations.slice(-1)[0].createdAt
|
||||
|
||||
const membershipValue = donations.reduce(
|
||||
(total, donation) => total + donation.grossFiatAmount,
|
||||
0
|
||||
)
|
||||
|
||||
const attestation = await getMembershipAttestation({
|
||||
donorName: metadata.donorName,
|
||||
donorEmail: metadata.donorEmail,
|
||||
donation,
|
||||
totalAmountToDate: membershipValue,
|
||||
periodStart: membershipStart,
|
||||
})
|
||||
|
||||
try {
|
||||
await sendDonationConfirmationEmail({
|
||||
to: metadata.donorEmail,
|
||||
donorName: metadata.donorName,
|
||||
donation,
|
||||
attestationMessage: attestation.message,
|
||||
attestationSignature: attestation.signature,
|
||||
})
|
||||
} catch (error) {
|
||||
log(
|
||||
'warn',
|
||||
`[Stripe webhook] Failed to send donation confirmation email for invoice ${invoice.id}. NOT rolling back. Cause:`
|
||||
)
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
log('info', `[Stripe webhook] Successfully processed invoice ${invoice.id}!`)
|
||||
}
|
||||
|
||||
export function getStripeWebhookHandler(fundSlug: FundSlug, secret: string) {
|
||||
return async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
@@ -39,244 +276,12 @@ export function getStripeWebhookHandler(fundSlug: FundSlug, secret: string) {
|
||||
// Store donation data when payment intent is valid
|
||||
// Subscriptions are handled on the invoice.paid event instead
|
||||
if (event.type === 'payment_intent.succeeded') {
|
||||
const paymentIntent = event.data.object
|
||||
const metadata = paymentIntent.metadata as DonationMetadata
|
||||
|
||||
// Payment intents for subscriptions will not have metadata
|
||||
if (!metadata) return res.status(200).end()
|
||||
if (JSON.stringify(metadata) === '{}') return res.status(200).end()
|
||||
if (metadata.isSubscription === 'true') return res.status(200).end()
|
||||
|
||||
// Skip this event if intent is still not fully paid
|
||||
if (paymentIntent.amount_received !== paymentIntent.amount) return res.status(200).end()
|
||||
|
||||
const shouldGivePointsBack = metadata.givePointsBack === 'true'
|
||||
const grossFiatAmount = paymentIntent.amount_received / 100
|
||||
const netFiatAmount = shouldGivePointsBack
|
||||
? Number((grossFiatAmount * 0.9).toFixed(2))
|
||||
: grossFiatAmount
|
||||
const pointsAdded = shouldGivePointsBack ? Math.floor(grossFiatAmount / POINTS_PER_USD) : 0
|
||||
let membershipExpiresAt = null
|
||||
|
||||
if (
|
||||
paymentIntent.metadata.isMembership === 'true' &&
|
||||
paymentIntent.metadata.membershipTerm === 'monthly'
|
||||
) {
|
||||
membershipExpiresAt = dayjs().add(1, 'month').toDate()
|
||||
}
|
||||
|
||||
if (
|
||||
paymentIntent.metadata.isMembership === 'true' &&
|
||||
paymentIntent.metadata.membershipTerm === 'annually'
|
||||
) {
|
||||
membershipExpiresAt = dayjs().add(1, 'year').toDate()
|
||||
}
|
||||
|
||||
// Add PG forum user to membership group
|
||||
if (metadata.isMembership && metadata.fundSlug === 'privacyguides' && metadata.userId) {
|
||||
await addUserToPgMembersGroup(metadata.userId)
|
||||
}
|
||||
|
||||
const donation = await prisma.donation.create({
|
||||
data: {
|
||||
userId: metadata.userId,
|
||||
stripePaymentIntentId: paymentIntent.id,
|
||||
projectName: metadata.projectName,
|
||||
projectSlug: metadata.projectSlug,
|
||||
fundSlug: metadata.fundSlug,
|
||||
grossFiatAmount,
|
||||
netFiatAmount,
|
||||
pointsAdded,
|
||||
membershipExpiresAt,
|
||||
membershipTerm: metadata.membershipTerm || null,
|
||||
showDonorNameOnLeaderboard: metadata.showDonorNameOnLeaderboard === 'true',
|
||||
donorName: metadata.donorName,
|
||||
},
|
||||
})
|
||||
|
||||
// Add points
|
||||
if (shouldGivePointsBack && metadata.userId) {
|
||||
// Get balance for project/fund by finding user's last point history
|
||||
const currentBalance = await getUserPointBalance(metadata.userId)
|
||||
|
||||
await strapiApi.post<any, any, StrapiCreatePointBody>('/points', {
|
||||
data: {
|
||||
balanceChange: pointsAdded.toString(),
|
||||
balance: (currentBalance + pointsAdded).toString(),
|
||||
userId: metadata.userId,
|
||||
donationId: donation.id,
|
||||
donationProjectName: donation.projectName,
|
||||
donationProjectSlug: donation.projectSlug,
|
||||
donationFundSlug: donation.fundSlug,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Get attestation and send confirmation email
|
||||
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,
|
||||
amount: Number(grossFiatAmount.toFixed(2)),
|
||||
term: metadata.membershipTerm,
|
||||
method: 'Fiat',
|
||||
fundName: funds[metadata.fundSlug].title,
|
||||
fundSlug: metadata.fundSlug,
|
||||
periodStart: new Date(),
|
||||
periodEnd: membershipExpiresAt!,
|
||||
})
|
||||
|
||||
attestationMessage = attestation.message
|
||||
attestationSignature = attestation.signature
|
||||
}
|
||||
|
||||
if (metadata.isMembership === 'false') {
|
||||
const attestation = await getDonationAttestation({
|
||||
donorName: metadata.donorName,
|
||||
donorEmail: metadata.donorEmail,
|
||||
amount: grossFiatAmount,
|
||||
method: 'Fiat',
|
||||
fundName: funds[metadata.fundSlug].title,
|
||||
fundSlug: metadata.fundSlug,
|
||||
projectName: metadata.projectName,
|
||||
date: new Date(),
|
||||
donationId: donation.id,
|
||||
})
|
||||
|
||||
attestationMessage = attestation.message
|
||||
attestationSignature = attestation.signature
|
||||
}
|
||||
|
||||
sendDonationConfirmationEmail({
|
||||
to: metadata.donorEmail,
|
||||
donorName: metadata.donorName,
|
||||
fundSlug: metadata.fundSlug,
|
||||
projectName: metadata.projectName,
|
||||
isMembership: metadata.isMembership === 'true',
|
||||
isSubscription: false,
|
||||
stripeUsdAmount: paymentIntent.amount_received / 100,
|
||||
pointsReceived: pointsAdded,
|
||||
attestationMessage,
|
||||
attestationSignature,
|
||||
})
|
||||
}
|
||||
handleDonationOrNonRecurringMembership(event.data.object)
|
||||
}
|
||||
|
||||
// Store subscription data when subscription invoice is paid
|
||||
if (event.type === 'invoice.paid') {
|
||||
const invoice = event.data.object
|
||||
|
||||
if (!invoice.subscription) return res.status(200).end()
|
||||
|
||||
const metadata = event.data.object.subscription_details?.metadata as DonationMetadata
|
||||
const invoiceLine = invoice.lines.data.find((line) => line.invoice === invoice.id)
|
||||
|
||||
if (!invoiceLine) {
|
||||
console.error(
|
||||
`[/api/stripe/${metadata.fundSlug}-webhook] Line not fund for invoice ${invoice.id}`
|
||||
)
|
||||
return res.status(200).end()
|
||||
}
|
||||
|
||||
const shouldGivePointsBack = metadata.givePointsBack === 'true'
|
||||
const grossFiatAmount = invoice.total / 100
|
||||
const netFiatAmount = shouldGivePointsBack
|
||||
? Number((grossFiatAmount * 0.9).toFixed(2))
|
||||
: grossFiatAmount
|
||||
const pointsAdded = shouldGivePointsBack ? parseInt(String(grossFiatAmount * 100)) : 0
|
||||
const membershipExpiresAt = new Date(invoiceLine.period.end * 1000)
|
||||
|
||||
// Add PG forum user to membership group
|
||||
if (metadata.isMembership && metadata.fundSlug === 'privacyguides' && metadata.userId) {
|
||||
await addUserToPgMembersGroup(metadata.userId)
|
||||
}
|
||||
|
||||
const donation = await prisma.donation.create({
|
||||
data: {
|
||||
userId: metadata.userId as string,
|
||||
stripeInvoiceId: invoice.id,
|
||||
stripeSubscriptionId: invoice.subscription.toString(),
|
||||
projectName: metadata.projectName,
|
||||
projectSlug: metadata.projectSlug,
|
||||
fundSlug: metadata.fundSlug,
|
||||
grossFiatAmount,
|
||||
netFiatAmount,
|
||||
pointsAdded,
|
||||
membershipExpiresAt,
|
||||
membershipTerm: metadata.membershipTerm || null,
|
||||
showDonorNameOnLeaderboard: metadata.showDonorNameOnLeaderboard === 'true',
|
||||
donorName: metadata.donorName,
|
||||
},
|
||||
})
|
||||
|
||||
// Add points
|
||||
if (shouldGivePointsBack && metadata.userId) {
|
||||
// Get balance for project/fund by finding user's last point history
|
||||
const currentBalance = await getUserPointBalance(metadata.userId)
|
||||
|
||||
await strapiApi.post('/points', {
|
||||
data: {
|
||||
balanceChange: pointsAdded,
|
||||
pointsBalance: currentBalance + pointsAdded,
|
||||
userId: metadata.userId,
|
||||
donationId: donation.id,
|
||||
donationProjectName: donation.projectName,
|
||||
donationProjectSlug: donation.projectSlug,
|
||||
donationFundSlug: donation.fundSlug,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (metadata.donorEmail && metadata.donorName && metadata.membershipTerm) {
|
||||
const donations = await prisma.donation.findMany({
|
||||
where: {
|
||||
stripeSubscriptionId: invoice.subscription.toString(),
|
||||
membershipExpiresAt: { not: null },
|
||||
},
|
||||
orderBy: { membershipExpiresAt: 'desc' },
|
||||
})
|
||||
|
||||
const membershipStart = donations.slice(-1)[0].createdAt
|
||||
|
||||
const membershipValue = donations.reduce(
|
||||
(total, donation) => total + donation.grossFiatAmount,
|
||||
0
|
||||
)
|
||||
|
||||
const attestation = await getMembershipAttestation({
|
||||
donorName: metadata.donorName,
|
||||
donorEmail: metadata.donorEmail,
|
||||
amount: membershipValue,
|
||||
method: 'Fiat',
|
||||
term: metadata.membershipTerm,
|
||||
fundName: funds[metadata.fundSlug].title,
|
||||
fundSlug: metadata.fundSlug,
|
||||
periodStart: membershipStart,
|
||||
periodEnd: membershipExpiresAt,
|
||||
})
|
||||
|
||||
sendDonationConfirmationEmail({
|
||||
to: metadata.donorEmail,
|
||||
donorName: metadata.donorName,
|
||||
fundSlug: metadata.fundSlug,
|
||||
projectName: metadata.projectName,
|
||||
isMembership: metadata.isMembership === 'true',
|
||||
isSubscription: metadata.isSubscription === 'true',
|
||||
stripeUsdAmount: invoice.total / 100,
|
||||
pointsReceived: pointsAdded,
|
||||
attestationMessage: attestation.message,
|
||||
attestationSignature: attestation.signature,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Handle subscription end
|
||||
if (event.type === 'customer.subscription.deleted') {
|
||||
console.log(event.data.object)
|
||||
handleRecurringMembership(event.data.object)
|
||||
}
|
||||
|
||||
// Return a 200 response to acknowledge receipt of the event
|
||||
|
||||
54
server/workers/donation-migration.ts
Normal file
54
server/workers/donation-migration.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { Worker } from 'bullmq'
|
||||
import { redisConnection as connection } from '../../config/redis'
|
||||
import { prisma } from '../services'
|
||||
import { DonationCryptoPayments } from '../types'
|
||||
import { log } from '../../utils/logging'
|
||||
import { Prisma } from '@prisma/client'
|
||||
|
||||
const globalForWorker = global as unknown as { hasInitializedWorkers: boolean }
|
||||
|
||||
if (!globalForWorker.hasInitializedWorkers)
|
||||
new Worker(
|
||||
'DonationMigration',
|
||||
async (job) => {
|
||||
// Finds unmigrated donations and updates them
|
||||
log('info', '[Donation migration] Migrating old donations...')
|
||||
|
||||
const donations = await prisma.donation.findMany({
|
||||
where: {
|
||||
btcPayInvoiceId: { not: null },
|
||||
cryptoPayments: { equals: Prisma.DbNull },
|
||||
},
|
||||
})
|
||||
|
||||
if (!donations.length) return
|
||||
|
||||
await Promise.all(
|
||||
donations.map(async (donation) => {
|
||||
const cryptoPayments: DonationCryptoPayments = [
|
||||
{
|
||||
cryptoCode: donation.cryptoCode as DonationCryptoPayments[0]['cryptoCode'],
|
||||
grossAmount: Number(donation.grossCryptoAmount!),
|
||||
netAmount: Number(donation.netCryptoAmount),
|
||||
rate: donation.grossFiatAmount / Number(donation.grossCryptoAmount),
|
||||
},
|
||||
]
|
||||
|
||||
await prisma.donation.update({
|
||||
where: { id: donation.id },
|
||||
data: {
|
||||
cryptoPayments,
|
||||
cryptoCode: null,
|
||||
grossCryptoAmount: null,
|
||||
netCryptoAmount: null,
|
||||
},
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
log('info', `[Donation migration] Successfully updated ${donations.length} records!!!!!!!!!!`)
|
||||
},
|
||||
{ connection }
|
||||
)
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') globalForWorker.hasInitializedWorkers = true
|
||||
@@ -3,7 +3,15 @@ import { AxiosResponse } from 'axios'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
|
||||
import { redisConnection as connection } from '../../config/redis'
|
||||
import { estimatePrintfulOrderCost, getUserPointBalance } from '../utils/perks'
|
||||
import {
|
||||
cancelPrintfulOrder,
|
||||
createPrintfulOrder,
|
||||
createStrapiOrder,
|
||||
deductPointsFromUser,
|
||||
deleteStrapiOrder,
|
||||
estimatePrintfulOrderCost,
|
||||
getPointsBalance,
|
||||
} from '../utils/perks'
|
||||
import { POINTS_REDEEM_PRICE_USD } from '../../config'
|
||||
import {
|
||||
PrintfulCreateOrderReq,
|
||||
@@ -11,22 +19,26 @@ import {
|
||||
StrapiCreateOrderBody,
|
||||
StrapiCreateOrderRes,
|
||||
StrapiCreatePointBody,
|
||||
StrapiOrder,
|
||||
StrapiPerk,
|
||||
} from '../types'
|
||||
import { printfulApi, strapiApi } from '../services'
|
||||
import { sendPerkPurchaseConfirmationEmail } from '../utils/mailing'
|
||||
import { log } from '../../utils/logging'
|
||||
|
||||
export type PerkPurchaseWorkerData = {
|
||||
perk: StrapiPerk
|
||||
perkPrintfulSyncVariantId?: number
|
||||
shippingAddressLine1?: string
|
||||
shippingAddressLine2?: string
|
||||
shippingZip?: string
|
||||
shippingCity?: string
|
||||
shippingState?: string
|
||||
shippingCountry?: string
|
||||
shippingPhone?: string
|
||||
shippingTaxNumber?: string
|
||||
shipping?: {
|
||||
addressLine1: string
|
||||
addressLine2?: string
|
||||
city: string
|
||||
stateCode: string
|
||||
countryCode: string
|
||||
zip: string
|
||||
phone: string
|
||||
taxNumber?: string
|
||||
}
|
||||
userId: string
|
||||
userEmail: string
|
||||
userFullname: string
|
||||
@@ -41,18 +53,15 @@ if (!globalForWorker.hasInitializedWorkers)
|
||||
// Check if user has enough balance
|
||||
let deductionAmount = 0
|
||||
|
||||
if (job.data.perk.printfulProductId && job.data.perkPrintfulSyncVariantId) {
|
||||
if (
|
||||
job.data.perk.printfulProductId &&
|
||||
job.data.perkPrintfulSyncVariantId &&
|
||||
job.data.shipping
|
||||
) {
|
||||
const printfulCostEstimate = await estimatePrintfulOrderCost({
|
||||
address1: job.data.shippingAddressLine1!,
|
||||
address2: job.data.shippingAddressLine2 || '',
|
||||
city: job.data.shippingCity!,
|
||||
stateCode: job.data.shippingState!,
|
||||
countryCode: job.data.shippingCountry!,
|
||||
zip: job.data.shippingZip!,
|
||||
phone: job.data.shippingPhone!,
|
||||
shipping: job.data.shipping,
|
||||
name: job.data.userFullname,
|
||||
email: job.data.userEmail,
|
||||
tax_number: job.data.shippingTaxNumber,
|
||||
printfulSyncVariantId: job.data.perkPrintfulSyncVariantId,
|
||||
})
|
||||
|
||||
@@ -61,86 +70,84 @@ if (!globalForWorker.hasInitializedWorkers)
|
||||
deductionAmount = job.data.perk.price
|
||||
}
|
||||
|
||||
const currentBalance = await getUserPointBalance(job.data.userId)
|
||||
const currentBalance = await getPointsBalance(job.data.userId)
|
||||
const balanceAfterPurchase = currentBalance - deductionAmount
|
||||
|
||||
if (balanceAfterPurchase < 0) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Insufficient balance.' })
|
||||
}
|
||||
|
||||
let printfulOrder: PrintfulCreateOrderRes | null = null
|
||||
|
||||
// Create printful order (if applicable)
|
||||
if (job.data.perk.printfulProductId && job.data.perkPrintfulSyncVariantId) {
|
||||
const result = await printfulApi.post<
|
||||
{},
|
||||
AxiosResponse<PrintfulCreateOrderRes>,
|
||||
PrintfulCreateOrderReq
|
||||
>(process.env.NODE_ENV === 'production' ? '/orders?confirm=true' : '/orders', {
|
||||
recipient: {
|
||||
address1: job.data.shippingAddressLine1!,
|
||||
address2: job.data.shippingAddressLine2 || '',
|
||||
city: job.data.shippingCity!,
|
||||
state_code: job.data.shippingState!,
|
||||
country_code: job.data.shippingCountry!,
|
||||
zip: job.data.shippingZip!,
|
||||
phone: job.data.shippingPhone!,
|
||||
if (
|
||||
job.data.perk.printfulProductId &&
|
||||
job.data.perkPrintfulSyncVariantId &&
|
||||
job.data.shipping
|
||||
) {
|
||||
try {
|
||||
printfulOrder = await createPrintfulOrder({
|
||||
shipping: job.data.shipping,
|
||||
name: job.data.userFullname,
|
||||
email: job.data.userEmail,
|
||||
tax_number: job.data.shippingTaxNumber,
|
||||
},
|
||||
items: [{ quantity: 1, sync_variant_id: job.data.perkPrintfulSyncVariantId }],
|
||||
})
|
||||
printfulSyncVariantId: job.data.perkPrintfulSyncVariantId,
|
||||
})
|
||||
} catch (error) {
|
||||
log('error', `[Perk purchase worker] Failed to create Printful order.`)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
let strapiOrder: StrapiOrder | null = null
|
||||
|
||||
// Create strapi order
|
||||
const {
|
||||
data: { data: order },
|
||||
} = await strapiApi.post<StrapiCreateOrderRes, any, StrapiCreateOrderBody>('/orders', {
|
||||
data: {
|
||||
perk: job.data.perk.documentId,
|
||||
try {
|
||||
strapiOrder = await createStrapiOrder({
|
||||
perkId: job.data.perk.documentId,
|
||||
userId: job.data.userId,
|
||||
userEmail: job.data.userEmail,
|
||||
shippingAddressLine1: job.data.shippingAddressLine1,
|
||||
shippingAddressLine2: job.data.shippingAddressLine2,
|
||||
shippingCity: job.data.shippingCity,
|
||||
shippingState: job.data.shippingState,
|
||||
shippingCountry: job.data.shippingCountry,
|
||||
shippingZip: job.data.shippingZip,
|
||||
shippingPhone: job.data.shippingPhone,
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
// Deduct points
|
||||
await strapiApi.post<any, any, StrapiCreatePointBody>('/points', {
|
||||
data: {
|
||||
balanceChange: (-deductionAmount).toString(),
|
||||
balance: balanceAfterPurchase.toString(),
|
||||
userId: job.data.userId,
|
||||
perk: job.data.perk.documentId,
|
||||
order: order.documentId,
|
||||
},
|
||||
shipping: job.data.shipping,
|
||||
})
|
||||
} catch (error) {
|
||||
// If it fails, delete order
|
||||
await strapiApi.delete(`/orders/${order.documentId}`)
|
||||
log('error', `[Perk purchase worker] Failed to create Strapi order. Rolling back.`)
|
||||
await cancelPrintfulOrder(printfulOrder?.externalId!)
|
||||
throw error
|
||||
}
|
||||
|
||||
sendPerkPurchaseConfirmationEmail({
|
||||
to: job.data.userEmail,
|
||||
perkName: job.data.perk.name,
|
||||
pointsRedeemed: deductionAmount,
|
||||
address: job.data.shippingAddressLine1
|
||||
? {
|
||||
address1: job.data.shippingAddressLine1,
|
||||
address2: job.data.shippingAddressLine2,
|
||||
state: job.data.shippingState,
|
||||
city: job.data.shippingCity!,
|
||||
country: job.data.shippingCountry!,
|
||||
zip: job.data.shippingZip!,
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
try {
|
||||
// Deduct points
|
||||
await deductPointsFromUser({
|
||||
deductionAmount,
|
||||
orderId: strapiOrder.documentId,
|
||||
perkId: job.data.perk.documentId,
|
||||
userId: job.data.userId,
|
||||
})
|
||||
} catch (error) {
|
||||
log('error', `[Perk purchase worker] Failed to deduct points. Rolling back.`)
|
||||
if (printfulOrder) await cancelPrintfulOrder(printfulOrder.externalId)
|
||||
await deleteStrapiOrder(strapiOrder.documentId)
|
||||
throw error
|
||||
}
|
||||
|
||||
try {
|
||||
sendPerkPurchaseConfirmationEmail({
|
||||
to: job.data.userEmail,
|
||||
perkName: job.data.perk.name,
|
||||
pointsRedeemed: deductionAmount,
|
||||
address: job.data.shipping,
|
||||
})
|
||||
} catch (error) {
|
||||
log(
|
||||
'error',
|
||||
`[Perk purchase worker] Failed to send puchase confirmation email. NOT rolling back.`
|
||||
)
|
||||
throw error
|
||||
}
|
||||
|
||||
log(
|
||||
'info',
|
||||
`[Perk purchase worker] Successfully processed perk purchase! Order ID: ${strapiOrder.documentId}`
|
||||
)
|
||||
},
|
||||
{ connection, concurrency: 1 }
|
||||
)
|
||||
|
||||
@@ -18,12 +18,17 @@ export const funds: Record<FundSlug, ProjectItem & { slug: FundSlug }> = {
|
||||
goal: 100000,
|
||||
numDonationsBTC: 0,
|
||||
numDonationsXMR: 0,
|
||||
numDonationsLTC: 0,
|
||||
numDonationsFiat: 0,
|
||||
numDonationsManual: 0,
|
||||
totalDonationsBTC: 0,
|
||||
totalDonationsXMR: 0,
|
||||
totalDonationsLTC: 0,
|
||||
totalDonationsFiat: 0,
|
||||
totalDonationsBTCInFiat: 0,
|
||||
totalDonationsXMRInFiat: 0,
|
||||
totalDonationsLTCInFiat: 0,
|
||||
totalDonationsManual: 0,
|
||||
},
|
||||
firo: {
|
||||
fund: 'firo',
|
||||
@@ -40,12 +45,17 @@ export const funds: Record<FundSlug, ProjectItem & { slug: FundSlug }> = {
|
||||
goal: 100000,
|
||||
numDonationsBTC: 0,
|
||||
numDonationsXMR: 0,
|
||||
numDonationsLTC: 0,
|
||||
numDonationsFiat: 0,
|
||||
numDonationsManual: 0,
|
||||
totalDonationsBTC: 0,
|
||||
totalDonationsXMR: 0,
|
||||
totalDonationsLTC: 0,
|
||||
totalDonationsFiat: 0,
|
||||
totalDonationsBTCInFiat: 0,
|
||||
totalDonationsXMRInFiat: 0,
|
||||
totalDonationsLTCInFiat: 0,
|
||||
totalDonationsManual: 0,
|
||||
},
|
||||
privacyguides: {
|
||||
fund: 'privacyguides',
|
||||
@@ -67,12 +77,17 @@ export const funds: Record<FundSlug, ProjectItem & { slug: FundSlug }> = {
|
||||
goal: 100000,
|
||||
numDonationsBTC: 0,
|
||||
numDonationsXMR: 0,
|
||||
numDonationsLTC: 0,
|
||||
numDonationsFiat: 0,
|
||||
numDonationsManual: 0,
|
||||
totalDonationsBTC: 0,
|
||||
totalDonationsXMR: 0,
|
||||
totalDonationsLTC: 0,
|
||||
totalDonationsFiat: 0,
|
||||
totalDonationsBTCInFiat: 0,
|
||||
totalDonationsXMRInFiat: 0,
|
||||
totalDonationsLTCInFiat: 0,
|
||||
totalDonationsManual: 0,
|
||||
},
|
||||
general: {
|
||||
fund: 'general',
|
||||
@@ -93,12 +108,17 @@ export const funds: Record<FundSlug, ProjectItem & { slug: FundSlug }> = {
|
||||
goal: 100000,
|
||||
numDonationsBTC: 0,
|
||||
numDonationsXMR: 0,
|
||||
numDonationsLTC: 0,
|
||||
numDonationsFiat: 0,
|
||||
numDonationsManual: 0,
|
||||
totalDonationsBTC: 0,
|
||||
totalDonationsXMR: 0,
|
||||
totalDonationsLTC: 0,
|
||||
totalDonationsFiat: 0,
|
||||
totalDonationsBTCInFiat: 0,
|
||||
totalDonationsXMRInFiat: 0,
|
||||
totalDonationsLTCInFiat: 0,
|
||||
totalDonationsManual: 0,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
4
utils/logging.ts
Normal file
4
utils/logging.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export function log(level: 'info' | 'warn' | 'error', message: string) {
|
||||
const timestamp = new Date().toISOString()
|
||||
console.log(`[${timestamp}] [${level.toUpperCase()}] ${message}`)
|
||||
}
|
||||
42
utils/md.ts
42
utils/md.ts
@@ -7,7 +7,7 @@ import { Donation, FundSlug } from '@prisma/client'
|
||||
import { fundSlugs } from './funds'
|
||||
import { ProjectItem } from './types'
|
||||
import { prisma } from '../server/services'
|
||||
import { env } from '../env.mjs'
|
||||
import { DonationCryptoPayments } from '../server/types'
|
||||
|
||||
const directories: Record<FundSlug, string> = {
|
||||
monero: join(process.cwd(), 'docs/monero/projects'),
|
||||
@@ -62,12 +62,17 @@ export function getProjectBySlug(slug: string, fundSlug: FundSlug) {
|
||||
staticXMRaddress: data.staticXMRaddress || null,
|
||||
numDonationsBTC: data.numDonationsBTC || 0,
|
||||
numDonationsXMR: data.numDonationsXMR || 0,
|
||||
numDonationsLTC: data.numDonationsLTC || 0,
|
||||
numDonationsFiat: data.numDonationsFiat || 0,
|
||||
numDonationsManual: data.numDonationsManual || 0,
|
||||
totalDonationsBTC: data.totalDonationsBTC || 0,
|
||||
totalDonationsXMR: data.totalDonationsXMR || 0,
|
||||
totalDonationsLTC: data.totalDonationsLTC || 0,
|
||||
totalDonationsFiat: data.totalDonationsFiat || 0,
|
||||
totalDonationsManual: data.totalDonationsManual || 0,
|
||||
totalDonationsBTCInFiat: data.totalDonationsBTCInFiat || 0,
|
||||
totalDonationsXMRInFiat: data.totalDonationsXMRInFiat || 0,
|
||||
totalDonationsLTCInFiat: data.totalDonationsLTCInFiat || 0,
|
||||
}
|
||||
|
||||
return project
|
||||
@@ -118,19 +123,32 @@ export async function getProjects(fundSlug?: FundSlug) {
|
||||
}
|
||||
|
||||
donations.forEach((donation) => {
|
||||
if (donation.cryptoCode === 'XMR') {
|
||||
project.numDonationsXMR += 1
|
||||
project.totalDonationsXMR += donation.netCryptoAmount || 0
|
||||
project.totalDonationsXMRInFiat += donation.netFiatAmount
|
||||
}
|
||||
;(donation.cryptoPayments as DonationCryptoPayments | null)?.forEach((payment) => {
|
||||
if (payment.cryptoCode === 'XMR') {
|
||||
project.numDonationsXMR += 1
|
||||
project.totalDonationsXMR += payment.netAmount
|
||||
project.totalDonationsXMRInFiat += payment.netAmount * payment.rate
|
||||
}
|
||||
|
||||
if (donation.cryptoCode === 'BTC') {
|
||||
project.numDonationsBTC += 1
|
||||
project.totalDonationsBTC += donation.netCryptoAmount || 0
|
||||
project.totalDonationsBTCInFiat += donation.netFiatAmount
|
||||
}
|
||||
if (payment.cryptoCode === 'BTC') {
|
||||
project.numDonationsBTC += 1
|
||||
project.totalDonationsBTC += payment.netAmount
|
||||
project.totalDonationsBTCInFiat += payment.netAmount * payment.rate
|
||||
}
|
||||
|
||||
if (donation.cryptoCode === null) {
|
||||
if (payment.cryptoCode === 'LTC') {
|
||||
project.numDonationsLTC += 1
|
||||
project.totalDonationsLTC += payment.netAmount
|
||||
project.totalDonationsLTCInFiat += payment.netAmount * payment.rate
|
||||
}
|
||||
|
||||
if (payment.cryptoCode === 'MANUAL') {
|
||||
project.numDonationsManual += 1
|
||||
project.totalDonationsManual += payment.netAmount * payment.rate
|
||||
}
|
||||
})
|
||||
|
||||
if (!donation.cryptoPayments) {
|
||||
project.numDonationsFiat += 1
|
||||
project.totalDonationsFiat += donation.netFiatAmount
|
||||
}
|
||||
|
||||
17
utils/money-formating.ts
Normal file
17
utils/money-formating.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export function formatUsd(dollars: number): string {
|
||||
if (dollars == 0) {
|
||||
return '$0'
|
||||
} else if (dollars / 1000 > 1) {
|
||||
return `$${Math.round(dollars / 1000)}k+`
|
||||
} else {
|
||||
return `$${dollars.toFixed(0)}`
|
||||
}
|
||||
}
|
||||
|
||||
export function formatBtc(bitcoin: number) {
|
||||
if (bitcoin > 0.1) {
|
||||
return `${bitcoin.toFixed(3) || 0.0} BTC`
|
||||
} else {
|
||||
return `${Math.floor(bitcoin * 100000000).toLocaleString()} sats`
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ export type ProjectItem = {
|
||||
content?: string
|
||||
title: string
|
||||
summary: string
|
||||
coverImage: string
|
||||
coverImage?: string
|
||||
website: string
|
||||
socialLinks: string[]
|
||||
date: string
|
||||
@@ -16,12 +16,17 @@ export type ProjectItem = {
|
||||
isFunded?: boolean
|
||||
numDonationsBTC: number
|
||||
numDonationsXMR: number
|
||||
numDonationsLTC: number
|
||||
numDonationsFiat: number
|
||||
numDonationsManual: number
|
||||
totalDonationsBTC: number
|
||||
totalDonationsXMR: number
|
||||
totalDonationsLTC: number
|
||||
totalDonationsFiat: number
|
||||
totalDonationsManual: number
|
||||
totalDonationsBTCInFiat: number
|
||||
totalDonationsXMRInFiat: number
|
||||
totalDonationsLTCInFiat: number
|
||||
}
|
||||
|
||||
export type PayReq = {
|
||||
@@ -43,6 +48,16 @@ export type ProjectDonationStats = {
|
||||
amount: number
|
||||
fiatAmount: number
|
||||
}
|
||||
ltc: {
|
||||
count: number
|
||||
amount: number
|
||||
fiatAmount: number
|
||||
}
|
||||
manual: {
|
||||
count: number
|
||||
amount: number
|
||||
fiatAmount: number
|
||||
}
|
||||
usd: {
|
||||
count: number
|
||||
amount: number
|
||||
|
||||
Reference in New Issue
Block a user