mirror of
https://github.com/MAGICGrants/campaign-site.git
synced 2026-01-09 12:27:59 -05:00
600 lines
19 KiB
TypeScript
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 }
|
|
}),
|
|
})
|