Skip to content
Open
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
Expand Up @@ -27,7 +27,7 @@ import { Customer, Project } from "@dub/prisma/client";
import { COUNTRIES_TO_CONTINENTS, nanoid, pick } from "@dub/utils";
import { waitUntil } from "@vercel/functions";
import type Stripe from "stripe";
import { getCheckoutSessionProductId } from "./utils/get-checkout-session-product-id";
import { getCheckoutSessionProducts } from "./utils/get-checkout-session-products";
import { getConnectedCustomer } from "./utils/get-connected-customer";
import { getPromotionCode } from "./utils/get-promotion-code";
import { updateCustomerWithStripeCustomerId } from "./utils/update-customer-with-stripe-customer-id";
Expand Down Expand Up @@ -358,9 +358,10 @@ export async function checkoutSessionCompleted(

if (!ok) {
console.info(
"[Stripe Webhook] Skipping already processed invoice.",
"[checkout.session.completed] Skipping already processed invoice.",
invoiceId,
);

return {
response: `Invoice with ID ${invoiceId} already processed, skipping...`,
workspaceId: workspace.id,
Expand Down Expand Up @@ -483,7 +484,7 @@ export async function checkoutSessionCompleted(
| undefined = undefined;

if (link && link.programId && link.partnerId) {
const productId = await getCheckoutSessionProductId({
const products = await getCheckoutSessionProducts({
checkoutSessionId: charge.id,
stripeAccountId,
mode,
Expand All @@ -506,7 +507,7 @@ export async function checkoutSessionCompleted(
signupDate: customer.createdAt,
},
sale: {
productId,
products,
amount: saleData.amount,
},
},
Expand Down
21 changes: 19 additions & 2 deletions apps/web/app/(ee)/api/stripe/integration/webhook/invoice-paid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ export async function invoicePaid(

if (!ok) {
console.info(
"[Stripe Webhook] Skipping already processed invoice.",
"[invoice.paid] Skipping already processed invoice.",
invoiceId,
);
return {
Expand Down Expand Up @@ -244,6 +244,23 @@ export async function invoicePaid(
| undefined = undefined;

if (link.programId && link.partnerId) {
const products = invoice.lines.data
.map((line) => {
const productId = line.pricing?.price_details?.product;

if (!productId) return null;

return {
id: productId,
amount: line.amount,
quantity: line.quantity,
};
})
.filter(
(p): p is { id: string; amount: number; quantity: number } =>
p !== null,
);
Comment thread
devkiran marked this conversation as resolved.

createdCommission = await createPartnerCommission({
event: "sale",
programId: link.programId,
Expand All @@ -261,7 +278,7 @@ export async function invoicePaid(
signupDate: customer.createdAt,
},
sale: {
productId: invoice.lines.data[0]?.pricing?.price_details?.product,
products,
amount: saleData.amount,
},
},
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { stripeAppClient } from "@/lib/stripe";
import { StripeMode } from "@/lib/types";
import type Stripe from "stripe";

export function productIdFromLineItemPrice(
price: Stripe.Price | string | null | undefined,
): string | null {
if (!price || typeof price === "string") {
return null;
}

if (!price.product) {
return null;
}

const product = price.product;

if (typeof product === "string") {
return product;
}

if ("deleted" in product && product.deleted) {
return null;
}

return product.id;
}

export async function getCheckoutSessionProducts({
checkoutSessionId,
stripeAccountId,
mode,
}: {
checkoutSessionId: string;
stripeAccountId?: string | null;
mode: StripeMode;
}) {
if (!stripeAccountId) {
return [];
}

try {
const stripeApp = stripeAppClient({
mode,
});

const lineItems = await stripeApp.checkout.sessions.listLineItems(
checkoutSessionId,
{
expand: ["data.price.product"],
limit: 10,
},
Comment thread
devkiran marked this conversation as resolved.
{
stripeAccount: stripeAccountId,
},
);

if (lineItems.data.length === 0) {
console.log(
`[getCheckoutSessionProducts] No line items found for checkout session ${checkoutSessionId}.`,
);
return [];
}

const products = lineItems.data
.map((line) => {
const productId = productIdFromLineItemPrice(line.price);

if (!productId) return null;

return {
id: productId,
amount: line.amount_total,
quantity: line.quantity,
};
})
.filter(
(p): p is { id: string; amount: number; quantity: number } =>
p !== null,
Comment on lines +73 to +79
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.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n apps/web/app/\(ee\)/api/stripe/integration/webhook/utils/get-checkout-session-products.ts | head -100

Repository: dubinc/dub

Length of output: 2800


🌐 Web query:

Stripe LineItem quantity field type nullable definition

💡 Result:

In Stripe's API, "LineItem" refers to different objects depending on context, but the query likely targets the Checkout Session line item (object type "item" in checkout.sessions.line_items.data), as it's commonly used for checkout line items. For Checkout Session LineItem: - quantity: integer, nullable As per official Stripe API docs for recent API versions (e.g., 2026-03-25.dahlia, 2025-03-31.basil, 2024-06-20): "line_items.data.quantity (integer, nullable) - The quantity of products being purchased." Examples always show a positive integer value (e.g., "quantity": 2), but docs explicitly mark it nullable. For comparison, Invoice LineItem (invoice.line_items.data): - quantity: integer, nullable (deprecated in favor of quantity_decimal: decimal string, nullable) When creating Checkout Sessions, line_items[].quantity is "integer, required conditionally" (required except for metered recurring usage_type). Sources confirm consistency across API versions up to 2026.

Citations:


🏁 Script executed:

# Check Stripe types definition in the project or node_modules
rg "LineItem|quantity" --type ts -A 3 -B 1 | head -100

Repository: dubinc/dub

Length of output: 7977


🏁 Script executed:

# Look for stripe types definition
find node_modules -name "*.d.ts" -path "*stripe*" -type f 2>/dev/null | head -20

Repository: dubinc/dub

Length of output: 36


🏁 Script executed:

# Check package.json for stripe version
cat package.json | grep -A 5 -B 5 stripe

Repository: dubinc/dub

Length of output: 36


🏁 Script executed:

# Find package.json files
find . -maxdepth 2 -name "package.json" -type f | head -5

Repository: dubinc/dub

Length of output: 69


🏁 Script executed:

grep -A 2 -B 2 '"stripe"' package.json

Repository: dubinc/dub

Length of output: 36


🏁 Script executed:

rg "stripe" package.json -i

Repository: dubinc/dub

Length of output: 36


🏁 Script executed:

find . -name "package.json" -type f -exec grep -l stripe {} \;

Repository: dubinc/dub

Length of output: 113


🏁 Script executed:

grep "stripe" apps/web/package.json | head -5

Repository: dubinc/dub

Length of output: 114


Ensure quantity field is non-null before returning products.

Line 74 passes line.quantity directly, but Stripe line-item quantity is nullable. The type predicate on lines 78-79 only checks if the object is null, not if individual fields are null, creating a type safety gap.

Suggested fix
         return {
           id: productId,
           amount: line.amount_total,
-          quantity: line.quantity,
+          quantity: line.quantity ?? 1,
         };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/web/app/`(ee)/api/stripe/integration/webhook/utils/get-checkout-session-products.ts
around lines 73 - 79, The mapped product creation is passing nullable Stripe
line.quantity directly (in the map that returns { id, amount, quantity }) and
the subsequent type predicate only checks for null objects, not for missing
quantity; update the mapping logic in get-checkout-session-products so it either
filters out line items with null/undefined quantity or converts nullable
quantities to a safe number (e.g., default 1) and then update the type predicate
used after .filter to assert quantity is a number (e.g., p is { id: string;
amount: number; quantity: number }) so callers of the resulting array can safely
rely on a non-null quantity without changing other code paths.

);

if (products.length === 0) {
console.log(
`[getCheckoutSessionProducts] No valid products found for checkout session ${checkoutSessionId}.`,
);
return [];
}

return products;
} catch (error) {
console.log(
"[getCheckoutSessionProducts] Failed to get checkout session products:",
error,
);
return [];
Comment on lines +90 to +95
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.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't collapse Stripe fetch failures into an empty product list.

Returning [] here makes a transient Stripe failure indistinguishable from a real empty cart. In apps/web/lib/partners/create-partner-commission.ts, products.length === 0 falls back to the generic sale path, so programs that use sale.productId modifiers can get the wrong commission instead of a safe skip/retry.

One way to preserve the failure signal
-export async function getCheckoutSessionProducts({
+export async function getCheckoutSessionProducts({
   checkoutSessionId,
   stripeAccountId,
   mode,
 }: {
   checkoutSessionId: string;
   stripeAccountId?: string | null;
   mode: StripeMode;
-}) {
+}): Promise<{ id: string; amount: number; quantity: number }[] | null> {
@@
   } catch (error) {
     console.log(
       "[getCheckoutSessionProducts] Failed to get checkout session products:",
       error,
     );
-    return [];
+    return null;
   }
 }

Then have checkout-session-completed.ts skip or retry commission creation when the result is null.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/web/app/`(ee)/api/stripe/integration/webhook/utils/get-checkout-session-products.ts
around lines 90 - 95, The catch in getCheckoutSessionProducts currently swallows
Stripe errors and returns an empty array, which conflates transient failures
with a legitimately empty cart; change the error handling in
getCheckoutSessionProducts so it preserves failure signals (either rethrow the
caught error or return null instead of []) and update callers (e.g.,
checkout-session-completed.ts and create-partner-commission logic that checks
products.length) to treat null as "fetch failed" and skip or retry commission
creation rather than falling back to the generic sale path.

}
}
107 changes: 95 additions & 12 deletions apps/web/lib/partners/create-partner-commission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { RewardContext, RewardProps } from "../types";
import { sendWorkspaceWebhook } from "../webhook/publish";
import { CommissionWebhookSchema } from "../zod/schemas/commissions";
import { DEFAULT_PARTNER_GROUP } from "../zod/schemas/groups";
import { rewardConditionsArraySchema } from "../zod/schemas/rewards";
import { aggregatePartnerLinksStats } from "./aggregate-partner-links-stats";
import { determinePartnerReward } from "./determine-partner-reward";
import { getRewardAmount } from "./get-reward-amount";
Expand All @@ -45,6 +46,11 @@ export type CreatePartnerCommissionProps = {
skipWorkflow?: boolean;
};

type RewardWithProduct = {
reward: RewardProps;
sale: { amount: number; quantity: number };
};

const constructWebhookPartner = (
programEnrollment: ProgramEnrollment & { partner: Partner; links: Link[] },
{
Expand Down Expand Up @@ -79,8 +85,9 @@ export const createPartnerCommission = async ({
skipWorkflow = false,
}: CreatePartnerCommissionProps) => {
let earnings = 0;
let reward: RewardProps | null = null;
let status: CommissionStatus = "pending";
let reward: RewardProps | null = null;
let rewards: RewardWithProduct[] = []; // TODO: Find a better name

const programEnrollment = await getProgramEnrollmentOrThrow({
partnerId,
Expand Down Expand Up @@ -147,17 +154,87 @@ export const createPartnerCommission = async ({
};
}

reward = determinePartnerReward({
event,
programEnrollment,
context,
});
const products = context?.sale?.products ?? [];

const modifiers = rewardConditionsArraySchema.safeParse(
programEnrollment.saleReward?.modifiers,
);

const hasProductIdModifier = modifiers.success
? modifiers.data.some((m) =>
m.conditions.some(
(c) => c.entity === "sale" && c.attribute === "productId",
),
)
: false;

// If there are products and a productId modifier,
// we need to calculate the reward for each product (for Stripe integration only)
if (products.length > 0 && hasProductIdModifier) {
for (const product of products) {
const reward = determinePartnerReward({
event,
programEnrollment,
context: {
...context,
sale: {
...context?.sale,
productId: product.id,
amount: product.amount,
},
},
});

if (reward) {
rewards.push({
reward,
sale: {
amount: product.amount,
quantity: product.quantity,
},
});
}
}

if (rewards.length > 0) {
reward = rewards[0].reward;
}
Comment thread
devkiran marked this conversation as resolved.
} else {
context = {
...context,
sale: {
...context?.sale,
// Callers that pass it explicitly keep their value, and
// Stripe webhooks (which send `products[]`) still surface a productId via the first line item
...(event === "sale" && {
productId: context?.sale?.productId ?? products[0]?.id,
}),
},
};

reward = determinePartnerReward({
event,
programEnrollment,
context,
});

if (reward) {
rewards.push({
reward,
sale: {
amount,
quantity,
},
});
}
}

// if there is no reward, skip commission creation
if (!reward) {
console.log(
`Partner ${partnerId} has no reward for ${event} event, skipping commission creation...`,
);

return {
commission: null,
programEnrollment,
Expand Down Expand Up @@ -273,12 +350,18 @@ export const createPartnerCommission = async ({
// for lead events, we just multiply the reward amount by the quantity
if (event === "lead") {
earnings = getRewardAmount(reward) * quantity;
// for sale events, we need to calculate the earnings based on the sale amount
} else {
earnings = calculateSaleEarnings({
reward,
sale: { quantity, amount },
});
}
// for sale events, we need to calculate the earnings based on the sale amount
else {
earnings = rewards.reduce(
(acc, { reward, sale }) =>
acc +
calculateSaleEarnings({
reward,
sale,
}),
0,
);
}
}
}
Expand Down
Loading
Loading