mirror of
https://github.com/MAGICGrants/campaign-site.git
synced 2026-01-09 12:27:59 -05:00
feat: add procedure for membership purchases with stripe and use db as single source of truth for donations
This commit is contained in:
@@ -2,7 +2,7 @@ module.exports = {
|
||||
semi: false,
|
||||
trailingComma: 'es5',
|
||||
singleQuote: true,
|
||||
printWidth: 80,
|
||||
printWidth: 100,
|
||||
tabWidth: 2,
|
||||
useTabs: false,
|
||||
jsxBracketSameLine: false,
|
||||
|
||||
@@ -56,21 +56,21 @@ export default async function handler(
|
||||
}
|
||||
|
||||
if (body.type === 'InvoiceSettled') {
|
||||
await prisma.cryptoDonation.update({
|
||||
await prisma.donation.update({
|
||||
where: { id: body.invoiceId },
|
||||
data: { status: 'Settled' },
|
||||
})
|
||||
}
|
||||
|
||||
if (body.type === 'InvoiceExpired') {
|
||||
await prisma.cryptoDonation.update({
|
||||
await prisma.donation.update({
|
||||
where: { id: body.invoiceId },
|
||||
data: { status: 'Expired' },
|
||||
})
|
||||
}
|
||||
|
||||
if (body.type === 'InvoiceInvalid') {
|
||||
await prisma.cryptoDonation.update({
|
||||
await prisma.donation.update({
|
||||
where: { id: body.invoiceId },
|
||||
data: { status: 'Invalid' },
|
||||
})
|
||||
|
||||
0
pages/api/stripe/webhook.ts
Normal file
0
pages/api/stripe/webhook.ts
Normal file
@@ -1,13 +1,14 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "DonationStatus" AS ENUM ('Expired', 'Invalid', 'New', 'Processing', 'Settled');
|
||||
CREATE TYPE "DonationStatus" AS ENUM ('Waiting', 'Expired', 'Invalid', 'Complete');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "CryptoDonation" (
|
||||
CREATE TABLE "Donation" (
|
||||
"id" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"invoiceId" TEXT NOT NULL,
|
||||
"btcPayinvoiceId" TEXT,
|
||||
"stripeInvoiceId" TEXT,
|
||||
"projectSlug" TEXT NOT NULL,
|
||||
"projectName" TEXT NOT NULL,
|
||||
"fund" TEXT NOT NULL,
|
||||
@@ -16,5 +17,11 @@ CREATE TABLE "CryptoDonation" (
|
||||
"membershipExpiresAt" TIMESTAMP(3),
|
||||
"status" "DonationStatus" NOT NULL,
|
||||
|
||||
CONSTRAINT "CryptoDonation_pkey" PRIMARY KEY ("id")
|
||||
CONSTRAINT "Donation_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Donation_userId_idx" ON "Donation"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Donation_btcPayinvoiceId_stripeInvoiceId_key" ON "Donation"("btcPayinvoiceId", "stripeInvoiceId");
|
||||
@@ -13,13 +13,14 @@ datasource db {
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model CryptoDonation {
|
||||
model Donation {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
userId String
|
||||
invoiceId String
|
||||
btcPayinvoiceId String?
|
||||
stripeInvoiceId String?
|
||||
projectSlug String
|
||||
projectName String
|
||||
fund String
|
||||
@@ -27,12 +28,14 @@ model CryptoDonation {
|
||||
fiatAmount Float
|
||||
membershipExpiresAt DateTime?
|
||||
status DonationStatus
|
||||
|
||||
@@unique([btcPayinvoiceId, stripeInvoiceId])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
enum DonationStatus {
|
||||
Waiting
|
||||
Expired
|
||||
Invalid
|
||||
New
|
||||
Processing
|
||||
Settled
|
||||
Complete
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { Stripe } from 'stripe'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { z } from 'zod'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
import { protectedProcedure, publicProcedure, router } from '../trpc'
|
||||
import {
|
||||
CURRENCY,
|
||||
@@ -10,10 +13,8 @@ import {
|
||||
import { env } from '../../env.mjs'
|
||||
import { btcpayApi, keycloak, prisma } from '../services'
|
||||
import { authenticateKeycloakClient } from '../utils/keycloak'
|
||||
import { Donation, DonationMetadata } from '../types'
|
||||
import { createInvoice } from '../utils/btcpay'
|
||||
import dayjs from 'dayjs'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { DonationMetadata } from '../types'
|
||||
import { Donation } from '@prisma/client'
|
||||
|
||||
const stripe = new Stripe(env.STRIPE_SECRET_KEY, {
|
||||
// https://github.com/stripe/stripe-node#configuration
|
||||
@@ -92,32 +93,6 @@ export const donationRouter = router({
|
||||
payment_intent_data: { metadata },
|
||||
}
|
||||
|
||||
// Use this for subscriptions
|
||||
// const subscriptionParams: Stripe.Checkout.SessionCreateParams = {
|
||||
// mode: 'subscription',
|
||||
// // submit_type: 'donate',
|
||||
// customer: stripeCustomerId || undefined,
|
||||
// currency: CURRENCY,
|
||||
// line_items: [
|
||||
// {
|
||||
// price_data: {
|
||||
// currency: CURRENCY,
|
||||
// product_data: {
|
||||
// name: `MAGIC Grants donation: ${input.projectName}`,
|
||||
// },
|
||||
// recurring: { interval: 'year' },
|
||||
// unit_amount: input.amount * 100,
|
||||
// },
|
||||
// quantity: 1,
|
||||
// },
|
||||
// ],
|
||||
// metadata,
|
||||
// success_url: `${env.APP_URL}/thankyou`,
|
||||
// cancel_url: `${env.APP_URL}/`,
|
||||
// // We need metadata in here for some reason
|
||||
// subscription_data: { metadata },
|
||||
// }
|
||||
|
||||
const checkoutSession = await stripe.checkout.sessions.create(params)
|
||||
|
||||
return { url: checkoutSession.url }
|
||||
@@ -164,22 +139,112 @@ export const donationRouter = router({
|
||||
}
|
||||
)
|
||||
|
||||
await prisma.cryptoDonation.create({
|
||||
await prisma.donation.create({
|
||||
data: {
|
||||
userId: metadata.userId as string,
|
||||
invoiceId: response.data.id,
|
||||
btcPayinvoiceId: response.data.id,
|
||||
crypto: 'XMR',
|
||||
projectName: metadata.projectName,
|
||||
projectSlug: metadata.projectSlug,
|
||||
fund: 'Monero Fund',
|
||||
fiatAmount: input.amount,
|
||||
status: 'New',
|
||||
status: 'Waiting',
|
||||
},
|
||||
})
|
||||
|
||||
return { url: response.data.checkoutLink }
|
||||
}),
|
||||
|
||||
payMembershipWithFiat: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
projectName: z.string().min(1),
|
||||
projectSlug: z.string().min(1),
|
||||
recurring: z.boolean(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const userId = ctx.session.user.sub
|
||||
|
||||
await authenticateKeycloakClient()
|
||||
const user = await keycloak.users.findOne({ id: userId })
|
||||
const email = user?.email!
|
||||
const name = user?.attributes?.name?.[0]!
|
||||
let stripeCustomerId = user?.attributes?.stripeCustomerId?.[0] || null
|
||||
|
||||
if (!stripeCustomerId) {
|
||||
const customer = await stripe.customers.create({ email, name })
|
||||
|
||||
stripeCustomerId = customer.id
|
||||
|
||||
await keycloak.users.update(
|
||||
{ id: userId },
|
||||
{ email: email, attributes: { stripeCustomerId } }
|
||||
)
|
||||
}
|
||||
|
||||
const metadata: DonationMetadata = {
|
||||
userId,
|
||||
donorName: name,
|
||||
donorEmail: email,
|
||||
projectSlug: input.projectSlug,
|
||||
projectName: input.projectName,
|
||||
membershipExpiresAt: dayjs().add(1, 'year').toISOString(),
|
||||
}
|
||||
|
||||
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 donation: ${input.projectName}`,
|
||||
},
|
||||
unit_amount: MEMBERSHIP_PRICE * 100,
|
||||
},
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
metadata,
|
||||
success_url: `${env.APP_URL}/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 donation: ${input.projectName}`,
|
||||
},
|
||||
recurring: { interval: 'year' },
|
||||
unit_amount: MEMBERSHIP_PRICE * 100,
|
||||
},
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
metadata,
|
||||
success_url: `${env.APP_URL}/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({
|
||||
@@ -190,10 +255,10 @@ export const donationRouter = router({
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const userId = ctx.session.user.sub
|
||||
|
||||
const userHasMembership = await prisma.cryptoDonation.findFirst({
|
||||
const userHasMembership = await prisma.donation.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
projectSlug: input.projectSlug,
|
||||
status: 'Settled',
|
||||
membershipExpiresAt: { gt: new Date() },
|
||||
},
|
||||
})
|
||||
@@ -229,17 +294,17 @@ export const donationRouter = router({
|
||||
}
|
||||
)
|
||||
|
||||
await prisma.cryptoDonation.create({
|
||||
await prisma.donation.create({
|
||||
data: {
|
||||
userId,
|
||||
invoiceId: response.data.id,
|
||||
btcPayinvoiceId: response.data.id,
|
||||
crypto: 'XMR',
|
||||
projectName: metadata.projectName,
|
||||
projectSlug: metadata.projectSlug,
|
||||
fund: 'Monero Fund',
|
||||
fiatAmount: 100,
|
||||
fiatAmount: MEMBERSHIP_PRICE,
|
||||
membershipExpiresAt: metadata.membershipExpiresAt,
|
||||
status: 'New',
|
||||
status: 'Waiting',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -248,128 +313,23 @@ export const donationRouter = router({
|
||||
|
||||
donationList: protectedProcedure.query(async ({ ctx }) => {
|
||||
await authenticateKeycloakClient()
|
||||
const user = await keycloak.users.findOne({ id: ctx.session.user.sub })
|
||||
let stripeCustomerId = user?.attributes?.stripeCustomerId?.[0]
|
||||
const userId = ctx.session.user.sub
|
||||
const user = await keycloak.users.findOne({ id: userId })
|
||||
|
||||
const donations: Donation[] = []
|
||||
|
||||
// TODO: Paginate?
|
||||
let stripePayments: Stripe.PaymentIntent[] = []
|
||||
|
||||
if (stripeCustomerId) {
|
||||
stripePayments = (
|
||||
await stripe.paymentIntents.list({
|
||||
customer: stripeCustomerId,
|
||||
})
|
||||
).data
|
||||
}
|
||||
|
||||
stripePayments.forEach((payment) => {
|
||||
// Filter out subscriptions as paymentIntents returns an empty metadata obj
|
||||
if (payment.invoice) return
|
||||
|
||||
// Exclude failed payments that are more than 30 days old
|
||||
if (
|
||||
payment.status !== 'succeeded' &&
|
||||
payment.created * 1000 < Date.now() - 1000 * 60 * 60 * 24 * 30
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
donations.push({
|
||||
projectName: payment.metadata.projectName,
|
||||
fundName: 'Monero Fund',
|
||||
type: 'one_time',
|
||||
method: 'fiat',
|
||||
invoiceId: payment.id,
|
||||
stripePaymentStatus: payment.status,
|
||||
stripeSubscriptionStatus: null,
|
||||
btcPayStatus: null,
|
||||
amount: Number((payment.amount / 100).toFixed(2)),
|
||||
subscriptionCancelAt: null,
|
||||
createdAt: new Date(payment.created * 1000),
|
||||
})
|
||||
// Get all user's donations that are not expired OR are expired AND are less than 1 month old
|
||||
const donations = await prisma.donation.findMany({
|
||||
where: {
|
||||
userId,
|
||||
OR: [
|
||||
{ status: { not: 'Expired' } },
|
||||
{
|
||||
status: 'Expired',
|
||||
createdAt: { gt: dayjs().subtract(1, 'month').toDate() },
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
const stripeSubscriptions = await stripe.subscriptions.list({
|
||||
customer: stripeCustomerId,
|
||||
})
|
||||
|
||||
stripeSubscriptions.data.forEach((subscription) => {
|
||||
// Exclude failed subscriptions that are more than 30 days old
|
||||
if (
|
||||
subscription.status !== 'incomplete_expired' &&
|
||||
subscription.created * 1000 < Date.now() - 1000 * 60 * 60 * 24 * 30
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
donations.push({
|
||||
projectName: subscription.metadata.projectName,
|
||||
fundName: 'Monero Fund',
|
||||
type: 'recurring',
|
||||
method: 'fiat',
|
||||
invoiceId: subscription.id,
|
||||
stripePaymentStatus: null,
|
||||
stripeSubscriptionStatus: subscription.status,
|
||||
btcPayStatus: null,
|
||||
amount: Number(
|
||||
(subscription.items.data[0].price.unit_amount! / 100).toFixed(2)
|
||||
),
|
||||
subscriptionCancelAt: subscription.cancel_at
|
||||
? new Date(subscription.cancel_at * 1000)
|
||||
: null,
|
||||
createdAt: new Date(subscription.created * 1000),
|
||||
})
|
||||
})
|
||||
|
||||
const cryptoDonations = await prisma.cryptoDonation.findMany({
|
||||
where: { userId: ctx.session.user.sub },
|
||||
})
|
||||
|
||||
cryptoDonations.forEach((donation) => {
|
||||
// Exclude failed subscriptions that are more than 30 days old
|
||||
if (
|
||||
donation.status !== 'Expired' &&
|
||||
donation.createdAt.getTime() < Date.now() - 1000 * 60 * 60 * 24 * 30
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
donations.push({
|
||||
projectName: donation.projectName,
|
||||
fundName: donation.fund,
|
||||
type: 'one_time',
|
||||
method: 'crypto',
|
||||
invoiceId: donation.invoiceId,
|
||||
stripePaymentStatus: null,
|
||||
stripeSubscriptionStatus: null,
|
||||
btcPayStatus: donation.status,
|
||||
amount: donation.fiatAmount,
|
||||
subscriptionCancelAt: null,
|
||||
createdAt: donation.createdAt,
|
||||
})
|
||||
})
|
||||
|
||||
const donationsSorted = donations.sort(
|
||||
(a, b) =>
|
||||
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
)
|
||||
|
||||
let billingPortalUrl: string | null = null
|
||||
|
||||
if (stripeCustomerId) {
|
||||
const billingPortalSession = await stripe.billingPortal.sessions.create({
|
||||
customer: stripeCustomerId,
|
||||
return_url: `${env.APP_URL}/account/my-donations`,
|
||||
})
|
||||
|
||||
billingPortalUrl = billingPortalSession.url
|
||||
}
|
||||
|
||||
return {
|
||||
donations: donationsSorted,
|
||||
billingPortalUrl,
|
||||
}
|
||||
return donations
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import Stripe from 'stripe'
|
||||
|
||||
export type DonationMetadata = {
|
||||
userId: string | null
|
||||
donorEmail: string | null
|
||||
@@ -8,17 +6,3 @@ export type DonationMetadata = {
|
||||
projectName: string
|
||||
membershipExpiresAt: string | null
|
||||
}
|
||||
|
||||
export type Donation = {
|
||||
projectName: string
|
||||
fundName: string
|
||||
invoiceId: string
|
||||
type: 'one_time' | 'recurring'
|
||||
method: 'fiat' | 'crypto'
|
||||
stripePaymentStatus: Stripe.PaymentIntent.Status | null
|
||||
stripeSubscriptionStatus: Stripe.Subscription.Status | null
|
||||
btcPayStatus: 'Expired' | 'Invalid' | 'New' | 'Processing' | 'Settled' | null
|
||||
amount: number
|
||||
subscriptionCancelAt: Date | null
|
||||
createdAt: Date
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user