mirror of
https://github.com/MAGICGrants/campaign-site.git
synced 2026-01-09 12:27:59 -05:00
Merge pull request #79 from MAGICGrants/funding-required-endpoint
Funding required endpoint
This commit is contained in:
@@ -7,9 +7,9 @@ services:
|
||||
expose:
|
||||
- '49392'
|
||||
environment:
|
||||
BTCPAY_POSTGRES: User ID=postgres;Host=postgres;Port=5432;Application Name=btcpayserver;Database=btcpayserver${NBITCOIN_NETWORK:-regtest}
|
||||
BTCPAY_EXPLORERPOSTGRES: User ID=postgres;Host=postgres;Port=5432;Application Name=btcpayserver;MaxPoolSize=80;Database=nbxplorer${NBITCOIN_NETWORK:-regtest}
|
||||
BTCPAY_NETWORK: ${NBITCOIN_NETWORK:-regtest}
|
||||
BTCPAY_POSTGRES: User ID=postgres;Host=postgres;Port=5432;Application Name=btcpayserver;Database=btcpayserver${NBITCOIN_NETWORK:-mainnet}
|
||||
BTCPAY_EXPLORERPOSTGRES: User ID=postgres;Host=postgres;Port=5432;Application Name=btcpayserver;MaxPoolSize=80;Database=nbxplorer${NBITCOIN_NETWORK:-mainnet}
|
||||
BTCPAY_NETWORK: ${NBITCOIN_NETWORK:-mainnet}
|
||||
BTCPAY_BIND: 0.0.0.0:49392
|
||||
BTCPAY_ROOTPATH: ${BTCPAY_ROOTPATH:-/}
|
||||
BTCPAY_SSHCONNECTION: 'root@host.docker.internal'
|
||||
@@ -57,11 +57,11 @@ services:
|
||||
expose:
|
||||
- '32838'
|
||||
environment:
|
||||
NBXPLORER_NETWORK: ${NBITCOIN_NETWORK:-regtest}
|
||||
NBXPLORER_NETWORK: ${NBITCOIN_NETWORK:-mainnet}
|
||||
NBXPLORER_BIND: 0.0.0.0:32838
|
||||
NBXPLORER_TRIMEVENTS: 10000
|
||||
NBXPLORER_SIGNALFILESDIR: /datadir
|
||||
NBXPLORER_POSTGRES: User ID=postgres;Host=postgres;Port=5432;Application Name=nbxplorer;MaxPoolSize=20;Database=nbxplorer${NBITCOIN_NETWORK:-regtest}
|
||||
NBXPLORER_POSTGRES: User ID=postgres;Host=postgres;Port=5432;Application Name=nbxplorer;MaxPoolSize=20;Database=nbxplorer${NBITCOIN_NETWORK:-mainnet}
|
||||
NBXPLORER_AUTOMIGRATE: 1
|
||||
NBXPLORER_NOMIGRATEEVTS: 1
|
||||
NBXPLORER_DELETEAFTERMIGRATION: 1
|
||||
|
||||
@@ -3,6 +3,7 @@ fund: monero
|
||||
title: 'ETH<>XMR Atomic Swap Continued Development'
|
||||
summary: 'A trustless way to exchange Monero and Ethereum.'
|
||||
nym: 'noot'
|
||||
date: '2022-09-01'
|
||||
coverImage: '/img/project/Ethereum_logo.png'
|
||||
website: 'https://github.com/AthanorLabs/atomic-swap'
|
||||
personalWebsite: 'https://github.com/noot'
|
||||
|
||||
@@ -3,6 +3,7 @@ fund: monero
|
||||
title: 'Ring Signature Resiliency to AI Analysis'
|
||||
summary: "A test of machine learning attacks on Monero's untraceability."
|
||||
nym: 'ACK-J'
|
||||
date: '2022-03-01'
|
||||
coverImage: '/img/project/ring_sig.png'
|
||||
website: 'https://magicgrants.org/Monero-Tracing-Research/'
|
||||
personalWebsite: 'https://github.com/ACK-J'
|
||||
|
||||
86
package-lock.json
generated
86
package-lock.json
generated
@@ -2760,9 +2760,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/env": {
|
||||
"version": "14.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.9.tgz",
|
||||
"integrity": "sha512-hnDAoDPMii31V0ivibI8p6b023jOF1XblWTVjsDUoZKwnZlaBtJFZKDwFqi22R8r9i6W08dThUWU7Bsh2Rg8Ww==",
|
||||
"version": "14.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.12.tgz",
|
||||
"integrity": "sha512-3fP29GIetdwVIfIRyLKM7KrvJaqepv+6pVodEbx0P5CaMLYBtx+7eEg8JYO5L9sveJO87z9eCReceZLi0hxO1Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@next/eslint-plugin-next": {
|
||||
@@ -2776,9 +2776,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-arm64": {
|
||||
"version": "14.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.9.tgz",
|
||||
"integrity": "sha512-/kfQifl3uLYi3DlwFlzCkgxe6fprJNLzzTUFknq3M5wGYicDIbdGlxUl6oHpVLJpBB/CBY3Y//gO6alz/K4NWA==",
|
||||
"version": "14.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.12.tgz",
|
||||
"integrity": "sha512-crHJ9UoinXeFbHYNok6VZqjKnd8rTd7K3Z2zpyzF1ch7vVNKmhjv/V7EHxep3ILoN8JB9AdRn/EtVVyG9AkCXw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2792,9 +2792,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-x64": {
|
||||
"version": "14.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.9.tgz",
|
||||
"integrity": "sha512-tK/RyhCmOCiXQ9IVdFrBbZOf4/1+0RSuJkebXU2uMEsusS51TjIJO4l8ZmEijH9gZa0pJClvmApRHi7JuBqsRw==",
|
||||
"version": "14.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.12.tgz",
|
||||
"integrity": "sha512-JbEaGbWq18BuNBO+lCtKfxl563Uw9oy2TodnN2ioX00u7V1uzrsSUcg3Ep9ce+P0Z9es+JmsvL2/rLphz+Frcw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2808,9 +2808,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-gnu": {
|
||||
"version": "14.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.9.tgz",
|
||||
"integrity": "sha512-tS5eqwsp2nO7mzywRUuFYmefNZsUKM/mTG3exK2jIHv9TEVklE1SByB1KMhFkqlit1PxS9YK1tV8BOV90Wpbrw==",
|
||||
"version": "14.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.12.tgz",
|
||||
"integrity": "sha512-qBy7OiXOqZrdp88QEl2H4fWalMGnSCrr1agT/AVDndlyw2YJQA89f3ttR/AkEIP9EkBXXeGl6cC72/EZT5r6rw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2824,9 +2824,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-musl": {
|
||||
"version": "14.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.9.tgz",
|
||||
"integrity": "sha512-8svpeTFNAMTUMKQbEzE8qRAwl9o7mNBv7LR1bmSkQvo1oy4WrNyZbhWsldOiKrc4mZ5dfQkGYsI9T75mIFMfeA==",
|
||||
"version": "14.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.12.tgz",
|
||||
"integrity": "sha512-EfD9L7o9biaQxjwP1uWXnk3vYZi64NVcKUN83hpVkKocB7ogJfyH2r7o1pPnMtir6gHZiGCeHKagJ0yrNSLNHw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2840,9 +2840,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-gnu": {
|
||||
"version": "14.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.9.tgz",
|
||||
"integrity": "sha512-0HNulLWpKTB7H5BhHCkEhcRAnWUHeAYCftrrGw3QC18+ZywTdAoPv/zEqKy/0adqt+ks4JDdlgSQ1lNKOKjo0A==",
|
||||
"version": "14.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.12.tgz",
|
||||
"integrity": "sha512-iQ+n2pxklJew9IpE47hE/VgjmljlHqtcD5UhZVeHICTPbLyrgPehaKf2wLRNjYH75udroBNCgrSSVSVpAbNoYw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2856,9 +2856,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-musl": {
|
||||
"version": "14.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.9.tgz",
|
||||
"integrity": "sha512-hhVFViPHLAVUJRNtwwm609p9ozWajOmRvzOZzzKXgiVGwx/CALxlMUeh+M+e0Zj6orENhWLZeilOPHpptuENsA==",
|
||||
"version": "14.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.12.tgz",
|
||||
"integrity": "sha512-rFkUkNwcQ0ODn7cxvcVdpHlcOpYxMeyMfkJuzaT74xjAa5v4fxP4xDk5OoYmPi8QNLDs3UgZPMSBmpBuv9zKWA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2872,9 +2872,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-arm64-msvc": {
|
||||
"version": "14.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.9.tgz",
|
||||
"integrity": "sha512-p/v6XlOdrk06xfN9z4evLNBqftVQUWiyduQczCwSj7hNh8fWTbzdVxsEiNOcajMXJbQiaX/ZzZdFgKVmmJnnGQ==",
|
||||
"version": "14.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.12.tgz",
|
||||
"integrity": "sha512-PQFYUvwtHs/u0K85SG4sAdDXYIPXpETf9mcEjWc0R4JmjgMKSDwIU/qfZdavtP6MPNiMjuKGXHCtyhR/M5zo8g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2888,9 +2888,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-ia32-msvc": {
|
||||
"version": "14.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.9.tgz",
|
||||
"integrity": "sha512-IcW9dynWDjMK/0M05E3zopbRen7v0/yEaMZbHFOSS1J/w+8YG3jKywOGZWNp/eCUVtUUXs0PW+7Lpz8uLu+KQA==",
|
||||
"version": "14.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.12.tgz",
|
||||
"integrity": "sha512-FAj2hMlcbeCV546eU2tEv41dcJb4NeqFlSXU/xL/0ehXywHnNpaYajOUvn3P8wru5WyQe6cTZ8fvckj/2XN4Vw==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -2904,9 +2904,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-x64-msvc": {
|
||||
"version": "14.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.9.tgz",
|
||||
"integrity": "sha512-gcbpoXyWZdVOBgNa5BRzynrL5UR1nb2ZT38yKgnphYU9UHjeecnylMHntrQiMg/QtONDcJPFC/PmsS47xIRYoA==",
|
||||
"version": "14.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.12.tgz",
|
||||
"integrity": "sha512-yu8QvV53sBzoIVRHsxCHqeuS8jYq6Lrmdh0briivuh+Brsp6xjg80MAozUsBTAV9KNmY08KlX0KYTWz1lbPzEg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -9227,12 +9227,12 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/next": {
|
||||
"version": "14.2.9",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-14.2.9.tgz",
|
||||
"integrity": "sha512-3CzBNo6BuJnRjcQvRw+irnU1WiuJNZEp+dkzkt91y4jeIDN/Emg95F+takSYiLpJ/HkxClVQRyqiTwYce5IVqw==",
|
||||
"version": "14.2.12",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-14.2.12.tgz",
|
||||
"integrity": "sha512-cDOtUSIeoOvt1skKNihdExWMTybx3exnvbFbb9ecZDIxlvIbREQzt9A5Km3Zn3PfU+IFjyYGsHS+lN9VInAGKA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@next/env": "14.2.9",
|
||||
"@next/env": "14.2.12",
|
||||
"@swc/helpers": "0.5.5",
|
||||
"busboy": "1.6.0",
|
||||
"caniuse-lite": "^1.0.30001579",
|
||||
@@ -9247,15 +9247,15 @@
|
||||
"node": ">=18.17.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@next/swc-darwin-arm64": "14.2.9",
|
||||
"@next/swc-darwin-x64": "14.2.9",
|
||||
"@next/swc-linux-arm64-gnu": "14.2.9",
|
||||
"@next/swc-linux-arm64-musl": "14.2.9",
|
||||
"@next/swc-linux-x64-gnu": "14.2.9",
|
||||
"@next/swc-linux-x64-musl": "14.2.9",
|
||||
"@next/swc-win32-arm64-msvc": "14.2.9",
|
||||
"@next/swc-win32-ia32-msvc": "14.2.9",
|
||||
"@next/swc-win32-x64-msvc": "14.2.9"
|
||||
"@next/swc-darwin-arm64": "14.2.12",
|
||||
"@next/swc-darwin-x64": "14.2.12",
|
||||
"@next/swc-linux-arm64-gnu": "14.2.12",
|
||||
"@next/swc-linux-arm64-musl": "14.2.12",
|
||||
"@next/swc-linux-x64-gnu": "14.2.12",
|
||||
"@next/swc-linux-x64-musl": "14.2.12",
|
||||
"@next/swc-win32-arm64-msvc": "14.2.12",
|
||||
"@next/swc-win32-ia32-msvc": "14.2.12",
|
||||
"@next/swc-win32-x64-msvc": "14.2.12"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": "^1.1.0",
|
||||
|
||||
@@ -3,9 +3,14 @@ import getRawBody from 'raw-body'
|
||||
import crypto from 'crypto'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
import { DonationMetadata } from '../../../server/types'
|
||||
import {
|
||||
BtcPayGetRatesRes,
|
||||
BtcPayGetPaymentMethodsRes,
|
||||
DonationMetadata,
|
||||
} from '../../../server/types'
|
||||
import { btcpayApi as _btcpayApi, btcpayApi, prisma } from '../../../server/services'
|
||||
import { env } from '../../../env.mjs'
|
||||
import axios from 'axios'
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
@@ -13,7 +18,7 @@ export const config = {
|
||||
},
|
||||
}
|
||||
|
||||
type BtcpayBody = {
|
||||
type BtcpayBody = Record<string, any> & {
|
||||
deliveryId: string
|
||||
webhookId: string
|
||||
originalDeliveryId: string
|
||||
@@ -25,12 +30,6 @@ type BtcpayBody = {
|
||||
metadata: DonationMetadata
|
||||
}
|
||||
|
||||
type BtcpayPaymentMethodsResponse = {
|
||||
rate: string
|
||||
amount: string
|
||||
cryptoCode: string
|
||||
}[]
|
||||
|
||||
async function handleBtcpayWebhook(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method !== 'POST') {
|
||||
res.setHeader('Allow', ['POST'])
|
||||
@@ -59,30 +58,71 @@ async function handleBtcpayWebhook(req: NextApiRequest, res: NextApiResponse) {
|
||||
return
|
||||
}
|
||||
|
||||
if (body.type === 'InvoiceSettled') {
|
||||
const { data: paymentMethods } = await btcpayApi.get<BtcpayPaymentMethodsResponse>(
|
||||
`/invoices/${body.invoiceId}/payment-methods`
|
||||
if (body.type === 'InvoicePaymentSettled') {
|
||||
// Handle payments to funding required API invoices ONLY
|
||||
if (body.metadata.staticGeneratedForApi === 'false') {
|
||||
return res.status(200).json({ success: true })
|
||||
}
|
||||
|
||||
const cryptoCode = body.paymentMethod === 'BTC-OnChain' ? 'BTC' : 'XMR'
|
||||
|
||||
const { data: rates } = await btcpayApi.get<BtcPayGetRatesRes>(
|
||||
`/rates?currencyPair=${cryptoCode}_USD`
|
||||
)
|
||||
|
||||
const cryptoAmount = Number(paymentMethods[0].amount)
|
||||
const fiatAmount = Number(paymentMethods[0].amount) * Number(paymentMethods[0].rate)
|
||||
const cryptoRate = Number(rates[0].rate)
|
||||
const cryptoAmount = Number(body.payment.value)
|
||||
|
||||
await prisma.donation.create({
|
||||
data: {
|
||||
userId: body.metadata.userId,
|
||||
userId: null,
|
||||
btcPayInvoiceId: body.invoiceId,
|
||||
projectName: body.metadata.projectName,
|
||||
projectSlug: body.metadata.projectSlug,
|
||||
fundSlug: body.metadata.fundSlug,
|
||||
cryptoCode: paymentMethods[0].cryptoCode,
|
||||
cryptoCode,
|
||||
cryptoAmount,
|
||||
fiatAmount: Number(fiatAmount.toFixed(2)),
|
||||
membershipExpiresAt:
|
||||
body.metadata.isMembership === 'true' ? dayjs().add(1, 'year').toDate() : null,
|
||||
fiatAmount: Number((cryptoAmount * cryptoRate).toFixed(2)),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (body.type === 'InvoiceSettled') {
|
||||
// If this is a funding required API invoice, let InvoiceReceivedPayment handle it instead
|
||||
if (body.metadata.staticGeneratedForApi === 'true') {
|
||||
return res.status(200).json({ success: true })
|
||||
}
|
||||
|
||||
const { data: paymentMethods } = await btcpayApi.get<BtcPayGetPaymentMethodsRes>(
|
||||
`/invoices/${body.invoiceId}/payment-methods`
|
||||
)
|
||||
|
||||
await Promise.all(
|
||||
paymentMethods.map(async (paymentMethod) => {
|
||||
const cryptoAmount = Number(paymentMethod.amount)
|
||||
|
||||
if (!cryptoAmount) return
|
||||
|
||||
const fiatAmount = Number(paymentMethod.amount) * Number(paymentMethod.rate)
|
||||
|
||||
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.cryptoCode,
|
||||
cryptoAmount,
|
||||
fiatAmount: Number(fiatAmount.toFixed(2)),
|
||||
membershipExpiresAt:
|
||||
body.metadata.isMembership === 'true' ? dayjs().add(1, 'year').toDate() : null,
|
||||
},
|
||||
})
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true })
|
||||
}
|
||||
|
||||
|
||||
263
pages/api/funding-required.ts
Normal file
263
pages/api/funding-required.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
import { FundSlug } from '@prisma/client'
|
||||
import { z } from 'zod'
|
||||
import dayjs from 'dayjs'
|
||||
import path from 'path'
|
||||
|
||||
import { getProjects } from '../../utils/md'
|
||||
import { env } from '../../env.mjs'
|
||||
import { btcpayApi, prisma } from '../../server/services'
|
||||
import { CURRENCY } from '../../config'
|
||||
import {
|
||||
BtcPayCreateInvoiceRes,
|
||||
BtcPayGetPaymentMethodsRes,
|
||||
BtcPayGetRatesRes,
|
||||
DonationMetadata,
|
||||
} from '../../server/types'
|
||||
import { funds, fundSlugs } from '../../utils/funds'
|
||||
|
||||
const ASSETS = ['BTC', 'XMR', 'USD'] as const
|
||||
|
||||
type Asset = (typeof ASSETS)[number]
|
||||
|
||||
type ResponseBody = {
|
||||
title: string
|
||||
fund: FundSlug
|
||||
date: string
|
||||
author: string
|
||||
url: string
|
||||
is_funded: boolean
|
||||
raised_amount_percent: number
|
||||
contributions: number
|
||||
target_amount_btc: number
|
||||
target_amount_xmr: number
|
||||
target_amount_usd: number
|
||||
remaining_amount_btc: number
|
||||
remaining_amount_xmr: number
|
||||
remaining_amount_usd: number
|
||||
address_btc: string | null
|
||||
address_xmr: string | null
|
||||
}[]
|
||||
|
||||
type ResponseBodySpecificAsset = {
|
||||
title: string
|
||||
fund: FundSlug
|
||||
date: string
|
||||
author: string
|
||||
url: string
|
||||
is_funded: boolean
|
||||
raised_amount_percent: number
|
||||
contributions: number
|
||||
asset: Asset
|
||||
target_amount: number
|
||||
remaining_amount: number
|
||||
address: string | null
|
||||
}[]
|
||||
|
||||
// The cache key should be: fund-asset-project_status
|
||||
const cachedResponses: Record<
|
||||
string,
|
||||
{ data: ResponseBody | ResponseBodySpecificAsset; expiresAt: Date } | undefined
|
||||
> = {}
|
||||
|
||||
const querySchema = z.object({
|
||||
fund: z.enum(fundSlugs).optional(),
|
||||
asset: z.enum(ASSETS).optional(),
|
||||
project_status: z.enum(['FUNDED', 'NOT_FUNDED', 'ANY']).default('NOT_FUNDED'),
|
||||
})
|
||||
|
||||
async function handle(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse<ResponseBody | ResponseBodySpecificAsset>
|
||||
) {
|
||||
if (req.method !== 'GET') {
|
||||
res.setHeader('Allow', ['GET'])
|
||||
return res.status(405).end(`Method ${req.method} Not Allowed`)
|
||||
}
|
||||
|
||||
const query = await querySchema.parseAsync(req.query)
|
||||
|
||||
// Get response from cache
|
||||
const cacheKey = `${query.fund}-${query.asset}-${query.project_status}`
|
||||
const cachedResponse = cachedResponses[cacheKey]
|
||||
if (cachedResponse && cachedResponse.expiresAt > new Date()) {
|
||||
return res.send(cachedResponse.data)
|
||||
}
|
||||
|
||||
const projects = (await getProjects(query.fund)).filter((project) =>
|
||||
query.project_status === 'ANY' || query.project_status === 'FUNDED'
|
||||
? project.isFunded
|
||||
: !project.isFunded
|
||||
)
|
||||
|
||||
const rates: Record<string, number | undefined> = {}
|
||||
|
||||
// Get exchange rates if target asset is not USD (or if there is no target asset)
|
||||
if (query.asset !== 'USD') {
|
||||
const assetsWithoutUsd = ASSETS.filter((asset) => asset !== 'USD')
|
||||
const params = assetsWithoutUsd.map((asset) => `currencyPair=${asset}_USD`).join('&')
|
||||
const { data: _rates } = await btcpayApi.get<BtcPayGetRatesRes>(`/rates?${params}`)
|
||||
|
||||
_rates.forEach((rate) => {
|
||||
const asset = rate.currencyPair.split('_')[0] as string
|
||||
rates[asset] = Number(rate.rate)
|
||||
})
|
||||
}
|
||||
|
||||
let responseBody: ResponseBody | ResponseBodySpecificAsset = await Promise.all(
|
||||
projects.map(async (project): Promise<ResponseBody[0]> => {
|
||||
let bitcoinAddress: string | null = null
|
||||
let moneroAddress: string | null = null
|
||||
|
||||
if (!project.isFunded) {
|
||||
const existingAddresses = await prisma.projectAddresses.findFirst({
|
||||
where: { projectSlug: project.slug, fundSlug: project.fund },
|
||||
})
|
||||
|
||||
// Create invoice if there's no existing address
|
||||
if (!existingAddresses) {
|
||||
const metadata: DonationMetadata = {
|
||||
userId: null,
|
||||
donorName: null,
|
||||
donorEmail: null,
|
||||
projectSlug: project.slug,
|
||||
projectName: project.title,
|
||||
fundSlug: project.fund as FundSlug,
|
||||
isMembership: 'false',
|
||||
isSubscription: 'false',
|
||||
isTaxDeductible: 'false',
|
||||
staticGeneratedForApi: 'true',
|
||||
}
|
||||
|
||||
const { data: invoice } = await btcpayApi.post<BtcPayCreateInvoiceRes>('/invoices', {
|
||||
currency: CURRENCY,
|
||||
metadata,
|
||||
})
|
||||
|
||||
const paymentMethodsResponse = await btcpayApi.get<BtcPayGetPaymentMethodsRes>(
|
||||
`/invoices/${invoice.id}/payment-methods`
|
||||
)
|
||||
|
||||
paymentMethodsResponse.data.forEach((paymentMethod: any) => {
|
||||
if (paymentMethod.paymentMethod === 'BTC-OnChain') {
|
||||
bitcoinAddress = paymentMethod.destination
|
||||
}
|
||||
|
||||
if (paymentMethod.paymentMethod === 'XMR') {
|
||||
moneroAddress = paymentMethod.destination
|
||||
}
|
||||
})
|
||||
|
||||
if (!bitcoinAddress && process.env.NODE_ENV !== 'development')
|
||||
throw new Error(
|
||||
'[/api/funding-required] Could not get bitcoin address from payment methods.'
|
||||
)
|
||||
|
||||
if (!moneroAddress)
|
||||
throw new Error(
|
||||
'[/api/funding-required] Could not get monero address from payment methods.'
|
||||
)
|
||||
|
||||
await prisma.projectAddresses.create({
|
||||
data: {
|
||||
projectSlug: project.slug,
|
||||
fundSlug: project.fund,
|
||||
btcPayInvoiceId: invoice.id,
|
||||
bitcoinAddress: bitcoinAddress || '',
|
||||
moneroAddress: moneroAddress,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (existingAddresses) {
|
||||
bitcoinAddress = existingAddresses.bitcoinAddress
|
||||
moneroAddress = existingAddresses.moneroAddress
|
||||
}
|
||||
}
|
||||
|
||||
const targetAmountBtc = project.goal / (rates.BTC || 0)
|
||||
const targetAmountXmr = project.goal / (rates.XMR || 0)
|
||||
const targetAmountUsd = project.goal
|
||||
|
||||
const allDonationsSumUsd =
|
||||
project.totaldonationsinfiatbtc +
|
||||
project.totaldonationsinfiatxmr +
|
||||
project.fiattotaldonationsinfiat
|
||||
|
||||
const remainingAmountBtc = (project.goal - allDonationsSumUsd) / (rates.BTC || 0)
|
||||
const remainingAmountXmr = (project.goal - allDonationsSumUsd) / (rates.XMR || 0)
|
||||
const remainingAmountUsd = project.goal - allDonationsSumUsd
|
||||
|
||||
return {
|
||||
title: project.title,
|
||||
fund: project.fund,
|
||||
date: project.date,
|
||||
author: project.nym,
|
||||
url: path.join(env.APP_URL, project.fund, project.slug),
|
||||
is_funded: !!project.isFunded,
|
||||
target_amount_btc: Number(targetAmountBtc.toFixed(8)),
|
||||
target_amount_xmr: Number(targetAmountXmr.toFixed(12)),
|
||||
target_amount_usd: Number(targetAmountUsd.toFixed(2)),
|
||||
remaining_amount_btc: Number((remainingAmountBtc > 0 ? remainingAmountBtc : 0).toFixed(8)),
|
||||
remaining_amount_xmr: Number((remainingAmountXmr > 0 ? remainingAmountXmr : 0).toFixed(12)),
|
||||
remaining_amount_usd: Number((remainingAmountUsd > 0 ? remainingAmountUsd : 0).toFixed(2)),
|
||||
address_btc: bitcoinAddress,
|
||||
address_xmr: moneroAddress,
|
||||
raised_amount_percent:
|
||||
((project.totaldonationsinfiatxmr +
|
||||
project.totaldonationsinfiatbtc +
|
||||
project.fiattotaldonationsinfiat) /
|
||||
project.goal) *
|
||||
100,
|
||||
contributions: project.numdonationsbtc + project.numdonationsxmr + project.fiatnumdonations,
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
if (query.asset) {
|
||||
responseBody = responseBody.map<ResponseBodySpecificAsset[0]>((project) => {
|
||||
const targetAmounts: Record<Asset, number> = {
|
||||
BTC: project.target_amount_btc,
|
||||
XMR: project.target_amount_xmr,
|
||||
USD: project.target_amount_usd,
|
||||
}
|
||||
|
||||
const remainingAmounts: Record<Asset, number> = {
|
||||
BTC: project.remaining_amount_btc,
|
||||
XMR: project.remaining_amount_xmr,
|
||||
USD: project.remaining_amount_usd,
|
||||
}
|
||||
|
||||
const addresses: Record<Asset, string | null> = {
|
||||
BTC: project.address_btc,
|
||||
XMR: project.address_xmr,
|
||||
USD: null,
|
||||
}
|
||||
|
||||
return {
|
||||
title: project.title,
|
||||
fund: project.fund,
|
||||
date: project.date,
|
||||
author: project.author,
|
||||
url: project.url,
|
||||
is_funded: project.is_funded,
|
||||
target_amount: targetAmounts[query.asset!],
|
||||
remaining_amount: remainingAmounts[query.asset!],
|
||||
address: addresses[query.asset!],
|
||||
raised_amount_percent: project.raised_amount_percent,
|
||||
contributions: project.contributions,
|
||||
asset: query.asset!,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Store response in cache
|
||||
cachedResponses[cacheKey] = {
|
||||
data: responseBody,
|
||||
expiresAt: dayjs().add(10, 'minutes').toDate(),
|
||||
}
|
||||
|
||||
return res.send(responseBody)
|
||||
}
|
||||
|
||||
export default handle
|
||||
@@ -0,0 +1,16 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "ProjectAddresses" (
|
||||
"id" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"projectSlug" TEXT NOT NULL,
|
||||
"fundSlug" "FundSlug" NOT NULL,
|
||||
"btcPayInvoiceId" TEXT NOT NULL,
|
||||
"bitcoinAddress" TEXT NOT NULL,
|
||||
"moneroAddress" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "ProjectAddresses_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ProjectAddresses_projectSlug_key" ON "ProjectAddresses"("projectSlug");
|
||||
@@ -0,0 +1,5 @@
|
||||
-- DropIndex
|
||||
DROP INDEX "Donation_btcPayInvoiceId_key";
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Donation_btcPayInvoiceId_idx" ON "Donation"("btcPayInvoiceId");
|
||||
@@ -13,13 +13,20 @@ datasource db {
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
enum FundSlug {
|
||||
monero
|
||||
firo
|
||||
privacyguides
|
||||
general
|
||||
}
|
||||
|
||||
model Donation {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
userId String?
|
||||
btcPayInvoiceId String? @unique
|
||||
btcPayInvoiceId String?
|
||||
stripePaymentIntentId String? // For donations and non-recurring memberships
|
||||
stripeInvoiceId String? @unique // For recurring memberships
|
||||
stripeSubscriptionId String? // For recurring memberships
|
||||
@@ -31,14 +38,20 @@ model Donation {
|
||||
cryptoAmount Float?
|
||||
membershipExpiresAt DateTime?
|
||||
|
||||
@@index([btcPayInvoiceId])
|
||||
@@index([stripePaymentIntentId])
|
||||
@@index([stripeSubscriptionId])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
enum FundSlug {
|
||||
monero
|
||||
firo
|
||||
privacyguides
|
||||
general
|
||||
model ProjectAddresses {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
projectSlug String @unique
|
||||
fundSlug FundSlug
|
||||
btcPayInvoiceId String
|
||||
bitcoinAddress String
|
||||
moneroAddress String
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { CURRENCY, MAX_AMOUNT, MEMBERSHIP_PRICE, MIN_AMOUNT } from '../../config
|
||||
import { env } from '../../env.mjs'
|
||||
import { btcpayApi, keycloak, prisma, stripe as _stripe } from '../services'
|
||||
import { authenticateKeycloakClient } from '../utils/keycloak'
|
||||
import { DonationMetadata } from '../types'
|
||||
import { BtcPayCreateInvoiceRes, DonationMetadata } from '../types'
|
||||
import { fundSlugs } from '../../utils/funds'
|
||||
import { fundSlugToCustomerIdAttr } from '../utils/funds'
|
||||
|
||||
@@ -65,6 +65,7 @@ export const donationRouter = router({
|
||||
isMembership: 'false',
|
||||
isSubscription: 'false',
|
||||
isTaxDeductible: input.taxDeductible ? 'true' : 'false',
|
||||
staticGeneratedForApi: 'false',
|
||||
}
|
||||
|
||||
const params: Stripe.Checkout.SessionCreateParams = {
|
||||
@@ -130,16 +131,17 @@ export const donationRouter = router({
|
||||
isMembership: 'false',
|
||||
isSubscription: 'false',
|
||||
isTaxDeductible: input.taxDeductible ? 'true' : 'false',
|
||||
staticGeneratedForApi: 'false',
|
||||
}
|
||||
|
||||
const response = await btcpayApi.post(`/invoices`, {
|
||||
const { data: invoice } = await btcpayApi.post<BtcPayCreateInvoiceRes>(`/invoices`, {
|
||||
amount: input.amount,
|
||||
currency: CURRENCY,
|
||||
metadata,
|
||||
checkout: { redirectURL: `${env.APP_URL}/${input.fundSlug}/thankyou` },
|
||||
})
|
||||
|
||||
return { url: response.data.checkoutLink }
|
||||
return { url: invoice.checkoutLink }
|
||||
}),
|
||||
|
||||
payMembershipWithFiat: protectedProcedure
|
||||
@@ -199,6 +201,7 @@ export const donationRouter = router({
|
||||
isMembership: 'true',
|
||||
isSubscription: input.recurring ? 'true' : 'false',
|
||||
isTaxDeductible: input.taxDeductible ? 'true' : 'false',
|
||||
staticGeneratedForApi: 'false',
|
||||
}
|
||||
|
||||
const purchaseParams: Stripe.Checkout.SessionCreateParams = {
|
||||
@@ -296,16 +299,17 @@ export const donationRouter = router({
|
||||
isMembership: 'true',
|
||||
isSubscription: 'false',
|
||||
isTaxDeductible: input.taxDeductible ? 'true' : 'false',
|
||||
staticGeneratedForApi: 'false',
|
||||
}
|
||||
|
||||
const response = await btcpayApi.post(`/invoices`, {
|
||||
const { data: invoice } = await btcpayApi.post<BtcPayCreateInvoiceRes>(`/invoices`, {
|
||||
amount: MEMBERSHIP_PRICE,
|
||||
currency: CURRENCY,
|
||||
metadata,
|
||||
checkout: { redirectURL: `${env.APP_URL}/${input.fundSlug}/thankyou` },
|
||||
})
|
||||
|
||||
return { url: response.data.checkoutLink }
|
||||
return { url: invoice.checkoutLink }
|
||||
}),
|
||||
|
||||
donationList: protectedProcedure
|
||||
|
||||
@@ -10,4 +10,44 @@ export type DonationMetadata = {
|
||||
isMembership: 'true' | 'false'
|
||||
isSubscription: 'true' | 'false'
|
||||
isTaxDeductible: 'true' | 'false'
|
||||
staticGeneratedForApi: 'true' | 'false'
|
||||
}
|
||||
|
||||
export type BtcPayGetRatesRes = [
|
||||
{
|
||||
currencyPair: string
|
||||
errors: string[]
|
||||
rate: string
|
||||
},
|
||||
]
|
||||
|
||||
export type BtcPayGetPaymentMethodsRes = {
|
||||
rate: string
|
||||
amount: string
|
||||
cryptoCode: string
|
||||
}[]
|
||||
|
||||
export type BtcPayCreateInvoiceBody = {
|
||||
amount?: number
|
||||
currency?: string
|
||||
metadata: DonationMetadata
|
||||
}
|
||||
|
||||
export type BtcPayCreateInvoiceRes = {
|
||||
metadata: DonationMetadata
|
||||
checkout: any
|
||||
receipt: any
|
||||
id: string
|
||||
storeId: string
|
||||
amount: string
|
||||
currency: string
|
||||
type: string
|
||||
checkoutLink: string
|
||||
createdTime: number
|
||||
expirationTime: number
|
||||
monitoringExpiration: number
|
||||
status: 'Expired' | 'Invalid' | 'New' | 'Processing' | 'Settled'
|
||||
additionalStatus: string
|
||||
availableStatusesForManualMarking: any
|
||||
archived: boolean
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ export const funds: Record<FundSlug, ProjectItem & { slug: FundSlug }> = {
|
||||
'Help us to provide sustainable funding for free and open-source contributors working on freedom tech and projects that help Monero flourish.',
|
||||
coverImage: '/img/crystalball.jpg',
|
||||
// The attributes below can be ignored
|
||||
date: '',
|
||||
goal: 100000,
|
||||
fund: 'monero',
|
||||
fiatnumdonations: 0,
|
||||
@@ -44,6 +45,7 @@ export const funds: Record<FundSlug, ProjectItem & { slug: FundSlug }> = {
|
||||
summary: 'Support contributors to Firo',
|
||||
coverImage: '/img/crystalball.jpg',
|
||||
// The attributes below can be ignored
|
||||
date: '',
|
||||
goal: 100000,
|
||||
fund: 'firo',
|
||||
fiatnumdonations: 0,
|
||||
@@ -70,6 +72,7 @@ export const funds: Record<FundSlug, ProjectItem & { slug: FundSlug }> = {
|
||||
summary: 'Support contributors to Privacy Guides',
|
||||
coverImage: '/img/crystalball.jpg',
|
||||
// The attributes below can be ignored
|
||||
date: '',
|
||||
goal: 100000,
|
||||
fund: 'privacyguides',
|
||||
fiatnumdonations: 0,
|
||||
@@ -96,6 +99,7 @@ export const funds: Record<FundSlug, ProjectItem & { slug: FundSlug }> = {
|
||||
summary: 'Support contributors to MAGIC',
|
||||
coverImage: '/img/crystalball.jpg',
|
||||
// The attributes below can be ignored
|
||||
date: '',
|
||||
goal: 100000,
|
||||
fund: 'general',
|
||||
fiatnumdonations: 0,
|
||||
|
||||
16
utils/md.ts
16
utils/md.ts
@@ -106,16 +106,18 @@ export async function getProjects(fundSlug?: FundSlug) {
|
||||
.flat()
|
||||
}
|
||||
|
||||
// Sort projects
|
||||
projects = projects
|
||||
.sort(() => 0.5 - Math.random())
|
||||
.sort((a, b) => {
|
||||
// Make active campaigns always come first
|
||||
// Make active projects always come first
|
||||
if (!a.isFunded && b.isFunded) return -1
|
||||
if (a.isFunded && !b.isFunded) return 1
|
||||
return 0
|
||||
})
|
||||
.slice(0, 6)
|
||||
|
||||
// Get donation stats for active projects
|
||||
await Promise.all(
|
||||
projects.map(async (project) => {
|
||||
if (project.isFunded) return
|
||||
@@ -145,6 +147,18 @@ export async function getProjects(fundSlug?: FundSlug) {
|
||||
project.fiattotaldonationsinfiat += donation.fiatAmount
|
||||
}
|
||||
})
|
||||
|
||||
// Make isFunded true if goal has been reached
|
||||
const donationsSum =
|
||||
((project.totaldonationsinfiatxmr +
|
||||
project.totaldonationsinfiatbtc +
|
||||
project.fiattotaldonationsinfiat) /
|
||||
project.goal) *
|
||||
100
|
||||
|
||||
if (donationsSum >= project.goal) {
|
||||
project.isFunded = true
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ export type ProjectItem = {
|
||||
coverImage: string
|
||||
website: string
|
||||
socialLinks: string[]
|
||||
date?: string
|
||||
date: string
|
||||
staticXMRaddress?: string
|
||||
goal: number
|
||||
isFunded?: boolean
|
||||
|
||||
Reference in New Issue
Block a user