Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Conversation

@steven-tey
Copy link
Collaborator

@steven-tey steven-tey commented Sep 16, 2025

Summary by CodeRabbit

  • Bug Fixes

    • Improved duplicate-event handling for Stripe checkout and invoice webhooks to further reduce double-processed sales, duplicate notifications, and inconsistent billing.
  • Chores

    • Enhanced idempotency checks to record richer event metadata for more reliable duplicate detection during a temporary compatibility transition; no changes to public APIs or user workflows.

@vercel
Copy link
Contributor

vercel bot commented Sep 16, 2025

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Preview Updated (UTC)
dub Ready Ready Preview Sep 17, 2025 3:22am

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Sep 16, 2025

Walkthrough

Both Stripe webhook handlers switched their Redis idempotency key from dub_sale_events:invoiceId:<invoiceId> storing 1 to trackSale:stripe:invoiceId:<invoiceId> storing a JSON metadata object; semantics remain NX with 7-day TTL and duplicate detection still logs and returns early.

Changes

Cohort / File(s) Summary
Stripe webhook idempotency key change
apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts, apps/web/app/(ee)/api/stripe/integration/webhook/invoice-paid.ts
Replaced idempotency key dub_sale_events:invoiceId:<invoiceId> (value 1) with trackSale:stripe:invoiceId:<invoiceId> storing a JSON object: { timestamp, dubCustomerId, stripeCustomerId, stripeAccountId, invoiceId, customerId, workspaceId, amount, currency }. SET still uses NX and EX=7 days; on failure handlers log and return. Added comment: // here we assume that Stripe's invoice ID is unique across all customers. No other API or control-flow changes.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  participant Stripe
  participant Webhook as Webhook Handler
  participant Redis

  Stripe->>Webhook: POST /webhook (checkout_session.completed / invoice.paid)
  Webhook->>Redis: SET trackSale:stripe:invoiceId:<invoiceId> = {metadata JSON} NX EX 7d
  Redis-->>Webhook: OK or null
  alt Key set succeeded (OK)
    Webhook->>Webhook: continue existing processing flow
    Webhook-->>Stripe: 200 OK
  else Key exists (null)
    Webhook->>Webhook: log duplicate detection and return early
    Webhook-->>Stripe: 200 OK
  end
  Note right of Redis: value stores structured Stripe/session invoice metadata (not a numeric flag)
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Suggested reviewers

  • TWilson023

Poem

I nibble keys and stash the prize,
A stripe-shaped carrot in my eyes.
If one is found, I hop away—
No double munching, that's my way.
I leave a note: "Invoice saved for seven days." 🥕🐇

Pre-merge checks and finishing touches

✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title Check ✅ Passed The title "Update Stripe invoice deduplication to use trackSale key" is concise and accurately summarizes the primary change in the PR: switching the Redis deduplication key/payload from the previous dub_sale_events prefix to the new trackSale:stripe:invoiceId key and structured payload. It names both the intent (invoice deduplication) and the specific key change, so a reviewer scanning history can immediately understand the main behavioral change. The title contains no extraneous file lists, emojis, or vague phrasing and is appropriate as a single-sentence summary.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
✨ Finishing touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch dedup-stripe-invoice

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (3)
apps/web/app/(ee)/api/stripe/integration/webhook/invoice-paid.ts (1)

68-75: Consider namespacing by Stripe account to avoid hypothetical cross-account collisions.

Low risk, but adding account scoping can aid debugging and resilience.

Example:

  • trackSale:stripe:acct:${stripeAccountId}:invoice:${invoiceId}
  • dub_sale_events:acct:${stripeAccountId}:invoice:${invoiceId}
apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts (2)

215-230: Add a fallback idempotency key when invoiceId is absent.

For one-time payments or edge cases where invoiceId is null, there’s no dedup today. Use checkout.session id or payment_intent id.

Example (outside the if (invoiceId) block):

  • payment mode: key = trackSale:stripe:pi:${charge.payment_intent}
  • subscription mode: key = trackSale:stripe:cs:${charge.id}
    Then apply the same SET NX pattern.

