Files
campaign-site/pages/api/funding-required.ts
Artur 82955be4cb Campaign Site V2 (#81)
* Apply OpenSats UI enhancements

* Add register modal and some UI fixes

* Add login modal

* Add reset password button

* Use @t3-oss/env-nextjs for env variables

* Email verification without keycloak UI

* Password reset without keycloak UI

* Add "My donations" page with one-time stripe donations list

* Display crypto donations in "My donations" page

* Donation form fixes and improvements

* Display recurring annual fiat donations

* Include keycloak realm export file and remove hardcoded client values

* fix: correctly handle btcpay webhooks and fix donationList query

* feat: add membership modal and implement membership payment using btcpay

* feat: add procedure for membership purchases with stripe and use db as single source of truth for donations

* feat: use webhooks to update stripe donation/membership status

* feat: memberships list page and fixes

* feat: db schema changes and webhook fixes

* feat: re-add "donate" and add "get annual membership" buttons to project page

* feat: open register modal when clicking membership button while logged out

* feat: replace "Get Membership" button with "My Memberships" button when user already has a membership for that project

* feat: multiple funds support

* deps: bump axios

* feat: add different color schemes for each fund and some fixes

* feat: add home page

* feat: add missing titles and responsiveness improvements

* chore: add prod workflow file and compose file

* chore(deploy workflow): set environment name

* chore(Dockerfile): add necessary lines for prisma

* chore: make it skip env validation on build

* fix: prevent donation amounts from being fetched from db during build

* chore(nginx.conf): remove copy-paste junk

* chore(docker compose): correctly set APP_URL env

* feat: replace Sendgrid with SES

* deps: audit fix

* chore(deploy.yml): remove unecessary env

* fix: correctly manage client and server env

* fix(trpc.submitApplication): get recipient emails from server side env

* fix(Dockerfile): define NEXT_PUBLIC_ env on build

* chore(trpc): make it log any errors

* chore(trpc): improve displaying of errors

* chore(trpc): improve displaying of errors

* fix: buggy link buttons

* feat: use single btcpay store

* feat: show form 8283 info in donation form and handle tax deductible donations

* feat: have only one privacy and terms page for the entire site

* feat: support many social links

* feat: add funding required endpoint (wip)

* feat: get rates using btcpay api and small refactor

* deps: audit fix

* fix: correctly handle payment methods on InvoiceSettled event

* fix: make index on Donation.btcPayInvoiceId

* feat(funding-required): improve asset parameter response

* feat(funding-required): add project_status param

* feat(funding-required): add fund param

* feat(funding-required): implement caching

* fix(funding-required): minor fixes

* feat(funding-required): add remaining_amount_<currency> fields and fixes

* chore: include all services in docker-compose.dev.yml and update .env.example

* feat: move terms and privacy links to footer

* fix: address font not always loading bug

* feat: use fund logos as header image

* feat: donation confirmation email

* fix: use correct stripe client for each fund on webhooks

* feat: add account settings page with change password form

* feat: add email change form to settings page

* fix: address wrong btcpay invoice url redirect

* chore: email change request debug

* fix(api): better handle user attributes

* feat: ui improvements

* feat: add btcpay invoice item description

* chore(nginx): api rate limit

* feat: remove typing component from fund landing pages

* feat: implement refresh token rotation using keycloak

* refactor: have gross and net amounts for donations

* feat: invalidate user sessions on password/email change

* fix: make "Create an account" button work on donate/membership modals

* refactor: project props

* fix(utils.md): correctly load md project attributes

* chore(prisma): make composite unique constraint for fundSlug and projectSlug on ProjectAddresses

* chore: mark example project as not funded

* fix(utils.md): serialization error

* chore(funding-required): btcpay invoice payment methods debug

* fix(funding-required): get bitcoin address from correct payment method

* fix(funding-required): correctly handle project_status ANY filter

* fix(btcpay webhook handler): correctly handle payment methods on InvoicePaymentSettled

* chore(docker-compose.yml): expose nginx port 80

* fix(funding-required): correctly concat project url

* feat: ui improvements for smaller screens

* fix(btcpay webhook handler): correctly get payment method amount on InvoiceSettled

* fix(btcpay webhook handler): respond with 200 immediately if there is no metadata

* chore(funding-required): debugging

* chore(funding-required): debugging

* chore(funding-required): debugging

* fix(Dockerfile): define BUILD_MODE as arg instead of env to make it blank at runtime

* fix: correctly pass current and goal values to project card progress

* fix(funding-required): set high monitoring time for static address invoice

* fix: correctly handle refresh token expiration on the ui

* feat: ui improvements

* chore: update README

* chore: update README

* Initial site text

* fix: colors

* chore: mention funds accordingly in texts

* chore: update realm-export.json

* chore: rename docker compose files

* Update emails

* Form updates

* Remove unused pages and page improvements

* feat: allow editing navbar links for each fund

* Cleanup and Firo projects

* chore(deploy.yml): change deploy branch to master

* fix(auth): use fetch instead of axios when fetching refresh token due to edge runtime compatibility

* fix: keep empty project folders

* Fix code scanning alert no. 20: DOM text reinterpreted as HTML

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* chore: sanitize md file paths

* Text and link updates

---------

Co-authored-by: Artur N <arturnunespe@gmail.com>
Co-authored-by: Justin Ehrenhofer <12520755+SamsungGalaxyPlayer@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2024-10-17 10:29:40 -05:00

267 lines
8.4 KiB
TypeScript

import { NextApiRequest, NextApiResponse } from 'next'
import { FundSlug } from '@prisma/client'
import { z } from 'zod'
import dayjs from 'dayjs'
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 { 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 === 'FUNDED'
? project.isFunded
: query.project_status === 'ANY'
? true
: !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.findUnique({
where: { projectSlug_fundSlug: { 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', {
checkout: { monitoringMinutes: 9999999 },
currency: CURRENCY,
metadata,
})
const { data: paymentMethods } = await btcpayApi.get<BtcPayGetPaymentMethodsRes>(
`/invoices/${invoice.id}/payment-methods`
)
paymentMethods.forEach((paymentMethod) => {
if (paymentMethod.paymentMethod === 'BTC') {
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.totalDonationsBTCInFiat +
project.totalDonationsXMRInFiat +
project.totalDonationsFiat
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: `${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: Math.floor(
((project.totalDonationsBTCInFiat +
project.totalDonationsXMRInFiat +
project.totalDonationsFiat) /
project.goal) *
100
),
contributions: project.numDonationsBTC + project.numDonationsXMR + project.numDonationsFiat,
}
})
)
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