diff --git a/components/PageHeading.tsx b/components/PageHeading.tsx index ae4da65..b44b84a 100644 --- a/components/PageHeading.tsx +++ b/components/PageHeading.tsx @@ -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) => JSX.Element> = { + monero: MoneroLogo, + firo: FiroLogo, + privacyguides: PrivacyGuidesLogo, + general: MagicLogo, +} + export default function PageHeading({ project, children }: Props) { + const PlaceholderImage = placeholderImages[project.fund] + return (
- avatar + {project.coverImage ? ( + avatar + ) : ( + + )}

{!!project.website && ( diff --git a/components/Progress.tsx b/components/Progress.tsx index e3617b9..c95a71c 100644 --- a/components/Progress.tsx +++ b/components/Progress.tsx @@ -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) => { />

- {percent < 100 ? percent : 100}% + + Raised {percent < 100 ? percent : 100}%{' '} + {!percentOnly && ( + <> + of ${numberFormat.format(goal)} Goal + + )} +
) } diff --git a/components/ProjectCard.tsx b/components/ProjectCard.tsx index 1bc330b..11cbb45 100644 --- a/components/ProjectCard.tsx +++ b/components/ProjectCard.tsx @@ -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) => JSX.Element> = { + monero: MoneroLogo, + firo: FiroLogo, + privacyguides: PrivacyGuidesLogo, + general: MagicLogo, +} + const ProjectCard: React.FC = ({ project, customImageStyles }) => { - const [isHorizontal, setIsHorizontal] = useState(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 ( @@ -40,16 +39,20 @@ const ProjectCard: React.FC = ({ project, customImageStyles }) project.fund === 'general' && 'border-primary' )} > -
- {project.title} +
+ {project.coverImage ? ( + {project.title} + ) : ( + + )}
@@ -73,6 +76,7 @@ const ProjectCard: React.FC = ({ project, customImageStyles }) project.totalDonationsFiat } goal={project.goal} + percentOnly />
diff --git a/config/index.ts b/config/index.ts index 94c4b40..95ac4cd 100644 --- a/config/index.ts +++ b/config/index.ts @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 1a9637b..03495f8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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' diff --git a/package-lock.json b/package-lock.json index 1a918b7..8dc9c55 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/pages/[fund]/donate/[slug].tsx b/pages/[fund]/donate/[slug].tsx index b97158c..a52ac14 100644 --- a/pages/[fund]/donate/[slug].tsx +++ b/pages/[fund]/donate/[slug].tsx @@ -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) => 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) {
- {project.title} + {project.coverImage ? ( + {project.title} + ) : ( +
+ +
+ )} +

Donate to {project.title}

Pledge your support

diff --git a/pages/[fund]/membership.tsx b/pages/[fund]/membership.tsx index caad8b4..9a2e6a5 100644 --- a/pages/[fund]/membership.tsx +++ b/pages/[fund]/membership.tsx @@ -189,7 +189,7 @@ function MembershipPage({ fund: fundSlug, project }: Props) {
{project.title} { 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) { ( Address line 1 * @@ -438,7 +444,7 @@ function Perk({ perk, balance }: Props) { ( Address line 2 @@ -452,7 +458,7 @@ function Perk({ perk, balance }: Props) { ( Country * @@ -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 && ( ( State * @@ -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) { ( City * @@ -604,7 +610,7 @@ function Perk({ perk, balance }: Props) { ( Postal code * @@ -618,7 +624,7 @@ function Perk({ perk, balance }: Props) { ( Phone number * @@ -633,7 +639,7 @@ function Perk({ perk, balance }: Props) { {shippingCountry === 'BR' && ( ( Tax number (Brazilian CPF/CNPJ) * @@ -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( `/perks/${params?.id!}?populate[images][fields]=formats` ), diff --git a/pages/[fund]/projects/[slug].tsx b/pages/[fund]/projects/[slug].tsx index 1301519..5cb7b62 100644 --- a/pages/[fund]/projects/[slug].tsx +++ b/pages/[fund]/projects/[slug].tsx @@ -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) => JSX.Element> = { + monero: MoneroLogo, + firo: FiroLogo, + privacyguides: PrivacyGuidesLogo, + general: MagicLogo, +} + const Project: NextPage = ({ 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 @@ -68,21 +60,27 @@ const Project: NextPage = ({ project, donationStats }) = return ( <> - Monero Fund | {project.title} + + {project.title} - {funds[project.fund].title} +
- avatar + {coverImage ? ( + avatar + ) : ( + + )} -
+
{!project.isFunded && (
@@ -91,47 +89,52 @@ const Project: NextPage = ({ project, donationStats }) =
)} -
-

Raised

+ - - -
    -
  • - {`${formatUsd(donationStats.xmr.fiatAmount + donationStats.btc.fiatAmount + donationStats.usd.fiatAmount)}`}{' '} - - in{' '} - {donationStats.xmr.count + donationStats.btc.count + donationStats.usd.count}{' '} - donations total - -
  • -
  • - {donationStats.xmr.amount} XMR{' '} - - in {donationStats.xmr.count} donations - -
  • -
  • - {formatBtc(donationStats.btc.amount)}{' '} - - in {donationStats.btc.count} donations - -
  • -
  • - {`${formatUsd(donationStats.usd.amount)}`} Fiat{' '} - - in {donationStats.usd.count} donations - -
  • -
-
+
    +
  • + {`${formatUsd(donationStats.xmr.fiatAmount + donationStats.btc.fiatAmount + donationStats.ltc.fiatAmount + donationStats.manual.fiatAmount + donationStats.usd.fiatAmount)}`}{' '} + + in{' '} + {donationStats.xmr.count + + donationStats.btc.count + + donationStats.manual.count + + donationStats.usd.count}{' '} + donations total + +
  • +
  • + {donationStats.xmr.amount.toFixed(2)} XMR{' '} + + in {donationStats.xmr.count} donations + +
  • +
  • + {formatBtc(donationStats.btc.amount)}{' '} + + in {donationStats.btc.count} donations + +
  • +
  • + {donationStats.ltc.amount.toFixed(2)} LTC{' '} + + in {donationStats.ltc.count} donations + +
  • +
  • + {`${formatUsd(donationStats.usd.amount + donationStats.manual.fiatAmount)}`}{' '} + + in {donationStats.usd.count + donationStats.manual.count} donations + +
  • +
@@ -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 diff --git a/pages/api/btcpay/webhook.ts b/pages/api/btcpay/webhook.ts index 835591d..b926fd6 100644 --- a/pages/api/btcpay/webhook.ts +++ b/pages/api/btcpay/webhook.ts @@ -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 & { +type WebhookBody = Record & { + manuallyMarked: boolean deliveryId: string webhookId: string originalDeliveryId: string @@ -43,6 +42,237 @@ type BtcpayBody = Record & { 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( + `/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( - `/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( - `/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('/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 }) diff --git a/prisma/migrations/20250402210015_donation_crypto_payments_array/migration.sql b/prisma/migrations/20250402210015_donation_crypto_payments_array/migration.sql new file mode 100644 index 0000000..7a0f1d2 --- /dev/null +++ b/prisma/migrations/20250402210015_donation_crypto_payments_array/migration.sql @@ -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"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 40b32dd..48024dc 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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]) diff --git a/server/queues.ts b/server/queues.ts index 41d7f5a..5608d09 100644 --- a/server/queues.ts +++ b/server/queues.ts @@ -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('PerkPurchase', { connection, @@ -11,8 +12,16 @@ export const perkPurchaseQueue = new Queue('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' } +) diff --git a/server/routers/donation.ts b/server/routers/donation.ts index 4b154ef..99e1fcd 100644 --- a/server/routers/donation.ts +++ b/server/routers/donation.ts @@ -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 } diff --git a/server/routers/perk.ts b/server/routers/perk.ts index f991276..b596c47 100644 --- a/server/routers/perk.ts +++ b/server/routers/perk.ts @@ -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], diff --git a/server/types.ts b/server/types.ts index 8d88b71..96ae536 100644 --- a/server/types.ts +++ b/server/types.ts @@ -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 diff --git a/server/utils/attestation.ts b/server/utils/attestation.ts index b90f4d8..d1908af 100644 --- a/server/utils/attestation.ts +++ b/server/utils/attestation.ts @@ -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'), diff --git a/server/utils/btcpayserver.ts b/server/utils/btcpayserver.ts new file mode 100644 index 0000000..b5c9cc6 --- /dev/null +++ b/server/utils/btcpayserver.ts @@ -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(`/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( + `/invoices/${invoiceId}/payment-methods` + ) + + return paymentMethods + } catch (error) { + log('error', `Failed to get BTCPayServer payment methods for invoice ${invoiceId}.`) + throw error + } +} diff --git a/server/utils/mailing.ts b/server/utils/mailing.ts index 2b38ae1..cd3da43 100644 --- a/server/utils/mailing.ts +++ b/server/utils/mailing.ts @@ -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}` : '' } diff --git a/server/utils/perks.ts b/server/utils/perks.ts index 8f0b467..7681347 100644 --- a/server/utils/perks.ts +++ b/server/utils/perks.ts @@ -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 { +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 { const { data: { data: pointHistory }, } = await strapiApi.get( @@ -19,32 +36,111 @@ export async function getUserPointBalance(userId: string): Promise { 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('/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('/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, 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, + 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}`) +} diff --git a/server/utils/webhooks.ts b/server/utils/webhooks.ts index d7f60f8..7063ad6 100644 --- a/server/utils/webhooks.ts +++ b/server/utils/webhooks.ts @@ -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('/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 diff --git a/server/workers/donation-migration.ts b/server/workers/donation-migration.ts new file mode 100644 index 0000000..7d4c33b --- /dev/null +++ b/server/workers/donation-migration.ts @@ -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 diff --git a/server/workers/perk.ts b/server/workers/perk.ts index f9e52c9..1bac0c4 100644 --- a/server/workers/perk.ts +++ b/server/workers/perk.ts @@ -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, - 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('/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('/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 } ) diff --git a/utils/funds.ts b/utils/funds.ts index 4d5b08f..509798a 100644 --- a/utils/funds.ts +++ b/utils/funds.ts @@ -18,12 +18,17 @@ export const funds: Record = { 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 = { 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 = { 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 = { 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, }, } diff --git a/utils/logging.ts b/utils/logging.ts new file mode 100644 index 0000000..f85cea6 --- /dev/null +++ b/utils/logging.ts @@ -0,0 +1,4 @@ +export function log(level: 'info' | 'warn' | 'error', message: string) { + const timestamp = new Date().toISOString() + console.log(`[${timestamp}] [${level.toUpperCase()}] ${message}`) +} diff --git a/utils/md.ts b/utils/md.ts index 0ad6be1..4832fc0 100644 --- a/utils/md.ts +++ b/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 = { 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 } diff --git a/utils/money-formating.ts b/utils/money-formating.ts new file mode 100644 index 0000000..7c30f8b --- /dev/null +++ b/utils/money-formating.ts @@ -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` + } +} diff --git a/utils/types.ts b/utils/types.ts index 4009861..8336e05 100644 --- a/utils/types.ts +++ b/utils/types.ts @@ -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