mirror of
https://github.com/MAGICGrants/campaign-site.git
synced 2026-01-09 12:27:59 -05:00
feat: db schema changes and webhook fixes
This commit is contained in:
@@ -13,14 +13,7 @@ import { Button } from './ui/button'
|
||||
import { RadioGroup, RadioGroupItem } from './ui/radio-group'
|
||||
import { Label } from './ui/label'
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from './ui/form'
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from './ui/form'
|
||||
import { Input } from './ui/input'
|
||||
import { DollarSign } from 'lucide-react'
|
||||
import { ProjectItem } from '../utils/types'
|
||||
@@ -39,25 +32,20 @@ const DonationFormModal: React.FC<Props> = ({ project }) => {
|
||||
.object({
|
||||
name: z.string().optional(),
|
||||
email: z.string().email().optional(),
|
||||
amount: z.coerce.number().min(1).max(MAX_AMOUNT),
|
||||
amount: z.coerce
|
||||
.number()
|
||||
.min(1)
|
||||
.max(MAX_AMOUNT / 100),
|
||||
taxDeductible: z.enum(['yes', 'no']),
|
||||
})
|
||||
.refine(
|
||||
(data) =>
|
||||
!isAuthed && data.taxDeductible === 'yes' ? !!data.name : true,
|
||||
{
|
||||
message: 'Name is required when the donation is tax deductible.',
|
||||
path: ['name'],
|
||||
}
|
||||
)
|
||||
.refine(
|
||||
(data) =>
|
||||
!isAuthed && data.taxDeductible === 'yes' ? !!data.email : true,
|
||||
{
|
||||
message: 'Email is required when the donation is tax deductible.',
|
||||
path: ['email'],
|
||||
}
|
||||
)
|
||||
.refine((data) => (!isAuthed && data.taxDeductible === 'yes' ? !!data.name : true), {
|
||||
message: 'Name is required when the donation is tax deductible.',
|
||||
path: ['name'],
|
||||
})
|
||||
.refine((data) => (!isAuthed && data.taxDeductible === 'yes' ? !!data.email : true), {
|
||||
message: 'Email is required when the donation is tax deductible.',
|
||||
path: ['email'],
|
||||
})
|
||||
|
||||
type FormInputs = z.infer<typeof schema>
|
||||
|
||||
@@ -159,9 +147,7 @@ const DonationFormModal: React.FC<Props> = ({ project }) => {
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Name {taxDeductible === 'no' && '(optional)'}
|
||||
</FormLabel>
|
||||
<FormLabel>Name {taxDeductible === 'no' && '(optional)'}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="John Doe" {...field} />
|
||||
</FormControl>
|
||||
@@ -175,9 +161,7 @@ const DonationFormModal: React.FC<Props> = ({ project }) => {
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Email {taxDeductible === 'no' && '(optional)'}
|
||||
</FormLabel>
|
||||
<FormLabel>Email {taxDeductible === 'no' && '(optional)'}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="johndoe@example.com" {...field} />
|
||||
</FormControl>
|
||||
@@ -231,9 +215,7 @@ const DonationFormModal: React.FC<Props> = ({ project }) => {
|
||||
name="taxDeductible"
|
||||
render={({ field }) => (
|
||||
<FormItem className="space-y-3">
|
||||
<FormLabel>
|
||||
Do you want this donation to be tax deductible? (US only)
|
||||
</FormLabel>
|
||||
<FormLabel>Do you want this donation to be tax deductible? (US only)</FormLabel>
|
||||
<FormControl>
|
||||
<RadioGroup
|
||||
onValueChange={field.onChange}
|
||||
@@ -271,7 +253,7 @@ const DonationFormModal: React.FC<Props> = ({ project }) => {
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faMonero} className="h-5 w-5" />
|
||||
)}
|
||||
Donate with Monero
|
||||
Donate with Crypto
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
@@ -285,7 +267,7 @@ const DonationFormModal: React.FC<Props> = ({ project }) => {
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faCreditCard} className="h-5 w-5" />
|
||||
)}
|
||||
Donate with fiat
|
||||
Donate with Fiat
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -23,7 +23,7 @@ type Props = {
|
||||
project: ProjectItem | undefined
|
||||
}
|
||||
|
||||
const MembershipModal: React.FC<Props> = ({ project }) => {
|
||||
const MembershipFormModal: React.FC<Props> = ({ project }) => {
|
||||
const session = useSession()
|
||||
const isAuthed = session.status === 'authenticated'
|
||||
|
||||
@@ -31,7 +31,10 @@ const MembershipModal: React.FC<Props> = ({ project }) => {
|
||||
.object({
|
||||
name: z.string().optional(),
|
||||
email: z.string().email().optional(),
|
||||
amount: z.coerce.number().min(1).max(MAX_AMOUNT),
|
||||
amount: z.coerce
|
||||
.number()
|
||||
.min(1)
|
||||
.max(MAX_AMOUNT / 100),
|
||||
taxDeductible: z.enum(['yes', 'no']),
|
||||
recurring: z.enum(['yes', 'no']),
|
||||
})
|
||||
@@ -245,7 +248,7 @@ const MembershipModal: React.FC<Props> = ({ project }) => {
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faMonero} className="h-5 w-5" />
|
||||
)}
|
||||
Pay with Monero
|
||||
Pay with Crypto
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
@@ -259,7 +262,7 @@ const MembershipModal: React.FC<Props> = ({ project }) => {
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faCreditCard} className="h-5 w-5" />
|
||||
)}
|
||||
Pay with fiat
|
||||
Pay with Fiat
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -280,4 +283,4 @@ const MembershipModal: React.FC<Props> = ({ project }) => {
|
||||
)
|
||||
}
|
||||
|
||||
export default MembershipModal
|
||||
export default MembershipFormModal
|
||||
@@ -1,7 +1,7 @@
|
||||
export const CURRENCY = 'usd'
|
||||
// Set your amount limits: Use float for decimal currencies and
|
||||
// Integer for zero-decimal currencies: https://stripe.com/docs/currencies#zero-decimal.
|
||||
export const MIN_AMOUNT = 1.0
|
||||
export const MAX_AMOUNT = 5000.0
|
||||
export const AMOUNT_STEP = 5.0
|
||||
export const MEMBERSHIP_PRICE = 100
|
||||
export const MIN_AMOUNT = 100
|
||||
export const MAX_AMOUNT = 500000
|
||||
export const AMOUNT_STEP = 500
|
||||
export const MEMBERSHIP_PRICE = 10000
|
||||
|
||||
@@ -38,22 +38,17 @@ function MyDonations() {
|
||||
<TableHead>Project</TableHead>
|
||||
<TableHead>Fund</TableHead>
|
||||
<TableHead>Method</TableHead>
|
||||
<TableHead>Invoice ID</TableHead>
|
||||
<TableHead>Amount</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Date</TableHead>
|
||||
<TableHead></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{donationListQuery.data?.map((donation) => (
|
||||
<TableRow key={donation.createdAt.toISOString()}>
|
||||
<TableCell className="font-medium">{donation.projectName}</TableCell>
|
||||
<TableCell>{donation.projectName}</TableCell>
|
||||
<TableCell>{donation.fund}</TableCell>
|
||||
<TableCell>{donation.stripeInvoiceId ? 'Fiat' : 'Monero'}</TableCell>
|
||||
<TableCell>{donation.stripeInvoiceId}</TableCell>
|
||||
<TableCell>${donation.fiatAmount}</TableCell>
|
||||
<TableCell>{donation.status}</TableCell>
|
||||
<TableCell>{donation.btcPayInvoiceId ? 'Crypto' : 'Fiat'}</TableCell>
|
||||
<TableCell>${donation.fiatAmount / 100}</TableCell>
|
||||
<TableCell>{dayjs(donation.createdAt).format('lll')}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
|
||||
@@ -32,7 +32,7 @@ function MyMemberships() {
|
||||
href={membershipListQuery.data?.billingPortalUrl}
|
||||
aria-label="Manage Fiat Subscriptions"
|
||||
>
|
||||
Manage Fiat Subscriptions
|
||||
Manage Recurring Memberships
|
||||
</CustomLink>
|
||||
)}
|
||||
</div>
|
||||
@@ -43,8 +43,7 @@ function MyMemberships() {
|
||||
<TableHead>Project</TableHead>
|
||||
<TableHead>Fund</TableHead>
|
||||
<TableHead>Method</TableHead>
|
||||
<TableHead>Invoice ID</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Recurring</TableHead>
|
||||
<TableHead>Date</TableHead>
|
||||
<TableHead>Period End</TableHead>
|
||||
</TableRow>
|
||||
@@ -52,11 +51,10 @@ function MyMemberships() {
|
||||
<TableBody>
|
||||
{membershipListQuery.data?.memberships.map((membership) => (
|
||||
<TableRow key={membership.createdAt.toISOString()}>
|
||||
<TableCell className="font-medium">{membership.projectName}</TableCell>
|
||||
<TableCell>{membership.projectName}</TableCell>
|
||||
<TableCell>{membership.fund}</TableCell>
|
||||
<TableCell>{membership.stripeInvoiceId ? 'Fiat' : 'Monero'}</TableCell>
|
||||
<TableCell>{membership.stripeInvoiceId || membership.btcPayInvoiceId}</TableCell>
|
||||
<TableCell>{membership.status}</TableCell>
|
||||
<TableCell>{membership.btcPayInvoiceId ? 'Crypto' : 'Fiat'}</TableCell>
|
||||
<TableCell>{membership.stripeSubscriptionId ? 'Yes' : 'No'}</TableCell>
|
||||
<TableCell>{dayjs(membership.createdAt).format('lll')}</TableCell>
|
||||
<TableCell>{dayjs(membership.membershipExpiresAt).format('lll')}</TableCell>
|
||||
</TableRow>
|
||||
|
||||
@@ -5,6 +5,7 @@ import getRawBody from 'raw-body'
|
||||
import { env } from '../../../env.mjs'
|
||||
import { btcpayApi, prisma } from '../../../server/services'
|
||||
import { DonationMetadata } from '../../../server/types'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
type Body = {
|
||||
deliveryId: string
|
||||
@@ -15,19 +16,22 @@ type Body = {
|
||||
timestamp: number
|
||||
storeId: string
|
||||
invoiceId: string
|
||||
metadata: Record<string, any> | null
|
||||
metadata: DonationMetadata
|
||||
}
|
||||
|
||||
type PaymentMethodsResponse = {
|
||||
rate: string
|
||||
amount: string
|
||||
cryptoCode: string
|
||||
}[]
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: false,
|
||||
},
|
||||
}
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse
|
||||
) {
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method !== 'POST') {
|
||||
res.setHeader('Allow', ['POST'])
|
||||
res.status(405).end(`Method ${req.method} Not Allowed`)
|
||||
@@ -56,23 +60,26 @@ export default async function handler(
|
||||
}
|
||||
|
||||
if (body.type === 'InvoiceSettled') {
|
||||
await prisma.donation.updateMany({
|
||||
where: { btcPayInvoiceId: body.invoiceId },
|
||||
data: { status: 'Complete' },
|
||||
})
|
||||
}
|
||||
const { data: paymentMethods } = await btcpayApi.get<PaymentMethodsResponse>(
|
||||
`stores/${env.BTCPAY_STORE_ID}/invoices/${body.invoiceId}/payment-methods`
|
||||
)
|
||||
|
||||
if (body.type === 'InvoiceExpired') {
|
||||
await prisma.donation.updateMany({
|
||||
where: { btcPayInvoiceId: body.invoiceId },
|
||||
data: { status: 'Expired' },
|
||||
})
|
||||
}
|
||||
const fiatAmount = Math.round(
|
||||
Number(paymentMethods[0].amount) * Number(paymentMethods[0].rate) * 100
|
||||
)
|
||||
|
||||
if (body.type === 'InvoiceInvalid') {
|
||||
await prisma.donation.updateMany({
|
||||
where: { btcPayInvoiceId: body.invoiceId },
|
||||
data: { status: 'Invalid' },
|
||||
await prisma.donation.create({
|
||||
data: {
|
||||
userId: body.metadata.userId,
|
||||
btcPayInvoiceId: body.invoiceId,
|
||||
projectName: body.metadata.projectName,
|
||||
projectSlug: body.metadata.projectSlug,
|
||||
fund: 'Monero Fund',
|
||||
cryptoCode: paymentMethods[0].cryptoCode,
|
||||
fiatAmount,
|
||||
membershipExpiresAt:
|
||||
body.metadata.isMembership === 'true' ? dayjs().add(1, 'year').toDate() : null,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { NextApiRequest, NextApiResponse } from 'next'
|
||||
import { env } from '../../../env.mjs'
|
||||
import { prisma, stripe } from '../../../server/services'
|
||||
import { DonationMetadata } from '../../../server/types'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
@@ -31,64 +32,54 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
|
||||
console.log(event.type)
|
||||
|
||||
// Marks donation as complete when payment intent is succeeded
|
||||
// Store donation data when payment intent is valid
|
||||
// Subscriptions are handled on the invoice.paid event instead
|
||||
if (event.type === 'payment_intent.succeeded') {
|
||||
const paymentIntent = event.data.object
|
||||
const metadata = paymentIntent.metadata as DonationMetadata
|
||||
|
||||
await prisma.donation.updateMany({
|
||||
where: { stripeInvoiceId: paymentIntent.id },
|
||||
data: { status: 'Complete' },
|
||||
})
|
||||
// Skip this event if intent is still not fully paid
|
||||
if (paymentIntent.amount_received !== paymentIntent.amount) return
|
||||
|
||||
// Payment intents for subscriptions will not have metadata
|
||||
if (metadata.isSubscription === 'false')
|
||||
await prisma.donation.create({
|
||||
data: {
|
||||
userId: metadata.userId,
|
||||
stripePaymentIntentId: paymentIntent.id,
|
||||
projectName: metadata.projectName,
|
||||
projectSlug: metadata.projectSlug,
|
||||
fund: 'Monero Fund',
|
||||
fiatAmount: paymentIntent.amount_received,
|
||||
membershipExpiresAt:
|
||||
metadata.isMembership === 'true' ? dayjs().add(1, 'year').toDate() : null,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Marks donation as invalid when payment intent is canceled
|
||||
if (event.type === 'payment_intent.canceled') {
|
||||
const paymentIntent = event.data.object
|
||||
// Store subscription data when subscription invoice is paid
|
||||
if (event.type === 'invoice.paid') {
|
||||
const invoice = event.data.object
|
||||
|
||||
await prisma.donation.updateMany({
|
||||
where: { stripeInvoiceId: paymentIntent.id },
|
||||
data: { status: 'Invalid' },
|
||||
})
|
||||
}
|
||||
if (invoice.subscription) {
|
||||
const metadata = event.data.object.subscription_details?.metadata as DonationMetadata
|
||||
const invoiceLine = invoice.lines.data.find((line) => line.invoice === invoice.id)
|
||||
|
||||
// Marks donation as invalid when payment intent is failed
|
||||
if (event.type === 'payment_intent.payment_failed') {
|
||||
const paymentIntent = event.data.object
|
||||
if (!invoiceLine) return
|
||||
|
||||
await prisma.donation.updateMany({
|
||||
where: { stripeInvoiceId: paymentIntent.id },
|
||||
data: { status: 'Invalid' },
|
||||
})
|
||||
}
|
||||
|
||||
// Create subscription when subscription is created
|
||||
if (event.type === 'customer.subscription.created') {
|
||||
const subscription = event.data.object
|
||||
const metadata = subscription.metadata as DonationMetadata
|
||||
|
||||
await prisma.donation.create({
|
||||
data: {
|
||||
userId: metadata.userId as string,
|
||||
stripeInvoiceId: subscription.id,
|
||||
projectName: metadata.projectName,
|
||||
projectSlug: metadata.projectSlug,
|
||||
fund: 'Monero Fund',
|
||||
currency: 'USD',
|
||||
fiatAmount: 100,
|
||||
status: 'Complete',
|
||||
membershipExpiresAt: new Date(subscription.current_period_end * 1000),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Update subscription expiration date when subscription is updated
|
||||
if (event.type === 'customer.subscription.updated') {
|
||||
const subscription = event.data.object
|
||||
|
||||
await prisma.donation.updateMany({
|
||||
where: { stripeInvoiceId: subscription.id },
|
||||
data: { membershipExpiresAt: new Date(subscription.current_period_end * 1000) },
|
||||
})
|
||||
await prisma.donation.create({
|
||||
data: {
|
||||
userId: metadata.userId as string,
|
||||
stripeInvoiceId: invoice.id,
|
||||
stripeSubscriptionId: invoice.subscription.toString(),
|
||||
projectName: metadata.projectName,
|
||||
projectSlug: metadata.projectSlug,
|
||||
fund: 'Monero Fund',
|
||||
fiatAmount: invoice.total,
|
||||
membershipExpiresAt: new Date(invoiceLine.period.end * 1000),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Return a 200 response to acknowledge receipt of the event
|
||||
|
||||
@@ -10,7 +10,7 @@ import CustomLink from '../components/CustomLink'
|
||||
import { Button } from '../components/ui/button'
|
||||
import { Dialog, DialogContent } from '../components/ui/dialog'
|
||||
import DonationFormModal from '../components/DonationFormModal'
|
||||
import MembershipModal from '../components/MembershipModal'
|
||||
import MembershipFormModal from '../components/MembershipFormModal'
|
||||
|
||||
// These shouldn't be swept up in the regular list so we hardcode them
|
||||
const generalFund: ProjectItem = {
|
||||
@@ -44,9 +44,8 @@ const Home: NextPage<{ projects: any }> = ({ projects }) => {
|
||||
Support <Typing />
|
||||
</h1>
|
||||
<p className="text-xl leading-7 text-gray-500 dark:text-gray-400">
|
||||
Help us to provide sustainable funding for free and open-source
|
||||
contributors working on freedom tech and projects that help Monero
|
||||
flourish.
|
||||
Help us to provide sustainable funding for free and open-source contributors working on
|
||||
freedom tech and projects that help Monero flourish.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap space-x-4 py-4">
|
||||
@@ -109,7 +108,7 @@ const Home: NextPage<{ projects: any }> = ({ projects }) => {
|
||||
|
||||
<Dialog open={memberModalOpen} onOpenChange={setMemberModalOpen}>
|
||||
<DialogContent>
|
||||
<MembershipModal project={generalFund} />
|
||||
<MembershipFormModal project={generalFund} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "DonationStatus" AS ENUM ('Waiting', 'Expired', 'Invalid', 'Complete');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Donation" (
|
||||
"id" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"btcPayInvoiceId" TEXT,
|
||||
"stripeInvoiceId" TEXT,
|
||||
"projectSlug" TEXT NOT NULL,
|
||||
"projectName" TEXT NOT NULL,
|
||||
"fund" TEXT NOT NULL,
|
||||
"currency" TEXT NOT NULL,
|
||||
"fiatAmount" DOUBLE PRECISION NOT NULL,
|
||||
"membershipExpiresAt" TIMESTAMP(3),
|
||||
"status" "DonationStatus" NOT NULL,
|
||||
|
||||
CONSTRAINT "Donation_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Donation_btcPayInvoiceId_idx" ON "Donation"("btcPayInvoiceId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Donation_stripeInvoiceId_idx" ON "Donation"("stripeInvoiceId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Donation_userId_idx" ON "Donation"("userId");
|
||||
34
prisma/migrations/20240812172948_init/migration.sql
Normal file
34
prisma/migrations/20240812172948_init/migration.sql
Normal file
@@ -0,0 +1,34 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Donation" (
|
||||
"id" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"userId" TEXT,
|
||||
"btcPayInvoiceId" TEXT,
|
||||
"stripePaymentIntentId" TEXT,
|
||||
"stripeInvoiceId" TEXT,
|
||||
"stripeSubscriptionId" TEXT,
|
||||
"projectSlug" TEXT NOT NULL,
|
||||
"projectName" TEXT NOT NULL,
|
||||
"fund" TEXT NOT NULL,
|
||||
"fiatAmount" INTEGER NOT NULL,
|
||||
"cryptoCode" TEXT,
|
||||
"membershipExpiresAt" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "Donation_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Donation_btcPayInvoiceId_key" ON "Donation"("btcPayInvoiceId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Donation_stripeInvoiceId_key" ON "Donation"("stripeInvoiceId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Donation_stripePaymentIntentId_idx" ON "Donation"("stripePaymentIntentId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Donation_stripeSubscriptionId_idx" ON "Donation"("stripeSubscriptionId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Donation_userId_idx" ON "Donation"("userId");
|
||||
@@ -18,25 +18,19 @@ model Donation {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
userId String
|
||||
btcPayInvoiceId String?
|
||||
stripeInvoiceId String?
|
||||
projectSlug String
|
||||
projectName String
|
||||
fund String
|
||||
currency String
|
||||
fiatAmount Float
|
||||
membershipExpiresAt DateTime?
|
||||
status DonationStatus
|
||||
userId String?
|
||||
btcPayInvoiceId String? @unique
|
||||
stripePaymentIntentId String? // For donations and non-recurring memberships
|
||||
stripeInvoiceId String? @unique // For recurring memberships
|
||||
stripeSubscriptionId String? // For recurring memberships
|
||||
projectSlug String
|
||||
projectName String
|
||||
fund String
|
||||
fiatAmount Int
|
||||
cryptoCode String?
|
||||
membershipExpiresAt DateTime?
|
||||
|
||||
@@index([btcPayInvoiceId])
|
||||
@@index([stripeInvoiceId])
|
||||
@@index([stripePaymentIntentId])
|
||||
@@index([stripeSubscriptionId])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
enum DonationStatus {
|
||||
Waiting
|
||||
Expired
|
||||
Invalid
|
||||
Complete
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { env } from '../../env.mjs'
|
||||
import { btcpayApi, keycloak, prisma, stripe } from '../services'
|
||||
import { authenticateKeycloakClient } from '../utils/keycloak'
|
||||
import { DonationMetadata } from '../types'
|
||||
import { Donation } from '@prisma/client'
|
||||
|
||||
export const donationRouter = router({
|
||||
donateWithFiat: publicProcedure
|
||||
@@ -18,7 +19,10 @@ export const donationRouter = router({
|
||||
email: z.string().email().nullable(),
|
||||
projectName: z.string().min(1),
|
||||
projectSlug: z.string().min(1),
|
||||
amount: z.number().min(MIN_AMOUNT).max(MAX_AMOUNT),
|
||||
amount: z
|
||||
.number()
|
||||
.min(MIN_AMOUNT / 100)
|
||||
.max(MAX_AMOUNT / 100),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
@@ -55,7 +59,8 @@ export const donationRouter = router({
|
||||
donorName: name,
|
||||
projectSlug: input.projectSlug,
|
||||
projectName: input.projectName,
|
||||
membershipExpiresAt: null,
|
||||
isMembership: 'false',
|
||||
isSubscription: 'false',
|
||||
}
|
||||
|
||||
const params: Stripe.Checkout.SessionCreateParams = {
|
||||
@@ -94,7 +99,10 @@ export const donationRouter = router({
|
||||
email: z.string().trim().email().nullable(),
|
||||
projectName: z.string().min(1),
|
||||
projectSlug: z.string().min(1),
|
||||
amount: z.number().min(MIN_AMOUNT).max(MAX_AMOUNT),
|
||||
amount: z
|
||||
.number()
|
||||
.min(MIN_AMOUNT / 100)
|
||||
.max(MAX_AMOUNT / 100),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
@@ -115,7 +123,8 @@ export const donationRouter = router({
|
||||
donorEmail: email,
|
||||
projectSlug: input.projectSlug,
|
||||
projectName: input.projectName,
|
||||
membershipExpiresAt: null,
|
||||
isMembership: 'false',
|
||||
isSubscription: 'false',
|
||||
}
|
||||
|
||||
const response = await btcpayApi.post(`/stores/${env.BTCPAY_STORE_ID}/invoices`, {
|
||||
@@ -125,19 +134,6 @@ export const donationRouter = router({
|
||||
checkout: { redirectURL: `${env.APP_URL}/thankyou` },
|
||||
})
|
||||
|
||||
await prisma.donation.create({
|
||||
data: {
|
||||
userId: metadata.userId as string,
|
||||
btcPayInvoiceId: response.data.id,
|
||||
currency: 'USD',
|
||||
projectName: metadata.projectName,
|
||||
projectSlug: metadata.projectSlug,
|
||||
fund: 'Monero Fund',
|
||||
fiatAmount: input.amount,
|
||||
status: 'Waiting',
|
||||
},
|
||||
})
|
||||
|
||||
return { url: response.data.checkoutLink }
|
||||
}),
|
||||
|
||||
@@ -152,6 +148,21 @@ export const donationRouter = router({
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const userId = ctx.session.user.sub
|
||||
|
||||
const userHasMembership = await prisma.donation.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
projectSlug: input.projectSlug,
|
||||
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!
|
||||
@@ -175,7 +186,8 @@ export const donationRouter = router({
|
||||
donorEmail: email,
|
||||
projectSlug: input.projectSlug,
|
||||
projectName: input.projectName,
|
||||
membershipExpiresAt: dayjs().add(1, 'year').toISOString(),
|
||||
isMembership: 'true',
|
||||
isSubscription: input.recurring ? 'true' : 'false',
|
||||
}
|
||||
|
||||
const purchaseParams: Stripe.Checkout.SessionCreateParams = {
|
||||
@@ -213,7 +225,7 @@ export const donationRouter = router({
|
||||
name: `MAGIC Grants Annual Membership: ${input.projectName}`,
|
||||
},
|
||||
recurring: { interval: 'year' },
|
||||
unit_amount: MEMBERSHIP_PRICE * 100,
|
||||
unit_amount: MEMBERSHIP_PRICE,
|
||||
},
|
||||
quantity: 1,
|
||||
},
|
||||
@@ -267,34 +279,17 @@ export const donationRouter = router({
|
||||
donorEmail: email,
|
||||
projectSlug: input.projectSlug,
|
||||
projectName: input.projectName,
|
||||
membershipExpiresAt: dayjs().add(1, 'year').toISOString(),
|
||||
isMembership: 'true',
|
||||
isSubscription: 'false',
|
||||
}
|
||||
|
||||
console.log(1)
|
||||
|
||||
const response = await btcpayApi.post(`/stores/${env.BTCPAY_STORE_ID}/invoices`, {
|
||||
amount: MEMBERSHIP_PRICE,
|
||||
amount: MEMBERSHIP_PRICE / 100,
|
||||
currency: CURRENCY,
|
||||
metadata,
|
||||
checkout: { redirectURL: `${env.APP_URL}/thankyou` },
|
||||
})
|
||||
|
||||
console.log(2)
|
||||
|
||||
await prisma.donation.create({
|
||||
data: {
|
||||
userId,
|
||||
btcPayInvoiceId: response.data.id,
|
||||
currency: 'USD',
|
||||
projectName: metadata.projectName,
|
||||
projectSlug: metadata.projectSlug,
|
||||
fund: 'Monero Fund',
|
||||
fiatAmount: MEMBERSHIP_PRICE,
|
||||
membershipExpiresAt: metadata.membershipExpiresAt,
|
||||
status: 'Waiting',
|
||||
},
|
||||
})
|
||||
|
||||
return { url: response.data.checkoutLink }
|
||||
}),
|
||||
|
||||
@@ -305,14 +300,9 @@ export const donationRouter = router({
|
||||
const donations = await prisma.donation.findMany({
|
||||
where: {
|
||||
userId,
|
||||
OR: [
|
||||
{ status: { not: 'Expired' } },
|
||||
{
|
||||
status: 'Expired',
|
||||
createdAt: { gt: dayjs().subtract(1, 'month').toDate() },
|
||||
},
|
||||
],
|
||||
stripeSubscriptionId: null,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
|
||||
return donations
|
||||
@@ -339,8 +329,26 @@ export const donationRouter = router({
|
||||
userId,
|
||||
membershipExpiresAt: { not: null },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
|
||||
return { memberships, billingPortalUrl }
|
||||
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 }
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -4,5 +4,6 @@ export type DonationMetadata = {
|
||||
donorName: string | null
|
||||
projectSlug: string
|
||||
projectName: string
|
||||
membershipExpiresAt: string | null
|
||||
isMembership: 'true' | 'false'
|
||||
isSubscription: 'true' | 'false'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user