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 12, 2025

Summary by CodeRabbit

  • New Features

    • Event-specific commission handling: leads and sales use tailored earnings calculations.
    • Unified event-type processing for commissions.
    • UI: action buttons adapt label and sizing for mobile; partners page displays the table directly.
  • Bug Fixes

    • Prevent duplicate lead commissions with dedup logic.
    • Enforce one-time / max-duration rules for follow-on commissions; propagate fraud/canceled status.
    • Removed global payout cap (maxAmount).

@vercel
Copy link
Contributor

vercel bot commented Sep 12, 2025

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

Project Deployment Preview Updated (UTC)
dub Ready Ready Preview Sep 12, 2025 0:28am

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Sep 12, 2025

Walkthrough

Branch commission creation by event (lead vs sale), unify first-commission lookup by event, add lead deduplication and sale reward/duration checks with status propagation, split earnings calc by event, remove Reward.maxAmount, rename/manual-wire create-manual-commission action, update several button sizes/labels and simplify partners page render.

Changes

Cohort / File(s) Summary
Partner commission logic
apps/web/lib/partners/create-partner-commission.ts
Reworked control flow: firstCommission lookup uses event; lead dedup (skip if first exists); sale dedup/duration and rewardId checks (skip when expired or maxDuration===0); propagate fraud/canceled status; branch earnings calc by event (lead: amount×quantity; sale: calculateSaleEarnings); removed global maxAmount cap; updated logs.
Manual commission action rename
apps/web/lib/actions/partners/create-manual-commission.ts
Export renamed to createManualCommissionAction; implementation chain unchanged.
Reward schema changes
apps/web/lib/zod/schemas/rewards.ts, packages/prisma/schema/reward.prisma
Removed maxAmount from Zod RewardSchema and Prisma Reward model.
Create commission UI wiring
apps/web/app/.../program/commissions/create-commission-sheet.tsx
Switched imported/used action from createCommissionActioncreateManualCommissionAction.
Responsive buttons & labels
apps/web/app/.../program/*/create-*-button.tsx, .../commissions/commission-popover-buttons.tsx, .../partners/import-export-buttons.tsx, .../partners/invite-partner-button.tsx
Added useMediaQuery to shorten button labels on mobile; added responsive height classes like h-8 px-3 sm:h-9 or h-8 w-auto px-1.5 sm:h-9 to standardize button sizing.
Partners page wrapper removal
apps/web/app/.../program/partners/page-client.tsx, apps/web/app/.../program/partners/page.tsx
Deleted client wrapper component and updated page to render PartnersTable directly.

Sequence Diagram(s)

sequenceDiagram
    autonumber
    participant E as Event (lead/sale/click)
    participant S as createPartnerCommission
    participant DB as DB (Commissions/Rewards)

    E->>S: send {eventType, rewardId, quantity, amount, partner, link}
    S->>DB: find firstCommission by (partner, link, eventType)
    alt firstCommission exists
        alt eventType == lead
            S->>S: log "lead dedup" and skip creation
        else
            S->>DB: if rewardId ≠ firstCommission.rewardId fetch originalReward
            S->>S: check reward.maxDuration / originalReward.maxDuration
            alt maxDuration === 0 or expired
                S->>S: log & skip creation
            else
                S->>S: if firstCommission.status ∈ {fraud,canceled} -> propagate status
            end
        end
    end
    alt proceed to create
        alt eventType == lead
            S->>S: earnings = reward.amount * quantity
        else
            S->>S: earnings = calculateSaleEarnings({reward, sale:{quantity, amount}})
        end
        S->>DB: insert commission (rewardId, earnings, status)
        DB-->>S: created
    else
        S-->>E: no commission created
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Suggested reviewers

  • devkiran
  • TWilson023

"I hop through code with careful paw,
Leads get checked, sales measured by law.
Duplicates paused, durations keep time,
Rewards counted proper, statuses chime.
Thump! — the ledger’s snug and fine. 🐇"

Pre-merge checks (2 passed, 1 warning)

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title Check ✅ Passed The title "Deduplicate lead commission creation" is concise, specific, and accurately reflects the primary change in the diff (addition of lead deduplication logic in apps/web/lib/partners/create-partner-commission.ts). It avoids noisy details and will be clear to teammates scanning history, though the PR also includes related sale dedup/duration and schema/UI tweaks that the title does not mention. Overall the title correctly captures the main developer intent.
✨ Finishing touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch deduplicate-lead-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: 1

🧹 Nitpick comments (1)
apps/web/lib/partners/create-partner-commission.ts (1)

177-183: Propagated fraud/canceled status: also suppress notifications/workflows.

You set status = firstCommission.status, but later notifications and workflows only gate on isClawback. We should avoid notifying or triggering workflows for fraud/canceled commissions.

Outside this hunk, refine these flags:

-        const isClawback = earnings < 0;
-        const shouldTriggerWorkflow = !isClawback && !skipWorkflow;
+        const isClawback = earnings < 0;
+        const isInvalidStatus = status === "fraud" || status === "canceled";
+        const shouldTriggerWorkflow = !isClawback && !isInvalidStatus && !skipWorkflow;

And guard notifications similarly:

-          !isClawback &&
+          !isClawback && !isInvalidStatus &&
             notifyPartnerCommission({ /* ... */ })
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 0633998 and ac45809.

