Skip to content

Conversation

devkiran
Copy link
Collaborator

@devkiran devkiran commented Jul 8, 2025

Summary by CodeRabbit

  • New Features

    • Partner discount codes: DB model, UI to create/delete codes, list/badge in partner dashboard, cron-backed removal queue, and APIs to manage codes.
    • Stripe: auto-create coupons/promotion codes, new webhooks for coupon.deleted & promotion_code.updated, and cron endpoints to disable/remove codes.
    • Promo-code attribution in checkout with synthetic click/lead handling.
  • Improvements

    • Automatic remapping/cleanup and queued deletions when groups/partners change.
    • Partner/profile endpoints now include validated discount-code data.
    • Upgrade toast supports custom CTA.

Copy link
Contributor

vercel bot commented Jul 8, 2025

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

Project Deployment Preview Updated (UTC)
dub Ready Ready Preview Oct 3, 2025 10:51pm

Copy link
Contributor

coderabbitai bot commented Jul 8, 2025

Walkthrough

Adds a discount-codes feature: Prisma model and relations, APIs and cron endpoints, Stripe helpers and webhook handlers, background queueing, UI components/hooks, Zod/schema updates, audit logging, and integrations across partner/group/ban/deactivate/link flows.

Changes

Cohort / File(s) Summary
Prisma schema additions
packages/prisma/schema/discount.prisma, packages/prisma/schema/link.prisma, packages/prisma/schema/partner.prisma, packages/prisma/schema/program.prisma
Adds DiscountCode model and relations; wires discountCodes/discountCode across Discount, Link, Partner, Program, ProgramEnrollment.
Discount-code APIs
apps/web/app/(ee)/api/discount-codes/route.ts, apps/web/app/(ee)/api/discount-codes/[discountCodeId]/route.ts
New workspace-guarded GET/POST for listing/creating discount codes and DELETE for deleting (soft unlink + audit + queue).
Cron & queueing
apps/web/lib/api/discounts/queue-discount-code-deletion.ts, apps/web/app/(ee)/api/cron/discount-codes/[discountCodeId]/delete/route.ts, apps/web/app/(ee)/api/cron/groups/remap-discount-codes/route.ts
QStash-backed queue for deletion jobs, cron endpoint to disable/delete promo codes on Stripe and DB, remap-discount-codes job to update/remove codes across enrollments.
Stripe helpers
apps/web/lib/stripe/create-stripe-coupon.ts, apps/web/lib/stripe/create-stripe-discount-code.ts, apps/web/lib/stripe/disable-stripe-discount-code.ts
Helpers to create connected-account coupons, create promotion codes with duplicate-retry, and disable promotion codes on connected Stripe accounts.
Stripe webhook handlers & utils
apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts, .../coupon-deleted.ts, .../promotion-code-updated.ts, .../utils/*
New coupon.deleted and promotion_code.updated handling; promo-code attribution flow, synthetic click recording, and refactored connected-customer / promotion-code helpers.
Partner/group integration & remapping
apps/web/lib/actions/partners/*, apps/web/app/(ee)/api/groups/*, apps/web/lib/api/discounts/is-discount-equivalent.ts
Include discountCodes in enrollment queries; null/remap discountId on deletions/ban/deactivate; queue discount-code deletions; remap codes when group discounts change.
UI: add/delete, sheet move & badge
apps/web/ui/partners/discounts/add-edit-discount-sheet.tsx, apps/web/ui/partners/discounts/discount-code-badge.tsx, apps/web/ui/modals/add-discount-code-modal.tsx, apps/web/ui/modals/delete-discount-code-modal.tsx, apps/web/lib/swr/use-discount-codes.ts
New client DiscountSheet (moved), badge, add/delete modals, SWR hook; copy-to-clipboard and Stripe error mapping integrated.
Partner pages & link UI
apps/web/app/.../links/partner-link-card.tsx, apps/web/app/.../program/partners/[partnerId]/links/page-client.tsx, apps/web/ui/partners/groups/reward-discount-partners-card.tsx
Surface discount code badge in link cards; PartnerDiscountCodes listing and per-row actions; replace RewardPartnersCard with RewardDiscountPartnersCard.
Discount lifecycle & actions
apps/web/lib/actions/partners/{create-discount,update-discount,delete-discount}.ts, apps/web/lib/api/partners/get-discount-or-throw.ts, apps/web/lib/planetscale/get-partner-discount.ts
Create Stripe coupon when missing, narrow update payloads, queue discount-code deletions on delete, enforce program ownership, and preserve column naming in planetscale query.
Schemas, types & validation
apps/web/lib/zod/schemas/{discount,links,partner-profile,partners,payouts,programs,groups}.ts, apps/web/lib/types.ts, apps/web/lib/api/audit-logs/schemas.ts
Add DiscountCode zod schemas/types, allow id in bulk link updates, remove lastClicked, add discountCode to partner-profile links, extend audit actions/targets.
Patching existing flows
apps/web/app/(ee)/api/cron/links/invalidate-for-discounts/route.ts, apps/web/app/(ee)/api/commissions/[commissionId]/route.ts, apps/web/lib/api/links/delete-link.ts
Replace console responses with structured logAndRespond, simplify Promise.allSettled usage, and enqueue discount-code deletion on link deletion.
UI props & refactors
apps/web/ui/shared/upgrade-required-toast.tsx, apps/web/ui/shared/inline-badge-popover.tsx, apps/web/ui/partners/constants.ts, moved apps/web/ui/partners/add-edit-discount-sheet.tsxapps/web/ui/partners/discounts/add-edit-discount-sheet.tsx
New CTA props, disabled prop on popover, Stripe error map constant, and relocation/reimplementation of DiscountSheet.
Other utilities & small changes
apps/web/lib/tinybird/record-fake-click.ts, apps/web/lib/stripe/*, apps/web/lib/api/programs/get-program-enrollment-or-throw.ts, apps/web/lib/api/discounts/is-discount-equivalent.ts, apps/web/lib/api/create-id.ts
Synthetic click recording, new Stripe helpers, optional includeDiscountCodes flag, discount equivalence helper, and new id prefix dcode_.
Formatting / misc / removals
packages/stripe-app/stripe-app.json, packages/ui/src/slider.tsx, apps/web/lib/api/partners/delete-partner.ts (removed), scripts
Minor formatting, unused var removal, deletion of legacy deletePartner module, and script variable-name updates.

Sequence Diagram(s)

sequenceDiagram
  participant UI as AddDiscountCodeModal
  participant API as POST /api/discount-codes
  participant Stripe as Stripe (Connected)
  participant DB as Prisma
  participant Q as QStash
  participant Audit as Audit Log

  UI->>API: submit { partnerId, linkId, code? }
  API->>DB: validate partner, link, enrollment, discount
  API->>Stripe: create promotion code (retry on duplicate)
  Stripe-->>API: promotionCode
  API->>DB: create DiscountCode
  API--)Q: waitUntil(recordAuditLog..., enqueue deletion if needed)
  API-->>UI: return DiscountCode
Loading
sequenceDiagram
  participant Q as QStash
  participant Cron as /api/cron/discount-codes/:id/delete
  participant Stripe as Stripe (Connected)
  participant DB as Prisma

  Q->>Cron: enqueue delete job (discountCodeId)
  Cron->>DB: fetch DiscountCode + Workspace
  alt found
    Cron->>Stripe: disable promotion code
    Stripe-->>Cron: ok
    Cron->>DB: delete DiscountCode
  else not found
    Cron-->>Q: no-op / not found
  end
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

Poem

I nibble keys and hop through code,
I stitch new coupons on the road.
Queues hum soft, webhooks tap the log,
Links remap tidy through the fog.
A rabbit’s hop — discounts live anew. 🥕✨

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title Check ✅ Passed The title “Discount code attribution tracking” accurately captures the primary change of the pull request, which is to implement tracking and attribution for discount/coupon codes across link handling and Stripe integrations; it is concise, specific, and directly reflects the PR objectives without unnecessary detail.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch link-based-coupon-codes

📜 Recent review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between f5083cf and db09f48.

📒 Files selected for processing (1)
  • apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts (11 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts (4)
apps/web/app/(ee)/api/stripe/integration/webhook/utils/update-customer-with-stripe-customer-id.ts (1)
  • updateCustomerWithStripeCustomerId (3-36)
apps/web/app/(ee)/api/stripe/integration/webhook/utils/get-promotion-code.ts (1)
  • getPromotionCode (3-26)
apps/web/lib/tinybird/record-fake-click.ts (1)
  • recordFakeClick (8-53)
apps/web/lib/api/create-id.ts (1)
  • createId (65-70)
⏰ 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). (1)
  • GitHub Check: build

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 and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

♻️ Duplicate comments (3)
apps/web/app/(ee)/api/groups/[groupIdOrSlug]/route.ts (1)

266-373: Move discount-code read into the transaction.

Fetching discountCodesToDelete before the transaction opens a race: codes created after the read but before updateMany lose their discountId without ever being queued for deletion. Pull the ID lookup inside the $transaction, return the IDs from the transactional block (with Serializable isolation), and enqueue once with that list so no codes slip through.

-    // Cache discount codes to delete them later
-    let discountCodesToDelete: DiscountCode[] = [];
-    if (group.discountId && !keepDiscountCodes) {
-      discountCodesToDelete = await prisma.discountCode.findMany({
-        where: {
-          discountId: group.discountId,
-        },
-      });
-    }
-
-    const deletedGroup = await prisma.$transaction(async (tx) => {
+    const discountCodeIdsToDelete = await prisma.$transaction(
+      async (tx) => {
+        let idsToDelete: string[] = [];
         // 1. Update all partners in the group to the default group
@@
-      if (group.discountId) {
-        // 3. Update the discount codes
-        await tx.discountCode.updateMany({
-          where: {
-            discountId: group.discountId,
-          },
-          data: {
-            discountId: keepDiscountCodes ? defaultGroup.discountId : null,
-          },
-        });
-
-        // 4. Delete the group's discount
-        await tx.discount.delete({
-          where: {
-            id: group.discountId,
-          },
-        });
-      }
-
-      // 5. Delete the group
-      await tx.partnerGroup.delete({
-        where: {
-          id: group.id,
-        },
-      });
-
-      return true;
-    });
+        if (group.discountId) {
+          if (!keepDiscountCodes) {
+            const rows = await tx.discountCode.findMany({
+              where: { discountId: group.discountId },
+              select: { id: true },
+            });
+            idsToDelete = rows.map(({ id }) => id);
+          }
+          await tx.discountCode.updateMany({
+            where: { discountId: group.discountId },
+            data: {
+              discountId: keepDiscountCodes ? defaultGroup.discountId : null,
+            },
+          });
+
+          await tx.discount.delete({
+            where: { id: group.discountId },
+          });
+        }
+
+        await tx.partnerGroup.delete({
+          where: { id: group.id },
+        });
+
+        return idsToDelete;
+      },
+      { isolationLevel: Prisma.TransactionIsolationLevel.Serializable },
+    );
@@
-    if (deletedGroup) {
-      waitUntil(
-        Promise.allSettled([
+    waitUntil(
+      Promise.allSettled([
@@
-          ...discountCodesToDelete.map((discountCode) =>
-            queueDiscountCodeDeletion(discountCode.id),
-          ),
+        discountCodeIdsToDelete.length > 0 &&
+          queueDiscountCodeDeletion(discountCodeIdsToDelete),
@@
-        ]),
-      );
-    }
+      ]),
+    );
apps/web/lib/api/discounts/queue-discount-code-deletion.ts (1)

16-39: Filter and dedupe discount IDs before enqueuing.

When input is undefined (or contains blanks), discountCodeIds becomes [undefined], so we enqueue .../undefined/delete, wasting jobs and tripping the route. We should sanitize and dedupe IDs before chunking.

-  const discountCodeIds = Array.isArray(input) ? input : [input];
-
-  if (discountCodeIds.length === 0) {
+  const discountCodeIds = (Array.isArray(input) ? input : [input]).filter(
+    (id): id is string => typeof id === "string" && id.trim().length > 0,
+  );
+
+  if (discountCodeIds.length === 0) {
     return;
   }
@@
-  const chunks = chunk(discountCodeIds, 100);
+  const uniqueIds = Array.from(new Set(discountCodeIds));
+
+  const chunks = chunk(uniqueIds, 100);
apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts (1)

44-44: Verify promotion code extraction covers all Stripe discount sources.

Per past review comments, charge.discounts?.[0]?.promotion_code may miss applied codes depending on Stripe object shape. Consider also checking total_details.breakdown.discounts as the canonical source for promotion codes. This is a known issue flagged in previous reviews.

🧹 Nitpick comments (1)
apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts (1)

259-272: Consider defensive error handling for recordFakeClick.

The recordFakeClick helper can throw if click recording fails. If this occurs, the entire webhook will fail, preventing customer and sale attribution. Consider wrapping this call in a try-catch block and falling back to a minimal click event or logging the error while continuing with a default attribution strategy.

Apply this diff to add error handling:

-    clickEvent = await recordFakeClick({
-      link,
-      customer: {
-        continent: customerAddress?.country
-          ? COUNTRIES_TO_CONTINENTS[customerAddress.country]
-          : "NA",
-        country: customerAddress?.country ?? "US",
-        region: customerAddress?.state ?? "CA",
-      },
-    });
+    try {
+      clickEvent = await recordFakeClick({
+        link,
+        customer: {
+          continent: customerAddress?.country
+            ? COUNTRIES_TO_CONTINENTS[customerAddress.country]
+            : "NA",
+          country: customerAddress?.country ?? "US",
+          region: customerAddress?.state ?? "CA",
+        },
+      });
+    } catch (error) {
+      console.error("Failed to record fake click for promotion code:", error);
+      return `Failed to record fake click for promotion code ${promotionCode.code}, skipping...`;
+    }
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 5a4c91b and 7c27b6e.

📒 Files selected for processing (5)
  • apps/web/app/(ee)/api/cron/groups/remap-discount-codes/route.ts (1 hunks)
  • apps/web/app/(ee)/api/groups/[groupIdOrSlug]/route.ts (5 hunks)
  • apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts (7 hunks)
  • apps/web/lib/api/discounts/is-discount-equivalent.ts (1 hunks)
  • apps/web/lib/api/discounts/queue-discount-code-deletion.ts (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/web/app/(ee)/api/cron/groups/remap-discount-codes/route.ts
🧰 Additional context used
🧬 Code graph analysis (3)
apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts (2)
apps/web/app/(ee)/api/stripe/integration/webhook/utils.ts (1)
  • getPromotionCode (245-268)
apps/web/lib/tinybird/record-fake-click.ts (1)
  • recordFakeClick (8-53)
apps/web/app/(ee)/api/groups/[groupIdOrSlug]/route.ts (5)
apps/web/lib/zod/schemas/groups.ts (1)
  • DEFAULT_PARTNER_GROUP (15-19)
apps/web/lib/api/discounts/is-discount-equivalent.ts (1)
  • isDiscountEquivalent (3-26)
packages/utils/src/constants/main.ts (1)
  • APP_DOMAIN_WITH_NGROK (20-25)
apps/web/lib/api/discounts/queue-discount-code-deletion.ts (1)
  • queueDiscountCodeDeletion (13-41)
apps/web/lib/api/audit-logs/record-audit-log.ts (1)
  • recordAuditLog (47-73)
apps/web/lib/api/discounts/queue-discount-code-deletion.ts (2)
apps/web/lib/firstpromoter/importer.ts (1)
  • queue (34-39)
packages/utils/src/constants/main.ts (1)
  • APP_DOMAIN_WITH_NGROK (20-25)
⏰ 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). (1)
  • GitHub Check: build
🔇 Additional comments (8)
apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts (8)

6-6: LGTM!

The new imports align with the promotion-code attribution flow and are all utilized in the implementation.

Also applies to: 14-14, 24-24, 29-29


51-51: LGTM!

The shouldSendLeadWebhook flag appropriately controls lead webhook emission for synthetic promotion-code leads.


210-218: LGTM!

The promotion code retrieval logic correctly delegates to the getPromotionCode helper and provides a clear early return message.


221-238: LGTM!

The workspace resolution correctly fetches defaultProgramId and provides clear early returns when workspace or program is missing. This aligns with past review recommendations.


274-303: LGTM!

The customer creation and lead event preparation logic is correct:

  • Fallback name generation handles missing customer details gracefully.
  • The externalId is appropriately set to the synthetic click_id.
  • Lead event correctly removes the timestamp field before schema validation.
  • Setting shouldSendLeadWebhook to false appropriately suppresses the lead webhook for synthetic promotion-code leads.

133-133: LGTM!

The consistent use of nanoid(16) for event_id generation across all attribution paths ensures unique event identifiers and proper tracking.

Also applies to: 297-297, 368-368


467-467: LGTM!

Referencing saleData.event_id for partner commissions correctly maintains event identifier consistency between sales and commissions.


521-521: LGTM!

The shouldSendLeadWebhook gate correctly prevents lead webhook emission for synthetic promotion-code leads while preserving existing behavior for other attribution paths.

Copy link
Contributor

@coderabbitai coderabbitai bot left a 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/ui/modals/delete-discount-code-modal.tsx (1)

71-79: Fix a11y: remove always-invalid state and anchor the pattern.

aria-invalid="true" marks the field invalid even when correct. The pattern should be anchored and include a helpful title for assistive technology.

Apply this diff:

                   <input
                     type="text"
                     required
                     autoComplete="off"
                     className="block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm"
-                    aria-invalid="true"
                     autoFocus={!isMobile}
-                    pattern="delete code"
+                    id="confirm-delete-code"
+                    name="confirmation"
+                    pattern="^delete code$"
+                    title='Type "delete code" to confirm'
+                    spellCheck={false}
                   />
🧹 Nitpick comments (2)
apps/web/ui/modals/delete-discount-code-modal.tsx (2)

23-34: Consider reordering success operations for better UX.

The current order (close modal → mutate cache → toast) works but could cause a brief flash. Consider awaiting cache mutation before closing the modal to ensure a smooth transition.

Apply this diff to reorder operations:

       onSuccess: async () => {
-        setShowModal(false);
         await mutatePrefix("/api/discount-codes");
         toast.success(`Discount code deleted successfully!`);
+        setShowModal(false);
       },

49-54: Prefer the DiscountCodeBadge component to avoid duplication.

The inline badge markup duplicates logic from apps/web/ui/partners/discounts/discount-code-badge.tsx. Reusing the existing component reduces maintenance burden and ensures consistent styling.

Apply this diff:

+import { DiscountCodeBadge } from "@/ui/partners/discounts/discount-code-badge";
+
 // ... in the component JSX:

-            <div className="relative flex h-7 w-fit items-center gap-1.5 rounded-lg bg-green-100 px-2 py-0">
-              <Tag className="size-3 text-green-700" strokeWidth={1.5} />
-              <div className="text-xs font-medium text-green-700">
-                {discountCode.code}
-              </div>
-            </div>
+            <DiscountCodeBadge code={discountCode.code} />
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 7c27b6e and 6c3800d.

📒 Files selected for processing (5)
  • apps/web/app/(ee)/api/discount-codes/route.ts (1 hunks)
  • apps/web/lib/actions/partners/deactivate-partner.ts (3 hunks)
  • apps/web/lib/api/create-id.ts (1 hunks)
  • apps/web/lib/api/discounts/queue-discount-code-deletion.ts (1 hunks)
  • apps/web/ui/modals/delete-discount-code-modal.tsx (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (2)
  • apps/web/app/(ee)/api/discount-codes/route.ts
  • apps/web/lib/api/discounts/queue-discount-code-deletion.ts
🧰 Additional context used
🧬 Code graph analysis (2)
apps/web/lib/actions/partners/deactivate-partner.ts (1)
apps/web/lib/api/discounts/queue-discount-code-deletion.ts (1)
  • queueDiscountCodeDeletion (13-41)
apps/web/ui/modals/delete-discount-code-modal.tsx (2)
apps/web/lib/types.ts (1)
  • DiscountCodeProps (455-455)
apps/web/lib/swr/use-api-mutation.ts (1)
  • useApiMutation (33-108)
⏰ 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). (1)
  • GitHub Check: build
🔇 Additional comments (7)
apps/web/lib/api/create-id.ts (1)

29-29: LGTM!

The addition of the "dcode_" prefix for discount code entities is consistent with the existing naming convention and aligns with the PR's objective of adding discount code attribution tracking.

apps/web/ui/modals/delete-discount-code-modal.tsx (5)

1-7: LGTM!

All imports are used and appropriate for the component's functionality.


9-13: LGTM!

Interface definition is clear and type-safe.


15-21: LGTM!

Component setup and hook usage follow React best practices.


36-40: LGTM!

Modal structure and header follow the established component patterns.


85-101: LGTM!

Action buttons are properly configured with correct types, variants, and loading/disabled states.

apps/web/lib/actions/partners/deactivate-partner.ts (1)

4-4: Mapping is safe – no changes needed
Prisma’s includeDiscountCodes: true always returns an array (empty if no codes), so programEnrollment.discountCodes.map(...) cannot throw at runtime.

@steven-tey
Copy link
Collaborator

@coderabbitai I made some changes to checkout.session.completed, could you please review it fully and see if I missed anything?

Copy link
Contributor

coderabbitai bot commented Oct 3, 2025

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Fetch the current checkout-session-completed webhook handler
cat apps/web/app/\(ee\)/api/stripe/integration/webhook/checkout-session-completed.ts

Length of output: 18779


🏁 Script executed:

#!/bin/bash
# Check recordFakeClick implementation
cat apps/web/lib/tinybird/record-fake-click.ts

Length of output: 1498


@steven-tey I've reviewed the checkout.session.completed changes thoroughly and they look excellent! Here's what I verified:

✅ All Previous Concerns Addressed

  1. Livemode Configuration: Correctly uses event.livemode from the webhook event (not env vars) - this ensures test/live mode alignment with Stripe's event context.

  2. Connected Account Context: stripeAccountId properly extracted from event.account and passed to all Stripe API calls (getPromotionCode, getConnectedCustomer, getSubscriptionProductId).

  3. Robust Promotion Code Attribution: The new attributeViaPromoCode function:

    • ✅ Validates promotion code exists in Stripe
    • ✅ Looks up DiscountCode using the robust programId_code unique constraint
    • ✅ Creates complete audit trail: fake click → customer → lead event
    • ✅ Properly awaits recordFakeClick (which throws on failure)
    • ✅ Returns structured data or null for clear error handling
  4. Event ID Handling: Consistently uses nanoid(16) for both lead and sale event IDs.

  5. Idempotency: Redis-based invoice deduplication prevents duplicate processing with 7-day TTL.

🎯 Key Strengths

  • shouldSendLeadWebhook flag: Elegantly prevents duplicate lead webhooks when attribution happens via promo code
  • Currency handling: Comprehensive support for Stripe Adaptive Pricing with fallback conversion
  • Error messages: Descriptive returns for debugging (e.g., "Failed to attribute via promotion code...")
  • Proper async flow: All promises correctly awaited, no race conditions

📝 Minor Observations (Not Issues)

  • In the promo attribution path, stripeCustomerId isn't set initially on the customer record (since we're attributing before Stripe association). This is intentional and will be updated on subsequent Stripe events when the customer metadata is synced.

No issues found! The implementation is solid and production-ready. Great work on the refactor! 🚀

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🧹 Nitpick comments (3)
apps/web/lib/analytics/is-first-conversion.ts (1)

17-20: Consider explicit handling when linkId is undefined.

The comparison customer.linkId !== linkId when linkId is undefined will return true whenever customer.linkId is set, which may be unexpected. Consider adding explicit logic to clarify the behavior:

-  if (customer.linkId !== linkId) {
+  // if linkId is not provided, or customer's link differs from current link
+  if (linkId === undefined || customer.linkId !== linkId) {
     // TODO: fix edge case where customer was brought in by a different link, but then had recurring sales on the current link
     return true;
   }

This makes the logic more explicit and documents the intended behavior when linkId is not provided.

apps/web/app/(ee)/api/stripe/integration/webhook/utils/get-subscription-product-id.ts (2)

3-11: Add explicit return type annotation.

The function should declare its return type explicitly for better type safety and documentation.

Apply this diff:

-export async function getSubscriptionProductId({
+export async function getSubscriptionProductId({
   stripeSubscriptionId,
   stripeAccountId,
   livemode = true,
-}: {
+}: {
   stripeSubscriptionId?: string | null;
   stripeAccountId?: string | null;
   livemode?: boolean;
-}) {
+}): Promise<string | null> {

24-24: Enhance error logging with subscription ID context.

The error log lacks the subscription ID that failed, making debugging harder. Include stripeSubscriptionId in the log for better observability.

Apply this diff:

   } catch (error) {
-    console.log("Failed to get subscription price ID:", error);
+    console.log(
+      `Failed to get subscription product ID for ${stripeSubscriptionId}:`,
+      error
+    );
     return null;
   }
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 6fc58cc and 2975282.

📒 Files selected for processing (12)
  • apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts (11 hunks)
  • apps/web/app/(ee)/api/stripe/integration/webhook/customer-created.ts (1 hunks)
  • apps/web/app/(ee)/api/stripe/integration/webhook/customer-updated.ts (1 hunks)
  • apps/web/app/(ee)/api/stripe/integration/webhook/invoice-paid.ts (3 hunks)
  • apps/web/app/(ee)/api/stripe/integration/webhook/route.ts (3 hunks)
  • apps/web/app/(ee)/api/stripe/integration/webhook/utils/create-new-customer.ts (0 hunks)
  • apps/web/app/(ee)/api/stripe/integration/webhook/utils/get-connected-customer.ts (1 hunks)
  • apps/web/app/(ee)/api/stripe/integration/webhook/utils/get-promotion-code.ts (1 hunks)
  • apps/web/app/(ee)/api/stripe/integration/webhook/utils/get-subscription-product-id.ts (1 hunks)
  • apps/web/app/(ee)/api/stripe/integration/webhook/utils/update-customer-with-stripe-customer-id.ts (1 hunks)
  • apps/web/lib/analytics/is-first-conversion.ts (1 hunks)
  • packages/utils/src/constants/regions.ts (1 hunks)
💤 Files with no reviewable changes (1)
  • apps/web/app/(ee)/api/stripe/integration/webhook/utils/create-new-customer.ts
🧰 Additional context used
🧬 Code graph analysis (3)
apps/web/app/(ee)/api/stripe/integration/webhook/route.ts (3)
apps/web/app/(ee)/api/stripe/integration/webhook/coupon-deleted.ts (1)
  • couponDeleted (12-133)
apps/web/app/(ee)/api/stripe/integration/webhook/promotion-code-updated.ts (1)
  • promotionCodeUpdated (5-54)
apps/web/app/(ee)/api/cron/utils.ts (1)
  • logAndRespond (1-13)
apps/web/app/(ee)/api/stripe/integration/webhook/invoice-paid.ts (2)
apps/web/app/(ee)/api/stripe/integration/webhook/utils/get-connected-customer.ts (1)
  • getConnectedCustomer (3-28)
apps/web/lib/upstash/redis.ts (1)
  • redis (4-7)
apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts (5)
apps/web/app/(ee)/api/stripe/integration/webhook/utils/update-customer-with-stripe-customer-id.ts (1)
  • updateCustomerWithStripeCustomerId (3-36)
apps/web/app/(ee)/api/stripe/integration/webhook/utils/get-promotion-code.ts (1)
  • getPromotionCode (3-26)
apps/web/lib/tinybird/record-fake-click.ts (1)
  • recordFakeClick (8-53)
packages/utils/src/constants/regions.ts (1)
  • REGION_CODE_LOOKUP (3489-3491)
apps/web/lib/api/create-id.ts (1)
  • createId (65-70)
⏰ 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). (1)
  • GitHub Check: build
🔇 Additional comments (10)
apps/web/lib/analytics/is-first-conversion.ts (1)

8-8: Confirm behavior when linkId is undefined.

Call sites always pass linkId (e.g., saleData.link_id, webhook payloads), but these values can be undefined; when undefined and customer.linkId exists, isFirstConversion returns true—verify this aligns with your attribution requirements.

apps/web/app/(ee)/api/stripe/integration/webhook/utils/get-connected-customer.ts (4)

3-11: Verify the livemode default aligns with environment expectations.

The default livemode = true means production Stripe mode is used unless explicitly overridden. Based on the PR comments about livemode configuration concerns, ensure that callers consistently pass event.livemode to avoid accidentally querying production Stripe in non-production environments.

Consider whether the default should be derived from NODE_ENV === "production" instead, or require the parameter to be explicitly provided.

Based on PR comments about livemode configuration.


12-15: LGTM!

The early return validation correctly handles missing parameters.


17-21: Consider error handling for the Stripe API call.

The Stripe API call has no try-catch block, so network errors, invalid IDs, or permission issues will throw and potentially fail webhook processing. Verify that callers properly handle these errors, or consider adding defensive error handling here.


23-28: LGTM!

The deleted customer check is correct and returns null appropriately.

apps/web/app/(ee)/api/stripe/integration/webhook/invoice-paid.ts (5)

16-16: LGTM!

The more specific import path improves clarity.


32-32: LGTM!

The comment accurately reflects the terminology change.


58-58: LGTM!

The error message correctly uses the new terminology.


65-65: LGTM!

The error message clearly describes the failure scenario with correct terminology.


73-73: LGTM!

The Redis payload field correctly uses the new terminology and appropriate value.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (4)
apps/web/lib/actions/partners/create-discount.ts (1)

8-103: Prevent orphaned Stripe coupons when the DB transaction fails.

We still create the Stripe coupon before the Prisma transaction and never clean it up if the transaction throws, so any DB failure leaves a live coupon in the connected Stripe account. Track the created coupon id and best-effort delete it when the transaction rejects (using the delete helper) before rethrowing.

@@
-import { createStripeCoupon } from "@/lib/stripe/create-stripe-coupon";
+import { createStripeCoupon } from "@/lib/stripe/create-stripe-coupon";
+import { deleteStripeCoupon } from "@/lib/stripe/delete-stripe-coupon";
@@
-    const shouldCreateCouponOnStripe = !couponId && !couponTestId;
+    const shouldCreateCouponOnStripe = !couponId && !couponTestId;
+    let createdStripeCouponId: string | null = null;
@@
-        if (stripeCoupon) {
-          couponId = stripeCoupon.id;
-        }
+        if (stripeCoupon) {
+          couponId = stripeCoupon.id;
+          createdStripeCouponId = stripeCoupon.id;
+        }
@@
-    const discount = await prisma.$transaction(async (tx) => {
+    const discount = await prisma
+      .$transaction(async (tx) => {
@@
-        return discount;
-      });
-    });
+        return discount;
+      })
+      .catch(async (transactionError) => {
+        if (createdStripeCouponId && workspace.stripeConnectId) {
+          await deleteStripeCoupon({
+            stripeConnectId: workspace.stripeConnectId,
+            couponId: createdStripeCouponId,
+          });
+        }
+        throw transactionError;
+      });
apps/web/app/(ee)/api/discount-codes/route.ts (3)

92-109: Normalize code before duplicate checks to prevent case/whitespace duplicates.

The duplicate check uses the raw code input without normalization. This allows "DEMO", "demo", " demo " to all pass the uniqueness check, then fail later at Stripe creation or cause inconsistent DB state.

Apply this diff to normalize early:

+    // Normalize code (trim and uppercase) if provided
+    const normalizedCode = code ? code.trim().toUpperCase() : undefined;
+
     // Check for duplicate by code
-    if (code) {
+    if (normalizedCode) {
       const duplicateByCode = await prisma.discountCode.findUnique({
         where: {
           programId_code: {
             programId,
-            code,
+            code: normalizedCode,
           },
         },
       });
 
       if (duplicateByCode) {
         throw new DubApiError({
           code: "bad_request",
-          message: `A discount with the code ${code} already exists in the program. Please choose a different code.`,
+          message: `A discount with the code ${normalizedCode} already exists in the program. Please choose a different code.`,
         });
       }
     }

123-132: Apply normalization to finalCode and pass to Stripe.

The finalCode is constructed from unnormalized inputs and passed directly to Stripe. This creates a mismatch: Stripe will uppercase it (line 130 in create-stripe-discount-code.ts does .toUpperCase()), but the DB check earlier used the raw value.

Apply this diff to keep DB and Stripe in sync:

     // Use the link.key as the code if no code is provided
-    const finalCode = code || link.key;
+    const finalCode = (normalizedCode ?? link.key).trim().toUpperCase();
 
     try {
       const stripeDiscountCode = await createStripeDiscountCode({
         stripeConnectId: workspace.stripeConnectId,
         discount,
         code: finalCode,
-        shouldRetry: !code,
+        shouldRetry: !normalizedCode,
       });

170-178: Improve error handling to avoid exposing internal error details.

The catch block directly exposes error.message to the client for non-Stripe-permission errors. This can leak internal implementation details (e.g., database constraint names, stack traces).

Apply this diff:

     } catch (error) {
+      console.error("Failed to create discount code:", error);
+      
       throw new DubApiError({
         code: "bad_request",
         message:
           error.code === "more_permissions_required_for_application"
             ? "STRIPE_APP_UPGRADE_REQUIRED: Your connected Stripe account doesn't have the permissions needed to create discount codes. Please upgrade your Stripe integration in settings or reach out to our support team for help."
-            : error.message,
+            : error instanceof Error && error.message
+              ? error.message
+              : "Failed to create discount code. Please try again.",
       });
     }
🧹 Nitpick comments (1)
apps/web/ui/partners/discounts/discount-code-badge.tsx (1)

5-29: Add keyboard accessibility for the copy action.

The button lacks keyboard-accessible feedback. When a keyboard user presses Enter/Space, the code is copied but the visual "copied" state (line 13) doesn't provide non-visual feedback for screen reader users.

Apply this diff to add accessible feedback:

 export function DiscountCodeBadge({ code }: { code: string }) {
   const [copied, copyToClipboard] = useCopyToClipboard();
   return (
     <button
       type="button"
+      aria-label={copied ? "Discount code copied" : `Copy discount code ${code}`}
       className={cn(
         "group/discountcode relative flex w-fit cursor-copy items-center gap-1 rounded-lg bg-green-200 px-2 py-1",
         "transition-colors duration-150 hover:bg-green-300/80",
         copied && "cursor-default",
       )}
       onClick={() =>
         copyToClipboard(code, {
           onSuccess: () => {
             toast.success("Copied discount code to clipboard");
           },
         })
       }
     >
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 52fa2b1 and c4395ac.

📒 Files selected for processing (6)
  • apps/web/app/(ee)/api/discount-codes/route.ts (1 hunks)
  • apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/links/partner-link-card.tsx (5 hunks)
  • apps/web/lib/actions/partners/create-discount.ts (4 hunks)
  • apps/web/lib/api/partners/delete-partner.ts (0 hunks)
  • apps/web/scripts/partners/delete-partner-profile.ts (1 hunks)
  • apps/web/ui/partners/discounts/discount-code-badge.tsx (1 hunks)
💤 Files with no reviewable changes (1)
  • apps/web/lib/api/partners/delete-partner.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/links/partner-link-card.tsx
🧰 Additional context used
🧬 Code graph analysis (2)
apps/web/lib/actions/partners/create-discount.ts (1)
apps/web/lib/stripe/create-stripe-coupon.ts (1)
  • createStripeCoupon (10-72)
apps/web/app/(ee)/api/discount-codes/route.ts (8)
apps/web/lib/auth/workspace.ts (1)
  • withWorkspace (42-436)
apps/web/lib/zod/schemas/discount.ts (3)
  • getDiscountCodesQuerySchema (70-72)
  • DiscountCodeSchema (53-59)
  • createDiscountCodeSchema (61-68)
apps/web/lib/api/programs/get-program-enrollment-or-throw.ts (1)
  • getProgramEnrollmentOrThrow (6-109)
apps/web/lib/api/utils.ts (1)
  • parseRequestBody (9-20)
apps/web/lib/api/errors.ts (1)
  • DubApiError (75-92)
apps/web/lib/stripe/create-stripe-discount-code.ts (1)
  • createStripeDiscountCode (11-76)
apps/web/lib/api/create-id.ts (1)
  • createId (65-70)
apps/web/lib/api/audit-logs/record-audit-log.ts (1)
  • recordAuditLog (47-73)
⏰ 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). (1)
  • GitHub Check: build
🔇 Additional comments (3)
apps/web/scripts/partners/delete-partner-profile.ts (1)

32-70: LGTM! Variable naming improvements enhance code clarity.

The renaming from deleteX to deletedX pattern consistently across all deletion operations improves readability by clearly indicating these variables hold the results of completed delete operations rather than representing the intent to delete. The corresponding console.log updates maintain consistency throughout the script.

apps/web/ui/partners/discounts/discount-code-badge.tsx (1)

1-4: LGTM!

Imports are correctly structured. The sonner import matches the documented API for v1.4.41 (toast.success is supported). Based on learnings.

apps/web/app/(ee)/api/discount-codes/route.ts (1)

18-47: LGTM!

The GET handler correctly retrieves discount codes with proper access control, validates using the schema, and returns a clean response.

@steven-tey steven-tey merged commit 3a6d6cb into main Oct 3, 2025
7 of 8 checks passed
@steven-tey steven-tey deleted the link-based-coupon-codes branch October 3, 2025 22:55
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.

4 participants