Skip to content
Draft
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
@@ -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}`;
14 changes: 6 additions & 8 deletions apps/web/lib/api/conversions/track-sale.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ 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 } from "./invoice-idempotency";

type TrackSaleParams = z.input<typeof trackSaleRequestSchema> & {
workspace: Pick<WorkspaceProps, "id" | "stripeConnectId" | "webhookEnabled">;
Expand Down Expand Up @@ -62,8 +63,9 @@ export const trackSale = async ({
// Return idempotent response if invoiceId is already processed
if (invoiceId) {
const cachedResponse = await redis.get(
`trackSale:${workspace.id}:invoiceId:${invoiceId}`,
invoiceDedupeKey(workspace.id, invoiceId),
);

if (cachedResponse) {
return cachedResponse;
Comment on lines 65 to 70

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.

}
Expand Down Expand Up @@ -707,13 +709,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