Handle manually marked invoices and display ltc donations

This commit is contained in:
Artur
2025-03-31 11:48:11 -03:00
parent f8bd668b9f
commit 9a25459ffb
9 changed files with 121 additions and 27 deletions

View File

@@ -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

View File

@@ -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<SingleProjectPageProps> = ({ project, donationStats }) => {
const router = useRouter()
const [registerIsOpen, setRegisterIsOpen] = useState(false)
const [loginIsOpen, setLoginIsOpen] = useState(false)
const [passwordResetIsOpen, setPasswordResetIsOpen] = useState(false)
const session = useSession()
const fundSlug = useFundSlug()
const { slug, title, summary, coverImage, content, nym, website, goal, isFunded } = project
@@ -106,10 +99,13 @@ const Project: NextPage<SingleProjectPageProps> = ({ project, donationStats }) =
<ul className="font-semibold space-y-1">
<li className="flex items-center space-x-1">
<span className="text-green-500 text-xl">{`${formatUsd(donationStats.xmr.fiatAmount + donationStats.btc.fiatAmount + donationStats.usd.fiatAmount)}`}</span>{' '}
<span className="text-green-500 text-xl">{`${formatUsd(donationStats.xmr.fiatAmount + donationStats.btc.fiatAmount + donationStats.ltc.fiatAmount + donationStats.manual.fiatAmount + donationStats.usd.fiatAmount)}`}</span>{' '}
<span className="font-normal text-sm text-gray">
in{' '}
{donationStats.xmr.count + donationStats.btc.count + donationStats.usd.count}{' '}
{donationStats.xmr.count +
donationStats.btc.count +
donationStats.manual.count +
donationStats.usd.count}{' '}
donations total
</span>
</li>
@@ -126,9 +122,16 @@ const Project: NextPage<SingleProjectPageProps> = ({ project, donationStats }) =
</span>
</li>
<li>
{`${formatUsd(donationStats.usd.amount)}`} Fiat{' '}
{donationStats.ltc.amount}LTC{' '}
<span className="font-normal text-sm text-gray">
in {donationStats.usd.count} donations
in {donationStats.ltc.count} donations
</span>
</li>
<li>
{`${formatUsd(donationStats.usd.amount + donationStats.manual.fiatAmount)}`}{' '}
Fiat{' '}
<span className="font-normal text-sm text-gray">
in {donationStats.usd.count + donationStats.manual.count} donations
</span>
</li>
</ul>
@@ -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) => {

View File

@@ -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<string, any> & {
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<BtcPayGetPaymentMethodsRes>(
`/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({

View File

@@ -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

View File

@@ -0,0 +1,26 @@
import { log } from '../../utils/logging'
import { btcpayApi } from '../services'
import { BtcPayGetInvoiceRes, BtcPayGetPaymentMethodsRes } from '../types'
export async function getBtcPayInvoice(id: string) {
try {
const { data: invoice } = await btcpayApi.get<BtcPayGetInvoiceRes>(`/invoices/${id}`)
return invoice
} catch (error) {
log('error', `Failed to get BTCPayServer invoice ${id}.`)
throw error
}
}
export async function getBtcPayInvoicePaymentMethods(invoiceId: string) {
try {
const { data: paymentMethods } = await btcpayApi.get<BtcPayGetPaymentMethodsRes>(
`/invoices/${invoiceId}/payment-methods`
)
return paymentMethods
} catch (error) {
log('error', `Failed to get BTCPayServer payment methods for invoice ${invoiceId}.`)
throw error
}
}

View File

@@ -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)

View File

@@ -20,6 +20,7 @@ export const funds: Record<FundSlug, ProjectItem & { slug: FundSlug }> = {
numDonationsXMR: 0,
numDonationsLTC: 0,
numDonationsFiat: 0,
numDonationsManual: 0,
totalDonationsBTC: 0,
totalDonationsXMR: 0,
totalDonationsLTC: 0,
@@ -27,6 +28,7 @@ export const funds: Record<FundSlug, ProjectItem & { slug: FundSlug }> = {
totalDonationsBTCInFiat: 0,
totalDonationsXMRInFiat: 0,
totalDonationsLTCInFiat: 0,
totalDonationsManual: 0,
},
firo: {
fund: 'firo',
@@ -45,6 +47,7 @@ export const funds: Record<FundSlug, ProjectItem & { slug: FundSlug }> = {
numDonationsXMR: 0,
numDonationsLTC: 0,
numDonationsFiat: 0,
numDonationsManual: 0,
totalDonationsBTC: 0,
totalDonationsXMR: 0,
totalDonationsLTC: 0,
@@ -52,6 +55,7 @@ export const funds: Record<FundSlug, ProjectItem & { slug: FundSlug }> = {
totalDonationsBTCInFiat: 0,
totalDonationsXMRInFiat: 0,
totalDonationsLTCInFiat: 0,
totalDonationsManual: 0,
},
privacyguides: {
fund: 'privacyguides',
@@ -75,6 +79,7 @@ export const funds: Record<FundSlug, ProjectItem & { slug: FundSlug }> = {
numDonationsXMR: 0,
numDonationsLTC: 0,
numDonationsFiat: 0,
numDonationsManual: 0,
totalDonationsBTC: 0,
totalDonationsXMR: 0,
totalDonationsLTC: 0,
@@ -82,6 +87,7 @@ export const funds: Record<FundSlug, ProjectItem & { slug: FundSlug }> = {
totalDonationsBTCInFiat: 0,
totalDonationsXMRInFiat: 0,
totalDonationsLTCInFiat: 0,
totalDonationsManual: 0,
},
general: {
fund: 'general',
@@ -104,6 +110,7 @@ export const funds: Record<FundSlug, ProjectItem & { slug: FundSlug }> = {
numDonationsXMR: 0,
numDonationsLTC: 0,
numDonationsFiat: 0,
numDonationsManual: 0,
totalDonationsBTC: 0,
totalDonationsXMR: 0,
totalDonationsLTC: 0,
@@ -111,6 +118,7 @@ export const funds: Record<FundSlug, ProjectItem & { slug: FundSlug }> = {
totalDonationsBTCInFiat: 0,
totalDonationsXMRInFiat: 0,
totalDonationsLTCInFiat: 0,
totalDonationsManual: 0,
},
}

View File

@@ -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<FundSlug, string> = {
@@ -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) {

View File

@@ -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