feat: db schema changes and webhook fixes

This commit is contained in:
Artur N
2024-08-12 14:34:18 -03:00
parent 7b63490781
commit b533f48088
13 changed files with 214 additions and 232 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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");

View File

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

View File

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

View File

@@ -4,5 +4,6 @@ export type DonationMetadata = {
donorName: string | null
projectSlug: string
projectName: string
membershipExpiresAt: string | null
isMembership: 'true' | 'false'
isSubscription: 'true' | 'false'
}