-
Notifications
You must be signed in to change notification settings - Fork 1.3k
Pro seat management and organization settings restructure #1640
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 30 commits
51e7277
7673b00
179ead4
6f5ad5c
2d1354e
272923c
7e7520f
a6c7cb4
9faadfd
a6143bc
da98ca6
3e55f5f
8611afd
20b5e46
c081e04
5519f91
bf8db1c
f858a56
b3a63b8
c6fd14a
2e5442c
1fa3fd8
f3ab753
c82516f
024bf20
2af60c4
65547e6
968703b
89b0d4f
aa9d327
7caa889
1a1b41f
516031a
c1c743c
15d94d3
df8a6b0
3eeb701
4610f9c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,74 @@ | ||
| "use server"; | ||
|
|
||
| import { db } from "@cap/database"; | ||
| import { getCurrentUser } from "@cap/database/auth/session"; | ||
| import { organizations, users } from "@cap/database/schema"; | ||
| import { stripe } from "@cap/utils"; | ||
| import type { Organisation } from "@cap/web-domain"; | ||
| import { eq } from "drizzle-orm"; | ||
|
|
||
| export type SubscriptionDetails = { | ||
| planName: string; | ||
| status: string; | ||
| billingInterval: "month" | "year"; | ||
| pricePerSeat: number; | ||
| currentQuantity: number; | ||
| currentPeriodEnd: number; | ||
| currency: string; | ||
| }; | ||
|
|
||
| export async function getSubscriptionDetails( | ||
| organizationId: Organisation.OrganisationId, | ||
| ): Promise<SubscriptionDetails | null> { | ||
| const user = await getCurrentUser(); | ||
| if (!user) throw new Error("Unauthorized"); | ||
|
|
||
| const [organization] = await db() | ||
| .select() | ||
| .from(organizations) | ||
| .where(eq(organizations.id, organizationId)) | ||
| .limit(1); | ||
|
|
||
| if (!organization) throw new Error("Organization not found"); | ||
| if (organization.ownerId !== user.id) | ||
| throw new Error("Only the owner can view subscription details"); | ||
|
|
||
| const [owner] = await db() | ||
| .select({ | ||
| stripeSubscriptionId: users.stripeSubscriptionId, | ||
| }) | ||
| .from(users) | ||
| .where(eq(users.id, user.id)) | ||
| .limit(1); | ||
|
|
||
| if (!owner?.stripeSubscriptionId) { | ||
| return null; | ||
| } | ||
|
|
||
| const subscription = await stripe().subscriptions.retrieve( | ||
| owner.stripeSubscriptionId, | ||
| ); | ||
|
|
||
| if (subscription.status !== "active" && subscription.status !== "trialing") { | ||
| return null; | ||
| } | ||
|
|
||
| const item = subscription.items.data[0]; | ||
| if (!item) return null; | ||
|
|
||
| const price = item.price; | ||
| const unitAmount = price.unit_amount ?? 0; | ||
| const interval = price.recurring?.interval === "year" ? "year" : "month"; | ||
| const pricePerSeat = | ||
| interval === "year" ? unitAmount / 100 / 12 : unitAmount / 100; | ||
|
|
||
| return { | ||
| planName: "Cap Pro", | ||
| status: subscription.status, | ||
| billingInterval: interval, | ||
| pricePerSeat, | ||
| currentQuantity: item.quantity ?? 1, | ||
| currentPeriodEnd: subscription.current_period_end, | ||
| currency: price.currency, | ||
| }; | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -5,10 +5,15 @@ import { getCurrentUser } from "@cap/database/auth/session"; | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { sendEmail } from "@cap/database/emails/config"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { OrganizationInvite } from "@cap/database/emails/organization-invite"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { nanoId } from "@cap/database/helpers"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { organizationInvites, organizations } from "@cap/database/schema"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| organizationInvites, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| organizationMembers, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| organizations, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| users, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } from "@cap/database/schema"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { serverEnv } from "@cap/env"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import type { Organisation } from "@cap/web-domain"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { eq } from "drizzle-orm"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { and, eq, inArray } from "drizzle-orm"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { revalidatePath } from "next/cache"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export async function sendOrganizationInvites( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -21,48 +26,136 @@ export async function sendOrganizationInvites( | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| throw new Error("Unauthorized"); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const organization = await db() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const [organization] = await db() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .select() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .from(organizations) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .where(eq(organizations.id, organizationId)); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!organization || organization.length === 0) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!organization) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| throw new Error("Organization not found"); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (organization[0]?.ownerId !== user.id) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| throw new Error("Only the owner can send organization invites"); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (organization.ownerId !== user.id) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| throw new Error("Only the organization owner can send invites"); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const MAX_INVITES = 50; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (invitedEmails.length > MAX_INVITES) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| throw new Error(`Cannot send more than ${MAX_INVITES} invites at once`); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const validEmails = invitedEmails.filter((email) => | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| emailRegex.test(email.trim()), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const validEmails = Array.from( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| new Set( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| invitedEmails | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .map((email) => email.trim().toLowerCase()) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .filter((email) => emailRegex.test(email)), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (validEmails.length === 0) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return { success: true, failedEmails: [] as string[] }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const inviteRecords = await db().transaction(async (tx) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const [existingInvites, existingMembers] = await Promise.all([ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| tx | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .select({ invitedEmail: organizationInvites.invitedEmail }) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .from(organizationInvites) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .where( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| and( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| eq(organizationInvites.organizationId, organizationId), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| inArray(organizationInvites.invitedEmail, validEmails), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| tx | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .select({ email: users.email }) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .from(organizationMembers) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .innerJoin(users, eq(organizationMembers.userId, users.id)) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .where( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| and( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| eq(organizationMembers.organizationId, organizationId), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| inArray(users.email, validEmails), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ]); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const existingInviteEmails = new Set( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| existingInvites.map((i) => i.invitedEmail.toLowerCase()), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const existingMemberEmails = new Set( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| existingMembers.map((m) => m.email.toLowerCase()), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const emailsToInvite = validEmails.filter( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| (email) => | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| !existingInviteEmails.has(email) && !existingMemberEmails.has(email), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const records = emailsToInvite.map((email) => ({ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| id: nanoId(), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| email, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| })); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (records.length > 0) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| await tx.insert(organizationInvites).values( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| records.map((r) => ({ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| id: r.id, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| organizationId: organizationId, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| invitedEmail: r.email, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| invitedByUserId: user.id, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| role: "member" as const, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| })), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return records; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const emailResults = await Promise.allSettled( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| inviteRecords.map((record) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const inviteUrl = `${serverEnv().WEB_URL}/invite/${record.id}`; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return sendEmail({ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| email: record.email, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| subject: `Invitation to join ${organization.name} on Cap`, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| react: OrganizationInvite({ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| email: record.email, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| url: inviteUrl, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| organizationName: organization.name, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const failedInvites = inviteRecords.filter( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| (_, i) => emailResults[i]?.status === "rejected", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const failedEmails = failedInvites.map((r) => r.email); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| for (const email of validEmails) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const inviteId = nanoId(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| await db().insert(organizationInvites).values({ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| id: inviteId, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| organizationId: organizationId, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| invitedEmail: email.trim(), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| invitedByUserId: user.id, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| role: "member", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Send invitation email | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const inviteUrl = `${serverEnv().WEB_URL}/invite/${inviteId}`; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| await sendEmail({ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| email: email.trim(), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| subject: `Invitation to join ${organization[0].name} on Cap`, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| react: OrganizationInvite({ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| email: email.trim(), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| url: inviteUrl, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| organizationName: organization[0].name, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (failedInvites.length > 0) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| await db() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .delete(organizationInvites) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .where( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| inArray( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| organizationInvites.id, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| failedInvites.map((r) => r.id), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } catch (cleanupError) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| console.error( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "Failed to clean up invite records after email delivery failure:", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| failedInviteIds: failedInvites.map((r) => r.id), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| failedEmails, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| error: cleanupError, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+136
to
156
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Unhandled cleanup failure can orphan invite records When email delivery fails, the subsequent Wrap the cleanup in a try/catch to detect and log the failure:
Suggested change
Prompt To Fix With AIThis is a comment left during a code review.
Path: apps/web/actions/organization/send-invites.ts
Line: 136-145
Comment:
**Unhandled cleanup failure can orphan invite records**
When email delivery fails, the subsequent `db().delete()` cleanup (lines 136–145) runs outside the transaction with no error handling. If this delete fails due to a transient DB error, the undelivered invite records will persist indefinitely — creating a data integrity gap. A user who somehow obtains the invite ID (e.g., from a server log) could still accept the invite without having been notified.
Wrap the cleanup in a try/catch to detect and log the failure:
```suggestion
if (failedInvites.length > 0) {
try {
await db()
.delete(organizationInvites)
.where(
inArray(
organizationInvites.id,
failedInvites.map((r) => r.id),
),
);
} catch (cleanupError) {
console.error(
"CRITICAL: Failed to clean up undelivered invite records:",
failedInvites.map((r) => r.id),
cleanupError,
);
}
}
```
How can I resolve this? If you propose a fix, please make it concise. |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| revalidatePath("/dashboard/settings/organization"); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Since settings now live under
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return { success: true }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return { success: true, failedEmails }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
stripeSubscriptionStatusis selected but unused here; removing it avoids unused-data drift.