Skip to content
Draft
Show file tree
Hide file tree
Changes from 4 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
@@ -1,5 +1,9 @@
import { convertCurrency } from "@/lib/analytics/convert-currency";
import { isFirstConversion } from "@/lib/analytics/is-first-conversion";
import {
invoiceDedupeKey,
legacyStripeInvoiceDedupeKey,
} from "@/lib/api/conversions/invoice-idempotency";
import { createId } from "@/lib/api/create-id";
import { getOrCreateCustomer } from "@/lib/api/customers/get-or-create-customer";
import { includeTags } from "@/lib/api/links/include-tags";
Expand Down Expand Up @@ -334,9 +338,24 @@ export async function checkoutSessionCompleted({
}

if (invoiceId) {
const legacyRecord = await redis.get(
legacyStripeInvoiceDedupeKey(invoiceId),
);

if (legacyRecord) {
console.info(
"[checkout.session.completed] Skipping already processed invoice (legacy key).",
invoiceId,
);

return {
response: `Invoice with ID ${invoiceId} already processed, skipping...`,
};
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// 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
invoiceDedupeKey(workspace.id, invoiceId),
{
timestamp: new Date().toISOString(),
dubCustomerExternalId,
Expand Down
18 changes: 17 additions & 1 deletion apps/web/app/(ee)/api/stripe/integration/webhook/invoice-paid.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { convertCurrency } from "@/lib/analytics/convert-currency";
import { isFirstConversion } from "@/lib/analytics/is-first-conversion";
import {
invoiceDedupeKey,
legacyStripeInvoiceDedupeKey,
} from "@/lib/api/conversions/invoice-idempotency";
import { includeTags } from "@/lib/api/links/include-tags";
import { syncPartnerLinksStats } from "@/lib/api/partners/sync-partner-links-stats";
import { executeWorkflows } from "@/lib/api/workflows/execute-workflows";
Expand Down Expand Up @@ -135,9 +139,21 @@ export async function invoicePaid({
? invoice.total_excluding_tax
: invoice.amount_paid;

const legacyRecord = await redis.get(legacyStripeInvoiceDedupeKey(invoiceId));

if (legacyRecord) {
console.info(
"[invoice.paid] Skipping already processed invoice (legacy key).",
invoiceId,
);
return {
response: `Invoice with ID ${invoiceId} already processed, skipping...`,
};
}

// 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
invoiceDedupeKey(workspace.id, invoiceId),
{
timestamp: new Date().toISOString(),
dubCustomerExternalId: customer.externalId,
Expand Down
9 changes: 9 additions & 0 deletions apps/web/lib/api/conversions/invoice-idempotency.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// TODO: remove after 2026-08-10 (10 days after rollout) once the transition
// window has elapsed and no keys written under the old Stripe-only format
// remain (they carry a 7-day TTL).

export const invoiceDedupeKey = (workspaceId: string, invoiceId: string) =>
`trackSale:${workspaceId}:invoiceId:${invoiceId}`;

export const legacyStripeInvoiceDedupeKey = (invoiceId: string) =>
`trackSale:stripe:invoiceId:${invoiceId}`;
37 changes: 26 additions & 11 deletions apps/web/lib/api/conversions/track-sale.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ import * as z from "zod/v4";
import { createId } from "../create-id";
import { syncPartnerLinksStats } from "../partners/sync-partner-links-stats";
import { executeWorkflows } from "../workflows/execute-workflows";
import {
invoiceDedupeKey,
legacyStripeInvoiceDedupeKey,
} from "./invoice-idempotency";

type TrackSaleParams = z.input<typeof trackSaleRequestSchema> & {
workspace: Pick<WorkspaceProps, "id" | "stripeConnectId" | "webhookEnabled">;
Expand Down Expand Up @@ -61,11 +65,26 @@ export const trackSale = async ({

// Return idempotent response if invoiceId is already processed
if (invoiceId) {
const cachedResponse = await redis.get(
`trackSale:${workspace.id}:invoiceId:${invoiceId}`,
);
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.

}
}

Expand Down Expand Up @@ -707,13 +726,9 @@ const _trackSale = async ({

if (invoiceId) {
waitUntil(
redis.set(
`trackSale:${workspace.id}:invoiceId:${invoiceId}`,
trackSaleResponse,
{
ex: 60 * 60 * 24 * 7, // cache for 1 week
},
),
redis.set(invoiceDedupeKey(workspace.id, invoiceId), trackSaleResponse, {
ex: 60 * 60 * 24 * 7, // cache for 1 week
}),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
);
}

Expand Down
Loading