Skip to content

invoiceId dedupe key between stripe and /track/sale - #4225

Draft
pepeladeira wants to merge 5 commits into
mainfrom
stripe-tracksale-invoice-dedup
Draft

invoiceId dedupe key between stripe and /track/sale#4225
pepeladeira wants to merge 5 commits into
mainfrom
stripe-tracksale-invoice-dedup

Conversation

@pepeladeira

@pepeladeira pepeladeira commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

Summary by CodeRabbit

  • Bug Fixes
    • Improved Stripe webhook handling for both checkout session completion and invoice paid events by adding Redis-backed idempotency to prevent duplicate invoice processing.
    • Introduced workspace-scoped invoice deduplication while keeping backward compatibility for existing legacy dedupe keys during the migration window.
    • Updated invoice tracking so repeated events reliably return idempotent results instead of triggering duplicate sale/customer creation.

@vercel

vercel Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

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

Project Deployment Actions Updated (UTC)
dub Ready Ready Preview Jul 28, 2026 9:00pm

Request Review

@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Invoice idempotency now uses workspace-scoped Redis keys across track-sale processing and Stripe webhooks, while checking legacy Stripe-only keys during the transition.

Changes

Invoice idempotency migration

Layer / File(s) Summary
Invoice dedupe key contract
apps/web/lib/api/conversions/invoice-idempotency.ts
Adds helpers for workspace-scoped and legacy Stripe-only invoice Redis keys.
Track-sale deduplication
apps/web/lib/api/conversions/track-sale.ts
Reads and stores invoice idempotency data using the workspace-scoped key while preserving the existing TTL.
Stripe webhook compatibility checks
apps/web/app/(ee)/api/stripe/integration/webhook/*.ts
Checks legacy records and uses workspace-scoped keys in checkout-session-completed and invoice-paid processing.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

  • dubinc/dub#3885: Both changes modify the Stripe checkout-session-completed webhook flow.
  • dubinc/dub#3970: Both changes modify Stripe invoice webhook idempotency and duplicate sale prevention.

Suggested reviewers: devkiran, steven-tey

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: unifying invoiceId dedupe keys across Stripe webhooks and /track/sale.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch stripe-tracksale-invoice-dedup

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.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@apps/web/app/`(ee)/api/stripe/integration/webhook/checkout-session-completed.ts:
- Around line 340-354: Move the legacy deduplication checks to immediately after
extracting invoiceId, before any customer, lead, promotion, or other mutable
work. Apply this in
apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts
at lines 340-354 and
apps/web/app/(ee)/api/stripe/integration/webhook/invoice-paid.ts at lines
142-152, preserving the existing duplicate response behavior.

In `@apps/web/lib/api/conversions/track-sale.ts`:
- Around line 69-81: Update the cachedResponse branch in the track-sale flow to
validate or discriminate the value as a trackSaleResponse before returning it;
never return Stripe webhook metadata markers from invoiceDedupeKey. When
validation fails, return the existing inert idempotent result, while preserving
the legacyRecord handling.
- Around line 716-720: Update the _trackSale flow so invoiceDedupeKey is not
written as completed before all waitUntil-scheduled sale side effects and
persistence finish successfully. Separate in-progress from completed state, or
move the completed-key write into a success path that runs only after durable
work succeeds, with retry-safe handling that leaves failed sales eligible for
reprocessing.
- Around line 716-720: The shared invoice deduplication key must distinguish an
in-progress claim from a completed sale: in
apps/web/lib/api/conversions/track-sale.ts:716-720, move publishing the
completed response until the scheduled sale work succeeds; in
apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts:356-385
and apps/web/app/(ee)/api/stripe/integration/webhook/invoice-paid.ts:154-182,
release or expire the processing claim whenever sale or invoice processing fails
so retries remain possible.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: bbd9389c-5612-4e22-8a00-e53a13eb4768

📥 Commits

Reviewing files that changed from the base of the PR and between 164628c and efc3927.

📒 Files selected for processing (4)
  • apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts
  • apps/web/app/(ee)/api/stripe/integration/webhook/invoice-paid.ts
  • apps/web/lib/api/conversions/invoice-idempotency.ts
  • apps/web/lib/api/conversions/track-sale.ts

Comment thread apps/web/lib/api/conversions/track-sale.ts Outdated
Comment thread apps/web/lib/api/conversions/track-sale.ts
@pepeladeira

Copy link
Copy Markdown
Collaborator Author

@coderabbitai full review. any breaking changes?

@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

@pepeladeira I’ll perform a full review of #4225, including a focused check for breaking changes and idempotency/compatibility regressions.

✅ Action performed

Full review finished.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/web/lib/api/conversions/track-sale.ts`:
- Around line 69-88: Update the deduplication handling in the track-sale flow to
schema-validate legacyRecord with trackSaleResponseSchema.safeParse before
returning the inert placeholder. Return the parsed legacy response when valid,
while preserving the existing cachedResponse handling and fallback for webhook
marker records or invalid legacy data.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 03e4905a-727f-48fc-b744-e4562dcf3544

📥 Commits

Reviewing files that changed from the base of the PR and between 164628c and 43133df.

📒 Files selected for processing (4)
  • apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts
  • apps/web/app/(ee)/api/stripe/integration/webhook/invoice-paid.ts
  • apps/web/lib/api/conversions/invoice-idempotency.ts
  • apps/web/lib/api/conversions/track-sale.ts

Comment on lines +69 to +88
const [cachedResponse, legacyRecord] = await redis.mget([
invoiceDedupeKey(workspace.id, invoiceId),
legacyStripeInvoiceDedupeKey(invoiceId),
]);

if (cachedResponse) {
return cachedResponse;
const parsedCachedResponse =
trackSaleResponseSchema.safeParse(cachedResponse);

if (parsedCachedResponse.success) {
return parsedCachedResponse.data;
}
}

if (cachedResponse || legacyRecord) {
return {
eventName,
customer: null,
sale: null,
};

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.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Preserve valid track-sale responses stored under the legacy key.

The legacy key may contain a serialized trackSaleResponse from pre-migration track-sale requests, not only Stripe webhook markers. Because only cachedResponse is schema-validated, retries with a valid legacyRecord incorrectly return the empty placeholder response.

Parse legacyRecord with trackSaleResponseSchema.safeParse before falling back to the inert result; webhook marker objects will still fail validation.

Proposed fix
     if (cachedResponse) {
       const parsedCachedResponse =
         trackSaleResponseSchema.safeParse(cachedResponse);

       if (parsedCachedResponse.success) {
         return parsedCachedResponse.data;
       }
     }

+    const parsedLegacyResponse =
+      trackSaleResponseSchema.safeParse(legacyRecord);
+
+    if (parsedLegacyResponse.success) {
+      return parsedLegacyResponse.data;
+    }
+
     if (cachedResponse || legacyRecord) {
📝 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
const [cachedResponse, legacyRecord] = await redis.mget([
invoiceDedupeKey(workspace.id, invoiceId),
legacyStripeInvoiceDedupeKey(invoiceId),
]);
if (cachedResponse) {
return cachedResponse;
const parsedCachedResponse =
trackSaleResponseSchema.safeParse(cachedResponse);
if (parsedCachedResponse.success) {
return parsedCachedResponse.data;
}
}
if (cachedResponse || legacyRecord) {
return {
eventName,
customer: null,
sale: null,
};
const [cachedResponse, legacyRecord] = await redis.mget([
invoiceDedupeKey(workspace.id, invoiceId),
legacyStripeInvoiceDedupeKey(invoiceId),
]);
if (cachedResponse) {
const parsedCachedResponse =
trackSaleResponseSchema.safeParse(cachedResponse);
if (parsedCachedResponse.success) {
return parsedCachedResponse.data;
}
}
const parsedLegacyResponse =
trackSaleResponseSchema.safeParse(legacyRecord);
if (parsedLegacyResponse.success) {
return parsedLegacyResponse.data;
}
if (cachedResponse || legacyRecord) {
return {
eventName,
customer: null,
sale: null,
};
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/lib/api/conversions/track-sale.ts` around lines 69 - 88, Update the
deduplication handling in the track-sale flow to schema-validate legacyRecord
with trackSaleResponseSchema.safeParse before returning the inert placeholder.
Return the parsed legacy response when valid, while preserving the existing
cachedResponse handling and fallback for webhook marker records or invalid
legacy data.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/web/lib/api/conversions/track-sale.ts`:
- Around line 65-70: Update the cached-response branch in the track-sale flow to
validate cachedResponse with trackSaleResponseSchema.safeParse before returning
it. Return the parsed track-sale response only when validation succeeds;
otherwise continue through the inert idempotent fallback rather than returning
shared invoice-key marker objects.
- Around line 65-70: Update the deduplication lookup around invoiceDedupeKey to
read both the workspace-scoped key and legacyStripeInvoiceDedupeKey, preserving
the legacy key during migration. Schema-validate each stored response and return
the first valid cached response; continue processing only when neither lookup
yields a valid result.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 0a57171a-0dc2-4a57-8ce6-86f0d893f597

📥 Commits

Reviewing files that changed from the base of the PR and between 89e94bb and 17f7e46.

📒 Files selected for processing (1)
  • apps/web/lib/api/conversions/track-sale.ts

Comment on lines 65 to 70
const cachedResponse = await redis.get(
`trackSale:${workspace.id}:invoiceId:${invoiceId}`,
invoiceDedupeKey(workspace.id, invoiceId),
);

if (cachedResponse) {
return cachedResponse;

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.

🗄️ Data Integrity & Integration | 🟠 Major

Restore cached-response validation before returning.

Line 69 returns any value stored under the shared invoice key, including Stripe webhook marker objects. Restore trackSaleResponseSchema.safeParse(cachedResponse) and return only validated track-sale responses; otherwise preserve the inert idempotent fallback.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/lib/api/conversions/track-sale.ts` around lines 65 - 70, Update the
cached-response branch in the track-sale flow to validate cachedResponse with
trackSaleResponseSchema.safeParse before returning it. Return the parsed
track-sale response only when validation succeeds; otherwise continue through
the inert idempotent fallback rather than returning shared invoice-key marker
objects.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Preserve the legacy Stripe-key read during migration.

This lookup only checks invoiceDedupeKey(workspace.id, invoiceId), but legacyStripeInvoiceDedupeKey(invoiceId) remains part of the migration contract. Existing invoices stored under the legacy key will miss deduplication and may be processed again. Read both keys and schema-validate either stored response before continuing.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/lib/api/conversions/track-sale.ts` around lines 65 - 70, Update the
deduplication lookup around invoiceDedupeKey to read both the workspace-scoped
key and legacyStripeInvoiceDedupeKey, preserving the legacy key during
migration. Schema-validate each stored response and return the first valid
cached response; continue processing only when neither lookup yields a valid
result.

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