From 9a25459ffb75d2a46f31e54d819ed14bb541f150 Mon Sep 17 00:00:00 2001 From: Artur <33733651+Keeqler@users.noreply.github.com> Date: Mon, 31 Mar 2025 11:48:11 -0300 Subject: [PATCH] Handle manually marked invoices and display ltc donations --- config/index.ts | 1 + pages/[fund]/projects/[slug].tsx | 31 +++++++++++++------- pages/api/btcpay/webhook.ts | 49 +++++++++++++++++++++++++------- server/types.ts | 7 ++++- server/utils/btcpayserver.ts | 26 +++++++++++++++++ server/utils/webhooks.ts | 6 ++-- utils/funds.ts | 8 ++++++ utils/md.ts | 8 +++++- utils/types.ts | 12 ++++++++ 9 files changed, 121 insertions(+), 27 deletions(-) create mode 100644 server/utils/btcpayserver.ts 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/pages/[fund]/projects/[slug].tsx b/pages/[fund]/projects/[slug].tsx index a5c1bee..ff0f825 100644 --- a/pages/[fund]/projects/[slug].tsx +++ b/pages/[fund]/projects/[slug].tsx @@ -1,5 +1,3 @@ -import { useEffect, useState } from 'react' -import { useSession } from 'next-auth/react' import { useRouter } from 'next/router' import { GetServerSidePropsContext, NextPage } from 'next/types' import Head from 'next/head' @@ -15,7 +13,6 @@ 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 { useFundSlug } from '../../../utils/use-fund-slug' @@ -31,10 +28,6 @@ type SingleProjectPageProps = { 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 @@ -106,10 +99,13 @@ const Project: NextPage = ({ project, donationStats }) = @@ -206,6 +209,11 @@ export async function getServerSideProps({ params, resolvedUrl }: GetServerSideP 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, @@ -222,6 +230,7 @@ export async function getServerSideProps({ params, resolvedUrl }: GetServerSideP BTC: donationStats.btc, XMR: donationStats.xmr, LTC: donationStats.ltc, + MANUAL: donationStats.manual, } as const donations.forEach((donation) => { diff --git a/pages/api/btcpay/webhook.ts b/pages/api/btcpay/webhook.ts index a62a0fa..22bee07 100644 --- a/pages/api/btcpay/webhook.ts +++ b/pages/api/btcpay/webhook.ts @@ -13,10 +13,14 @@ import { btcpayApi as _btcpayApi, btcpayApi, prisma } from '../../../server/serv import { env } from '../../../env.mjs' 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 { addUserToPgMembersGroup } from '../../../utils/pg-forum-connection' import { log } from '../../../utils/logging' +import { + getBtcPayInvoice, + getBtcPayInvoicePaymentMethods, +} from '../../../server/utils/btcpayserver' export const config = { api: { @@ -25,6 +29,7 @@ export const config = { } type WebhookBody = Record & { + manuallyMarked: boolean deliveryId: string webhookId: string originalDeliveryId: string @@ -72,11 +77,7 @@ async function handleFundingRequiredApiDonation(body: WebhookBody) { // This handles both donations and memberships. async function handleDonationOrMembership(body: WebhookBody) { - if (!body.metadata) return - - const { data: paymentMethods } = await btcpayApi.get( - `/invoices/${body.invoiceId}/payment-methods` - ) + if (!body.metadata || JSON.stringify(body.metadata) === '{}') return const termToMembershipExpiresAt = { monthly: dayjs().add(1, 'month').toDate(), @@ -90,17 +91,20 @@ async function handleDonationOrMembership(body: WebhookBody) { } 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 shouldGivePointsBack = body.metadata.givePointsBack === 'true' const cryptoRate = Number(paymentMethod.rate) const grossCryptoAmount = Number(paymentMethod.paymentMethodPaid) // Deduct 10% of amount if donator wants points - const netCryptoAmount = shouldGivePointsBack ? grossCryptoAmount * 0.9 : grossCryptoAmount + const netCryptoAmount = shouldGivePointsBack + ? grossCryptoAmount * NET_DONATION_AMOUNT_WITH_POINTS_RATE + : grossCryptoAmount // Move on if amound paid with current method is 0 if (!grossCryptoAmount) return @@ -113,17 +117,40 @@ async function handleDonationOrMembership(body: WebhookBody) { }) }) - const grossFiatAmount = Object.values(cryptoPayments).reduce( + // 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 = Object.values(cryptoPayments).reduce( + const netFiatAmount = cryptoPayments.reduce( (total, paymentMethod) => total + paymentMethod.netAmount * paymentMethod.rate, 0 ) - const shouldGivePointsBack = body.metadata.givePointsBack === 'true' const pointsToGive = shouldGivePointsBack ? Math.floor(grossFiatAmount / POINTS_PER_USD) : 0 const donation = await prisma.donation.create({ diff --git a/server/types.ts b/server/types.ts index 861202b..96ae536 100644 --- a/server/types.ts +++ b/server/types.ts @@ -30,7 +30,7 @@ export type DonationMetadata = { } export type DonationCryptoPayments = { - cryptoCode: 'BTC' | 'XMR' | 'LTC' + cryptoCode: 'BTC' | 'XMR' | 'LTC' | 'MANUAL' grossAmount: number netAmount: number rate: number @@ -44,6 +44,11 @@ export type BtcPayGetRatesRes = [ }, ] +export type BtcPayGetInvoiceRes = { + id: string + amount: string +} + export type BtcPayGetPaymentMethodsRes = { rate: string amount: string 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/webhooks.ts b/server/utils/webhooks.ts index b58cb26..3001070 100644 --- a/server/utils/webhooks.ts +++ b/server/utils/webhooks.ts @@ -13,7 +13,7 @@ import { import { DonationMetadata, StrapiCreatePointBody } from '../../server/types' import { sendDonationConfirmationEmail } from './mailing' import { getPointsBalance, givePointsToUser } from './perks' -import { POINTS_PER_USD } from '../../config' +import { NET_DONATION_AMOUNT_WITH_POINTS_RATE, POINTS_PER_USD } from '../../config' import { getDonationAttestation, getMembershipAttestation } from './attestation' import { addUserToPgMembersGroup } from '../../utils/pg-forum-connection' import { log } from '../../utils/logging' @@ -32,7 +32,7 @@ async function handleDonationOrNonRecurringMembership(paymentIntent: Stripe.Paym const shouldGivePointsBack = metadata.givePointsBack === 'true' const grossFiatAmount = paymentIntent.amount_received / 100 const netFiatAmount = shouldGivePointsBack - ? Number((grossFiatAmount * 0.9).toFixed(2)) + ? Number((grossFiatAmount * NET_DONATION_AMOUNT_WITH_POINTS_RATE).toFixed(2)) : grossFiatAmount const pointsToGive = shouldGivePointsBack ? Math.floor(grossFiatAmount / POINTS_PER_USD) : 0 let membershipExpiresAt = null @@ -143,7 +143,7 @@ async function handleRecurringMembership(invoice: Stripe.Invoice) { const shouldGivePointsBack = metadata.givePointsBack === 'true' const grossFiatAmount = invoice.total / 100 const netFiatAmount = shouldGivePointsBack - ? Number((grossFiatAmount * 0.9).toFixed(2)) + ? 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) diff --git a/utils/funds.ts b/utils/funds.ts index 153d705..509798a 100644 --- a/utils/funds.ts +++ b/utils/funds.ts @@ -20,6 +20,7 @@ export const funds: Record = { numDonationsXMR: 0, numDonationsLTC: 0, numDonationsFiat: 0, + numDonationsManual: 0, totalDonationsBTC: 0, totalDonationsXMR: 0, totalDonationsLTC: 0, @@ -27,6 +28,7 @@ export const funds: Record = { totalDonationsBTCInFiat: 0, totalDonationsXMRInFiat: 0, totalDonationsLTCInFiat: 0, + totalDonationsManual: 0, }, firo: { fund: 'firo', @@ -45,6 +47,7 @@ export const funds: Record = { numDonationsXMR: 0, numDonationsLTC: 0, numDonationsFiat: 0, + numDonationsManual: 0, totalDonationsBTC: 0, totalDonationsXMR: 0, totalDonationsLTC: 0, @@ -52,6 +55,7 @@ export const funds: Record = { totalDonationsBTCInFiat: 0, totalDonationsXMRInFiat: 0, totalDonationsLTCInFiat: 0, + totalDonationsManual: 0, }, privacyguides: { fund: 'privacyguides', @@ -75,6 +79,7 @@ export const funds: Record = { numDonationsXMR: 0, numDonationsLTC: 0, numDonationsFiat: 0, + numDonationsManual: 0, totalDonationsBTC: 0, totalDonationsXMR: 0, totalDonationsLTC: 0, @@ -82,6 +87,7 @@ export const funds: Record = { totalDonationsBTCInFiat: 0, totalDonationsXMRInFiat: 0, totalDonationsLTCInFiat: 0, + totalDonationsManual: 0, }, general: { fund: 'general', @@ -104,6 +110,7 @@ export const funds: Record = { numDonationsXMR: 0, numDonationsLTC: 0, numDonationsFiat: 0, + numDonationsManual: 0, totalDonationsBTC: 0, totalDonationsXMR: 0, totalDonationsLTC: 0, @@ -111,6 +118,7 @@ export const funds: Record = { totalDonationsBTCInFiat: 0, totalDonationsXMRInFiat: 0, totalDonationsLTCInFiat: 0, + totalDonationsManual: 0, }, } diff --git a/utils/md.ts b/utils/md.ts index 717cdf5..4832fc0 100644 --- a/utils/md.ts +++ b/utils/md.ts @@ -7,7 +7,6 @@ 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 = { @@ -65,10 +64,12 @@ export function getProjectBySlug(slug: string, fundSlug: FundSlug) { 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, @@ -140,6 +141,11 @@ export async function getProjects(fundSlug?: FundSlug) { 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) { diff --git a/utils/types.ts b/utils/types.ts index 94ed4e7..4a5a9ea 100644 --- a/utils/types.ts +++ b/utils/types.ts @@ -18,10 +18,12 @@ export type ProjectItem = { numDonationsXMR: number numDonationsLTC: number numDonationsFiat: number + numDonationsManual: number totalDonationsBTC: number totalDonationsXMR: number totalDonationsLTC: number totalDonationsFiat: number + totalDonationsManual: number totalDonationsBTCInFiat: number totalDonationsXMRInFiat: number totalDonationsLTCInFiat: number @@ -46,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