217-224: Optional: namespace keys by account for clarity.

Same suggestion as invoice-paid.

Use: trackSale:stripe:acct:${stripeAccountId}:invoice:${invoiceId}

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between e091cd0 and 4b7b803.

📒 Files selected for processing (2)
  • apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts (1 hunks)
  • apps/web/app/(ee)/api/stripe/integration/webhook/invoice-paid.ts (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts (1)
apps/web/lib/upstash/redis.ts (1)
  • redis (4-7)
apps/web/app/(ee)/api/stripe/integration/webhook/invoice-paid.ts (1)
apps/web/lib/upstash/redis.ts (1)
  • redis (4-7)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
  • GitHub Check: Vade Review
  • GitHub Check: build
🔇 Additional comments (2)
apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts (1)

217-224: Confirm Upstash Redis client method signatures (set/exists/del)

Repo uses redis.set(key, value, { ex: ..., nx: true }), redis.exists(key) and redis.del(key) in multiple files — verify those option shapes and return types match the installed Upstash client version (check package.json / installed package).

apps/web/app/(ee)/api/stripe/integration/webhook/invoice-paid.ts (1)

68-75: Verify Upstash client call shapes & key writers

  • Redis client: apps/web/lib/upstash/redis.ts (new Redis({ url, token })); code uses redis.set(..., { ex: … }) / { nx: true } and redis.mget/exists/del across the repo.
  • Writers found: apps/web/lib/api/conversions/track-sale.ts sets trackSale:${workspace.id}:invoiceId:${invoiceId}; apps/web/lib/integrations/shopify/create-sale.ts sets dub_sale_events:linkId:${linkId}:invoiceId:${invoiceId}.
  • Readers found: webhooks apps/web/app/(ee)/api/stripe/integration/webhook/invoice-paid.ts and .../checkout-session-completed.ts call redis.mget([trackSale:stripe:invoiceId:${invoiceId}, dub_sale_events:invoiceId:${invoiceId}]).
  • Findings/action: no other writer found for trackSale:stripe:invoiceId or dub_sale_events:invoiceId in the repo — confirm the installed @upstash/redis version in package.json/lockfile and verify redis.set/mget option shapes (ex, nx, etc.) match that version; decide whether to keep/remove the legacy oldKey logic.

Comment on lines 217 to 224
// TODO: remove oldKeyValue stuff after 7 days (on Sep 23)
const [newKeyValue, oldKeyValue] = await redis.mget([
`trackSale:stripe:invoiceId:${invoiceId}`, // here we assume that Stripe's invoice ID is unique across all customers
`dub_sale_events:invoiceId:${invoiceId}`,
]);

if (!ok) {
if (newKeyValue || oldKeyValue) {
console.info(
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue

Same race: make the dedup guard atomic with SET NX and respect legacy key.

Mirror the invoice-paid fix to prevent double-processing when Stripe retries or parallel webhooks fire.

Apply this diff:

-    // TODO: remove oldKeyValue stuff after 7 days (on Sep 23)
-    const [newKeyValue, oldKeyValue] = await redis.mget([
-      `trackSale:stripe:invoiceId:${invoiceId}`, // here we assume that Stripe's invoice ID is unique across all customers
-      `dub_sale_events:invoiceId:${invoiceId}`,
-    ]);
-
-    if (newKeyValue || oldKeyValue) {
+    // TODO: remove legacyKey check after Sep 23
+    const trackSaleKey = `trackSale:stripe:invoiceId:${invoiceId}`; // Stripe invoice IDs are globally unique
+    const legacyKey = `dub_sale_events:invoiceId:${invoiceId}`;
+    const ttlSeconds = 60 * 60 * 24 * 14;
+    const claimed = await redis.set(trackSaleKey, "1", { nx: true, ex: ttlSeconds });
+    if (!claimed || (await redis.exists(legacyKey))) {
+      if (claimed) await redis.del(trackSaleKey);
       console.info(
         "[Stripe Webhook] Skipping already processed invoice.",
         invoiceId,
       );
       return `Invoice with ID ${invoiceId} already processed, skipping...`;
     }
📝 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
// TODO: remove oldKeyValue stuff after 7 days (on Sep 23)
const [newKeyValue, oldKeyValue] = await redis.mget([
`trackSale:stripe:invoiceId:${invoiceId}`, // here we assume that Stripe's invoice ID is unique across all customers
`dub_sale_events:invoiceId:${invoiceId}`,
]);
if (!ok) {
if (newKeyValue || oldKeyValue) {
console.info(
// TODO: remove legacyKey check after Sep 23
const trackSaleKey = `trackSale:stripe:invoiceId:${invoiceId}`; // Stripe invoice IDs are globally unique
const legacyKey = `dub_sale_events:invoiceId:${invoiceId}`;
const ttlSeconds = 60 * 60 * 24 * 14;
const claimed = await redis.set(trackSaleKey, "1", { nx: true, ex: ttlSeconds });
if (!claimed || (await redis.exists(legacyKey))) {
if (claimed) await redis.del(trackSaleKey);
console.info(
"[Stripe Webhook] Skipping already processed invoice.",
invoiceId,
);
return `Invoice with ID ${invoiceId} already processed, skipping...`;
}
🤖 Prompt for AI Agents
In
apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts
around lines 217-224, replace the current mget+if dedupe logic with an atomic
SET NX on the new dedupe key while still checking the legacy key: first check
EXISTS for the legacy key (`dub_sale_events:invoiceId:${invoiceId}`) and return
if present, then perform a single redis.set(newKey, someMarker, { NX: true, EX:
<7 days in seconds> }) and treat a null return as already-processed (skip); this
makes the guard atomic and still respects the old key so retries/parallel
webhooks won’t double-process.

Comment on lines 68 to 75
// TODO: remove oldKeyValue stuff after 7 days (on Sep 23)
const [newKeyValue, oldKeyValue] = await redis.mget([
`trackSale:stripe:invoiceId:${invoiceId}`, // here we assume that Stripe's invoice ID is unique across all customers
`dub_sale_events:invoiceId:${invoiceId}`,
]);

if (!ok) {
if (newKeyValue || oldKeyValue) {
console.info(
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue

Non-atomic dedup allows double-processing under concurrency. Switch to SET NX (+ TTL) claim.

Reading both keys via mget is a TOCTOU race: two concurrent deliveries can both see “absent” and proceed, causing duplicate sales/commissions/webhooks. Claim the new key atomically with SET NX (with TTL), and still respect the legacy key during the migration window.

Apply this diff:

-  // TODO: remove oldKeyValue stuff after 7 days (on Sep 23)
-  const [newKeyValue, oldKeyValue] = await redis.mget([
-    `trackSale:stripe:invoiceId:${invoiceId}`, // here we assume that Stripe's invoice ID is unique across all customers
-    `dub_sale_events:invoiceId:${invoiceId}`,
-  ]);
-
-  if (newKeyValue || oldKeyValue) {
+  // TODO: remove legacyKey check after Sep 23
+  const trackSaleKey = `trackSale:stripe:invoiceId:${invoiceId}`; // Stripe invoice IDs are globally unique
+  const legacyKey = `dub_sale_events:invoiceId:${invoiceId}`;
+  const ttlSeconds = 60 * 60 * 24 * 14; // 14d; align with your retention policy
+  const claimed = await redis.set(trackSaleKey, "1", { nx: true, ex: ttlSeconds });
+  if (!claimed || (await redis.exists(legacyKey))) {
+    if (claimed) await redis.del(trackSaleKey); // respect legacy key during migration
     console.info(
       "[Stripe Webhook] Skipping already processed invoice.",
       invoiceId,
     );
     return `Invoice with ID ${invoiceId} already processed, skipping...`;
   }
📝 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
// TODO: remove oldKeyValue stuff after 7 days (on Sep 23)
const [newKeyValue, oldKeyValue] = await redis.mget([
`trackSale:stripe:invoiceId:${invoiceId}`, // here we assume that Stripe's invoice ID is unique across all customers
`dub_sale_events:invoiceId:${invoiceId}`,
]);
if (!ok) {
if (newKeyValue || oldKeyValue) {
console.info(
// TODO: remove legacyKey check after Sep 23
const trackSaleKey = `trackSale:stripe:invoiceId:${invoiceId}`; // Stripe invoice IDs are globally unique
const legacyKey = `dub_sale_events:invoiceId:${invoiceId}`;
const ttlSeconds = 60 * 60 * 24 * 14; // 14d; align with your retention policy
const claimed = await redis.set(trackSaleKey, "1", { nx: true, ex: ttlSeconds });
if (!claimed || (await redis.exists(legacyKey))) {
if (claimed) await redis.del(trackSaleKey); // respect legacy key during migration
console.info(
"[Stripe Webhook] Skipping already processed invoice.",
invoiceId,
);
return `Invoice with ID ${invoiceId} already processed, skipping...`;
}

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts (1)

215-233: Avoid dropped sales: release the idempotency claim on processing errors.

If any downstream step throws after the claim, retries will skip for up to 7 days and the sale may be lost. Wrap post-claim logic in a try/catch and delete the claim on error.

Example pattern (outside this hunk):

let claimedKey: string | null = null;

// after successful claim
claimedKey = trackSaleKey;

try {
  // existing processing (recordSale, prisma updates, webhooks, etc.)
} catch (err) {
  if (claimedKey) {
    await redis.del(claimedKey); // allow retry
  }
  throw err; // or return a 500 so Stripe retries
}
♻️ Duplicate comments (1)
apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts (1)

215-233: Make the dedupe guard atomic, respect the legacy key, and robustly derive invoiceId.

  • Handle cases where charge.invoice is an object (expanded) to avoid [object Object] key collisions.
  • Keep the atomic SET NX, but also gate on the legacy key to avoid reprocessing invoices that were already handled before this change.
  • If the legacy key is present, roll back the new claim to avoid blocking the other webhook.

Apply this diff:

   if (invoiceId) {
-    // Skip if invoice id is already processed
-    const ok = await redis.set(
-      `trackSale:stripe:invoiceId:${invoiceId}`, // here we assume that Stripe's invoice ID is unique across all customers
-      charge,
-      {
-        ex: 60 * 60 * 24 * 7,
-        nx: true,
-      },
-    );
-
-    if (!ok) {
-      console.info(
-        "[Stripe Webhook] Skipping already processed invoice.",
-        invoiceId,
-      );
-      return `Invoice with ID ${invoiceId} already processed, skipping...`;
-    }
+    // Robustly resolve the invoice ID (can be string or expanded object)
+    const invoiceIdStr =
+      typeof invoiceId === "string" ? invoiceId : invoiceId?.id;
+
+    if (!invoiceIdStr) {
+      console.warn(
+        "[Stripe Webhook] invoice present but missing id, skipping dedupe guard."
+      );
+    } else {
+      const trackSaleKey = `trackSale:stripe:invoiceId:${invoiceIdStr}`; // Stripe invoice IDs are globally unique
+      const legacyKey = `dub_sale_events:invoiceId:${invoiceIdStr}`;
+      const ttlSeconds = 60 * 60 * 24 * 7; // 7 days
+
+      // Claim atomically; also respect legacy key during migration window
+      const claimed = await redis.set(trackSaleKey, charge, {
+        nx: true,
+        ex: ttlSeconds,
+      });
+      if (!claimed || (await redis.exists(legacyKey))) {
+        if (claimed) await redis.del(trackSaleKey);
+        console.info(
+          "[Stripe Webhook] Skipping already processed invoice.",
+          invoiceIdStr
+        );
+        return `Invoice with ID ${invoiceIdStr} already processed, skipping...`;
+      }
+    }
   }
🧹 Nitpick comments (3)
apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts (3)

215-233: Add a fallback dedupe key when no invoice is present (one‑time payments).

For payment-mode sessions without an invoice, retries can double-count because no dedupe runs. Fall back to payment_intent or the session id.

Snippet (outside this hunk):

if (!invoiceId) {
  const paymentIntentId =
    typeof charge.payment_intent === "string"
      ? charge.payment_intent
      : charge.payment_intent?.id;

  const fallbackKey = paymentIntentId
    ? `trackSale:stripe:pi:${paymentIntentId}`
    : `trackSale:stripe:cs:${charge.id}`;

  const claimed = await redis.set(fallbackKey, "1", { nx: true, ex: 60 * 60 * 24 * 7 });
  if (!claimed) {
    console.info("[Stripe Webhook] Skipping already processed session.", fallbackKey);
    return `Session already processed, skipping...`;
  }
}

To confirm where one-time payments appear most, search for mode === "payment" usage and whether duplicates have been observed.


217-224: Consider storing a minimal marker instead of the full session object.

Storing the entire charge inflates Redis memory and egress. A constant like "1" (or a tiny JSON { at: <ts> }) is sufficient for dedupe.


217-224: TTL consistent; legacy dub_sale_events key still present — remove after migration

Both webhooks (apps/web/app/(ee)/api/stripe/integration/webhook/invoice-paid.ts and checkout-session-completed.ts) set trackSale:stripe:invoiceId:${invoiceId} with ex: 60*60*24*7. apps/web/lib/api/conversions/track-sale.ts still mgets trackSale:${workspace.id}:invoiceId:${invoiceId} and dub_sale_events:invoiceId:${invoiceId} — drop dub_sale_events and/or align key naming after the migration window. Locations: invoice-paid.ts (~lines 69–74), checkout-session-completed.ts (~216–223), track-sale.ts (mget ~63–66; TTL ~567).

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 4b7b803 and dbbb9a2.

📒 Files selected for processing (2)
  • apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts (1 hunks)
  • apps/web/app/(ee)/api/stripe/integration/webhook/invoice-paid.ts (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/web/app/(ee)/api/stripe/integration/webhook/invoice-paid.ts
🧰 Additional context used
🧬 Code graph analysis (1)
apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts (1)
apps/web/lib/upstash/redis.ts (1)
  • redis (4-7)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
  • GitHub Check: api-tests
  • GitHub Check: Vade Review

nx: true,
});
const ok = await redis.set(
`trackSale:stripe:invoiceId:${invoiceId}`, // here we assume that Stripe's invoice ID is unique across all customers
Copy link
Contributor

Choose a reason for hiding this comment

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

The Redis key pattern uses "stripe" instead of the workspace ID, which won't match the key pattern expected by the track-sale function, breaking idempotency checks.

View Details
📝 Patch Details
diff --git a/apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts b/apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts
index b0abad60d..448bcf912 100644
--- a/apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts
+++ b/apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts
@@ -215,7 +215,7 @@ export async function checkoutSessionCompleted(event: Stripe.Event) {
   if (invoiceId) {
     // Skip if invoice id is already processed
     const ok = await redis.set(
-      `trackSale:stripe:invoiceId:${invoiceId}`, // here we assume that Stripe's invoice ID is unique across all customers
+      `trackSale:${customer.projectId}:invoiceId:${invoiceId}`,
       charge,
       {
         ex: 60 * 60 * 24 * 7,
diff --git a/apps/web/app/(ee)/api/stripe/integration/webhook/invoice-paid.ts b/apps/web/app/(ee)/api/stripe/integration/webhook/invoice-paid.ts
index ed4fb8c17..d6b39502b 100644
--- a/apps/web/app/(ee)/api/stripe/integration/webhook/invoice-paid.ts
+++ b/apps/web/app/(ee)/api/stripe/integration/webhook/invoice-paid.ts
@@ -66,7 +66,7 @@ export async function invoicePaid(event: Stripe.Event) {
 
   // Skip if invoice id is already processed
   const ok = await redis.set(
-    `trackSale:stripe:invoiceId:${invoiceId}`, // here we assume that Stripe's invoice ID is unique across all customers
+    `trackSale:${customer.projectId}:invoiceId:${invoiceId}`,
     invoice,
     {
       ex: 60 * 60 * 24 * 7,

Analysis

Redis key pattern mismatch breaks idempotency between Stripe webhooks and track-sale API

What fails: Stripe webhook handlers (invoice-paid.ts, checkout-session-completed.ts) use Redis key pattern trackSale:stripe:invoiceId:${invoiceId} but track-sale function expects trackSale:${workspace.id}:invoiceId:${invoiceId}, causing duplicate processing

How to reproduce:

  1. Process same invoice through both webhook handler and track-sale API
  2. Webhook sets key: trackSale:stripe:invoiceId:in_123
  3. Track-sale checks key: trackSale:clz456:invoiceId:in_123
  4. Keys don't match, so both process the same invoice

Result: Idempotency system fails - same invoice processed multiple times, causing duplicate sales records and incorrect metrics

Expected: Both systems should use identical workspace-based keys for proper idempotency isolation per workspace

nx: true,
});
const ok = await redis.set(
`trackSale:stripe:invoiceId:${invoiceId}`, // here we assume that Stripe's invoice ID is unique across all customers
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
`trackSale:stripe:invoiceId:${invoiceId}`, // here we assume that Stripe's invoice ID is unique across all customers
`trackSale:${workspace.id}:invoiceId:${invoiceId}`,

The Redis key pattern uses "stripe" instead of the workspace ID, which won't match the key pattern expected by the track-sale function, breaking idempotency checks.

View Details

Analysis

Redis key pattern mismatch breaks idempotency in Stripe webhook handler

What fails: checkoutSessionCompleted() in checkout-session-completed.ts uses Redis key pattern trackSale:stripe:invoiceId:${invoiceId} while trackSale() function expects trackSale:${workspace.id}:invoiceId:${invoiceId}, causing idempotency checks to fail

How to reproduce:

// Webhook sets: trackSale:stripe:invoiceId:inv_123
await redis.set(`trackSale:stripe:invoiceId:${invoiceId}`, charge, {...});

// Track-sale looks for: trackSale:ws_abc123:invoiceId:inv_123
const [cached] = await redis.mget([`trackSale:${workspace.id}:invoiceId:${invoiceId}`]);

Result: Keys never match, duplicate webhook events are not detected, potentially causing duplicate sale records and incorrect analytics

Expected: Both functions should use the same key pattern with workspace.id for proper workspace isolation and idempotency

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

♻️ Duplicate comments (1)
apps/web/app/(ee)/api/stripe/integration/webhook/invoice-paid.ts (1)

68-85: Fix key pattern to workspace‑scoped and bridge legacy keys to avoid duplicate sales

Key uses trackSale:stripe:invoiceId:${invoiceId} which mismatches the workspace‑scoped pattern used elsewhere (e.g., track-sale and the other Stripe handler), breaking idempotency across paths. Also, without honoring legacy keys during the migration window, previously processed invoices can be reprocessed. Apply the workspace‑scoped key and add a one‑time legacy check (remove after September 23, 2025).

-  const ok = await redis.set(
-    `trackSale:stripe:invoiceId:${invoiceId}`, // here we assume that Stripe's invoice ID is unique across all customers
-    {
-      timestamp: new Date().toISOString(),
-      dubCustomerId: customer.externalId,
-      stripeCustomerId,
-      stripeAccountId,
-      invoiceId,
-      customerId: customer.id,
-      workspaceId: customer.projectId,
-      amount: invoice.amount_paid,
-      currency: invoice.currency,
-    },
-    {
-      ex: 60 * 60 * 24 * 7,
-      nx: true,
-    },
-  );
+  // Workspace‑scoped idempotency key to align with track‑sale and other handlers
+  const trackSaleKey = `trackSale:${customer.projectId}:invoiceId:${invoiceId}`;
+  const trackSaleValue = {
+    timestamp: new Date().toISOString(),
+    dubCustomerId: customer.externalId,
+    stripeCustomerId,
+    stripeAccountId,
+    invoiceId,
+    customerId: customer.id,
+    workspaceId: customer.projectId,
+    amount: invoice.amount_paid,
+    currency: invoice.currency,
+  };
+  const ok = await redis.set(trackSaleKey, trackSaleValue, {
+    ex: 60 * 60 * 24 * 7,
+    nx: true,
+  });
+  // Migration bridge — remove after September 23, 2025
+  const legacyExists =
+    (await redis.exists(
+      `trackSale:stripe:invoiceId:${invoiceId}`,     // wrong pattern from earlier change
+      `dub_sale_events:invoiceId:${invoiceId}`,      // pre‑migration key
+    )) > 0;
 
-  if (!ok) {
+  if (!ok || legacyExists) {
+    if (ok && legacyExists) {
+      // Respect legacy claims during the window
+      await redis.del(trackSaleKey);
+    }
     console.info(
       "[Stripe Webhook] Skipping already processed invoice.",
       invoiceId,
     );
     return `Invoice with ID ${invoiceId} already processed, skipping...`;
   }

Follow-up: Mirror the same workspace‑scoped key in apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts to keep both handlers consistent.

Run to find any remaining mismatched keys:

#!/bin/bash
# Find any usages of the old/mismatched key patterns
rg -nP "trackSale:(stripe|\$\{?workspaceId\}?):invoiceId" -C2 --type=ts
rg -n "dub_sale_events:invoiceId" -C2 --type=ts

Also applies to: 87-93

🧹 Nitpick comments (1)
apps/web/app/(ee)/api/stripe/integration/webhook/invoice-paid.ts (1)

68-85: Optional: include livemode in the key namespace if Redis is shared across envs

If the same Upstash instance backs both test and live webhooks, consider namespacing by event.livemode (e.g., add :live/:test) to avoid extremely rare cross‑env collisions and to simplify debugging. If infra already isolates envs at the Redis level, ignore.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between dbbb9a2 and 98556cf.

📒 Files selected for processing (2)
  • apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts (1 hunks)
  • apps/web/app/(ee)/api/stripe/integration/webhook/invoice-paid.ts (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts
🧰 Additional context used
🧠 Learnings (1)
📓 Common learnings
Learnt from: devkiran
PR: dubinc/dub#2635
File: packages/prisma/schema/payout.prisma:24-25
Timestamp: 2025-07-11T16:28:55.693Z
Learning: In the Dub codebase, multiple payout records can now share the same stripeTransferId because payouts are grouped by partner and processed as single Stripe transfers. This is why the unique constraint was removed from the stripeTransferId field in the Payout model - a single transfer can include multiple payouts for the same partner.
🧬 Code graph analysis (1)
apps/web/app/(ee)/api/stripe/integration/webhook/invoice-paid.ts (1)
apps/web/lib/upstash/redis.ts (1)
  • redis (4-7)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
  • GitHub Check: Vade Review
  • GitHub Check: build

`trackSale:stripe:invoiceId:${invoiceId}`, // here we assume that Stripe's invoice ID is unique across all customers
{
timestamp: new Date().toISOString(),
dubCustomerId,
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
dubCustomerId,
dubCustomerId: customer.externalId,

The dubCustomerId variable can be undefined when processing checkout sessions with client_reference_id, causing undefined values to be stored in Redis duplicate-prevention metadata.

View Details

Analysis

Undefined dubCustomerId stored in Redis when processing dub_id checkout sessions

What fails: In checkoutSessionCompleted(), when processing checkout sessions with client_reference_id starting with "dub_id_", the dubCustomerId variable remains undefined from charge.metadata?.dubCustomerId and gets stored as undefined in Redis duplicate-prevention metadata

How to reproduce:

// Checkout session with client_reference_id but no dubCustomerId in metadata
const charge = {
  metadata: {}, // missing dubCustomerId
  client_reference_id: "dub_id_12345"
};
// dubCustomerId = charge.metadata?.dubCustomerId; // undefined
// Customer gets created but dubCustomerId never updated
// Redis stores: { dubCustomerId: undefined, ... }

Result: Redis duplicate-prevention metadata contains dubCustomerId: undefined, causing data inconsistency

Expected: Should store actual customer external ID like invoice-paid.ts does on line 72 using customer.externalId

@steven-tey steven-tey merged commit 9f220f8 into main Sep 17, 2025
9 checks passed
@steven-tey steven-tey deleted the dedup-stripe-invoice branch September 17, 2025 04:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants