Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Conversation

@steven-tey
Copy link
Collaborator

@steven-tey steven-tey commented Sep 17, 2025

Summary by CodeRabbit

  • New Features

    • Choose “Use existing events” or “Create new events” when creating a commission; submissions include commission type and event-source choice.
    • View matching customer events in a table (filter by lead/sale, Amount column for sales, clickable event links), with loading and empty states.
    • Optional Invoice ID and Product ID toggles to reveal inputs.
  • Improvements

    • “Customer” label renamed to “Customer and commission details”; consolidated lead/sale flow and refined loading/accordion behavior.
    • Error feedback shown via toasts only; partners table loading indicator simplified.
  • Chores

    • Partner details sheet UI removed.

@vercel
Copy link
Contributor

vercel bot commented Sep 17, 2025

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

Project Deployment Preview Updated (UTC)
dub Ready Ready Preview Sep 18, 2025 11:01pm

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Sep 17, 2025

Walkthrough

Adds an event-source toggle and optional identifier toggles to the commission UI; extends manual commission action to optionally reuse or duplicate Tinybird events when transferring customers; introduces Tinybird helpers/ingest schemas, zod schema fields, and related types; removes the PartnerDetailsSheet component.

Changes

Cohort / File(s) Summary of changes
UI: Commission form (event-source + optional IDs)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-commission-sheet.tsx
Adds useExistingEvents ToggleGroup controlling conditional rendering of existing-events table vs new-event fields; adds hasInvoiceId/hasProductId toggles with collapsible inputs; includes commissionType/partnerId/useExistingEvents in submit payload; renames Customer accordion trigger; removes console.log(error); adjusts loading skeletons and layout.
Backend action: Manual commission + event duplication
apps/web/lib/actions/partners/create-manual-commission.ts
Accepts commissionType and useExistingEvents; opt-in duplication path validates linkId, fetches events via Tinybird helper, duplicates click/lead/sale events under a new customer id (timestamped), batches Tinybird writes, nullifies/transfers original commission event IDs, updates link stats, and creates partner commissions; preserves non-duplication/new-event path and centralizes Tinybird writes before DB updates.
Schema: Commission validation
apps/web/lib/zod/schemas/commissions.ts
Adds commissionType: z.nativeEnum(CommissionType) and useExistingEvents: z.boolean() to createCommissionSchema.
Tinybird helpers & ingest schemas
apps/web/lib/tinybird/get-customer-events-tb.ts
apps/web/lib/tinybird/record-click-zod.ts
Adds getCustomerEventsTB wrapper for TB pipe (v2_customer_events) and recordClickZod + recordClickZodSchema ingest endpoint (datasource dub_click_events).
Analytics helper adjustment
apps/web/lib/analytics/get-customer-events.ts
Replaces inline Tinybird pipe usage with getCustomerEventsTB and adjusts function signature typing.
Types: Tinybird event types
apps/web/lib/types.ts
Adds exported type alias SaleEventTB = z.infer<typeof saleEventSchemaTB>.
Partners UI: PartnerDetailsSheet removal
apps/web/ui/partners/partner-details-sheet.tsx
apps/web/app/.../partners/partners-table.tsx
Removes PartnerDetailsSheet component file and its inline usage/import from partners-table.tsx; drops related state/effect and simplifies loading logic.
Partners: create-partner-commission typing & logging
apps/web/lib/partners/create-partner-commission.ts
Adds exported CreatePartnerCommissionProps type, updates function signature to use it, defaults amount = 0, normalizes eventId/invoiceId to null for DB writes, and adjusts post-creation logging to format earnings with currency.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  actor User
  participant UI as CreateCommission UI
  participant Server as createManualCommissionAction
  participant TB as Tinybird
  participant DB as Database

  User->>UI: Open form, choose event source & optional IDs
  UI->>User: Render conditional fields or events table
  User->>UI: Submit payload (partnerId, commissionType, useExistingEvents, optional IDs)
  UI->>Server: POST createManualCommission(payload)

  alt useExistingEvents = true AND transferring customer
    Server->>TB: getCustomerEventsTB(customerId, linkIds?)
    TB-->>Server: return events
    Server->>Server: generate new customer id (duplicate)
    loop per event
      Server->>TB: write duplicated event (timestamped)
    end
    Server->>DB: mark old customer, create new customer, update link stats
    Server->>DB: create partner commissions for duplicated events
  else useExistingEvents = true AND same partner
    Server->>DB: create commissions referencing existing event ids
  else useExistingEvents = false
    Server->>TB: recordClickZod (click) and write lead/sale events
    Server->>DB: create commissions for new events
  end

  Server-->>UI: success / failure
  UI-->>User: show toast
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested reviewers

  • devkiran
  • TWilson023

Poem

I’m a little rabbit in code so bright,
Toggling events through the soft moonlight.
Tinybird copies and hops with glee,
Commissions settle, customers free.
Hooray — data springs, light and spry! 🐇✨

Pre-merge checks and finishing touches

✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title Check ✅ Passed The title "Improve manual commission flow to reuse existing events if present" accurately and succinctly describes the PR's primary change: introducing a useExistingEvents workflow and related UI/schema/action adjustments to reuse or duplicate customer events when creating manual commissions. The raw_summary shows coordinated changes across the UI, create-manual-commission action, schemas, and Tinybird helpers that implement this behavior, so the title is relevant, specific, and clear for reviewers scanning history.
Docstring Coverage ✅ Passed No functions found in the changes. Docstring coverage check skipped.
✨ Finishing touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch improve-manual-commission

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: 6

🧹 Nitpick comments (3)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-commission-sheet.tsx (3)

106-116: Accordion state references a removed panel ("sale")

You’re setting openAccordions to include "sale", but there is no "sale" accordion item anymore. Remove it to avoid inconsistent UI state.

   if (commissionType === "custom") {
     setOpenAccordions([...baseValues, "commission"]);
   } else if (commissionType === "sale") {
-    setOpenAccordions([...baseValues, "customer", "sale"]);
+    setOpenAccordions([...baseValues, "customer"]);
   } else if (commissionType === "lead") {
     setOpenAccordions([...baseValues, "customer"]);
   }

181-187: Fix state setter typos and add selection state

Rename misspelled setters to avoid confusion and add state for the selected existing event.

-const [existingEvents, setExistingEventss] = useState<
+const [existingEvents, setExistingEvents] = useState<
   { type: string; date?: string; amount?: number }[]
 >([]);
-const [existingEventsLoading, setExistingEventssLoading] = useState(false);
+const [existingEventsLoading, setExistingEventsLoading] = useState(false);
+
+const [selectedExistingEvent, setSelectedExistingEvent] = useState<
+  { type: string; date?: string; amount?: number } | null
+>(null);
+
+useEffect(() => {
+  setSelectedExistingEvent(null);
+}, [customerId, commissionType, useExistingEvents]);

662-691: Apply money keydown handling to sale amount for consistency

Match the custom commission amount behavior and prevent invalid number input.

   render={({ field }) => (
     <input
       {...field}
       type="number"
       onWheel={(e) => e.currentTarget.blur()}
+      onKeyDown={handleMoneyKeyDown}
+      step="0.01"
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between b4081ab and 8c803cc.

📒 Files selected for processing (1)
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-commission-sheet.tsx (5 hunks)
⏰ 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). (2)
  • GitHub Check: Vade Review
  • GitHub Check: build
🔇 Additional comments (2)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-commission-sheet.tsx (2)

20-20: LGTM: Loading spinner import

Import looks correct and is used below.


454-454: Copy tweak is fine

“Customer and commission details” reads better given the merged flow.

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: 2

♻️ Duplicate comments (6)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-commission-sheet.tsx (6)

120-127: Accordion auto-open references a non-existent "sale" section

Keep only "customer". This was flagged already; fix here too.

Apply this diff:

-    } else if (commissionType === "sale") {
-      setOpenAccordions([...baseValues, "customer", "sale"]);
+    } else if (commissionType === "sale") {
+      setOpenAccordions([...baseValues, "customer"]);

528-550: Event buttons lacked click handlers previously—now addressed

Prior review noted missing onClick. Ensure the new handler also sets eventIds as shown above so the server receives them.


563-646: Lead toggles unreachable when collapsed

Switches live inside collapsing containers. Move switches outside; animate only inputs. Prior comment covers this; please adopt it.


712-768: Invoice ID toggle unreachable (same pattern)

Keep switch visible; animate input only. Prior comment covers exact diff.


769-824: Product ID toggle unreachable (same pattern)

Mirror the invoice fix. Prior comment covers exact diff.


521-555: Wire selection: set eventIds and prefill fields; fix header grammar

Buttons don’t update form state. Persist selection and set form values. Also fix “events” → “event”.

Apply this diff:

-                              <h3 className="mb-3 text-sm font-medium text-neutral-900">
-                                Select an existing events
-                              </h3>
+                              <h3 className="mb-3 text-sm font-medium text-neutral-900">
+                                Select an existing event
+                              </h3>
@@
-                                {existingEvents.map((event, index) => (
+                                {existingEvents.map((event, index) => (
                                   <button
                                     key={index}
                                     type="button"
-                                    className="flex w-full items-center justify-between rounded-md bg-white p-3 text-sm hover:bg-neutral-50 focus:bg-neutral-100 focus:outline-none"
+                                    onClick={() => {
+                                      setValue("eventIds", [event.id], { shouldDirty: true });
+                                      if (commissionType === "sale") {
+                                        // TODO: confirm units; convert cents->dollars if needed
+                                        setValue("saleEventDate", event.date ?? null, { shouldDirty: true });
+                                        setValue("saleAmount", event.amount ?? null, { shouldDirty: true });
+                                      } else if (commissionType === "lead") {
+                                        setValue("leadEventDate", event.date ?? null, { shouldDirty: true });
+                                      }
+                                    }}
+                                    className="flex w-full items-center justify-between rounded-md bg-white p-3 text-sm hover:bg-neutral-50 focus:bg-neutral-100 focus:outline-none"
                                   >
                                     <div>
                                       <span className="font-medium">
-                                        {event.type}
+                                        {event.type.charAt(0).toUpperCase() + event.type.slice(1)}
                                       </span>
🧹 Nitpick comments (5)
apps/web/lib/zod/schemas/commissions.ts (1)

152-153: Constrain and document eventIds; enforce non-empty and basic hygiene

Right now eventIds accepts empty arrays and empty strings. Tighten validation and add a simple cross‑field guard so event reuse can’t be combined with the “custom” flow.

Apply this diff:

-  eventIds: z.array(z.string()).nullish(),
+  // When provided, we will reuse existing events to create commissions.
+  eventIds: z
+    .array(z.string().min(1, "Event ID cannot be empty"))
+    .min(1, "Select at least one event")
+    .max(50, "Too many events selected")
+    .nullish(),

Optionally add a refine to disallow mixing custom commission fields with event reuse:

 export const createCommissionSchema = z.object({
   workspaceId: z.string(),
   partnerId: z.string(),
@@
   productId: z.string().nullish(),
 
-  eventIds: z.array(z.string()).nullish(),
+  eventIds: z
+    .array(z.string().min(1))
+    .min(1)
+    .max(50)
+    .nullish(),
 }).refine(
-  (data) => true,
+  (data) => !data.eventIds || (!data.date && !data.amount),
   {
-    message: "",
+    message:
+      "Do not combine eventIds with the one-time commission fields (date/amount).",
+    path: ["eventIds"],
   },
 );
apps/web/lib/actions/partners/create-manual-commission.ts (1)

123-137: Guard dummy-lead creation behind “no eventIds” earlier to reduce branching complexity

The else-if added is good, but with the early return above we can simplify: treat eventIds first, then fall back to existing/dummy lead. This reduces accidental double-processing.

Apply this diff to re-order the checks:

-    // if there is an existing lead event + no custom lead details were provided
+    // If eventIds present, reuse and return (handled below).
+    // If there is an existing lead event + no custom lead details were provided
     // we can use that leadEvent's existing details
-    if (
+    if (
       !leadEventDate &&
       !leadEventName &&
       existingLeadEvent &&
       existingLeadEvent.data.length > 0
     ) {
       leadEvent = leadEventSchemaTB.parse(existingLeadEvent.data[0]);
-    } else if (!eventIds || eventIds.length === 0) {
+    } else {
+      // dummy path executes only when eventIds not provided (the eventIds branch returns early)
+      if (eventIds && eventIds.length > 0) {
+        // handled later
+      } else {
         // else, if there's no existing lead event and there is also no custom leadEventName/Date
         // we need to create a dummy click + lead event (using the customer's country if available)
         const dummyRequest = new Request(link.url, {
           headers: new Headers({
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-commission-sheet.tsx (3)

171-189: Submission gating ignores eventIds; enable submit when reusing events

When using existing events, sale should not require manual saleAmount; lead should only require linkId/customerId. Incorporate eventIds and useExistingEvents.

Apply this diff:

-  const shouldDisableSubmit = useMemo(() => {
+  const shouldDisableSubmit = useMemo(() => {
     if (!partnerId) {
       return true;
     }
@@
-    if (commissionType === "sale") {
-      return !linkId || !customerId || !saleAmount;
+    if (commissionType === "sale") {
+      const hasExisting = useExistingEvents && (watch("eventIds")?.length ?? 0) > 0;
+      return !linkId || !customerId || (!saleAmount && !hasExisting);
     }
@@
-  }, [commissionType, partnerId, linkId, customerId, saleAmount, amount]);
+  }, [commissionType, partnerId, linkId, customerId, saleAmount, amount, useExistingEvents, watch]);

191-228: Race-safe fetch; include workspaceId in deps and check response.ok

Add a cancellation guard/AbortController, include workspaceId in deps, and handle non‑200s.

Apply this diff:

-  useEffect(() => {
+  useEffect(() => {
+    let cancelled = false;
+    const controller = new AbortController();
     if (
       !customerId ||
       !commissionType ||
       !workspaceId ||
       commissionType === "custom"
     ) {
       setExistingEvents([]);
       setExistingEventsLoading(false);
       return;
     }
 
     const fetchEvents = async () => {
       setExistingEventsLoading(true);
 
       try {
         const searchParams = new URLSearchParams({
           workspaceId,
           customerId,
           event: `${commissionType}s`,
         });
 
-        const response = await fetch(`/api/events?${searchParams.toString()}`, {
-          method: "GET",
-        });
-
-        setExistingEvents(await response.json());
+        const response = await fetch(`/api/events?${searchParams.toString()}`, {
+          method: "GET",
+          signal: controller.signal,
+        });
+        if (!response.ok) throw new Error(`Failed to fetch events: ${response.status}`);
+        const data = await response.json();
+        if (!cancelled) setExistingEvents(data ?? []);
       } catch (error) {
-        setExistingEvents([]);
+        if (!cancelled) setExistingEvents([]);
       } finally {
-        setExistingEventsLoading(false);
+        if (!cancelled) setExistingEventsLoading(false);
       }
     };
 
     fetchEvents();
-  }, [customerId, commissionType]);
+    return () => {
+      cancelled = true;
+      controller.abort();
+    };
+  }, [customerId, commissionType, workspaceId]);

229-230: Remove debug log before merge

console.log leaks internal state to devtools.

Apply this diff:

-  console.log({ existingEvents });
+
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 8c803cc and 224d469.

📒 Files selected for processing (3)
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-commission-sheet.tsx (5 hunks)
  • apps/web/lib/actions/partners/create-manual-commission.ts (4 hunks)
  • apps/web/lib/zod/schemas/commissions.ts (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-commission-sheet.tsx (1)
apps/web/lib/swr/use-rewards.ts (1)
  • useRewards (6-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). (2)
  • GitHub Check: Vade Review
  • GitHub Check: build
🔇 Additional comments (5)
apps/web/lib/actions/partners/create-manual-commission.ts (3)

66-81: Custom commission path LGTM

The custom (“one-time”) commission branch is self‑contained and returns early. No issues.


276-331: Avoid double-counting stats for reused events

The link/customer stat updates assume new events. When reusing existing events, skipping these increments (we return earlier) is correct; ensure this invariant stays documented to prevent future regressions.

Add a short comment above the early return noting that stats updates are intentionally skipped when eventIds are used.


23-26: Handle partner/customer mismatch when reusing events

When eventIds is provided but the existing customer's partnerId != provided partnerId, the code must either clone the customer into this workspace (copy essential fields and duplicate event IDs) or reject the request — currently unhandled.

Location: apps/web/lib/actions/partners/create-manual-commission.ts (lines 23–26)

Confirm desired behavior: create+duplicate vs reject. I can implement the guarded create-and-duplicate path once confirmed.

apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-commission-sheet.tsx (2)

286-304: Commission type toggle LGTM

Conditional options derived from rewards look correct and resilient to loading states.


662-709: Money input handling consistent; verify unit conversion for reused events

When pre-filling saleAmount from existing events, confirm whether fetched amounts are cents or dollars to avoid 100x errors. Frontend multiplies by 100 before send.

If the events API returns cents, set saleAmount to event.amount / 100 on selection.

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 (1)
apps/web/lib/actions/partners/create-manual-commission.ts (1)

271-333: Duplicate-events branch doesn’t create commissions or trigger workflows; also ignores reuse when link matches

  • No createPartnerCommission calls for duplicated events → user sees analytics but no commissions.
  • No executeWorkflows after duplication.
  • Reuse is skipped entirely when customer.linkId === linkId (events on same link): shouldDuplicateEvents prevents any handling, but users still expect commissions to be created for selected eventIds.
  • Minor: console.log(events) risks leaking PII in logs.
  • Fragile: inferring eventType from saleAmount can mismatch selected eventIds (e.g., reusing lead events while passing a sale amount, or vice versa).

Fix by always handling eventIds (create commissions), and only duplicating records when the link differs. Also emit the appropriate workflow trigger and remove the log.

Apply this diff:

-    // Duplicate the customer events
-    const shouldDuplicateEvents =
-      eventIds && eventIds.length > 0 && customer.linkId !== linkId;
-
-    if (shouldDuplicateEvents) {
-      const eventType = !saleAmount ? "leads" : "sales";
+    // Reuse or duplicate the selected events (always create commissions).
+    const hasEventIds =
+      Array.isArray(eventIds) && eventIds.length > 0;
+    if (hasEventIds) {
+      const shouldDuplicate = customer.linkId !== linkId;
+      const eventType = !saleAmount ? "leads" : "sales"; // NOTE: UI must ensure selected ids match this type.
@@
-      const pipe = tb.buildPipe({
+      const pipe = tb.buildPipe({
         pipe: "v2_events",
         parameters: eventsFilterTB,
         data: z.any(),
       });
@@
-      const events = response.data;
+      const events = response.data;
@@
-      if (events.length === 0) {
+      if (events.length === 0) {
         throw new Error("Selected events not found.");
       }
 
-      
-
-      // Duplicate the events for the new customer
-      if (eventType === "leads") {
-        await Promise.all(
-          events.map((event) =>
-            recordLeadWithTimestamp({
-              ...event,
-              customer_id: customerId, // TODO: should be new customerId
-              link_id: link.id,
-            }),
-          ),
-        );
-      } else if (eventType === "sales") {
-        await Promise.all(
-          events.map((event) =>
-            recordSaleWithTimestamp({
-              ...event,
-              customer_id: customerId, // TODO: should be new customerId
-              link_id: link.id,
-            }),
-          ),
-        );
-      }
-
-      console.log(events);
+      // 1) Duplicate events only if needed (link mismatch).
+      if (shouldDuplicate) {
+        if (eventType === "leads") {
+          await Promise.allSettled(
+            events.map((event: any) =>
+              recordLeadWithTimestamp({
+                ...event,
+                customer_id: customerId, // TODO: replace with new customerId when partner mismatch is handled
+                link_id: link.id,
+              }),
+            ),
+          );
+        } else {
+          await Promise.allSettled(
+            events.map((event: any) =>
+              recordSaleWithTimestamp({
+                ...event,
+                customer_id: customerId, // TODO: replace with new customerId when partner mismatch is handled
+                link_id: link.id,
+              }),
+            ),
+          );
+        }
+      }
+
+      // 2) Create commissions for each selected (or duplicated) event.
+      if (eventType === "leads") {
+        await Promise.allSettled(
+          events.map((event: any) =>
+            createPartnerCommission({
+              event: "lead",
+              programId,
+              partnerId,
+              linkId,
+              eventId: event.event_id,
+              customerId,
+              amount: 0,
+              quantity: 1,
+              createdAt: new Date(event.timestamp),
+              user,
+              context: {
+                customer: { country: customer.country },
+              },
+            }),
+          ),
+        );
+        await executeWorkflows({
+          trigger: WorkflowTrigger.leadRecorded,
+          programId,
+          partnerId,
+        });
+      } else {
+        await Promise.allSettled(
+          events.map((event: any) =>
+            createPartnerCommission({
+              event: "sale",
+              programId,
+              partnerId,
+              linkId,
+              eventId: event.event_id,
+              customerId,
+              amount: typeof event.amount === "number" ? event.amount : saleAmount ?? 0,
+              quantity: 1,
+              invoiceId,
+              currency: (event as any).currency ?? "usd",
+              createdAt: new Date(event.timestamp),
+              user,
+              context: {
+                customer: { country: customer.country },
+                sale: { productId },
+              },
+            }),
+          ),
+        );
+        await executeWorkflows({
+          trigger: WorkflowTrigger.saleRecorded,
+          programId,
+          partnerId,
+        });
+      }
     }
🧹 Nitpick comments (4)
apps/web/lib/zod/schemas/analytics.ts (2)

321-323: Remove non-existent key from omit() mask

page is not part of analyticsFilterTB’s shape; passing it to omit() will break type inference. Drop it.

Apply this diff:

-export const eventsFilterTB = analyticsFilterTB
-  .omit({ granularity: true, timezone: true, page: true, sortBy: true,  })
+export const eventsFilterTB = analyticsFilterTB
+  .omit({ granularity: true, timezone: true, sortBy: true })

329-330: Accept CSV for eventIds (parity with linkIds/folderIds)

Other filters accept both CSV string and array. Mirror that for eventIds to ease query construction and TB piping.

Apply this diff:

-      eventIds: z.array(z.string()).optional(),
+      eventIds: z
+        .union([z.string(), z.array(z.string())])
+        .transform((v) => (Array.isArray(v) ? v : v.split(",")))
+        .optional(),
apps/web/lib/actions/partners/create-manual-commission.ts (2)

300-301: Avoid PII in server logs

Remove console.log(events); events may include customer metadata.

Apply this diff:

-      console.log(events);
+      // Intentionally no logging of raw events to avoid PII in server logs.

133-221: Potential double-commission when mixing eventIds and synthetic lead/sale

If eventIds are provided and the branch above also created a synthetic lead (dummy click+lead) and a sale (when saleAmount && leadEvent), users could get extra commissions. Once the eventIds path is in place, consider short‑circuiting earlier synthetic creation when eventIds are present.

Would you like a patch to move the eventIds handling before the synthetic lead/sale logic and return early after completion?

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 224d469 and e4a599a.

📒 Files selected for processing (3)
  • apps/web/lib/actions/partners/create-manual-commission.ts (4 hunks)
  • apps/web/lib/analytics/types.ts (1 hunks)
  • apps/web/lib/zod/schemas/analytics.ts (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
apps/web/lib/zod/schemas/analytics.ts (1)
packages/utils/src/constants/misc.ts (1)
  • PAGINATION_LIMIT (31-31)
apps/web/lib/actions/partners/create-manual-commission.ts (1)
apps/web/lib/zod/schemas/analytics.ts (1)
  • eventsFilterTB (321-331)
⏰ 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). (2)
  • GitHub Check: Vade Review
  • GitHub Check: build
🔇 Additional comments (2)
apps/web/lib/analytics/types.ts (1)

55-56: LGTM: type surface aligned

eventIds?: string[] matches the intended usage on the client and the TB schema addition. No further changes needed.

apps/web/lib/actions/partners/create-manual-commission.ts (1)

276-297: Verify TB pipe parameter contract for v2_events

File: apps/web/lib/actions/partners/create-manual-commission.ts (lines 276–297)

rg failed to locate the pipe definition (error: "unrecognized file type: tsx"); confirm Tinybird pipe v2_events accepts an eventIds parameter and that filtering by eventType + eventIds is supported. If it doesn't, query by eventIds only or split requests by eventType.

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: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/web/lib/actions/partners/create-manual-commission.ts (1)

158-173: Avoid creating dummy lead/sale when eventIds is provided

With eventIds, the function still creates synthetic click/lead (and possibly a sale) before the duplication block. This yields extra events/commissions. Early-return after processing eventIds.

Covered by the early-return in the larger diff.

Also applies to: 192-253

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

303-361: Handle eventIds first: add reuse path, create commissions, and early-return

Currently, eventIds are ignored unless partner mismatch, and even then no commissions are created. Also, deriving eventType from saleAmount is brittle. Process eventIds up-front, reuse when partner/link match, duplicate with new IDs when migrating, create commissions in both cases, trigger workflows, and return early.

Apply this diff to replace the whole block:

-    // Duplicate the customer events
-    const shouldDuplicateEvents =
-      eventIds && eventIds.length > 0 && customer.linkId !== linkId;
-
-    if (shouldDuplicateEvents) {
-      const eventType = !saleAmount ? "leads" : "sales";
-
-      // Find the events by the eventIds
-      const { startDate, endDate } = getStartEndDates({
-        interval: "all",
-      });
-
-      const pipe = tb.buildPipe({
-        pipe: "v2_events",
-        parameters: eventsFilterTB,
-        data: z.any(),
-      });
-
-      const response = await pipe({
-        workspaceId: workspace.id,
-        eventType,
-        sortBy: "timestamp",
-        offset: 0,
-        limit: 100,
-        start: startDate.toISOString().replace("T", " ").replace("Z", ""),
-        end: endDate.toISOString().replace("T", " ").replace("Z", ""),
-        eventIds,
-      });
-
-      const events = response.data;
-
-      if (events.length === 0) {
-        throw new Error("Selected events not found.");
-      }
-
-      // Duplicate the events for the new customer
-      if (eventType === "leads") {
-        await Promise.all(
-          events.map((event) =>
-            recordLeadWithTimestamp({
-              ...event,
-              customer_id: customer.id,
-              link_id: link.id,
-            }),
-          ),
-        );
-      } else if (eventType === "sales") {
-        await Promise.all(
-          events.map((event) =>
-            recordSaleWithTimestamp({
-              ...event,
-              customer_id: customer.id,
-              link_id: link.id,
-            }),
-          ),
-        );
-      }
-    }
+    // Process provided eventIds: reuse or duplicate, then create commissions, then return.
+    if (Array.isArray(eventIds) && eventIds.length > 0) {
+      const { startDate, endDate } = getStartEndDates({ interval: "all" });
+      const pipe = tb.buildPipe({
+        pipe: "v2_events",
+        parameters: eventsFilterTB,
+        data: z.any(),
+      });
+      // Try leads first, then sales, so we don't rely on saleAmount to infer type.
+      const fetchByType = (eventType: "leads" | "sales") =>
+        pipe({
+          workspaceId: workspace.id,
+          eventType,
+          sortBy: "timestamp",
+          offset: 0,
+          limit: eventIds.length,
+          start: startDate.toISOString().replace("T", " ").replace("Z", ""),
+          end: endDate.toISOString().replace("T", " ").replace("Z", ""),
+          eventIds,
+        });
+
+      let response = await fetchByType("leads");
+      if (!response.data?.length) {
+        response = await fetchByType("sales");
+      }
+      const events = response.data ?? [];
+      if (events.length === 0) {
+        throw new Error("Selected events not found.");
+      }
+
+      const isPartnerMismatch = customer.linkId !== linkId;
+
+      if (isPartnerMismatch) {
+        // Duplicate events with fresh IDs for the new customer/link, then create commissions for those new IDs.
+        await Promise.allSettled(
+          events.map((event: any) => {
+            const isSale = "amount" in event && Number(event.amount) > 0;
+            const newEventId = nanoid(16);
+            const ts = new Date(event.timestamp);
+            const recordPromise = isSale
+              ? recordSaleWithTimestamp({
+                  ...event,
+                  event_id: newEventId,
+                  customer_id: customer.id,
+                  link_id: link.id,
+                })
+              : recordLeadWithTimestamp({
+                  ...event,
+                  event_id: newEventId,
+                  customer_id: customer.id,
+                  link_id: link.id,
+                });
+            const commissionPromise = createPartnerCommission({
+              event: isSale ? "sale" : "lead",
+              programId,
+              partnerId,
+              linkId,
+              eventId: newEventId,
+              customerId: customer.id,
+              amount: isSale ? Number(event.amount) : 0,
+              quantity: 1,
+              ...(isSale && { invoiceId, currency: "usd" }),
+              createdAt: ts,
+              user,
+              context: {
+                customer: { country: customer.country },
+                ...(isSale && { sale: { productId } }),
+              },
+            });
+            const workflowPromise = executeWorkflows({
+              trigger: isSale
+                ? WorkflowTrigger.saleRecorded
+                : WorkflowTrigger.leadRecorded,
+              programId,
+              partnerId,
+            });
+            return Promise.allSettled([
+              recordPromise,
+              commissionPromise,
+              workflowPromise,
+            ]);
+          }),
+        );
+      } else {
+        // Reuse existing events: create commissions referencing the existing IDs, no event writes.
+        const hasSale = events.some(
+          (e: any) => "amount" in e && Number(e.amount) > 0,
+        );
+        await Promise.allSettled(
+          events.map((event: any) =>
+            createPartnerCommission({
+              event:
+                "amount" in event && Number(event.amount) > 0
+                  ? "sale"
+                  : "lead",
+              programId,
+              partnerId,
+              linkId,
+              eventId: event.event_id,
+              customerId,
+              amount:
+                "amount" in event && Number(event.amount) > 0
+                  ? Number(event.amount)
+                  : 0,
+              quantity: 1,
+              ...(("amount" in event && Number(event.amount) > 0) && {
+                invoiceId,
+                currency: "usd",
+              }),
+              createdAt: new Date(event.timestamp),
+              user,
+              context: {
+                customer: { country: customer.country },
+                ...(("amount" in event && Number(event.amount) > 0) && {
+                  sale: { productId },
+                }),
+              },
+            }),
+          ),
+        );
+        await executeWorkflows({
+          trigger: hasSale
+            ? WorkflowTrigger.saleRecorded
+            : WorkflowTrigger.leadRecorded,
+          programId,
+          partnerId,
+        });
+      }
+      return;
+    }
🧹 Nitpick comments (2)
apps/web/lib/actions/partners/create-manual-commission.ts (2)

341-358: Send only accepted fields to TB record endpoints

Spreading events from v2_events into recordLead/Sale may include columns not accepted by the ingestion endpoints. Pass the whitelisted fields (event_id, event_name, customer_id, link_id, amount, currency, timestamp, metadata, etc.).

I can generate a typed pick for the TB schemas if helpful.


371-390: Stats updates may be inconsistent for migrated customers

  • isFirstConversion uses the (possibly cloned) customer object; if counters weren’t reset on the new record, first-conversion increments won’t fire.
  • Sale increments are gated by saleAmount, which may be undefined for reuse/duplicate flows.

Would you like a follow-up patch to compute link/customer increments based on the processed events (lead vs sale) rather than saleAmount?

Also applies to: 394-414

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between e4a599a and a9a8291.

📒 Files selected for processing (1)
  • apps/web/lib/actions/partners/create-manual-commission.ts (5 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
apps/web/lib/actions/partners/create-manual-commission.ts (2)
apps/web/lib/api/create-id.ts (1)
  • createId (62-67)
apps/web/lib/zod/schemas/analytics.ts (1)
  • eventsFilterTB (321-331)
⏰ 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). (2)
  • GitHub Check: Vade Review
  • GitHub Check: build

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (4)
apps/web/lib/actions/partners/create-manual-commission.ts (4)

223-246: Write events/commissions to the target customer after a move

Use targetCustomerId for both the event payload and the commission record.

Apply this diff:

         recordLeadWithTimestamp({
           ...leadEvent,
           timestamp: new Date(finalLeadEventDate).toISOString(),
+          customer_id: targetCustomerId,
         }),
@@
           eventId: leadEventId,
-          customerId,
+          customerId: targetCustomerId,

255-301: Sale path still references the original customer; fix attribution

Ensure sale event and commission target the correct customer after a move.

Apply this diff:

         recordSaleWithTimestamp({
           ...leadEvent,
           event_id: saleEventId,
           event_name: "Purchase",
           amount: saleAmount,
-          customer_id: customerId,
+          customer_id: targetCustomerId,
           payment_processor: "custom",
           currency: "usd",
           timestamp: saleDate.toISOString(),
           metadata: productId ? JSON.stringify({ productId }) : undefined,
         }),
@@
           eventId: saleEventId,
-          customerId,
+          customerId: targetCustomerId,

364-418: Stats update targets the wrong customer and may double-count

  • Uses customerId (old) after a move; should use targetCustomerId.
  • Consider skipping manual stat bumps when merely reusing existing events (no duplicates), and scaling counts when duplicating multiple events.

Apply the ID fix; then consider gating/counting as noted.

       // Update customer details / stats
-      (shouldUpdateCustomer || saleAmount) &&
+      (shouldUpdateCustomer || saleAmount) &&
         prisma.customer.update({
           where: {
-            id: customerId,
+            id: targetCustomerId,
           },

Optional follow-up (not shown as diff): wrap the whole waitUntil block behind a flag like shouldBumpStats = !eventIds?.length || shouldDuplicateEvents and, if duplicating, increment by leadRows.length / saleRows.length respectively.


86-101: Early reuse path for eventIds when not moving customers

If eventIds are provided but shouldCreateNewCustomer is false, they’re currently ignored. Implement a reuse path that creates commissions referencing the existing events (no duplication) and return early.

Proposed insertion (outside the shown diff; place after link validation and invoice guard):

if (eventIds?.length && !shouldCreateNewCustomer) {
  // Fetch both types by IDs (same `pipe` approach as duplication),
  // then create commissions that reference those eventIds directly
  // (no Tinybird `record*` calls). Trigger workflows per type.
  return;
}

I can wire this with the same helper used in the duplication block; want a patch?

♻️ Duplicate comments (3)
apps/web/lib/actions/partners/create-manual-commission.ts (3)

307-314: Don’t infer event type from saleAmount

const eventType = !saleAmount ? "leads" : "sales" will misclassify valid events. Fetch both types by ID(s) and branch by actual rows (see prior diff).


132-156: Don't spread Prisma entities into create(); also capture and use a targetCustomerId

Spreading ...customer can copy read-only/unique fields (and stale values from before the update), causing constraint issues and incorrect state. Also, later writes still use the original customerId. Introduce targetCustomerId and copy only safe fields.

Apply this focused diff:

-    if (shouldCreateNewCustomer) {
-      customer = await prisma.$transaction(async (tx) => {
+    let targetCustomerId = customer!.id;
+    if (shouldCreateNewCustomer) {
+      customer = await prisma.$transaction(async (tx) => {
         await tx.customer.update({
           where: {
             id: customer!.id,
           },
           data: {
             name: `${customer!.name} (old)`,
             externalId: null,
             stripeCustomerId: null,
             linkId: null,
             clickId: null,
           },
         });
-
-        return await tx.customer.create({
-          data: {
-            ...customer,
-            id: createId({ prefix: "cus_" }),
-            linkId: link.id,
-            projectId: workspace.id,
-          },
-        });
-      });
+        const newCustomer = await tx.customer.create({
+          data: {
+            id: createId({ prefix: "cus_" }),
+            name: customer!.name,
+            country: customer!.country,
+            linkId: link.id,
+            projectId: workspace.id,
+          },
+        });
+        return newCustomer;
+      });
+      targetCustomerId = customer.id;
     }

303-362: Duplication flow: generate new IDs, create commissions, trigger workflows, and remove console.log

Current code reuses event.event_id (collision risk), doesn’t create commissions for duplicates, doesn’t trigger workflows, and logs to console. Also, inferring eventType from saleAmount can misclassify events.

Apply this replacement to the duplication block:

-  const shouldDuplicateEvents =
-    eventIds && eventIds.length > 0 && shouldCreateNewCustomer;
+  const shouldDuplicateEvents =
+    eventIds && eventIds.length > 0 && shouldCreateNewCustomer;
@@
-  if (shouldDuplicateEvents) {
-    const eventType = !saleAmount ? "leads" : "sales";
-
-    // Find the events by the eventIds
-    const { startDate, endDate } = getStartEndDates({
-      interval: "all",
-    });
-
-    const pipe = tb.buildPipe({
-      pipe: "v2_events",
-      parameters: eventsFilterTB,
-      data: z.any(),
-    });
-
-    const response = await pipe({
-      workspaceId: workspace.id,
-      eventType,
-      sortBy: "timestamp",
-      offset: 0,
-      limit: 100,
-      start: startDate.toISOString().replace("T", " ").replace("Z", ""),
-      end: endDate.toISOString().replace("T", " ").replace("Z", ""),
-      eventIds,
-    });
-
-    const events = response.data;
-
-    if (events.length === 0) {
-      throw new Error("Selected events not found.");
-    }
-
-    console.log(events);
-
-    // Duplicate the events for the new customer
-    if (eventType === "leads") {
-      await Promise.all(
-        events.map((event) =>
-          recordLeadWithTimestamp({
-            ...event,
-            customer_id: customer.id,
-            link_id: link.id,
-          }),
-        ),
-      );
-    } else if (eventType === "sales") {
-      await Promise.all(
-        events.map((event) =>
-          recordSaleWithTimestamp({
-            ...event,
-            customer_id: customer.id,
-            link_id: link.id,
-          }),
-        ),
-      );
-    }
-  }
+  if (shouldDuplicateEvents) {
+    const { startDate, endDate } = getStartEndDates({ interval: "all" });
+    const pipe = tb.buildPipe({
+      pipe: "v2_events",
+      parameters: eventsFilterTB,
+      data: z.any(),
+    });
+
+    const fetchEvents = async (eventType: "leads" | "sales") => {
+      const res = await pipe({
+        workspaceId: workspace.id,
+        eventType,
+        sortBy: "timestamp",
+        offset: 0,
+        limit: eventIds.length,
+        start: startDate.toISOString().replace("T", " ").replace("Z", ""),
+        end: endDate.toISOString().replace("T", " ").replace("Z", ""),
+        eventIds,
+      });
+      return res.data as any[];
+    };
+
+    const [leadRows, saleRows] = await Promise.all([
+      fetchEvents("leads"),
+      fetchEvents("sales"),
+    ]);
+    if (leadRows.length + saleRows.length === 0) {
+      throw new Error("Selected events not found.");
+    }
+
+    // Duplicate leads
+    await Promise.allSettled(
+      leadRows.map(async (event) => {
+        const newId = nanoid(16);
+        await recordLeadWithTimestamp({
+          ...event,
+          event_id: newId,
+          customer_id: customer.id,
+          link_id: link.id,
+        });
+        await createPartnerCommission({
+          event: "lead",
+          programId,
+          partnerId,
+          linkId,
+          eventId: newId,
+          customerId: customer.id,
+          amount: 0,
+          quantity: 1,
+          createdAt: new Date(event.timestamp),
+          user,
+          context: { customer: { country: customer.country } },
+        });
+      }),
+    );
+
+    // Duplicate sales
+    await Promise.allSettled(
+      saleRows.map(async (event) => {
+        const newId = nanoid(16);
+        await recordSaleWithTimestamp({
+          ...event,
+          event_id: newId,
+          customer_id: customer.id,
+          link_id: link.id,
+        });
+        await createPartnerCommission({
+          event: "sale",
+          programId,
+          partnerId,
+          linkId,
+          eventId: newId,
+          customerId: customer.id,
+          amount: Number(event.amount) || 0,
+          quantity: 1,
+          invoiceId,
+          currency: (event.currency || "usd").toLowerCase(),
+          createdAt: new Date(event.timestamp),
+          user,
+          context: {
+            customer: { country: customer.country },
+            sale: { productId },
+          },
+        });
+      }),
+    );
+
+    await Promise.allSettled([
+      leadRows.length
+        ? executeWorkflows({
+            trigger: WorkflowTrigger.leadRecorded,
+            programId,
+            partnerId,
+          })
+        : Promise.resolve(),
+      saleRows.length
+        ? executeWorkflows({
+            trigger: WorkflowTrigger.saleRecorded,
+            programId,
+            partnerId,
+          })
+        : Promise.resolve(),
+    ]);
+    return;
+  }
🧹 Nitpick comments (4)
apps/web/lib/actions/partners/create-manual-commission.ts (4)

321-330: Limit should reflect the number of eventIds

Avoid truncating results when more than 100 IDs are selected.

Apply this tiny change (included in the larger diff too):

-        limit: 100,
+        limit: eventIds.length,

338-338: Remove debug logging

console.log(events) leaks potentially sensitive data and adds noise.

Apply this deletion (already removed in the larger diff).


27-30: Resolve or remove TODO

The code now implements the partner-move + duplication flow; update/remove the TODO to prevent confusion.


158-171: Existing-lead reuse may be wrong after partner move

getLeadEvent({ customerId }) queries the old customer. If you intend to skip dummy creation when eventIds are provided (either reuse or duplicate), short-circuit before this block. Otherwise, query using targetCustomerId after the move.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between a9a8291 and 45c63a2.

📒 Files selected for processing (1)
  • apps/web/lib/actions/partners/create-manual-commission.ts (5 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
apps/web/lib/actions/partners/create-manual-commission.ts (2)
apps/web/lib/api/create-id.ts (1)
  • createId (62-67)
apps/web/lib/zod/schemas/analytics.ts (1)
  • eventsFilterTB (321-331)
⏰ 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). (2)
  • GitHub Check: Vade Review
  • GitHub Check: build

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-commission-sheet.tsx (2)

177-193: Do not send eventIds when user chose “Create from scratch”.

Currently eventIds are always sent, which duplicates events even when not intended.

Apply:

-    // Get selected event IDs (exclude the excluded ones)
-    const selectedEventIds = existingEvents
+    // Get selected event IDs (exclude the excluded ones)
+    const selectedEventIds = existingEvents
       .filter(
         (event) => event.eventId && !excludedEventIds.includes(event.eventId),
       )
       .map((event) => event.eventId!);

     await executeAsync({
       ...data,
       workspaceId,
       date,
       amount: data.amount ? data.amount * 100 : null,
       saleAmount: data.saleAmount ? data.saleAmount * 100 : null,
       saleEventDate,
       leadEventDate,
-      eventIds: selectedEventIds,
+      eventIds: useExistingEvents ? selectedEventIds : null,
     });

200-218: Fix submit gating for “Use existing events”.

Sale submit is disabled without saleAmount even when existing events are selected; lead submit doesn’t guard zero selection.

-  const shouldDisableSubmit = useMemo(() => {
+  const shouldDisableSubmit = useMemo(() => {
+    const hasSelectedExisting =
+      useExistingEvents &&
+      existingEvents.some((e) => !excludedEventIds.includes(e.eventId || ""));
     if (!partnerId) {
       return true;
     }
     if (commissionType === "custom") {
       return !amount;
     }
     if (commissionType === "sale") {
-      return !linkId || !customerId || !saleAmount;
+      return !linkId || !customerId || (!hasSelectedExisting && !saleAmount);
     }
     if (commissionType === "lead") {
-      return !linkId || !customerId;
+      return !linkId || !customerId || (useExistingEvents && !hasSelectedExisting);
     }
     return false;
-  }, [commissionType, partnerId, linkId, customerId, saleAmount, amount]);
+  }, [
+    commissionType,
+    partnerId,
+    linkId,
+    customerId,
+    saleAmount,
+    amount,
+    useExistingEvents,
+    existingEvents,
+    excludedEventIds,
+  ]);
♻️ Duplicate comments (6)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-commission-sheet.tsx (6)

141-148: Remove non-existent “sale” accordion key.

No item with value="sale" exists; referencing it breaks auto-open behavior.

-  } else if (commissionType === "sale") {
-      setOpenAccordions([...baseValues, "customer", "sale"]);
+  } else if (commissionType === "sale") {
+      setOpenAccordions([...baseValues, "customer"]);

225-263: Harden events fetch: response.ok check, cancellation, and full deps.

Prevents UI crashes on HTTP errors, stale updates, and stale workspace/folder data. Also guard missing defaultFolderId.

 useEffect(() => {
-  if (
+  if (
     !customerId ||
     !commissionType ||
     !workspaceId ||
-    commissionType === "custom"
+    commissionType === "custom" ||
+    !program?.defaultFolderId
   ) {
     setExistingEvents([]);
     setExistingEventsLoading(false);
     return;
   }

-  const fetchEvents = async () => {
+  const controller = new AbortController();
+  let cancelled = false;
+
+  const fetchEvents = async () => {
     setExistingEventsLoading(true);

     try {
       const searchParams = new URLSearchParams({
         workspaceId,
         customerId,
-        folderId: program?.defaultFolderId!,
+        folderId: program.defaultFolderId,
         event: `${commissionType}s`,
       });

-      const response = await fetch(`/api/events?${searchParams.toString()}`, {
-        method: "GET",
-      });
-
-      setExistingEvents(await response.json());
+      const response = await fetch(`/api/events?${searchParams.toString()}`, {
+        method: "GET",
+        signal: controller.signal,
+      });
+      if (!response.ok) throw new Error(`Failed to fetch events: ${response.status}`);
+      const json = await response.json();
+      if (!cancelled) setExistingEvents(json);
     } catch (error) {
-      setExistingEvents([]);
+      if (!cancelled) setExistingEvents([]);
     } finally {
-      setExistingEventsLoading(false);
+      if (!cancelled) setExistingEventsLoading(false);
     }
   };

   fetchEvents();
-}, [customerId, commissionType]);
+  return () => {
+    cancelled = true;
+    controller.abort();
+  };
+}, [customerId, commissionType, workspaceId, program?.defaultFolderId]);

570-609: Lead date toggle is unreachable; keep switch visible, animate only input.

Current structure hides the Switch when collapsed.

-<AnimatedSizeContainer
-  height
-  transition={{ ease: "easeInOut", duration: 0.2 }}
-  style={{
-    height: hasCustomLeadEventDate ? "auto" : "0px",
-    overflow: "hidden",
-  }}
->
-  <div className="flex flex-col gap-6">
-    <div className="flex items-center gap-4">
-      <Switch
-        fn={setHasCustomLeadEventDate}
-        checked={hasCustomLeadEventDate}
-        trackDimensions="w-8 h-4"
-        thumbDimensions="w-3 h-3"
-        thumbTranslate="translate-x-4"
-      />
-      <div className="flex flex-col gap-1">
-        <h3 className="text-sm font-medium text-neutral-700">
-          Set a custom lead event date
-        </h3>
-      </div>
-    </div>
-
-    {hasCustomLeadEventDate && (
-      <div className="p-px">
-        <SmartDateTimePicker
-          value={leadEventDate}
-          onChange={(date) => {
-            setValue("leadEventDate", date, {
-              shouldDirty: true,
-            });
-          }}
-          label="Lead event date"
-          placeholder='E.g. "2024-03-01", "Last Thursday", "2 hours ago"'
-        />
-      </div>
-    )}
-  </div>
-</AnimatedSizeContainer>
+<div className="flex items-center gap-4">
+  <Switch
+    fn={setHasCustomLeadEventDate}
+    checked={hasCustomLeadEventDate}
+    trackDimensions="w-8 h-4"
+    thumbDimensions="w-3 h-3"
+    thumbTranslate="translate-x-4"
+  />
+  <div className="flex flex-col gap-1">
+    <h3 className="text-sm font-medium text-neutral-700">
+      Set a custom lead event date
+    </h3>
+  </div>
+</div>
+<AnimatedSizeContainer
+  height
+  transition={{ ease: "easeInOut", duration: 0.2 }}
+  style={{ height: hasCustomLeadEventDate ? "auto" : "0px", overflow: "hidden" }}
+>
+  {hasCustomLeadEventDate && (
+    <div className="p-px">
+      <SmartDateTimePicker
+        value={leadEventDate}
+        onChange={(date) => {
+          setValue("leadEventDate", date, { shouldDirty: true });
+        }}
+        label="Lead event date"
+        placeholder='E.g. "2024-03-01", "Last Thursday", "2 hours ago"'
+      />
+    </div>
+  )}
+</AnimatedSizeContainer>

610-653: Lead name toggle has same issue; keep switch visible.

-<AnimatedSizeContainer
-  height
-  transition={{ ease: "easeInOut", duration: 0.2 }}
-  style={{
-    height: hasCustomLeadEventName ? "auto" : "0px",
-    overflow: "hidden",
-  }}
->
-  <div className="flex flex-col gap-6">
-    <div className="flex items-center gap-4">
-      <Switch
-        fn={setHasCustomLeadEventName}
-        checked={hasCustomLeadEventName}
-        trackDimensions="w-8 h-4"
-        thumbDimensions="w-3 h-3"
-        thumbTranslate="translate-x-4"
-      />
-      <div className="flex flex-col gap-1">
-        <h3 className="text-sm font-medium text-neutral-700">
-          Set a custom lead event name
-        </h3>
-      </div>
-    </div>
-
-    {hasCustomLeadEventName && (
-      <div className="p-px">
-        <input
-          type="text"
-          className={cn(
-            "block w-full rounded-md border-neutral-300 px-3 py-2 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm",
-            errors.leadEventName &&
-              "border-red-600 focus:border-red-500 focus:ring-red-600",
-          )}
-          {...register("leadEventName", {
-            setValueAs: (value) =>
-              value === "" ? null : value,
-          })}
-          placeholder="Enter lead event name"
-        />
-      </div>
-    )}
-  </div>
-</AnimatedSizeContainer>
+<div className="flex items-center gap-4">
+  <Switch
+    fn={setHasCustomLeadEventName}
+    checked={hasCustomLeadEventName}
+    trackDimensions="w-8 h-4"
+    thumbDimensions="w-3 h-3"
+    thumbTranslate="translate-x-4"
+  />
+  <div className="flex flex-col gap-1">
+    <h3 className="text-sm font-medium text-neutral-700">
+      Set a custom lead event name
+    </h3>
+  </div>
+</div>
+<AnimatedSizeContainer
+  height
+  transition={{ ease: "easeInOut", duration: 0.2 }}
+  style={{ height: hasCustomLeadEventName ? "auto" : "0px", overflow: "hidden" }}
+>
+  {hasCustomLeadEventName && (
+    <div className="p-px">
+      <input
+        type="text"
+        className={cn(
+          "block w-full rounded-md border-neutral-300 px-3 py-2 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm",
+          errors.leadEventName &&
+            "border-red-600 focus:border-red-500 focus:ring-red-600",
+        )}
+        {...register("leadEventName", {
+          setValueAs: (value) => (value === "" ? null : value),
+        })}
+        placeholder="Enter lead event name"
+      />
+    </div>
+  )}
+</AnimatedSizeContainer>

719-775: Invoice ID toggle is unreachable; keep switch visible and animate only input.

-<AnimatedSizeContainer
-  height
-  transition={{ ease: "easeInOut", duration: 0.2 }}
-  className={!hasInvoiceId ? "hidden" : ""}
-  style={{
-    display: !hasInvoiceId ? "none" : "block",
-  }}
->
-  <div className="flex items-center gap-4">
-    <Switch
-      fn={setHasInvoiceId}
-      checked={hasInvoiceId}
-      trackDimensions="w-8 h-4"
-      thumbDimensions="w-3 h-3"
-      thumbTranslate="translate-x-4"
-    />
-    <div className="flex gap-1">
-      <h3 className="text-sm font-medium text-neutral-700">
-        Add{" "}
-      </h3>
-      <span className="rounded-md border border-neutral-200 bg-neutral-100 px-1 py-0.5 text-xs">
-        invoiceID
-      </span>
-    </div>
-  </div>
-
-  {hasInvoiceId && (
+<div className="flex items-center gap-4">
+  <Switch
+    fn={setHasInvoiceId}
+    checked={hasInvoiceId}
+    trackDimensions="w-8 h-4"
+    thumbDimensions="w-3 h-3"
+    thumbTranslate="translate-x-4"
+  />
+  <div className="flex gap-1">
+    <h3 className="text-sm font-medium text-neutral-700">Add</h3>
+    <span className="rounded-md border border-neutral-200 bg-neutral-100 px-1 py-0.5 text-xs">
+      invoiceID
+    </span>
+  </div>
+</div>
+<AnimatedSizeContainer
+  height
+  transition={{ ease: "easeInOut", duration: 0.2 }}
+  style={{ height: hasInvoiceId ? "auto" : "0px", overflow: "hidden" }}
+>
+  {hasInvoiceId && (
     <div className="mt-4">
       <label
         htmlFor="invoiceId"
         className="flex items-center space-x-2"
       >
         <h2 className="text-sm font-medium text-neutral-900">
           Invoice ID
         </h2>
       </label>
       <div className="mt-2 p-px">
         <input
           type="text"
           id="invoiceId"
           className={cn(
             "block w-full rounded-md border-neutral-300 px-3 py-2 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm",
             errors.invoiceId &&
               "border-red-600 focus:border-red-500 focus:ring-red-600",
           )}
           {...register("invoiceId", {
             required: hasInvoiceId,
             setValueAs: (value) => value === "" ? null : value,
           })}
           placeholder="Enter invoice ID"
         />
       </div>
     </div>
   )}
-</AnimatedSizeContainer>
+</AnimatedSizeContainer>

776-831: Product ID toggle is unreachable; mirror the invoice fix.

-<AnimatedSizeContainer
-  height
-  transition={{ ease: "easeInOut", duration: 0.2 }}
-  className={!hasProductId ? "hidden" : ""}
-  style={{
-    display: !hasProductId ? "none" : "block",
-  }}
->
-  <div className="flex items-center gap-4">
-    <Switch
-      fn={setHasProductId}
-      checked={hasProductId}
-      trackDimensions="w-8 h-4"
-      thumbDimensions="w-3 h-3"
-      thumbTranslate="translate-x-4"
-    />
-    <div className="flex gap-1">
-      <h3 className="text-sm font-medium text-neutral-700">
-        Add{" "}
-      </h3>
-      <span className="rounded-md border border-neutral-200 bg-neutral-100 px-1 py-0.5 text-xs">
-        productID
-      </span>
-    </div>
-  </div>
-
-  {hasProductId && (
+<div className="flex items-center gap-4">
+  <Switch
+    fn={setHasProductId}
+    checked={hasProductId}
+    trackDimensions="w-8 h-4"
+    thumbDimensions="w-3 h-3"
+    thumbTranslate="translate-x-4"
+  />
+  <div className="flex gap-1">
+    <h3 className="text-sm font-medium text-neutral-700">Add</h3>
+    <span className="rounded-md border border-neutral-200 bg-neutral-100 px-1 py-0.5 text-xs">
+      productID
+    </span>
+  </div>
+</div>
+<AnimatedSizeContainer
+  height
+  transition={{ ease: "easeInOut", duration: 0.2 }}
+  style={{ height: hasProductId ? "auto" : "0px", overflow: "hidden" }}
+>
+  {hasProductId && (
     <div className="mt-4">
       <label
         htmlFor="productId"
         className="flex items-center space-x-2"
       >
         <h2 className="text-sm font-medium text-neutral-900">
           Product ID
         </h2>
       </label>
       <div className="mt-2 p-px">
         <input
           type="text"
           id="productId"
           className={cn(
             "block w-full rounded-md border-neutral-300 px-3 py-2 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm",
             errors.productId &&
               "border-red-600 focus:border-red-500 focus:ring-red-600",
           )}
           {...register("productId", {
             required: hasProductId,
             setValueAs: (value) =>
               value === "" ? null : value,
           })}
           placeholder="Enter product ID"
         />
       </div>
     </div>
   )}
-</AnimatedSizeContainer>
+</AnimatedSizeContainer>
🧹 Nitpick comments (1)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-commission-sheet.tsx (1)

874-941: Provide stable row ids to the table.

Improves performance and selection consistency.

-  const table = useTable({
+  const table = useTable({
     data: events || [],
     columns: [
       {
         id: "date",
@@
     ],
+    getRowId: (row) => row.eventId || String(row.timestamp),
     thClassName: (id) =>
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 45c63a2 and d3eda3b.

📒 Files selected for processing (1)
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-commission-sheet.tsx (10 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-commission-sheet.tsx (3)
apps/web/lib/swr/use-rewards.ts (1)
  • useRewards (6-25)
packages/ui/src/table/table.tsx (2)
  • useTable (48-240)
  • Table (338-661)
packages/utils/src/functions/currency-formatter.ts (1)
  • currencyFormatter (5-16)
⏰ 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). (2)
  • GitHub Check: build
  • GitHub Check: Vade Review
🔇 Additional comments (1)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-commission-sheet.tsx (1)

44-48: API event shape matches CustomerEvent — no change required.

getEvents maps evt.event_id → eventId, sets timestamp (serialized to ISO), and returns both nested sale.amount and a top-level saleAmount (deprecated), so the component's CustomerEvent { eventId, timestamp, saleAmount? } matches the API response. See apps/web/lib/analytics/get-events.ts and apps/web/lib/zod/schemas/sales.ts.

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 (5)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-commission-sheet.tsx (5)

141-147: Remove non-existent "sale" accordion key

The component doesn’t render a value="sale" item; opening it has no effect.

Apply this diff:

-    } else if (commissionType === "sale") {
-      setOpenAccordions([...baseValues, "customer", "sale"]);
+    } else if (commissionType === "sale") {
+      setOpenAccordions([...baseValues, "customer"]);

62-62: Fix events fetch effect: missing deps, unsafe !, no abort, no response.ok check

Prevent bad folderId=undefined, stale updates, and swallowing HTTP errors.

Apply this diff:

-  const { program } = useProgram();
+  const { program, loading: programLoading } = useProgram();
@@
-  useEffect(() => {
-    if (
-      !customerId ||
-      !commissionType ||
-      !workspaceId ||
-      commissionType === "custom"
-    ) {
-      setExistingEvents([]);
-      setExistingEventsLoading(false);
-      return;
-    }
-
-    const fetchEvents = async () => {
-      setExistingEventsLoading(true);
-
-      try {
-        const searchParams = new URLSearchParams({
-          workspaceId,
-          customerId,
-          folderId: program?.defaultFolderId!,
-          event: `${commissionType}s`,
-        });
-
-        const response = await fetch(`/api/events?${searchParams.toString()}`, {
-          method: "GET",
-        });
-
-        setExistingEvents(await response.json());
-      } catch (error) {
-        setExistingEvents([]);
-      } finally {
-        setExistingEventsLoading(false);
-      }
-    };
-
-    fetchEvents();
-  }, [customerId, commissionType]);
+  useEffect(() => {
+    if (
+      !customerId ||
+      !commissionType ||
+      !workspaceId ||
+      commissionType === "custom" ||
+      programLoading ||
+      !program?.defaultFolderId
+    ) {
+      setExistingEvents([]);
+      setExistingEventsLoading(false);
+      return;
+    }
+
+    const controller = new AbortController();
+    const { signal } = controller;
+    let cancelled = false;
+
+    const fetchEvents = async () => {
+      setExistingEventsLoading(true);
+      try {
+        const searchParams = new URLSearchParams({
+          workspaceId,
+          customerId,
+          folderId: program.defaultFolderId,
+          event: `${commissionType}s`,
+        });
+        const response = await fetch(`/api/events?${searchParams.toString()}`, {
+          method: "GET",
+          signal,
+        });
+        if (!response.ok) {
+          throw new Error(`Failed to fetch events: ${response.status}`);
+        }
+        const json = await response.json();
+        if (!cancelled) setExistingEvents(Array.isArray(json) ? json : []);
+      } catch (err: any) {
+        if (err?.name === "AbortError") return;
+        if (!cancelled) setExistingEvents([]);
+      } finally {
+        if (!cancelled) setExistingEventsLoading(false);
+      }
+    };
+
+    fetchEvents();
+    return () => {
+      cancelled = true;
+      controller.abort();
+    };
+  }, [
+    customerId,
+    commissionType,
+    workspaceId,
+    programLoading,
+    program?.defaultFolderId,
+  ]);

Also applies to: 232-269


572-660: Lead toggles are unreachable (switch hidden inside collapsed container)

Keep the Switch visible; animate only the input block.

Apply this diff:

-                        <>
-                          <AnimatedSizeContainer
-                            height
-                            transition={{ ease: "easeInOut", duration: 0.2 }}
-                            style={{
-                              height: hasCustomLeadEventDate ? "auto" : "0px",
-                              overflow: "hidden",
-                            }}
-                          >
-                            <div className="flex flex-col gap-6">
-                              <div className="flex items-center gap-4">
-                                <Switch
-                                  fn={setHasCustomLeadEventDate}
-                                  checked={hasCustomLeadEventDate}
-                                  trackDimensions="w-8 h-4"
-                                  thumbDimensions="w-3 h-3"
-                                  thumbTranslate="translate-x-4"
-                                />
-                                <div className="flex flex-col gap-1">
-                                  <h3 className="text-sm font-medium text-neutral-700">
-                                    Set a custom lead event date
-                                  </h3>
-                                </div>
-                              </div>
-
-                              {hasCustomLeadEventDate && (
-                                <div className="p-px">
-                                  <SmartDateTimePicker
-                                    value={leadEventDate}
-                                    onChange={(date) => {
-                                      setValue("leadEventDate", date, {
-                                        shouldDirty: true,
-                                      });
-                                    }}
-                                    label="Lead event date"
-                                    placeholder='E.g. "2024-03-01", "Last Thursday", "2 hours ago"'
-                                  />
-                                </div>
-                              )}
-                            </div>
-                          </AnimatedSizeContainer>
+                        <>
+                          <div className="flex items-center gap-4">
+                            <Switch
+                              fn={setHasCustomLeadEventDate}
+                              checked={hasCustomLeadEventDate}
+                              trackDimensions="w-8 h-4"
+                              thumbDimensions="w-3 h-3"
+                              thumbTranslate="translate-x-4"
+                            />
+                            <div className="flex flex-col gap-1">
+                              <h3 className="text-sm font-medium text-neutral-700">
+                                Set a custom lead event date
+                              </h3>
+                            </div>
+                          </div>
+                          <AnimatedSizeContainer
+                            height
+                            transition={{ ease: "easeInOut", duration: 0.2 }}
+                            style={{
+                              height: hasCustomLeadEventDate ? "auto" : "0px",
+                              overflow: "hidden",
+                            }}
+                          >
+                            {hasCustomLeadEventDate && (
+                              <div className="p-px">
+                                <SmartDateTimePicker
+                                  value={leadEventDate}
+                                  onChange={(date) => {
+                                    setValue("leadEventDate", date, {
+                                      shouldDirty: true,
+                                    });
+                                  }}
+                                  label="Lead event date"
+                                  placeholder='E.g. "2024-03-01", "Last Thursday", "2 hours ago"'
+                                />
+                              </div>
+                            )}
+                          </AnimatedSizeContainer>
@@
-                          <AnimatedSizeContainer
-                            height
-                            transition={{ ease: "easeInOut", duration: 0.2 }}
-                            style={{
-                              height: hasCustomLeadEventName ? "auto" : "0px",
-                              overflow: "hidden",
-                            }}
-                          >
-                            <div className="flex flex-col gap-6">
-                              <div className="flex items-center gap-4">
-                                <Switch
-                                  fn={setHasCustomLeadEventName}
-                                  checked={hasCustomLeadEventName}
-                                  trackDimensions="w-8 h-4"
-                                  thumbDimensions="w-3 h-3"
-                                  thumbTranslate="translate-x-4"
-                                />
-                                <div className="flex flex-col gap-1">
-                                  <h3 className="text-sm font-medium text-neutral-700">
-                                    Set a custom lead event name
-                                  </h3>
-                                </div>
-                              </div>
-
-                              {hasCustomLeadEventName && (
-                                <div className="p-px">
-                                  <input
-                                    type="text"
-                                    className={cn(
-                                      "block w-full rounded-md border-neutral-300 px-3 py-2 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm",
-                                      errors.leadEventName &&
-                                        "border-red-600 focus:border-red-500 focus:ring-red-600",
-                                    )}
-                                    {...register("leadEventName", {
-                                      setValueAs: (value) =>
-                                        value === "" ? null : value,
-                                    })}
-                                    placeholder="Enter lead event name"
-                                  />
-                                </div>
-                              )}
-                            </div>
-                          </AnimatedSizeContainer>
+                          <div className="flex items-center gap-4">
+                            <Switch
+                              fn={setHasCustomLeadEventName}
+                              checked={hasCustomLeadEventName}
+                              trackDimensions="w-8 h-4"
+                              thumbDimensions="w-3 h-3"
+                              thumbTranslate="translate-x-4"
+                            />
+                            <div className="flex flex-col gap-1">
+                              <h3 className="text-sm font-medium text-neutral-700">
+                                Set a custom lead event name
+                              </h3>
+                            </div>
+                          </div>
+                          <AnimatedSizeContainer
+                            height
+                            transition={{ ease: "easeInOut", duration: 0.2 }}
+                            style={{
+                              height: hasCustomLeadEventName ? "auto" : "0px",
+                              overflow: "hidden",
+                            }}
+                          >
+                            {hasCustomLeadEventName && (
+                              <div className="p-px">
+                                <input
+                                  type="text"
+                                  className={cn(
+                                    "block w-full rounded-md border-neutral-300 px-3 py-2 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm",
+                                    errors.leadEventName &&
+                                      "border-red-600 focus:border-red-500 focus:ring-red-600",
+                                  )}
+                                  {...register("leadEventName", {
+                                    setValueAs: (value) =>
+                                      value === "" ? null : value,
+                                  })}
+                                  placeholder="Enter lead event name"
+                                />
+                              </div>
+                            )}
+                          </AnimatedSizeContainer>

726-782: Invoice ID toggle unreachable (switch hidden when off)

Move the Switch out; animate only the input.

Apply this diff:

-                          <AnimatedSizeContainer
-                            height
-                            transition={{ ease: "easeInOut", duration: 0.2 }}
-                            className={!hasInvoiceId ? "hidden" : ""}
-                            style={{
-                              display: !hasInvoiceId ? "none" : "block",
-                            }}
-                          >
-                            <div className="flex items-center gap-4">
-                              <Switch
-                                fn={setHasInvoiceId}
-                                checked={hasInvoiceId}
-                                trackDimensions="w-8 h-4"
-                                thumbDimensions="w-3 h-3"
-                                thumbTranslate="translate-x-4"
-                              />
-                              <div className="flex gap-1">
-                                <h3 className="text-sm font-medium text-neutral-700">
-                                  Add{" "}
-                                </h3>
-                                <span className="rounded-md border border-neutral-200 bg-neutral-100 px-1 py-0.5 text-xs">
-                                  invoiceID
-                                </span>
-                              </div>
-                            </div>
-
-                            {hasInvoiceId && (
-                              <div className="mt-4">
+                          <div className="flex items-center gap-4">
+                            <Switch
+                              fn={setHasInvoiceId}
+                              checked={hasInvoiceId}
+                              trackDimensions="w-8 h-4"
+                              thumbDimensions="w-3 h-3"
+                              thumbTranslate="translate-x-4"
+                            />
+                            <div className="flex gap-1">
+                              <h3 className="text-sm font-medium text-neutral-700">
+                                Add
+                              </h3>
+                              <span className="rounded-md border border-neutral-200 bg-neutral-100 px-1 py-0.5 text-xs">
+                                invoiceID
+                              </span>
+                            </div>
+                          </div>
+                          <AnimatedSizeContainer
+                            height
+                            transition={{ ease: "easeInOut", duration: 0.2 }}
+                            style={{
+                              height: hasInvoiceId ? "auto" : "0px",
+                              overflow: "hidden",
+                            }}
+                          >
+                            {hasInvoiceId && (
+                              <div className="mt-4">
                                 <label
                                   htmlFor="invoiceId"
                                   className="flex items-center space-x-2"
                                 >
                                   <h2 className="text-sm font-medium text-neutral-900">
                                     Invoice ID
                                   </h2>
                                 </label>
                                 <div className="mt-2 p-px">
                                   <input
                                     type="text"
                                     id="invoiceId"
                                     className={cn(
                                       "block w-full rounded-md border-neutral-300 px-3 py-2 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm",
                                       errors.invoiceId &&
                                         "border-red-600 focus:border-red-500 focus:ring-red-600",
                                     )}
                                     {...register("invoiceId", {
                                       required: hasInvoiceId,
                                       setValueAs: (value) =>
                                         value === "" ? null : value,
                                     })}
                                     placeholder="Enter invoice ID"
                                   />
                                 </div>
                               </div>
                             )}
-                          </AnimatedSizeContainer>
+                          </AnimatedSizeContainer>

783-838: Product ID toggle unreachable (same issue as Invoice ID)

Mirror the fix for Product ID.

Apply this diff:

-                          <AnimatedSizeContainer
-                            height
-                            transition={{ ease: "easeInOut", duration: 0.2 }}
-                            className={!hasProductId ? "hidden" : ""}
-                            style={{
-                              display: !hasProductId ? "none" : "block",
-                            }}
-                          >
-                            <div className="flex items-center gap-4">
-                              <Switch
-                                fn={setHasProductId}
-                                checked={hasProductId}
-                                trackDimensions="w-8 h-4"
-                                thumbDimensions="w-3 h-3"
-                                thumbTranslate="translate-x-4"
-                              />
-                              <div className="flex gap-1">
-                                <h3 className="text-sm font-medium text-neutral-700">
-                                  Add{" "}
-                                </h3>
-                                <span className="rounded-md border border-neutral-200 bg-neutral-100 px-1 py-0.5 text-xs">
-                                  productID
-                                </span>
-                              </div>
-                            </div>
-
-                            {hasProductId && (
-                              <div className="mt-4">
+                          <div className="flex items-center gap-4">
+                            <Switch
+                              fn={setHasProductId}
+                              checked={hasProductId}
+                              trackDimensions="w-8 h-4"
+                              thumbDimensions="w-3 h-3"
+                              thumbTranslate="translate-x-4"
+                            />
+                            <div className="flex gap-1">
+                              <h3 className="text-sm font-medium text-neutral-700">
+                                Add
+                              </h3>
+                              <span className="rounded-md border border-neutral-200 bg-neutral-100 px-1 py-0.5 text-xs">
+                                productID
+                              </span>
+                            </div>
+                          </div>
+                          <AnimatedSizeContainer
+                            height
+                            transition={{ ease: "easeInOut", duration: 0.2 }}
+                            style={{
+                              height: hasProductId ? "auto" : "0px",
+                              overflow: "hidden",
+                            }}
+                          >
+                            {hasProductId && (
+                              <div className="mt-4">
                                 <label
                                   htmlFor="productId"
                                   className="flex items-center space-x-2"
                                 >
                                   <h2 className="text-sm font-medium text-neutral-900">
                                     Product ID
                                   </h2>
                                 </label>
                                 <div className="mt-2 p-px">
                                   <input
                                     type="text"
                                     id="productId"
                                     className={cn(
                                       "block w-full rounded-md border-neutral-300 px-3 py-2 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm",
                                       errors.productId &&
                                         "border-red-600 focus:border-red-500 focus:ring-red-600",
                                     )}
                                     {...register("productId", {
                                       required: hasProductId,
                                       setValueAs: (value) =>
                                         value === "" ? null : value,
                                     })}
                                     placeholder="Enter product ID"
                                   />
                                 </div>
                               </div>
                             )}
-                          </AnimatedSizeContainer>
+                          </AnimatedSizeContainer>
🧹 Nitpick comments (1)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-commission-sheet.tsx (1)

874-951: Stabilize rows with getRowId for eventId

Better table behavior on updates; avoids index-based keys.

Apply this diff:

-  const table = useTable({
+  const table = useTable({
     data: events || [],
+    getRowId: (row) => row.eventId,
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between d3eda3b and aaff137.

📒 Files selected for processing (3)
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-commission-sheet.tsx (10 hunks)
  • apps/web/lib/actions/partners/create-manual-commission.ts (5 hunks)
  • apps/web/lib/zod/schemas/commissions.ts (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (2)
  • apps/web/lib/zod/schemas/commissions.ts
  • apps/web/lib/actions/partners/create-manual-commission.ts
🧰 Additional context used
🧬 Code graph analysis (1)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-commission-sheet.tsx (3)
apps/web/lib/swr/use-rewards.ts (1)
  • useRewards (6-25)
packages/ui/src/table/table.tsx (2)
  • useTable (48-240)
  • Table (338-661)
packages/utils/src/functions/currency-formatter.ts (1)
  • currencyFormatter (5-16)
⏰ 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). (2)
  • GitHub Check: Vade Review
  • GitHub Check: build
🔇 Additional comments (2)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-commission-sheet.tsx (2)

44-48: Confirm CustomerEvent payload shape and units

Verify /api/events returns eventId, timestamp (ISO), and saleAmount in cents. If not, map the response before setting state to avoid runtime mismatches.


130-136: Exclude/Include toggle logic looks good

Idempotent toggle with minimal state churn. LGTM.

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 (7)
apps/web/lib/actions/partners/create-manual-commission.ts (7)

165-169: Remove debug console logs (PII risk, noisy in prod logs)

These log raw customer/event payloads. Prefer structured logging behind a debug flag if needed.

-      console.log("Found existing click event: ", existingClickEvent);
-      console.log("Found existing lead event: ", existingLeadEvent);
-      console.log("Found existing sale events: ", existingSaleEvents);
@@
-      console.log(
-        `Duplicated customer ${customer.id} to ${duplicatedCustomer.id}: `,
-        duplicatedCustomer,
-      );
@@
-    console.log("recorded events in Tinybird: ", res);
@@
-        console.log(
-          "Final commissions to transfer event ids: ",
-          finalCommissionsToTransferEventIds,
-        );

Also applies to: 169-173, 315-318, 416-417, 425-428


328-339: Preserve country on generated click events

Without country, Zod defaults to "Unknown", which later overwrites customer country.

       const generatedClickEvent = recordClickZodSchema.parse({
         timestamp: clickTimestamp.toISOString(),
         identity_hash: customer.externalId || customer.id,
         click_id: clickId,
         link_id: link.id,
         url: link.url,
         ip: "127.0.0.1",
+        country: customer.country || "Unknown",
         continent: customer.country
           ? COUNTRIES_TO_CONTINENTS[customer.country.toUpperCase()] || "NA"
           : "NA",
       });

437-447: Use correct customer context for first-conversion calc after duplication

Passing the original customer inflates conversions. Use duplicated stats.

-              ...(isFirstConversion({
-                customer,
-                linkId,
-              }) && {
+              ...(isFirstConversion({
+                customer: useExistingEvents
+                  ? { sales: totalSales, linkId: link.id }
+                  : customer,
+                linkId,
+              }) && {

430-471: Update customer stats in non-reuse sale path for consistency

When creating new sale events (!useExistingEvents), customer.sales/saleAmount aren’t incremented.

         const updatedRes = await Promise.all([
           // update link stats
           prisma.link.update({
@@
           }),
+          // keep customer stats in sync for new sale events
+          !useExistingEvents &&
+            saleAmount &&
+            prisma.customer.update({
+              where: { id: customer.id },
+              data: {
+                sales: { increment: 1 },
+                saleAmount: { increment: saleAmount },
+              },
+            }),
           // update the commissions
-          finalCommissionsToTransferEventIds.length > 0 &&
-            prisma.commission.updateMany({
-              where: {
-                eventId: {
-                  in: finalCommissionsToTransferEventIds,
-                },
-              },
-              data: {
-                eventId: null,
-                invoiceId: null,
-              },
-            }),
+          /* provenance-preserving transfer handled elsewhere */
         ]);

175-183: Fix crash when no existing click; also ensure a click is recorded to maintain event chain

Spreading existingClickEvent when it’s undefined throws, and later you rely on clickEventData regardless. Build from a safe base, infer a timestamp/identity, and always record the click.

-      const clickEventData = recordClickZodSchema.parse({
-        ...existingClickEvent,
-        click_id: nanoid(16), // create new clickId
-        link_id: link.id, // set to new link.id,
-      });
-
-      if (existingClickEvent) {
-        tbEventsToRecord.push(recordClickZod(clickEventData));
-      }
+      const inferredClickTs =
+        (existingClickEvent?.timestamp as string | undefined) ??
+        (existingLeadEvent?.timestamp as string | undefined) ??
+        (existingSaleEvents[0]?.timestamp as string | undefined) ??
+        new Date().toISOString();
+
+      const clickEventData = recordClickZodSchema.parse({
+        ...(existingClickEvent ?? {}),
+        timestamp: inferredClickTs,
+        identity_hash:
+          existingClickEvent?.identity_hash ?? customer.externalId ?? customer.id,
+        click_id: nanoid(16),
+        link_id: link.id,
+      });
+
+      tbEventsToRecord.push(recordClickZod(clickEventData));

457-469: Do not nullify eventId/invoiceId — breaks dedup, reporting, and auditability

Nulling provenance fields severs links used across exports, payouts, and idempotency. Prefer updating attribution (partner/link/customer) or recording a transfer record; don’t destroy keys.

-          finalCommissionsToTransferEventIds.length > 0 &&
-            prisma.commission.updateMany({
-              where: {
-                eventId: {
-                  in: finalCommissionsToTransferEventIds,
-                },
-              },
-              data: {
-                eventId: null,
-                invoiceId: null,
-              },
-            }),
+          // Intentionally avoid nullifying provenance fields; handle attribution transfer explicitly elsewhere.

299-312: Do not spread Prisma entity into create(); copy safe fields and null external/unique fields

Spreading ...customer risks unique constraint violations (e.g., external/Stripe IDs), wrong timestamps, and leaking old attribution.

-        return await tx.customer.create({
-          data: {
-            ...customer,
-            id: duplicateCustomerId,
-            linkId: link.id,
-            clickId: clickEventData.click_id,
-            clickedAt: new Date(clickEventData.timestamp),
-            country: clickEventData.country,
-            ...(recordSaleEvents && {
-              sales: totalSales,
-              saleAmount: totalSaleAmount,
-            }),
-          },
-        });
+        return await tx.customer.create({
+          data: {
+            id: duplicateCustomerId,
+            name: customer.name,
+            email: customer.email,
+            avatar: customer.avatar,
+            // reset external/unique fields
+            externalId: null,
+            stripeCustomerId: null,
+            // preserve original country when available
+            country: customer.country ?? clickEventData.country,
+            linkId: link.id,
+            clickId: clickEventData.click_id,
+            clickedAt: new Date(
+              (existingLeadEvent?.timestamp as string | undefined) ??
+                (existingSaleEvents[0]?.timestamp as string | undefined) ??
+                clickEventData.timestamp
+            ),
+            ...(recordSaleEvents
+              ? { sales: totalSales, saleAmount: totalSaleAmount }
+              : {}),
+          },
+        });
🧹 Nitpick comments (2)
apps/web/lib/actions/partners/create-manual-commission.ts (2)

373-387: Guard sale-event creation by commissionType to avoid stray sales

Currently a “lead” commission with saleAmount records a sale event but no commission. Gate by type.

-      if (saleAmount) {
+      if (commissionType === "sale" && saleAmount) {

415-417: Handle Tinybird ingest failures explicitly

At least count rejects and surface an actionable error/log, not raw payloads.

-    const res = await Promise.allSettled(tbEventsToRecord);
-    console.log("recorded events in Tinybird: ", res);
+    const res = await Promise.allSettled(tbEventsToRecord);
+    const failures = res.filter((r) => r.status === "rejected");
+    if (failures.length > 0) {
+      // consider bubbling or structured logging
+      throw new Error(`Failed to record ${failures.length} event(s) to Tinybird`);
+    }
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 3bd0ed5 and 0507772.

📒 Files selected for processing (1)
  • apps/web/lib/actions/partners/create-manual-commission.ts (3 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
apps/web/lib/actions/partners/create-manual-commission.ts (10)
apps/web/lib/zod/schemas/leads.ts (1)
  • leadEventSchemaTB (98-107)
apps/web/lib/zod/schemas/sales.ts (1)
  • saleEventSchemaTB (120-137)
apps/web/lib/zod/schemas/commissions.ts (1)
  • createCommissionSchema (131-153)
apps/web/lib/tinybird/get-customer-events-tb.ts (1)
  • getCustomerEventsTB (10-21)
apps/web/lib/tinybird/record-click-zod.ts (2)
  • recordClickZodSchema (4-34)
  • recordClickZod (36-40)
apps/web/lib/api/create-id.ts (1)
  • createId (62-67)
apps/web/lib/partners/create-partner-commission.ts (1)
  • createPartnerCommission (25-326)
packages/utils/src/constants/continents.ts (1)
  • COUNTRIES_TO_CONTINENTS (13-264)
apps/web/lib/analytics/is-first-conversion.ts (1)
  • isFirstConversion (3-23)
apps/web/lib/api/workflows/execute-workflows.ts (1)
  • executeWorkflows (17-122)
⏰ 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). (2)
  • GitHub Check: build
  • GitHub Check: Vade Review

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-manual-commission.ts (4)

439-447: Compute first‑conversion against the correct customer in reuse flow.

isFirstConversion should use the duplicated customer’s stats, not the original.

Apply:

-              ...(isFirstConversion({
-                customer,
-                linkId,
-              }) && {
+              ...(isFirstConversion({
+                customer: useExistingEvents
+                  ? { sales: totalSales, linkId: link.id }
+                  : customer,
+                linkId,
+              }) && {

178-187: Fix: build clickEventData safely when no existing click; also ensure identity/timestamp.

Spreading possibly-undefined existingClickEvent is okay, but timestamp defaults to "" which later produces Invalid Date for clickedAt.

Apply:

+      const inferredClickTs =
+        (existingClickEvent?.timestamp as string | undefined) ??
+        (existingLeadEvent?.timestamp as string | undefined) ??
+        (existingSaleEvents[0]?.timestamp as string | undefined) ??
+        new Date().toISOString();
       const clickEventData = recordClickZodSchema.parse({
-        ...existingClickEvent,
-        ...newClickAttributes,
+        ...(existingClickEvent ?? {}),
+        timestamp: inferredClickTs,
+        identity_hash:
+          existingClickEvent?.identity_hash ?? customer.externalId ?? customer.id,
+        ...newClickAttributes,
       });

460-471: Provenance risk: nullifying eventId/invoiceId; also scope the update by programId.

Setting eventId/invoiceId to null severs audit/dedup trails. At minimum, constrain by programId; ideally, preserve originals in audit metadata.

Apply scoping fix:

-            prisma.commission.updateMany({
-              where: {
-                eventId: {
-                  in: finalCommissionsToTransferEventIds,
-                },
-              },
+            prisma.commission.updateMany({
+              where: {
+                programId,
+                eventId: { in: finalCommissionsToTransferEventIds },
+              },
               data: {
                 eventId: null,
                 invoiceId: null,
               },
             }),

Recommendation: record an explicit audit entry mapping old→new event/invoice IDs before nullifying. I can wire that if desired.


291-318: Critical: don’t spread a Prisma entity into create(); copy safe fields and fix clickedAt fallback.

Spreading ...customer risks unique/timestamp/counter fields being reinserted and sets clickedAt from possibly invalid timestamp.

Apply:

-        return await tx.customer.create({
-          data: {
-            ...customer,
-            id: duplicateCustomerId,
-            linkId: link.id,
-            clickId: clickEventData.click_id,
-            clickedAt: new Date(clickEventData.timestamp),
-            country: clickEventData.country,
-            ...(recordSaleEvents && {
-              sales: totalSales,
-              saleAmount: totalSaleAmount,
-            }),
-          },
-        });
+        return await tx.customer.create({
+          data: {
+            id: duplicateCustomerId,
+            projectId: workspace.id,
+            name: customer.name ?? null,
+            externalId: customer.externalId ?? null,
+            country: clickEventData.country,
+            linkId: link.id,
+            clickId: clickEventData.click_id,
+            clickedAt: new Date(
+              (existingLeadEvent?.timestamp as string | undefined) ??
+                (existingSaleEvents[0]?.timestamp as string | undefined) ??
+                clickEventData.timestamp
+            ),
+            ...(recordSaleEvents && {
+              sales: totalSales,
+              saleAmount: totalSaleAmount,
+            }),
+          },
+        });
🧹 Nitpick comments (4)
apps/web/lib/partners/create-partner-commission.ts (1)

165-176: Use event timestamp (createdAt) for max‑duration checks, not “now”.

Backfills can be incorrectly rejected/accepted when using the current time. Base differenceInMonths on the event’s createdAt when provided.

Apply:

-              const monthsDifference = differenceInMonths(
-                new Date(),
-                firstCommission.createdAt,
-              );
+              const referenceDate = createdAt ?? new Date();
+              const monthsDifference = differenceInMonths(
+                referenceDate,
+                firstCommission.createdAt,
+              );
apps/web/lib/actions/partners/create-manual-commission.ts (3)

334-345: Include country on generated click to preserve customer geo.

Without country, defaults can backfill “Unknown” and desync continent/country.

Apply:

       const generatedClickEvent = recordClickZodSchema.parse({
         timestamp: clickTimestamp.toISOString(),
         identity_hash: customer.externalId || customer.id,
         click_id: clickId,
         link_id: link.id,
         url: link.url,
         ip: "127.0.0.1",
+        country: customer.country || "Unknown",
         continent: customer.country
           ? COUNTRIES_TO_CONTINENTS[customer.country.toUpperCase()] || "NA"
           : "NA",
       });

165-176: Remove debug console logs (noise + PII risk).

These print event/customer payloads into server logs. Use structured logging with redaction if needed.

Apply deletions across the selected lines.

Also applies to: 187-191, 251-253, 321-324, 417-419, 427-430, 474-477, 479-480


432-456: Non‑reuse path: update customer sales stats when creating sale events.

To keep stats consistent with reuse path, increment customer.sales and saleAmount when saleAmount is provided.

Apply:

         const updatedRes = await Promise.all([
           // update link stats
           prisma.link.update({
             where: {
               id: link.id,
             },
             data: {
               ...(isFirstConversion({
                 customer: useExistingEvents
                   ? { sales: totalSales, linkId: link.id }
                   : customer,
                 linkId,
               }) && {
                 leads: {
                   increment: 1,
                 },
                 conversions: {
                   increment: 1,
                 },
               }),
               sales: {
                 increment: saleAmount ? 1 : totalSales,
               },
               saleAmount: {
                 increment: saleAmount ?? totalSaleAmount,
               },
             },
           }),
+          // keep customer stats in sync for non-reuse sales
+          !useExistingEvents &&
+            saleAmount &&
+            prisma.customer.update({
+              where: { id: customer.id },
+              data: {
+                sales: { increment: 1 },
+                saleAmount: { increment: saleAmount },
+              },
+            }),
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between b34c9cb and e6fca91.

📒 Files selected for processing (2)
  • apps/web/lib/actions/partners/create-manual-commission.ts (3 hunks)
  • apps/web/lib/partners/create-partner-commission.ts (3 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
apps/web/lib/partners/create-partner-commission.ts (3)
packages/prisma/client.ts (1)
  • CommissionType (8-8)
apps/web/lib/types.ts (1)
  • RewardContext (524-524)
packages/utils/src/functions/currency-formatter.ts (1)
  • currencyFormatter (5-16)
apps/web/lib/actions/partners/create-manual-commission.ts (10)
apps/web/lib/zod/schemas/leads.ts (1)
  • leadEventSchemaTB (98-107)
apps/web/lib/zod/schemas/sales.ts (1)
  • saleEventSchemaTB (120-137)
apps/web/lib/zod/schemas/commissions.ts (1)
  • createCommissionSchema (131-153)
apps/web/lib/partners/create-partner-commission.ts (1)
  • CreatePartnerCommissionProps (25-41)
apps/web/lib/tinybird/get-customer-events-tb.ts (1)
  • getCustomerEventsTB (10-21)
apps/web/lib/tinybird/record-click-zod.ts (2)
  • recordClickZodSchema (4-34)
  • recordClickZod (36-40)
apps/web/lib/api/create-id.ts (1)
  • createId (62-67)
packages/utils/src/constants/continents.ts (1)
  • COUNTRIES_TO_CONTINENTS (13-264)
apps/web/lib/analytics/is-first-conversion.ts (1)
  • isFirstConversion (3-23)
apps/web/lib/api/workflows/execute-workflows.ts (1)
  • executeWorkflows (17-122)
⏰ 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). (2)
  • GitHub Check: Vade Review
  • GitHub Check: build
🔇 Additional comments (1)
apps/web/lib/partners/create-partner-commission.ts (1)

25-41: Good: public input type is clear and future‑proof.

Exporting CreatePartnerCommissionProps improves API clarity and discoverability.

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/lib/partners/create-partner-commission.ts (1)

229-229: Guard currencyFormatter and redact payload to avoid crash and PII in logs

Passing { currency: undefined } overrides the default USD and makes Intl.NumberFormat throw; also avoid logging the full commission object.

-    console.log(
-      `Created a ${event} commission ${commission.id} (${currencyFormatter(commission.earnings / 100, { currency: commission.currency })}) for ${partnerId}: ${JSON.stringify(commission)}`,
-    );
+    console.log(
+      `Created a ${event} commission ${commission.id} (${
+        currencyFormatter(
+          commission.earnings / 100,
+          commission.currency ? { currency: commission.currency } : undefined,
+        )
+      }) for ${partnerId}`,
+      { commissionId: commission.id, partnerId, programId },
+    );
🧹 Nitpick comments (2)
apps/web/lib/partners/create-partner-commission.ts (2)

25-41: Encode per-event invariants with a discriminated union

This prevents invalid combinations (e.g., sale without amount/currency) at compile time.

-export type CreatePartnerCommissionProps = {
-  event: CommissionType;
-  partnerId: string;
-  programId: string;
-  linkId?: string;
-  customerId?: string;
-  eventId?: string;
-  invoiceId?: string | null;
-  amount?: number;
-  quantity: number;
-  currency?: string;
-  description?: string | null;
-  createdAt?: Date;
-  user?: Session["user"]; // user who created the manual commission
-  context?: RewardContext;
-  skipWorkflow?: boolean;
-};
+type BaseCommissionProps = {
+  partnerId: string;
+  programId: string;
+  linkId?: string;
+  customerId?: string;
+  eventId?: string;
+  invoiceId?: string | null;
+  description?: string | null;
+  createdAt?: Date;
+  user?: Session["user"];
+  context?: RewardContext;
+  skipWorkflow?: boolean;
+};
+
+export type CreatePartnerCommissionProps =
+  | (BaseCommissionProps & {
+      event: "custom";
+      amount: number;
+      quantity: number;
+      currency?: string;
+    })
+  | (BaseCommissionProps & {
+      event: "click";
+      quantity: number;
+    })
+  | (BaseCommissionProps & {
+      event: "lead";
+      quantity: number;
+    })
+  | (BaseCommissionProps & {
+      event: "sale";
+      amount: number;
+      quantity: number;
+      currency: string;
+      invoiceId?: string | null;
+    });

211-213: Trim and null-normalize IDs to handle whitespace-only input

Prevents storing " " instead of null.

-        eventId: eventId || null, // empty string should convert to null
-        invoiceId: invoiceId || null, // empty string should convert to null
+        eventId: eventId?.trim() || null, // empty/whitespace string -> null
+        invoiceId: invoiceId?.trim() || null, // empty/whitespace string -> 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 e6fca91 and 00ad79e.

📒 Files selected for processing (2)
  • apps/web/lib/actions/partners/create-manual-commission.ts (3 hunks)
  • apps/web/lib/partners/create-partner-commission.ts (4 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/web/lib/actions/partners/create-manual-commission.ts
🧰 Additional context used
🧬 Code graph analysis (1)
apps/web/lib/partners/create-partner-commission.ts (3)
packages/prisma/client.ts (1)
  • CommissionType (8-8)
apps/web/lib/types.ts (1)
  • RewardContext (524-524)
packages/utils/src/functions/currency-formatter.ts (1)
  • currencyFormatter (5-16)
⏰ 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). (2)
  • GitHub Check: Vade Review
  • GitHub Check: build
🔇 Additional comments (1)
apps/web/lib/partners/create-partner-commission.ts (1)

59-59: Signature/type extraction LGTM

Switching to the exported props type improves reusability and clarity.


if (commissionType === "sale") {
return !linkId || !customerId || !saleAmount;
if (!linkId || !customerId) {
Copy link
Contributor

Choose a reason for hiding this comment

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

The form validation no longer checks for required saleAmount when creating new sale events, allowing users to submit invalid sale commissions.

View Details
📝 Patch Details
diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-commission-sheet.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-commission-sheet.tsx
index 21a9f170c..2cec518b6 100644
--- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-commission-sheet.tsx
+++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-commission-sheet.tsx
@@ -271,6 +271,11 @@ function CreateCommissionSheetContent({
       return true;
     }
 
+    // For sale commissions when creating new events, require saleAmount
+    if (commissionType === "sale" && !useExistingEvents && !saleAmount) {
+      return true;
+    }
+
     return false;
   }, [
     commissionType,

Analysis

Missing saleAmount validation allows incomplete sale commission creation

What fails: shouldDisableSubmit() in CreateCommissionSheet doesn't validate saleAmount for sale commissions when creating new events, allowing form submission without required sale amount

How to reproduce:

  1. Open commission creation form
  2. Select commission type "Sale"
  3. Choose "Create new events" option
  4. Fill partner, link, customer, but leave "Sale amount" empty
  5. Submit form

Result: Form submits successfully, but backend skips sale event creation due to if (saleAmount) check at line 376 in create-manual-commission.ts. User gets incomplete commission with no sale event recorded.

Expected: Form should require saleAmount when commissionType === "sale" and useExistingEvents === false, preventing incomplete commission creation where sale events are expected but not created.

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 (5)
apps/web/lib/actions/partners/create-manual-commission.ts (5)

461-473: Don’t nullify eventId/invoiceId on existing commissions without an audit path.

This breaks provenance and may affect idempotency/reconciliation. Prefer transferring the commission (update partnerId/linkId/customerId), or record a “replacedByCommissionId”/“transferredFromEventId” audit, and keep invoiceId stable. If you must create new commissions, perform a coordinated migration and update dependent queries first.

Run to find dependencies on these fields:

#!/bin/bash
set -e
rg -n --hidden -S "eventId" -g '!**/node_modules/**' -C2 || true
rg -n --hidden -S "invoiceId" -g '!**/node_modules/**' -C2 || true
fd schema.prisma | xargs -I{} awk '/^model Commission\s*{/,/}/' {}

332-343: Preserve customer country when generating a click.

Without country, downstream duplication sets customer.country to "Unknown".

       const generatedClickEvent = recordClickZodSchema.parse({
         timestamp: clickTimestamp.toISOString(),
         identity_hash: customer.externalId || customer.id,
         click_id: clickId,
         link_id: link.id,
         url: link.url,
         ip: "127.0.0.1",
+        country: customer.country || "Unknown",
         continent: customer.country
           ? COUNTRIES_TO_CONTINENTS[customer.country.toUpperCase()] || "NA"
           : "NA",
       });

441-444: Use the correct customer reference for first‑conversion stats.

When duplicating, base the check on the duplicated customer’s state, not the original.

-              ...(isFirstConversion({
-                customer,
-                linkId,
-              }) && {
+              ...(isFirstConversion({
+                customer: useExistingEvents
+                  ? { sales: totalSales, linkId: link.id }
+                  : customer,
+                linkId,
+              }) && {

303-316: Do not spread Prisma entity into create(); copy safe fields explicitly.

Spreading ...customer risks writing read-only fields (createdAt/updatedAt), counters, and unintended uniques. Compose a whitelist and set projectId.

-        return await tx.customer.create({
-          data: {
-            ...customer,
-            id: duplicateCustomerId,
-            linkId: link.id,
-            clickId: clickEventData.click_id,
-            clickedAt: new Date(clickEventData.timestamp),
-            country: clickEventData.country,
-            ...(recordSaleEvents && {
-              sales: totalSales,
-              saleAmount: totalSaleAmount,
-            }),
-          },
-        });
+        return await tx.customer.create({
+          data: {
+            id: duplicateCustomerId,
+            projectId: workspace.id,
+            name: customer.name ?? null,
+            email: customer.email ?? null,
+            avatar: customer.avatar ?? null,
+            externalId: customer.externalId ?? null,
+            stripeCustomerId: customer.stripeCustomerId ?? null,
+            country: clickEventData.country || customer.country || "Unknown",
+            linkId: link.id,
+            clickId: clickEventData.click_id,
+            clickedAt: new Date(clickEventData.timestamp),
+            ...(recordSaleEvents && {
+              sales: totalSales,
+              saleAmount: totalSaleAmount,
+            }),
+          },
+        });

182-185: Fix crash: spreading possibly-undefined existingClickEvent.

{ ...existingClickEvent } throws when no click exists. Also ensure a valid timestamp and preserve country to avoid "Unknown" overwriting.

Apply:

-      const clickEventData = recordClickZodSchema.parse({
-        ...existingClickEvent,
-        ...newClickAttributes,
-      });
+      const inferredClickTs =
+        (existingClickEvent?.timestamp as string | undefined) ??
+        (existingLeadEvent?.timestamp as string | undefined) ??
+        (existingSaleEvents[0]?.timestamp as string | undefined) ??
+        new Date().toISOString();
+
+      const clickEventData = recordClickZodSchema.parse({
+        ...(existingClickEvent ?? {}),
+        timestamp: inferredClickTs,
+        identity_hash:
+          existingClickEvent?.identity_hash ?? customer.externalId ?? customer.id,
+        country: existingClickEvent?.country ?? customer.country ?? "Unknown",
+        continent:
+          (existingClickEvent?.country ?? customer.country)
+            ? COUNTRIES_TO_CONTINENTS[
+                (existingClickEvent?.country ?? customer.country)!.toUpperCase()
+              ] || "NA"
+            : "NA",
+        ...newClickAttributes,
+      });
🧹 Nitpick comments (3)
apps/web/lib/actions/partners/create-manual-commission.ts (3)

419-421: Handle ingestion failures; avoid proceeding on rejected writes.

allSettled logs but ignores rejections. Gate downstream updates/creations or surface an error.

-    const res = await Promise.allSettled(tbEventsToRecord);
-    console.log("recorded events in Tinybird: ", res);
+    const res = await Promise.allSettled(tbEventsToRecord);
+    const failures = res.filter((r) => r.status === "rejected");
+    if (failures.length > 0) {
+      throw new Error(`Failed to record ${failures.length} Tinybird event(s).`);
+    }

168-176: Remove debug console.log calls (PII/noise).

These leak event/customer details. Use structured logging behind a debug flag if needed.

-      console.log("Found existing click event: ", existingClickEvent);
-      console.log("Found existing lead event: ", existingLeadEvent);
-      console.log("Found existing sale events: ", existingSaleEvents);
-      console.log("Click event to record: ", clickEventData);
-        console.log("Lead event to record: ", leadEventData);
-        console.log("Sale events to record: ", saleEventsData);
-      console.log(
-        `Duplicated customer ${customer.id} to ${duplicatedCustomer.id}: `,
-        duplicatedCustomer,
-      );
-      console.log("New lead event to record: ", leadEventData);
-      console.log("New sale event to record: ", saleEventData);
-    console.log("recorded events in Tinybird: ", res);
-        console.log(
-          "Final commissions to transfer event ids: ",
-          finalCommissionsToTransferEventIds,
-        );
-        console.log(
-          `Updated link${finalCommissionsToTransferEventIds.length > 0 ? " and nullified old commissions" : ""}:`,
-          updatedRes,
-        );
-        console.log("Commissions to create: ", commissionsToCreate);

Also applies to: 187-187, 203-205, 249-251, 319-322, 356-356, 392-392, 420-421, 429-432, 476-479, 481-482


452-457: Update customer stats on new sale flow for consistency.

When !useExistingEvents and a sale is created, also update customer.sales and customer.saleAmount.

         const updatedRes = await Promise.all([
           // update link stats
           prisma.link.update({
             where: {
               id: link.id,
             },
             data: {
@@
               saleAmount: {
                 increment: saleAmount ?? totalSaleAmount,
               },
             },
           }),
+          !useExistingEvents &&
+            !!saleAmount &&
+            prisma.customer.update({
+              where: { id: customer.id },
+              data: {
+                sales: { increment: 1 },
+                saleAmount: { increment: saleAmount },
+              },
+            }),
           // update the commissions
           finalCommissionsToTransferEventIds.length > 0 &&
             prisma.commission.updateMany({
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 00ad79e and a04a1ad.

📒 Files selected for processing (1)
  • apps/web/lib/actions/partners/create-manual-commission.ts (3 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
apps/web/lib/actions/partners/create-manual-commission.ts (10)
apps/web/lib/zod/schemas/leads.ts (1)
  • leadEventSchemaTB (98-107)
apps/web/lib/zod/schemas/sales.ts (1)
  • saleEventSchemaTB (120-137)
apps/web/lib/zod/schemas/commissions.ts (1)
  • createCommissionSchema (131-153)
apps/web/lib/partners/create-partner-commission.ts (2)
  • CreatePartnerCommissionProps (25-41)
  • createPartnerCommission (43-328)
apps/web/lib/tinybird/get-customer-events-tb.ts (1)
  • getCustomerEventsTB (10-21)
apps/web/lib/tinybird/record-click-zod.ts (2)
  • recordClickZodSchema (4-34)
  • recordClickZod (36-40)
apps/web/lib/api/create-id.ts (1)
  • createId (62-67)
packages/utils/src/constants/continents.ts (1)
  • COUNTRIES_TO_CONTINENTS (13-264)
apps/web/lib/analytics/is-first-conversion.ts (1)
  • isFirstConversion (3-23)
apps/web/lib/api/workflows/execute-workflows.ts (1)
  • executeWorkflows (17-122)
⏰ 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). (2)
  • GitHub Check: Vade Review
  • GitHub Check: build
🔇 Additional comments (1)
apps/web/lib/actions/partners/create-manual-commission.ts (1)

238-251: recordSaleWithTimestamp bulk ingest OK

tb.buildIngestEndpoint (zod-bird) accepts either a single event object or an array (bulk NDJSON) and is awaitable; Tinybird Events API supports batched NDJSON ingest. (github.com)

eventId: leadEventData.event_id,
quantity: 1,
createdAt: finalLeadEventDate,
createdAt: new Date(leadEventData.timestamp + "Z"),
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue

Fix invalid Date construction: don’t append "Z" to an ISO string that already has "Z".

toISOString() returns a UTC string with "Z". Adding another "Z" yields Invalid Date.

-          createdAt: new Date(leadEventData.timestamp + "Z"),
+          createdAt: new Date(leadEventData.timestamp),
-            createdAt: new Date(saleEventData.timestamp + "Z"),
+            createdAt: new Date(saleEventData.timestamp),

Also applies to: 408-408

🤖 Prompt for AI Agents
In apps/web/lib/actions/partners/create-manual-commission.ts around lines 369
and 408, the code appends an extra "Z" to a timestamp string that may already
include "Z", producing Invalid Date; change the construction to not add a second
"Z" (e.g., pass the timestamp directly into new Date or strip a trailing "Z"
before appending) so the Date is parsed correctly.

Comment on lines +182 to +190
const clickEventData = recordClickZodSchema.parse({
...existingClickEvent,
...newClickAttributes,
});

console.log("Click event to record: ", clickEventData);
if (existingClickEvent) {
tbEventsToRecord.push(recordClickZod(clickEventData));
}
Copy link
Contributor

Choose a reason for hiding this comment

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

When using existing events, if no existing click event is found, the code will still try to use clickEventData (which contains undefined values) in lead and sale event creation, causing runtime errors.

View Details
📝 Patch Details
diff --git a/apps/web/lib/actions/partners/create-manual-commission.ts b/apps/web/lib/actions/partners/create-manual-commission.ts
index cb05d31af..1eaa7ac5f 100644
--- a/apps/web/lib/actions/partners/create-manual-commission.ts
+++ b/apps/web/lib/actions/partners/create-manual-commission.ts
@@ -175,6 +175,12 @@ export const createManualCommissionAction = authActionClient
       );
       console.log("Found existing sale events: ", existingSaleEvents);
 
+      if (!existingClickEvent) {
+        throw new Error(
+          `No existing click event found for customer ${customer.id}. Cannot duplicate events without a click event.`,
+        );
+      }
+
       const newClickAttributes = {
         click_id: nanoid(16), // create new clickId
         link_id: link.id, // set to new link.id,

Analysis

Runtime error in createManualCommissionAction when duplicating events without existing click event

What fails: leadEventSchemaTBWithTimestamp.parse() and saleEventSchemaTBWithTimestamp.parse() fail with Zod validation errors when existingClickEvent is undefined

How to reproduce:

// In createManualCommissionAction with useExistingEvents=true
// If customer has lead/sale events but no click event in existingCustomerEvents
const existingClickEvent = undefined;
const clickEventData = recordClickZodSchema.parse({
  ...existingClickEvent,  // spreads nothing - missing required fields
  ...newClickAttributes,
});
// Later: leadEventSchemaTBWithTimestamp.parse({...clickEventData, ...}) fails

Result: Zod validation error: Required for fields like url, timestamp when clickEventData only contains click_id and link_id

Expected: Should either generate synthetic click event data or throw clear error preventing invalid state

Root cause: Spreading undefined adds no properties, leaving required schema fields missing from clickEventData

@steven-tey steven-tey merged commit 8cfc32c into main Sep 18, 2025
8 of 9 checks passed
@steven-tey steven-tey deleted the improve-manual-commission branch September 18, 2025 23:08
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: 2

♻️ Duplicate comments (5)
apps/web/lib/actions/partners/create-manual-commission.ts (5)

441-444: Use the correct customer reference for first-conversion check.

When duplicating, pass the duplicated customer context (or derived stats), not the original.

-              ...(isFirstConversion({
-                customer,
-                linkId,
-              }) && {
+              ...(isFirstConversion({
+                customer: useExistingEvents
+                  ? { sales: totalSales, linkId: link.id }
+                  : customer,
+                linkId,
+              }) && {

333-343: Include country in generated click event to avoid “Unknown” overwrite.

Without country, you later persist Unknown into the duplicated customer.

       const generatedClickEvent = recordClickZodSchema.parse({
         timestamp: clickTimestamp.toISOString(),
         identity_hash: customer.externalId || customer.id,
         click_id: clickId,
         link_id: link.id,
         url: link.url,
-        ip: "127.0.0.1",
+        ip: "127.0.0.1",
+        country: customer.country || "Unknown",
         continent: customer.country
           ? COUNTRIES_TO_CONTINENTS[customer.country.toUpperCase()] || "NA"
           : "NA",
       });

165-177: Remove debug console logs (PII/noisy) or gate behind a debug flag.

These leak customer/event details into prod logs. Use structured logging only if essential.

Example deletions:

-      console.log("Found existing click event: ", existingClickEvent);
-      console.log("Found existing lead event: ", existingLeadEvent);
-      console.log("Found existing sale events: ", existingSaleEvents);
-      console.log("Click event to record: ", clickEventData);
-        console.log("Lead event to record: ", leadEventData);
-        console.log("Sale events to record: ", saleEventsData);
-      console.log(
-        `Duplicated customer ${customer.id} to ${duplicatedCustomer.id}: `,
-        duplicatedCustomer,
-      );
-      console.log("New lead event to record: ", leadEventData);
-      console.log("New sale event to record: ", saleEventData);
-    console.log("recorded events in Tinybird: ", res);
-        console.log(
-          "Final commissions to transfer event ids: ",
-          finalCommissionsToTransferEventIds,
-        );
-        console.log(
-          `Updated link${finalCommissionsToTransferEventIds.length > 0 ? " and nullified old commissions" : ""}:`,
-          updatedRes,
-        );
-        console.log("Commissions to create: ", commissionsToCreate);

Also applies to: 187-191, 203-205, 249-251, 319-322, 356-357, 392-395, 420-421, 429-432, 476-482


303-316: Don’t spread a Prisma entity into create(); copy safe fields and null unique/external IDs.

Spreading ...customer risks unique constraint violations (e.g., external/stripe IDs), wrong timestamps, and copying counters. Create a clean payload.

-        return await tx.customer.create({
-          data: {
-            ...customer,
-            id: duplicateCustomerId,
-            linkId: link.id,
-            clickId: clickEventData.click_id,
-            clickedAt: new Date(clickEventData.timestamp),
-            country: clickEventData.country,
-            ...(recordSaleEvents && {
-              sales: totalSales,
-              saleAmount: totalSaleAmount,
-            }),
-          },
-        });
+        return await tx.customer.create({
+          data: {
+            id: duplicateCustomerId,
+            name: customer.name,
+            email: customer.email,
+            avatar: customer.avatar,
+            externalId: null,
+            stripeCustomerId: null,
+            country: clickEventData.country ?? customer.country ?? "Unknown",
+            projectId: workspace.id,
+            linkId: link.id,
+            clickId: clickEventData.click_id,
+            clickedAt: new Date(
+              existingClickEvent?.timestamp ??
+                existingLeadEvent?.timestamp ??
+                existingSaleEvents[0]?.timestamp ??
+                clickEventData.timestamp,
+            ),
+            ...(recordSaleEvents && {
+              sales: totalSales,
+              saleAmount: totalSaleAmount,
+            }),
+          },
+        });

178-190: Fix crash when no existing click event (unsafe spread of undefined).

{ ...existingClickEvent } throws if no click exists. Also ensure a sane timestamp and identity hash fallback.

Apply:

-      const clickEventData = recordClickZodSchema.parse({
-        ...existingClickEvent,
-        ...newClickAttributes,
-      });
+      const inferredClickTs =
+        (existingClickEvent?.timestamp as string | undefined) ??
+        (existingLeadEvent?.timestamp as string | undefined) ??
+        (existingSaleEvents[0]?.timestamp as string | undefined) ??
+        new Date().toISOString();
+
+      const clickEventData = recordClickZodSchema.parse({
+        ...(existingClickEvent ?? {}),
+        timestamp: inferredClickTs,
+        identity_hash:
+          existingClickEvent?.identity_hash ?? customer.externalId ?? customer.id,
+        ...newClickAttributes,
+      });
🧹 Nitpick comments (3)
apps/web/lib/actions/partners/create-manual-commission.ts (3)

441-457: Link stats: lead increments should not depend on first-conversion.

You currently bump leads only when isFirstConversion is true. Leads recorded (both paths) should increment leads irrespective of conversion logic.

Suggestion: increment leads when a lead event was recorded in this action; keep conversions guarded by isFirstConversion.


24-24: Import path nit: avoid deep “/src” import.

Prefer @dub/utils (if exported) to reduce coupling to internal paths.


115-133: Invoice uniqueness: duplication path may race with nullification.

You check invoiceId only for user-supplied sales. When duplicating existing sale events, uniqueness relies on the later nullification. Consider guarding duplicates with a pre-check or wrapping the nullify+create in a single transaction to avoid races.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between a04a1ad and dd68fa0.

📒 Files selected for processing (1)
  • apps/web/lib/actions/partners/create-manual-commission.ts (3 hunks)
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-09-17T17:44:03.921Z
Learnt from: TWilson023
PR: dubinc/dub#2857
File: apps/web/lib/actions/partners/update-program.ts:96-0
Timestamp: 2025-09-17T17:44:03.921Z
Learning: In apps/web/lib/actions/partners/update-program.ts, the team prefers to keep the messagingEnabledAt update logic simple by allowing client-provided timestamps rather than implementing server-controlled timestamp logic to avoid added complexity.

Applied to files:

  • apps/web/lib/actions/partners/create-manual-commission.ts
🧬 Code graph analysis (1)
apps/web/lib/actions/partners/create-manual-commission.ts (9)
apps/web/lib/zod/schemas/leads.ts (1)
  • leadEventSchemaTB (98-107)
apps/web/lib/zod/schemas/sales.ts (1)
  • saleEventSchemaTB (120-137)
apps/web/lib/zod/schemas/commissions.ts (1)
  • createCommissionSchema (131-153)
apps/web/lib/partners/create-partner-commission.ts (2)
  • CreatePartnerCommissionProps (25-41)
  • createPartnerCommission (43-328)
apps/web/lib/tinybird/get-customer-events-tb.ts (1)
  • getCustomerEventsTB (10-21)
apps/web/lib/tinybird/record-click-zod.ts (2)
  • recordClickZodSchema (4-34)
  • recordClickZod (36-40)
apps/web/lib/api/create-id.ts (1)
  • createId (62-67)
apps/web/lib/analytics/is-first-conversion.ts (1)
  • isFirstConversion (3-23)
apps/web/lib/api/workflows/execute-workflows.ts (1)
  • executeWorkflows (17-122)
⏰ 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: Vade Review
🔇 Additional comments (1)
apps/web/lib/actions/partners/create-manual-commission.ts (1)

249-251: Confirm recordSaleWithTimestamp supports array payloads.

Else enqueue each event individually to avoid ingestion failure.

Fallback if needed:

-        tbEventsToRecord.push(recordSaleWithTimestamp(saleEventsData));
+        for (const e of saleEventsData) {
+          tbEventsToRecord.push(recordSaleWithTimestamp(e));
+        }

Comment on lines +219 to +221
createdAt: new Date(leadEventData.timestamp + "Z"), // add the "Z" to the timestamp to make it UTC
user,
context: {
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue

Stop appending an extra "Z" to ISO timestamps.

toISOString() already includes “Z”; appending another yields Invalid Date. Use the timestamp as-is.

-            createdAt: new Date(leadEventData.timestamp + "Z"), // add the "Z" to the timestamp to make it UTC
+            createdAt: new Date(leadEventData.timestamp),
-              createdAt: new Date(saleEventData.timestamp + "Z"), // add the "Z" to the timestamp to make it UTC
+              createdAt: new Date(saleEventData.timestamp),

Also applies to: 272-275

🤖 Prompt for AI Agents
In apps/web/lib/actions/partners/create-manual-commission.ts around lines
219-221 and 272-275, the code appends an extra "Z" to timestamps created from
toISOString(), producing invalid dates; remove the concatenated "Z" and pass the
timestamp string (or Date object) directly to new Date(...) so you rely on the
already-UTC ISO format, ensuring valid Date construction and consistent timezone
handling.

Comment on lines +461 to +474
// update the commissions
finalCommissionsToTransferEventIds.length > 0 &&
prisma.commission.updateMany({
where: {
eventId: {
in: finalCommissionsToTransferEventIds,
},
},
data: {
eventId: null,
invoiceId: null,
},
}),
]);
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

🧩 Analysis chain

Don’t nullify eventId/invoiceId — preserve provenance or record a transfer.

Blanking identifiers breaks dedupe, exports, and auditability. Prefer marking old commissions as transferred/clawed-back and linking via a transfer/audit record, or add fields like transferredFromEventId. Coordinate schema/consumers before changing.


I need to examine the codebase to understand the context of this commission management system and verify whether nullifying eventId and invoiceId is indeed problematic for data integrity and auditability.


🏁 Script executed:

#!/bin/bash
# First, let's examine the file in question to understand the full context
cat apps/web/lib/actions/partners/create-manual-commission.ts

Length of output: 16380


🏁 Script executed:

#!/bin/bash
# Look for the Commission model/schema to understand the data structure
ast-grep --pattern 'model Commission {
  $$$
}'

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Search for Commission schema definition in Prisma files
fd -e prisma -x cat {}

Length of output: 50721


🏁 Script executed:

#!/bin/bash
# Search for any references to Commission model across the codebase
rg -n --type=ts -C3 'Commission.*{' -g '!node_modules/**'

Length of output: 89


🏁 Script executed:

#!/bin/bash
# Look for existing patterns of handling commission transfers or updates
rg -n --type=ts -C5 'commission.*transfer' -g '!node_modules/**'

Length of output: 148


🏁 Script executed:

#!/bin/bash
# Search for other places where eventId or invoiceId are being nullified
rg -n --type=ts -C3 'eventId.*null|invoiceId.*null' -g '!node_modules/**'

Length of output: 148


Continuing verification of nullification patterns and related logic:


🏁 Script executed:

#!/bin/bash
# Find where eventId is set to null
rg -n "eventId: null" -g "apps/web/lib/**"

# Find where invoiceId is set to null
rg -n "invoiceId: null" -g "apps/web/lib/**"

# Locate commission.updateMany usages in this module
rg -n "commission.updateMany" -g "apps/web/lib/**"

# Inspect createPartnerCommission implementation
rg -n "export function createPartnerCommission" -g "apps/web/lib/partners/create-partner-commission.ts"

Length of output: 754


Preserve provenance — don’t nullify eventId/invoiceId. Replace the prisma.commission.updateMany call in apps/web/lib/actions/partners/create-manual-commission.ts (461-474) so that old commissions are marked (e.g. status=duplicate or via a new transferredFromEventId/audit record) instead of clearing their eventId/invoiceId.

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.

3 participants