Files
campaign-site/server/routers/donation.ts
2025-04-30 17:25:21 -03:00

600 lines
19 KiB
TypeScript

import { Stripe } from 'stripe'
import { TRPCError } from '@trpc/server'
import { Donation } from '@prisma/client'
import { z } from 'zod'
import UserRepresentation from '@keycloak/keycloak-admin-client/lib/defs/userRepresentation'
import { protectedProcedure, publicProcedure, router } from '../trpc'
import {
CURRENCY,
MAX_AMOUNT,
ANNUALLY_MEMBERSHIP_MIN_PRICE_USD,
MIN_AMOUNT,
MONTHLY_MEMBERSHIP_MIN_PRICE_USD,
} from '../../config'
import { env } from '../../env.mjs'
import { btcpayApi, keycloak, prisma, stripe as _stripe } from '../services'
import { authenticateKeycloakClient } from '../utils/keycloak'
import { BtcPayCreateInvoiceRes, DonationMetadata } from '../types'
import { funds, fundSlugs } from '../../utils/funds'
import { fundSlugToCustomerIdAttr } from '../utils/funds'
import { getDonationAttestation, getMembershipAttestation } from '../utils/attestation'
import { createCoinbaseCharge } from '../utils/coinbase-commerce'
export const donationRouter = router({
donateWithFiat: publicProcedure
.input(
z.object({
name: z.string().min(1).nullable(),
email: z.string().email().nullable(),
projectName: z.string().min(1),
projectSlug: z.string().min(1),
fundSlug: z.enum(fundSlugs),
amount: z.number().min(MIN_AMOUNT).max(MAX_AMOUNT),
taxDeductible: z.boolean(),
givePointsBack: z.boolean(),
showDonorNameOnLeaderboard: z.boolean(),
})
)
.mutation(async ({ input, ctx }) => {
const userId = ctx.session?.user.sub || null
if (!userId && input.showDonorNameOnLeaderboard && !input.name) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Name is required when you want it to be on the leaderboard.',
})
}
if (!userId && input.taxDeductible && !input.name) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Name is required when the donation is tax deductible.',
})
}
if (!userId && input.taxDeductible && !input.email) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Email is required when the donation is tax deductible.',
})
}
let email = input.email
let name = input.name
let stripeCustomerId: string | null = null
let user: UserRepresentation | null = null
if (userId) {
await authenticateKeycloakClient()
user = (await keycloak.users.findOne({ id: userId })!) || null
email = user?.email!
name = user?.attributes?.name?.[0]
stripeCustomerId = user?.attributes?.[fundSlugToCustomerIdAttr[input.fundSlug]]?.[0] || null
}
const stripe = _stripe[input.fundSlug]
if (!stripeCustomerId && userId && user && email && name) {
const customer = await stripe.customers.create({
email,
name,
})
stripeCustomerId = customer.id
await keycloak.users.update(
{ id: userId },
{ ...user, attributes: { ...user.attributes, stripeCustomerId } }
)
}
const metadata: DonationMetadata = {
userId,
donorEmail: email,
donorName: name,
projectSlug: input.projectSlug,
projectName: input.projectName,
fundSlug: input.fundSlug,
isMembership: 'false',
isSubscription: 'false',
membershipTerm: null,
isTaxDeductible: input.taxDeductible ? 'true' : 'false',
staticGeneratedForApi: 'false',
givePointsBack: input.givePointsBack ? 'true' : 'false',
showDonorNameOnLeaderboard: input.showDonorNameOnLeaderboard ? 'true' : 'false',
}
const params: Stripe.Checkout.SessionCreateParams = {
mode: 'payment',
submit_type: 'donate',
customer: stripeCustomerId || undefined,
currency: CURRENCY,
line_items: [
{
price_data: {
currency: CURRENCY,
product_data: {
name: `MAGIC Grants donation: ${input.projectName}`,
},
unit_amount: input.amount * 100,
},
quantity: 1,
},
],
metadata,
success_url: `${env.APP_URL}/${input.fundSlug}/thankyou`,
cancel_url: `${env.APP_URL}/`,
// We need metadata in here for some reason
payment_intent_data: { metadata },
}
const checkoutSession = await stripe.checkout.sessions.create(params)
return { url: checkoutSession.url }
}),
donateWithCrypto: publicProcedure
.input(
z.object({
paymentMethod: z.enum(['btc', 'xmr', 'ltc', 'evm']),
name: z.string().trim().min(1).nullable(),
email: z.string().trim().email().nullable(),
projectName: z.string().min(1),
projectSlug: z.string().min(1),
fundSlug: z.enum(fundSlugs),
amount: z.number().min(MIN_AMOUNT).max(MAX_AMOUNT),
taxDeductible: z.boolean(),
givePointsBack: z.boolean(),
showDonorNameOnLeaderboard: z.boolean(),
})
)
.mutation(async ({ input, ctx }) => {
let email = input.email
let name = input.name
const userId = ctx.session?.user.sub || null
if (userId) {
await authenticateKeycloakClient()
const user = await keycloak.users.findOne({ id: userId })
email = user?.email!
name = user?.attributes?.name?.[0] || null
}
const metadata: DonationMetadata = {
userId,
donorName: name,
donorEmail: email,
projectSlug: input.projectSlug,
projectName: input.projectName,
fundSlug: input.fundSlug,
itemDesc: `MAGIC ${funds[input.fundSlug].title}`,
isMembership: 'false',
membershipTerm: null,
isSubscription: 'false',
isTaxDeductible: input.taxDeductible ? 'true' : 'false',
staticGeneratedForApi: 'false',
givePointsBack: input.givePointsBack ? 'true' : 'false',
showDonorNameOnLeaderboard: input.showDonorNameOnLeaderboard ? 'true' : 'false',
}
let url = ''
if (input.paymentMethod !== 'evm') {
const { data: invoice } = await btcpayApi.post<BtcPayCreateInvoiceRes>(`/invoices`, {
amount: input.amount,
currency: CURRENCY,
metadata,
checkout: {
redirectURL: `${env.APP_URL}/${input.fundSlug}/thankyou`,
defaultPaymentMethod: input.paymentMethod.toUpperCase(),
},
})
url = invoice.checkoutLink.replace(/^(https?:\/\/)([^\/]+)/, env.BTCPAY_EXTERNAL_URL)
}
if (input.paymentMethod === 'evm') {
const charge = await createCoinbaseCharge({
amountUsd: 0.1,
fundSlug: input.fundSlug,
metadata,
})
url = charge.hosted_url
}
return { url }
}),
payMembershipWithFiat: protectedProcedure
.input(
z.object({
fundSlug: z.enum(fundSlugs),
amount: z.number(),
term: z.enum(['monthly', 'annually']),
recurring: z.boolean(),
taxDeductible: z.boolean(),
givePointsBack: z.boolean(),
})
)
.mutation(async ({ input, ctx }) => {
const stripe = _stripe[input.fundSlug]
const userId = ctx.session.user.sub
if (input.term === 'monthly' && input.amount < MONTHLY_MEMBERSHIP_MIN_PRICE_USD) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `Min. monthly amount is $${MONTHLY_MEMBERSHIP_MIN_PRICE_USD}.`,
})
}
if (input.term === 'annually' && input.amount < ANNUALLY_MEMBERSHIP_MIN_PRICE_USD) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `Min. anually amount is $${ANNUALLY_MEMBERSHIP_MIN_PRICE_USD}.`,
})
}
const userHasMembership = await prisma.donation.findFirst({
where: {
userId,
projectSlug: input.fundSlug,
membershipExpiresAt: { gt: new Date() },
},
})
if (userHasMembership) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'USER_HAS_ACTIVE_MEMBERSHIP',
})
}
await authenticateKeycloakClient()
const user = await keycloak.users.findOne({ id: userId })
const email = user?.email!
const name = user?.attributes?.name?.[0]!
if (!user || !user.id)
throw new TRPCError({
code: 'NOT_FOUND',
message: 'USER_NOT_FOUND',
})
let stripeCustomerId =
user?.attributes?.[fundSlugToCustomerIdAttr[input.fundSlug]]?.[0] || null
if (!stripeCustomerId) {
const customer = await stripe.customers.create({ email, name })
stripeCustomerId = customer.id
await keycloak.users.update(
{ id: userId },
{ ...user, attributes: { ...user.attributes, stripeCustomerId } }
)
}
const metadata: DonationMetadata = {
userId,
donorName: name,
donorEmail: email,
projectSlug: input.fundSlug,
projectName: funds[input.fundSlug].title,
fundSlug: input.fundSlug,
isMembership: 'true',
membershipTerm: input.term,
isSubscription: input.recurring ? 'true' : 'false',
isTaxDeductible: input.taxDeductible ? 'true' : 'false',
staticGeneratedForApi: 'false',
givePointsBack: input.givePointsBack ? 'true' : 'false',
showDonorNameOnLeaderboard: 'false',
}
const purchaseParams: Stripe.Checkout.SessionCreateParams = {
mode: 'payment',
submit_type: 'donate',
customer: stripeCustomerId || undefined,
currency: CURRENCY,
line_items: [
{
price_data: {
currency: CURRENCY,
product_data: {
name: `MAGIC Grants ${input.term === 'annually' ? 'Annual' : 'Monthly'} Membership: ${funds[input.fundSlug].title}`,
},
unit_amount: input.amount * 100,
},
quantity: 1,
},
],
metadata,
success_url: `${env.APP_URL}/${input.fundSlug}/thankyou`,
cancel_url: `${env.APP_URL}/`,
payment_intent_data: { metadata },
}
const subscriptionParams: Stripe.Checkout.SessionCreateParams = {
mode: 'subscription',
customer: stripeCustomerId || undefined,
currency: CURRENCY,
line_items: [
{
price_data: {
currency: CURRENCY,
product_data: {
name: `MAGIC Grants ${input.term === 'annually' ? 'Annual' : 'Monthly'} Membership: ${funds[input.fundSlug].title}`,
},
recurring: { interval: input.term === 'annually' ? 'year' : 'month' },
unit_amount: input.amount * 100,
},
quantity: 1,
},
],
metadata,
success_url: `${env.APP_URL}/${input.fundSlug}/thankyou`,
cancel_url: `${env.APP_URL}/`,
subscription_data: { metadata },
}
const checkoutSession = await stripe.checkout.sessions.create(
input.recurring ? subscriptionParams : purchaseParams
)
return { url: checkoutSession.url }
}),
payMembershipWithCrypto: protectedProcedure
.input(
z.object({
paymentMethod: z.enum(['btc', 'xmr', 'ltc', 'evm']),
fundSlug: z.enum(fundSlugs),
amount: z.number(),
term: z.enum(['monthly', 'annually']),
taxDeductible: z.boolean(),
givePointsBack: z.boolean(),
})
)
.mutation(async ({ input, ctx }) => {
if (input.term === 'monthly' && input.amount < MONTHLY_MEMBERSHIP_MIN_PRICE_USD) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `Min. monthly amount is $${MONTHLY_MEMBERSHIP_MIN_PRICE_USD}.`,
})
}
if (input.term === 'annually' && input.amount < ANNUALLY_MEMBERSHIP_MIN_PRICE_USD) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `Min. anually amount is $${ANNUALLY_MEMBERSHIP_MIN_PRICE_USD}.`,
})
}
const userId = ctx.session.user.sub
const userHasMembership = await prisma.donation.findFirst({
where: {
userId,
projectSlug: input.fundSlug,
membershipExpiresAt: { gt: new Date() },
},
})
if (userHasMembership) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'USER_HAS_ACTIVE_MEMBERSHIP',
})
}
await authenticateKeycloakClient()
const user = await keycloak.users.findOne({ id: userId })
const email = user?.email!
const name = user?.attributes?.name?.[0]!
const metadata: DonationMetadata = {
userId,
donorName: name,
donorEmail: email,
projectSlug: input.fundSlug,
projectName: funds[input.fundSlug].title,
itemDesc: `MAGIC ${funds[input.fundSlug].title}`,
fundSlug: input.fundSlug,
isMembership: 'true',
membershipTerm: input.term,
isSubscription: 'false',
isTaxDeductible: input.taxDeductible ? 'true' : 'false',
staticGeneratedForApi: 'false',
givePointsBack: input.givePointsBack ? 'true' : 'false',
showDonorNameOnLeaderboard: 'false',
}
let url = ''
if (input.paymentMethod !== 'evm') {
const { data: invoice } = await btcpayApi.post<BtcPayCreateInvoiceRes>(`/invoices`, {
amount: input.amount,
currency: CURRENCY,
metadata,
checkout: {
redirectURL: `${env.APP_URL}/${input.fundSlug}/thankyou`,
defaultPaymentMethod: input.paymentMethod.toUpperCase(),
},
})
url = invoice.checkoutLink.replace(/^(https?:\/\/)([^\/]+)/, env.BTCPAY_EXTERNAL_URL)
}
if (input.paymentMethod === 'evm') {
const charge = await createCoinbaseCharge({
amountUsd: input.amount,
fundSlug: input.fundSlug,
metadata,
})
url = charge.hosted_url
}
return { url }
}),
donationList: protectedProcedure
.input(z.object({ fundSlug: z.enum(fundSlugs) }))
.query(async ({ input, ctx }) => {
const userId = ctx.session.user.sub
const donations = await prisma.donation.findMany({
where: {
userId,
membershipExpiresAt: null,
fundSlug: input.fundSlug,
},
orderBy: { createdAt: 'desc' },
})
return donations
}),
membershipList: protectedProcedure
.input(z.object({ fundSlug: z.enum(fundSlugs) }))
.query(async ({ input, ctx }) => {
const stripe = _stripe[input.fundSlug]
await authenticateKeycloakClient()
const userId = ctx.session.user.sub
const user = await keycloak.users.findOne({ id: userId })
const stripeCustomerId = user?.attributes?.[fundSlugToCustomerIdAttr[input.fundSlug]]?.[0]
let billingPortalUrl: string | null = null
if (stripeCustomerId) {
const billingPortalSession = await stripe.billingPortal.sessions.create({
customer: stripeCustomerId,
return_url: `${env.APP_URL}/${input.fundSlug}/account/my-memberships`,
})
billingPortalUrl = billingPortalSession.url
}
const memberships = await prisma.donation.findMany({
where: {
userId,
membershipExpiresAt: { not: null },
fundSlug: input.fundSlug,
},
orderBy: { createdAt: 'desc' },
})
const subscriptionIds = new Set<string>()
const membershipsUniqueSubsId: Donation[] = []
memberships.forEach((membership) => {
if (!membership.stripeSubscriptionId) {
membershipsUniqueSubsId.push(membership)
return
}
if (subscriptionIds.has(membership.stripeSubscriptionId)) {
return
}
membershipsUniqueSubsId.push(membership)
subscriptionIds.add(membership.stripeSubscriptionId)
})
return { memberships: membershipsUniqueSubsId, billingPortalUrl }
}),
userHasMembership: protectedProcedure
.input(z.object({ projectSlug: z.string() }))
.query(async ({ input, ctx }) => {
const userId = ctx.session.user.sub
const membership = await prisma.donation.findFirst({
where: { userId, projectSlug: input.projectSlug, membershipExpiresAt: { gt: new Date() } },
})
return !!membership
}),
getDonationAttestation: protectedProcedure
.input(z.object({ donationId: z.string() }))
.mutation(async ({ input, ctx }) => {
const userId = ctx.session.user.sub
const donation = await prisma.donation.findFirst({
where: { id: input.donationId, membershipExpiresAt: null, userId },
})
if (!donation) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Donation not found.' })
}
await authenticateKeycloakClient()
const user = await keycloak.users.findOne({ id: userId })
if (!user || !user.id)
throw new TRPCError({
code: 'NOT_FOUND',
message: 'USER_NOT_FOUND',
})
const { message, signature } = await getDonationAttestation({
donorName: user.attributes?.name,
donorEmail: ctx.session.user.email,
donation,
})
return { message, signature }
}),
getMembershipAttestation: protectedProcedure
.input(z.object({ donationId: z.string().optional(), subscriptionId: z.string().optional() }))
.mutation(async ({ input, ctx }) => {
const userId = ctx.session.user.sub
const donations = await prisma.donation.findMany({
where: input.subscriptionId
? {
stripeSubscriptionId: input.subscriptionId,
membershipExpiresAt: { not: null },
userId,
}
: { id: input.donationId, membershipExpiresAt: { not: null }, userId },
orderBy: { membershipExpiresAt: 'desc' },
})
if (!donations.length) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Membership not found.' })
}
await authenticateKeycloakClient()
const user = await keycloak.users.findOne({ id: userId })
if (!user || !user.id)
throw new TRPCError({
code: 'NOT_FOUND',
message: 'USER_NOT_FOUND',
})
const membershipStart = donations.slice(-1)[0].createdAt
const membershipEnd = donations[0].membershipExpiresAt!
const membershipValue = donations.reduce(
(total, donation) => total + donation.grossFiatAmount,
0
)
const { message, signature } = await getMembershipAttestation({
donorName: user.attributes?.name,
donorEmail: ctx.session.user.email,
donation: donations[0],
periodStart: membershipStart,
totalAmountToDate: membershipValue,
})
return { message, signature }
}),
})