Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,17 @@ function MultiAccountForm({
resolver: zodResolver(saveMultiAccountPremiumBody),
defaultValues: {
emailAddresses: emailAddresses?.length
? [...emailAddresses, ...pendingInvites.map((email) => ({ email }))]
? (() => {
// Deduplicate to prevent showing the same email twice
const existingEmails = new Set(emailAddresses.map((e) => e.email));
const uniquePendingInvites = pendingInvites.filter(
(email) => !existingEmails.has(email),
);
return [
...emailAddresses,
...uniquePendingInvites.map((email) => ({ email })),
];
})()
Comment on lines +146 to +156
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

❓ Verification inconclusive

Don’t drop pendingInvites when there are 0 connected users; also dedup invites themselves (case-insensitive).
Current gating on emailAddresses?.length hides pending invites for brand‑new orgs, and duplicates inside pendingInvites can still surface. Normalize to lowercase for comparison, keep original casing for existing users, and only fallback to an empty row if the combined list is empty.

Apply:

-      emailAddresses: emailAddresses?.length
-        ? (() => {
-            // Deduplicate to prevent showing the same email twice
-            const existingEmails = new Set(emailAddresses.map((e) => e.email));
-            const uniquePendingInvites = pendingInvites.filter(
-              (email) => !existingEmails.has(email),
-            );
-            return [
-              ...emailAddresses,
-              ...uniquePendingInvites.map((email) => ({ email })),
-            ];
-          })()
-        : [{ email: "" }],
+      emailAddresses: (() => {
+        const existingLc = new Set(
+          (emailAddresses || [])
+            .map((e) => e.email?.toLowerCase())
+            .filter(Boolean) as string[],
+        );
+        const uniquePendingLc = Array.from(
+          new Set(
+            (pendingInvites || [])
+              .map((e) => e?.toLowerCase())
+              .filter(Boolean) as string[],
+          ),
+        ).filter((email) => !existingLc.has(email));
+        const combined = [
+          ...emailAddresses,
+          ...uniquePendingLc.map((email) => ({ email })),
+        ];
+        return combined.length ? combined : [{ email: "" }];
+      })(),

If invites are already normalized at write-time, confirm and we can simplify the lowercasing here.


Always merge pendingInvites & existing users unconditionally
Replace the emailAddresses?.length ? … : [{ email: "" }] branch with an unconditional merge that

  • normalizes only for comparison (case-insensitive dedupe of both existing emails and invites),
  • preserves original casing for both lists,
  • falls back to [{ email: "" }] only if the combined array is empty.
emailAddresses: (() => {
  const existingLc = new Set(
    (emailAddresses || []).map(e => e.email.toLowerCase())
  );
  const uniqueInvites = (pendingInvites || [])
    // remove invites already in existingEmails (case-insensitive)
    .filter(inv => !existingLc.has(inv.toLowerCase()))
    // dedupe invites themselves (case-insensitive), preserving first‐seen casing
    .filter((inv, i, arr) =>
      arr.findIndex(x => x.toLowerCase() === inv.toLowerCase()) === i
    );
  const combined = [
    ...(emailAddresses || []),
    ...uniqueInvites.map(email => ({ email })),
  ];
  return combined.length ? combined : [{ email: "" }];
})(),

Confirm whether pendingInvites values are already normalized upstream to simplify/remove the toLowerCase() calls.

🤖 Prompt for AI Agents
In apps/web/app/(app)/[emailAccountId]/settings/MultiAccountSection.tsx around
lines 146–156, replace the conditional branch that only merges pendingInvites
when emailAddresses exists with an unconditional merge: build a set of existing
emails lowercased for comparison, filter pendingInvites (or empty array) to
remove any invites that match existing emails case-insensitively, dedupe
pendingInvites themselves case-insensitively while preserving first-seen casing,
then concatenate the original emailAddresses (or empty array) with the unique
invites mapped to { email } and finally return that combined array or [{ email:
"" }] if it ends up empty; also confirm whether pendingInvites are already
normalized upstream so you can remove the toLowerCase() calls.

: [{ email: "" }],
},
});
Expand Down
19 changes: 15 additions & 4 deletions apps/web/ee/billing/stripe/sync-stripe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,8 @@ export async function syncStripeDataToDb({
pendingInvites: true,
users: {
select: {
_count: true,
email: true,
_count: { select: { emailAccounts: true } },
},
},
},
Expand All @@ -149,17 +150,27 @@ export async function syncStripeDataToDb({
async function syncSeats(
premium: Prisma.PremiumGetPayload<{
select: {
users: { select: { _count: { select: { emailAccounts: true } } } };
users: {
select: { email: true; _count: { select: { emailAccounts: true } } };
};
pendingInvites: true;
stripeSubscriptionItemId: true;
};
}>,
) {
try {
// total seats = premium users + pending invites
// Get all connected user emails
const connectedUserEmails = new Set(premium.users.map((u) => u.email));

// Filter out pending invites that are already connected users to avoid double counting
const uniquePendingInvites = (premium.pendingInvites || []).filter(
(email) => !connectedUserEmails.has(email),
);

// total seats = premium users + unique pending invites (excluding duplicates)
const totalSeats =
sumBy(premium.users, (u) => u._count.emailAccounts) +
(premium.pendingInvites?.length || 0);
uniquePendingInvites.length;

Comment on lines +162 to 174
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Still double-counts duplicate pending invites; compare and dedup case-insensitively to avoid overbilling.
Currently only excludes emails already connected; duplicates within pendingInvites are still counted. This can inflate totalSeats.

Apply:

-  // Get all connected user emails
-  const connectedUserEmails = new Set(premium.users.map((u) => u.email));
-
-  // Filter out pending invites that are already connected users to avoid double counting
-  const uniquePendingInvites = (premium.pendingInvites || []).filter(
-    (email) => !connectedUserEmails.has(email),
-  );
+  // Build lowercase sets for robust comparison
+  const connectedUserEmails = new Set(
+    premium.users
+      .map((u) => u.email?.toLowerCase())
+      .filter(Boolean) as string[],
+  );
+  // Unique pending invites (case-insensitive), excluding already-connected
+  const uniquePendingInvites = Array.from(
+    new Set(
+      (premium.pendingInvites || [])
+        .map((e) => e?.toLowerCase())
+        .filter(Boolean) as string[],
+    ),
+  ).filter((email) => !connectedUserEmails.has(email));

Optional: if emails are normalized to lowercase at creation time, confirm via a quick repo search and we can drop the lowercasing here for perf.


🏁 Script executed:

#!/bin/bash
# Display lines around the pendingInvites logic for context
sed -n '140,180p' apps/web/ee/billing/stripe/sync-stripe.ts

# Locate all references to the type or origin of `pendingInvites`
rg -n "pendingInvites" -g 'apps/web/**/*.ts'

Length of output: 2242


🏁 Script executed:

#!/bin/bash
# List all Prisma schema files to locate the Premium model definition
find . -type f -name '*.prisma'

Length of output: 65


🏁 Script executed:

#!/bin/bash
# Extract the Premium model definition from Prisma schema
sed -n '/model Premium {/,/}/p' apps/web/prisma/schema.prisma

# Inspect pendingInvites normalization logic in premium actions
sed -n '180,250p' apps/web/utils/actions/premium.ts

Length of output: 4740


🏁 Script executed:

#!/bin/bash
# Inspect how uniqueEmails is derived and normalized before setting pendingInvites
sed -n '1,200p' apps/web/utils/actions/premium.ts

Length of output: 6567


Deduplicate pendingInvites case-insensitively before counting
Normalize both connected user emails and pending invites to lowercase, use a Set to remove duplicates, then exclude any already-connected emails prior to computing totalSeats.

Apply in apps/web/ee/billing/stripe/sync-stripe.ts (around lines 162–174):

-  // Get all connected user emails
-  const connectedUserEmails = new Set(premium.users.map((u) => u.email));
-
-  // Filter out pending invites that are already connected users to avoid double counting
-  const uniquePendingInvites = (premium.pendingInvites || []).filter(
-    (email) => !connectedUserEmails.has(email),
-  );
+  // Build lowercase sets for robust comparison
+  const connectedUserEmails = new Set(
+    premium.users
+      .map((u) => u.email?.toLowerCase())
+      .filter(Boolean) as string[],
+  );
+  // Dedupe and exclude already-connected invites
+  const uniquePendingInvites = Array.from(
+    new Set(
+      (premium.pendingInvites || [])
+        .map((e) => e?.toLowerCase())
+        .filter(Boolean) as string[],
+    ),
+  ).filter((email) => !connectedUserEmails.has(email));
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Get all connected user emails
const connectedUserEmails = new Set(premium.users.map((u) => u.email));
// Filter out pending invites that are already connected users to avoid double counting
const uniquePendingInvites = (premium.pendingInvites || []).filter(
(email) => !connectedUserEmails.has(email),
);
// total seats = premium users + unique pending invites (excluding duplicates)
const totalSeats =
sumBy(premium.users, (u) => u._count.emailAccounts) +
(premium.pendingInvites?.length || 0);
uniquePendingInvites.length;
// Build lowercase sets for robust comparison
const connectedUserEmails = new Set(
premium.users
.map((u) => u.email?.toLowerCase())
.filter(Boolean) as string[],
);
// Dedupe and exclude already-connected invites
const uniquePendingInvites = Array.from(
new Set(
(premium.pendingInvites || [])
.map((e) => e?.toLowerCase())
.filter(Boolean) as string[],
),
).filter((email) => !connectedUserEmails.has(email));
// total seats = premium users + unique pending invites (excluding duplicates)
const totalSeats =
sumBy(premium.users, (u) => u._count.emailAccounts) +
uniquePendingInvites.length;
🤖 Prompt for AI Agents
In apps/web/ee/billing/stripe/sync-stripe.ts around lines 162 to 174,
pendingInvites are not deduplicated case-insensitively before counting and may
double-count invites differing only by case; normalize connected user emails and
pending invites to lowercase, build a Set of lowercased pendingInvites to remove
duplicates, remove any entries that exist in the lowercased connected user email
set, then use the size of that filtered Set as the uniquePendingInvites count
(guarding for null/undefined pendingInvites) and compute totalSeats as
sumBy(premium.users, u => u._count.emailAccounts) + uniquePendingInvitesCount.

await updateAccountSeatsForPremium(premium, totalSeats);
} catch (error) {
Expand Down
2 changes: 1 addition & 1 deletion version.txt
Original file line number Diff line number Diff line change
@@ -1 +1 @@
v2.8.10
v2.8.11
Loading