📒 Files selected for processing (1)
  • apps/web/lib/partners/create-partner-commission.ts (2 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 (4)
apps/web/lib/partners/create-partner-commission.ts (4)

89-91: LGTM: click earnings fast-path is clear and correct.

Simple and deterministic; no dedup or duration concerns for clicks.


115-121: Lead dedup early-return is correct, contingent on the fixed lookup scope.

Once the query is properly scoped by programId and enforced customerId, this return prevents duplicate lead payouts as intended.

Please confirm leads are intended to be “one per partner/customer per program.” If not, we should key dedup by a stronger event identity.


187-196: LGTM: per-event earnings branching is correct.

Lead = fixed amount × quantity; sale uses calculateSaleEarnings; aligns with reward semantics.


153-169: Use event timestamp (createdAt) for maxDuration gating — handle backfills

File: apps/web/lib/partners/create-partner-commission.ts (lines 153-169)

Use the event's timestamp when computing monthsDifference; fall back to now if createdAt is absent.

-              const monthsDifference = differenceInMonths(
-                new Date(),
-                firstCommission.createdAt,
-              );
+              const eventDate = createdAt ?? new Date();
+              const monthsDifference = differenceInMonths(
+                eventDate,
+                firstCommission.createdAt,
+              );

If maxDuration semantics are “N whole months inclusive starting from the first commission,” >= is appropriate; otherwise consider > and document the rule.

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

Caution

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

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

200-226: Make dedupe idempotent under concurrency (handle P2002 + add unique keys)

findFirst→create is racy. Add DB uniqueness and treat duplicates as no-ops.

   try {
-    const commission = await prisma.commission.create({
+    const commission = await prisma.commission.create({
       data: {
         id: createId({ prefix: "cm_" }),
         programId,
         partnerId,
         rewardId: reward?.id,
         customerId,
         linkId,
         eventId,
         invoiceId,
         userId: user?.id,
         quantity,
         amount,
         type: event,
         currency,
         earnings,
         status,
         description,
         createdAt,
       },
       include: {
         customer: true,
       },
     });
@@
-  } catch (error) {
-    console.error("Error creating commission", error);
+  } catch (error: any) {
+    // Idempotency: swallow unique violations (e.g., same event/invoice)
+    if (error && error.code === "P2002") {
+      console.warn("Duplicate commission detected (unique constraint). No-op.");
+      return;
+    }
+    console.error("Error creating commission", error);

Recommended DB indexes (migration examples):

  • Lead dedupe (one lead per partner/customer/program):
CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS commissions_unique_lead_per_customer
  ON "Commission" ("programId","partnerId","customerId")
  WHERE type = 'lead';
  • Event-level idempotency (sales/leads based on eventId):
CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS commissions_unique_program_event
  ON "Commission" ("programId","eventId")
  WHERE "eventId" IS NOT NULL;

Also applies to: 315-326

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

98-103: Clear leadEventDate is handled; also clear leadEventName when toggle is off

Without clearing, a previously set name will be submitted even when the toggle is disabled.

   useEffect(() => {
     if (!hasCustomLeadEventDate) {
       setValue("leadEventDate", null);
     }
   }, [hasCustomLeadEventDate, setValue]);
+
+  useEffect(() => {
+    if (!hasCustomLeadEventName) {
+      setValue("leadEventName", null);
+    }
+  }, [hasCustomLeadEventName, setValue]);

601-606: Invoice/Product toggles are hidden by default—user can’t enable them

The entire block (including the Switch) is hidden when hasInvoiceId/hasProductId is false, leaving no way to reveal the inputs. Remove the conditional className/style on the container; keep the input itself gated by the boolean.

   <AnimatedSizeContainer
     height
     transition={{ ease: "easeInOut", duration: 0.2 }}
-    className={!hasInvoiceId ? "hidden" : ""}
-    style={{ display: !hasInvoiceId ? "none" : "block" }}
   >
   <AnimatedSizeContainer
     height
     transition={{ ease: "easeInOut", duration: 0.2 }}
-    className={!hasProductId ? "hidden" : ""}
-    style={{ display: !hasProductId ? "none" : "block" }}
   >

Also applies to: 656-661

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

89-104: Fix dedupe scope and require customerId for lead/sale

The firstCommission query omits programId and uses a possibly undefined customerId, which Prisma ignores — causing cross-program/cross-customer dedupe and status bleed-through. Add a guard and scope the query.

-    } else {
+    } else {
+      // Require customerId for lead/sale to avoid cross-customer dedupe/status bleed-through
+      if ((event === "lead" || event === "sale") && !customerId) {
+        await log({
+          message: `Missing customerId for ${event} commission (program ${programId}, partner ${partnerId}) — skipping.`,
+          type: "errors",
+          mention: true,
+        });
+        return;
+      }
       const firstCommission = await prisma.commission.findFirst({
         where: {
+          programId,
           partnerId,
-          customerId,
+          customerId: customerId!, // guaranteed by guard above
           type: event,
         },
🧹 Nitpick comments (6)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/commission-popover-buttons.tsx (1)

22-39: Minor a11y: expose popover state to screen readers

Consider passing aria-expanded/aria-haspopup via Button or Popover so assistive tech reflects the open state.

-        <Button
+        <Button
           onClick={() => setOpenPopover(!openPopover)}
           variant="secondary"
           className="h-8 w-auto px-1.5 sm:h-9"
           icon={<ThreeDots className="h-5 w-5 text-neutral-500" />}
+          aria-haspopup="menu"
+          aria-expanded={openPopover}
         />

Also applies to: 42-58, 61-71

apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/import-export-buttons.tsx (1)

32-38: Minor a11y: reflect popover state

Same note as other popover—add aria-expanded/aria-haspopup on the trigger.

-        <Button
+        <Button
           onClick={() => setOpenPopover(!openPopover)}
           variant="secondary"
           className="h-8 w-auto px-1.5 sm:h-9"
           icon={<ThreeDots className="h-5 w-5 text-neutral-500" />}
+          aria-haspopup="menu"
+          aria-expanded={openPopover}
         />

Also applies to: 66-82, 85-95

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

58-61: Nit: guard/validation for quantity and currency (non-blocking)

Optionally validate quantity > 0 and default currency for sale/custom to reduce bad writes.

-  let earnings = 0;
+  let earnings = 0;
   let reward: RewardProps | null = null;
   let status: CommissionStatus = "pending";
+  if (quantity <= 0) {
+    await log({ message: `Invalid quantity (${quantity}) for ${event}`, type: "errors" });
+    return;
+  }

Also applies to: 200-221

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

7-7: Ensure accessible name and avoid mobile ambiguity

On mobile the visible label is just “Create”, which is ambiguous for screen readers and can cause a tiny hydration/layout shift when isMobile flips. Add a stable accessible name (and optional title).

       <Button
         type="button"
         onClick={() => setShowCreateCommissionSheet(true)}
         text={`Create${isMobile ? "" : " commission"}`}
+        aria-label="Create commission"
+        title="Create commission"
         shortcut="C"
         className="h-8 px-3 sm:h-9"
       />

Also applies to: 22-25

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

164-175: Don’t treat 0 as “missing” for numeric fields

Current checks disable submit when amount/saleAmount is 0. If 0 is a valid value per schema (min: 0), compare against null/undefined instead.

   if (commissionType === "custom") {
-    return !amount;
+    return amount == null;
   }
   if (commissionType === "sale") {
-    return !linkId || !customerId || !saleAmount;
+    return !linkId || !customerId || saleAmount == null;
   }
   if (commissionType === "lead") {
     return !linkId || !customerId;
   }

616-622: Microcopy: prefer “Invoice ID” / “Product ID” casing

Tiny polish for consistency with labels below.

-                          <span className="rounded-md border border-neutral-200 bg-neutral-100 px-1 py-0.5 text-xs">
-                            invoiceID
-                          </span>
+                          <span className="rounded-md border border-neutral-200 bg-neutral-100 px-1 py-0.5 text-xs">
+                            Invoice ID
+                          </span>
-                          <span className="rounded-md border border-neutral-200 bg-neutral-100 px-1 py-0.5 text-xs">
-                            productID
-                          </span>
+                          <span className="rounded-md border border-neutral-200 bg-neutral-100 px-1 py-0.5 text-xs">
+                            Product ID
+                          </span>

Also applies to: 671-676

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between ac45809 and 2fb3e75.

📒 Files selected for processing (13)
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/create-bounty-button.tsx (2 hunks)
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/commission-popover-buttons.tsx (1 hunks)
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-commission-button.tsx (2 hunks)
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-commission-sheet.tsx (2 hunks)
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/create-group-button.tsx (2 hunks)
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/import-export-buttons.tsx (1 hunks)
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/invite-partner-button.tsx (2 hunks)
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/page-client.tsx (0 hunks)
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/page.tsx (2 hunks)
  • apps/web/lib/actions/partners/create-manual-commission.ts (1 hunks)
  • apps/web/lib/partners/create-partner-commission.ts (2 hunks)
  • apps/web/lib/zod/schemas/rewards.ts (0 hunks)
  • packages/prisma/schema/reward.prisma (0 hunks)
💤 Files with no reviewable changes (3)
  • packages/prisma/schema/reward.prisma
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/page-client.tsx
  • apps/web/lib/zod/schemas/rewards.ts
🧰 Additional context used
🧬 Code graph analysis (2)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-commission-sheet.tsx (1)
apps/web/lib/actions/partners/create-manual-commission.ts (1)
  • createManualCommissionAction (23-318)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/page.tsx (1)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/partners-table.tsx (1)
  • PartnersTable (91-440)
⏰ 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 (10)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/commission-popover-buttons.tsx (1)

68-70: LGTM: consistent button sizing

The responsive height tweak aligns with the broader UI pattern. No behavioral change.

apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/import-export-buttons.tsx (1)

92-94: LGTM: consistent button sizing

Matches the standardized h-8/sm:h-9 pattern.

apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/page.tsx (1)

5-5: Replace wrapper with PartnersTable — confirmed client boundary

PartnersTable contains "use client" at apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/partners-table.tsx:1; safe to replace the wrapper.

apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/invite-partner-button.tsx (1)

3-3: LGTM: mobile-aware label + sizing

Clear pattern reuse with useMediaQuery and height classes; shortcut remains intact.

Also applies to: 7-7, 19-22

apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/create-bounty-button.tsx (1)

3-3: LGTM: responsive text and sizing

Consistent with other action buttons; no functional changes.

Also applies to: 7-7, 20-23

apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/create-group-button.tsx (1)

6-6: LGTM: responsive label and height

Reads well; conditional shortcut binding is a nice touch.

Also applies to: 10-10, 41-43

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

188-206: Workflow duplication check — no duplicate commission triggers found

Only WorkflowTrigger.commissionEarned is invoked in apps/web/lib/partners/create-partner-commission.ts; apps/web/lib/actions/partners/create-manual-commission.ts emits WorkflowTrigger.leadRecorded / .saleRecorded but does not itself emit commissionEarned — manual path will produce commissionEarned only via createPartnerCommission.


23-24: Rename acknowledged — verify downstream imports updated

Search for "createCommissionAction" returned no matches (empty output). Re-run a repo-wide search and confirm no lingering imports; update any occurrences to createManualCommissionAction. Example command: rg -n --hidden -S '\bcreateCommissionAction\b' .

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

116-126: LGTM: action wiring switched to createManualCommissionAction

Hook usage and success/error handlers remain compatible.


1-1: Import rename verified — no stale callers remain.
No matches for createCommissionAction; apps/web/lib/actions/partners/create-manual-commission.ts exports createManualCommissionAction (line 23).

@steven-tey
Copy link
Collaborator Author

@coderabbitai full review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Sep 12, 2025

✅ Actions performed

Full review triggered.

@steven-tey steven-tey merged commit 5c0c661 into main Sep 12, 2025
9 checks passed
@steven-tey steven-tey deleted the deduplicate-lead-commission branch September 12, 2025 00:48
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 (10)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/create-group-button.tsx (1)

26-33: Bug: disabled state bypassed via keyboard shortcut

handleCreateGroup opens the sheet when disabled (counts undefined) because the else branch still runs; the hotkey remains active. Guard early.

 const handleCreateGroup = () => {
-  if (!disabled && groupsCount >= groupsLimit)
-    setShowPartnersUpgradeModal(true);
-  else setShowCreateGroupSheet(true);
+  if (disabled) return;
+  if (groupsCount >= groupsLimit) {
+    setShowPartnersUpgradeModal(true);
+  } else {
+    setShowCreateGroupSheet(true);
+  }
 };

Optionally register/unregister the shortcut based on disabled if the hook supports deps.

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

118-131: Scope existing lead to the same link/partner or fall back

Reusing the first lead for a customer without checking link/partner can mis-attribute analytics and commissions.

-    if (
-      !leadEventDate &&
-      !leadEventName &&
-      existingLeadEvent &&
-      existingLeadEvent.data.length > 0
-    ) {
-      leadEvent = leadEventSchemaTB.parse(existingLeadEvent.data[0]);
+    if (
+      !leadEventDate &&
+      !leadEventName &&
+      existingLeadEvent &&
+      existingLeadEvent.data.length > 0
+    ) {
+      const candidate = leadEventSchemaTB.parse(existingLeadEvent.data[0]);
+      // Require same link to avoid cross-partner attribution
+      if (candidate.link_id === linkId) {
+        leadEvent = candidate;
+        // If the customer isn't linked yet, attach based on existing event
+        if (!customer.linkId) {
+          shouldUpdateCustomer = true;
+          clickEvent = {
+            ...candidate,
+            click_id: candidate.click_id,
+          } as ClickEventTB;
+        }
+      }
+      // else: let the dummy flow create a scoped click+lead below
     } else {

215-229: Don’t hardcode USD; accept currency from input and propagate

Allow currency to be provided, defaulting to workspace/program currency if available.

-          payment_processor: "custom",
-          currency: "usd",
+          payment_processor: "custom",
+          currency: parsedInput.currency ?? "usd",
-          currency: "usd",
+          currency: parsedInput.currency ?? "usd",

If schema doesn’t yet accept currency, extend createCommissionSchema accordingly.

Also applies to: 232-244


301-305: clickedAt expects a Date — convert ISO string

Prisma DateTime generally expects a Date; you’re passing an ISO string from TB.

-                clickedAt: clickEvent?.timestamp,
+                clickedAt: clickEvent?.timestamp
+                  ? new Date(clickEvent.timestamp)
+                  : undefined,
apps/web/lib/partners/create-partner-commission.ts (2)

226-228: Avoid logging full commission payload (PII risk)

JSON stringifying the included customer leaks PII to logs. Log only essentials.

-    console.log(
-      `Created a ${event} commission ${commission.id} (${currencyFormatter(commission.earnings)}) for ${partnerId}: ${JSON.stringify(commission)}`,
-    );
+    console.log(
+      `Created ${event} commission ${commission.id} for partner ${partnerId}, customer ${commission.customerId}, earnings ${currencyFormatter(commission.earnings)}`
+    );

200-221: Race-proof with DB uniqueness and duplicate handling

findFirst → create is TOCTOU; enforce idempotency in DB and swallow duplicates.

  • Leads: unique per (programId, partnerId, customerId) where type='lead'.
  • Sales: unique on eventId (or invoiceId+programId if applicable).

Migration (Postgres):

CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS commissions_unique_lead_per_customer
  ON "Commission" ("programId","partnerId","customerId")
  WHERE type = 'lead';

CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS commissions_unique_event_id
  ON "Commission" ("eventId")
  WHERE "eventId" IS NOT NULL;

Then catch Prisma P2002 on create() to no-op.

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

438-446: Switch is hidden initially due to container height=0 (cannot be toggled).

The AnimatedSizeContainer collapses the entire block to 0px when hasCustomLeadEventDate is false, hiding the Switch and making it impossible to enable. Keep the switch visible; only animate the extra input.

Minimal fix to keep the switch visible:

-                      <AnimatedSizeContainer
-                        height
-                        transition={{ ease: "easeInOut", duration: 0.2 }}
-                        style={{
-                          height: hasCustomLeadEventDate ? "auto" : "0px",
-                          overflow: "hidden",
-                        }}
-                      >
+                      <AnimatedSizeContainer
+                        height
+                        transition={{ ease: "easeInOut", duration: 0.2 }}
+                        style={{ height: "auto", overflow: "hidden" }}
+                      >

The actual input is already gated by hasCustomLeadEventDate && (...), so only the date picker appears when enabled.


486-489: Same issue for “custom lead event name” toggle.

Container collapses and hides the Switch. Keep container height auto.

-                      <AnimatedSizeContainer
-                        height
-                        transition={{ ease: "easeInOut", duration: 0.2 }}
-                        style={{
-                          height: hasCustomLeadEventName ? "auto" : "0px",
-                          overflow: "hidden",
-                        }}
-                      >
+                      <AnimatedSizeContainer
+                        height
+                        transition={{ ease: "easeInOut", duration: 0.2 }}
+                        style={{ height: "auto", overflow: "hidden" }}
+                      >

604-606: Invoice ID switch also hidden by CSS.

Hiding the whole container with hidden/display:none prevents enabling the switch. Remove these and gate only the input field.

-                      className={!hasInvoiceId ? "hidden" : ""}
-                      style={{ display: !hasInvoiceId ? "none" : "block" }}

659-661: Same issue for Product ID toggle.

Remove container hiding; keep only the input conditional.

-                      className={!hasProductId ? "hidden" : ""}
-                      style={{ display: !hasProductId ? "none" : "block" }}
♻️ Duplicate comments (2)
apps/web/lib/partners/create-partner-commission.ts (2)

98-104: Dedup scope missing programId and customer guard for lead/sale

  • Missing programId can dedupe across programs.
  • If customerId is undefined, Prisma ignores it, causing cross-customer dedupe/status bleed.

Apply both guard and scoping:

   } else {
+    // Require a customer for lead/sale to avoid cross-customer dedupe/propagation
+    if ((event === "lead" || event === "sale") && !customerId) {
+      await log({
+        message: `Missing customerId for ${event} commission (program ${programId}, partner ${partnerId}) — skipping.`,
+        type: "errors",
+        mention: true,
+      });
+      return;
+    }
     const firstCommission = await prisma.commission.findFirst({
       where: {
+        programId,
         partnerId,
-        customerId,
+        customerId: customerId!, // guarded above
         type: event,
       },

163-166: Use event/create timestamp for duration window, not “now”

Backfills/manuals get misclassified when using new Date().

-              const monthsDifference = differenceInMonths(
-                new Date(),
-                firstCommission.createdAt,
-              );
+              const effectiveCreatedAt = createdAt ?? new Date();
+              const monthsDifference = differenceInMonths(
+                effectiveCreatedAt,
+                firstCommission.createdAt,
+              );
🧹 Nitpick comments (8)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/import-export-buttons.tsx (2)

89-94: Add accessible name to icon-only trigger

Icon-only buttons need an aria-label for screen readers.

-        <Button
+        <Button
           onClick={() => setOpenPopover(!openPopover))}
           variant="secondary"
           className="h-8 w-auto px-1.5 sm:h-9"
           icon={<ThreeDots className="h-5 w-5 text-neutral-500" />}
+          aria-label="More partner actions"
         />

70-76: Explicit button type to avoid unintended form submits

If this popover sits inside a form in the future, default type="submit" can be problematic.

-              <button
+              <button
                 onClick={() => {
                   setOpenPopover(false);
                   setShowExportPartnersModal(true);
                 }}
                 className="w-full rounded-md p-2 hover:bg-neutral-100 active:bg-neutral-200"
+                type="button"
               >
-    <button
+    <button
       onClick={onClick}
       className="w-full rounded-md p-2 hover:bg-neutral-100 active:bg-neutral-200"
+      type="button"
     >

Also applies to: 108-114

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

65-71: Add accessible name to icon-only trigger

Provide aria-label for better a11y.

-        <Button
+        <Button
           onClick={() => setOpenPopover(!openPopover)}
           variant="secondary"
           className="h-8 w-auto px-1.5 sm:h-9"
           icon={<ThreeDots className="h-5 w-5 text-neutral-500" />}
+          aria-label="More commission actions"
         />

26-37: Explicit button type to avoid accidental submits

Harden inner buttons with type="button".

-              <button
+              <button
                 onClick={() => {
                   setOpenPopover(false);
                   setClawbackSheetOpen(true);
                 }}
                 className="w-full rounded-md p-2 hover:bg-neutral-100 active:bg-neutral-200"
+                type="button"
               >
-              <button
+              <button
                 onClick={() => {
                   setOpenPopover(false);
                   setShowExportCommissionsModal(true);
                 }}
                 className="w-full rounded-md p-2 hover:bg-neutral-100 active:bg-neutral-200"
+                type="button"
               >

Also applies to: 46-57

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

3-7: Avoid hydration flicker from responsive label.

useMediaQuery can change after mount, briefly flipping text between "Create commission" and "Create." Gate on a mounted flag if you want to avoid the flash. Optional, but improves polish.

Example:

+import { useEffect, useState } from "react";
 ...
 export function CreateCommissionButton() {
-  const { isMobile } = useMediaQuery();
+  const { isMobile } = useMediaQuery();
+  const [mounted, setMounted] = useState(false);
+  useEffect(() => setMounted(true), []);

Then use mounted && isMobile where needed.


20-25: Preserve descriptive labeling on mobile and hide shortcut badge.

"Create" alone is ambiguous for screen readers. Add an aria-label and suppress the keyboard shortcut hint on mobile.

       <Button
         type="button"
         onClick={() => setShowCreateCommissionSheet(true)}
-        text={`Create${isMobile ? "" : " commission"}`}
-        shortcut="C"
+        text={`Create${isMobile ? "" : " commission"}`}
+        aria-label="Create commission"
+        shortcut={isMobile ? undefined : "C"}
         className="h-8 px-3 sm:h-9"
       />
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-commission-sheet.tsx (2)

116-126: Harden error handling for next-safe-action.

error.serverError may be undefined. Provide a safe fallback and optionally surface validation errors.

-  const { executeAsync, isPending } = useAction(createManualCommissionAction, {
+  const { executeAsync, isPending } = useAction(createManualCommissionAction, {
     onSuccess: async () => {
       toast.success("A commission has been created for the partner!");
       setIsOpen(false);
       await mutatePrefix(`/api/commissions?workspaceId=${workspaceId}`);
     },
-    onError({ error }) {
-      console.log(error);
-      toast.error(error.serverError);
+    onError({ error }) {
+      console.log(error);
+      const msg =
+        error.serverError ??
+        (error.validationErrors
+          ? "Please check the highlighted fields."
+          : "Something went wrong. Please try again.");
+      toast.error(msg);
     },
   });

565-595: Align money input UX with Amount field.

Add onKeyDown={handleMoneyKeyDown} to saleAmount to prevent invalid characters (e/E,+,-), matching the “Amount” field behavior.

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

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 0633998 and 2fb3e75.

📒 Files selected for processing (13)
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/create-bounty-button.tsx (2 hunks)
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/commission-popover-buttons.tsx (1 hunks)
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-commission-button.tsx (2 hunks)
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-commission-sheet.tsx (2 hunks)
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/create-group-button.tsx (2 hunks)
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/import-export-buttons.tsx (1 hunks)
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/invite-partner-button.tsx (2 hunks)
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/page-client.tsx (0 hunks)
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/page.tsx (2 hunks)
  • apps/web/lib/actions/partners/create-manual-commission.ts (1 hunks)
  • apps/web/lib/partners/create-partner-commission.ts (2 hunks)
  • apps/web/lib/zod/schemas/rewards.ts (0 hunks)
  • packages/prisma/schema/reward.prisma (0 hunks)
💤 Files with no reviewable changes (3)
  • apps/web/lib/zod/schemas/rewards.ts
  • packages/prisma/schema/reward.prisma
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/page-client.tsx
🔇 Additional comments (5)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/page.tsx (1)

5-5: PartnersTable is a client component — safe to import into a server page

apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/partners-table.tsx begins with "use client" and uses client-only APIs (useState, useEffect, useSWR, next/navigation, window.confirm); no SSR-only/Node APIs were found in this file.

apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/invite-partner-button.tsx (1)

3-3: LGTM: responsive label + sizing

Mobile-aware label and height look good; keyboard shortcut wiring unchanged.

Also applies to: 7-7, 19-22

apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/create-group-button.tsx (1)

41-43: LGTM: responsive label + sizing

Consistent with other buttons in this PR.

apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/create-bounty-button.tsx (1)

3-3: LGTM: mobile-aware label + sizing

No functional changes; shortcut still “C”.

Also applies to: 7-7, 20-23

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

1-1: Import rename looks right — verify no stale callers remain

rg returned no matches — unable to confirm removal. Re-run from repo root: rg -nP '\bcreateCommissionAction\b|actions/partners/create-commission' or manually confirm all imports/usages were removed/replaced.

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.

2 participants