-
Notifications
You must be signed in to change notification settings - Fork 2.8k
Update Stripe invoice deduplication to use trackSale key
#2847
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
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
WalkthroughBoth Stripe webhook handlers switched their Redis idempotency key from 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)
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Suggested reviewers
Poem
Pre-merge checks and finishing touches✅ Passed checks (3 passed)
✨ Finishing touches
🧪 Generate unit tests
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. Comment |
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.
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
📒 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.
| // 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( |
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.
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.
| // 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.
| // 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( |
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.
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.
| // 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...`; | |
| } |
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.
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.invoiceis 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_intentor 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
chargeinflates 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 migrationBoth webhooks (apps/web/app/(ee)/api/stripe/integration/webhook/invoice-paid.ts and checkout-session-completed.ts) set
trackSale:stripe:invoiceId:${invoiceId}withex: 60*60*24*7. apps/web/lib/api/conversions/track-sale.ts still mgetstrackSale:${workspace.id}:invoiceId:${invoiceId}anddub_sale_events:invoiceId:${invoiceId}— dropdub_sale_eventsand/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
📒 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 |
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.
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:
- Process same invoice through both webhook handler and track-sale API
- Webhook sets key:
trackSale:stripe:invoiceId:in_123 - Track-sale checks key:
trackSale:clz456:invoiceId:in_123 - 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 |
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.
| `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
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.
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 salesKey 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=tsAlso 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 envsIf 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
📒 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, |
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.
| 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
Summary by CodeRabbit
Bug Fixes
Chores