-
Notifications
You must be signed in to change notification settings - Fork 2.7k
Discount code attribution tracking #2620
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
The latest updates on your projects. Learn more about Vercel for GitHub.
|
WalkthroughAdds a discount-codes feature: Prisma model and relations, APIs and cron endpoints, Stripe helpers and webhook handlers, background queueing, UI components/hooks, Zod/schema updates, audit logging, and integrations across partner/group/ban/deactivate/link flows. Changes
Sequence Diagram(s)sequenceDiagram
participant UI as AddDiscountCodeModal
participant API as POST /api/discount-codes
participant Stripe as Stripe (Connected)
participant DB as Prisma
participant Q as QStash
participant Audit as Audit Log
UI->>API: submit { partnerId, linkId, code? }
API->>DB: validate partner, link, enrollment, discount
API->>Stripe: create promotion code (retry on duplicate)
Stripe-->>API: promotionCode
API->>DB: create DiscountCode
API--)Q: waitUntil(recordAuditLog..., enqueue deletion if needed)
API-->>UI: return DiscountCode
sequenceDiagram
participant Q as QStash
participant Cron as /api/cron/discount-codes/:id/delete
participant Stripe as Stripe (Connected)
participant DB as Prisma
Q->>Cron: enqueue delete job (discountCodeId)
Cron->>DB: fetch DiscountCode + Workspace
alt found
Cron->>Stripe: disable promotion code
Stripe-->>Cron: ok
Cron->>DB: delete DiscountCode
else not found
Cron-->>Q: no-op / not found
end
Estimated code review effort🎯 5 (Critical) | ⏱️ ~120 minutes Possibly related PRs
Poem
Pre-merge checks and finishing touches❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✨ Finishing touches
🧪 Generate unit tests (beta)
📜 Recent review detailsConfiguration used: CodeRabbit UI Review profile: CHILL Plan: Pro 📒 Files selected for processing (1)
🧰 Additional context used🧬 Code graph analysis (1)apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts (4)
⏰ 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)
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. Comment |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 3
♻️ Duplicate comments (3)
apps/web/app/(ee)/api/groups/[groupIdOrSlug]/route.ts (1)
266-373
: Move discount-code read into the transaction.Fetching
discountCodesToDelete
before the transaction opens a race: codes created after the read but beforeupdateMany
lose theirdiscountId
without ever being queued for deletion. Pull the ID lookup inside the$transaction
, return the IDs from the transactional block (withSerializable
isolation), and enqueue once with that list so no codes slip through.- // Cache discount codes to delete them later - let discountCodesToDelete: DiscountCode[] = []; - if (group.discountId && !keepDiscountCodes) { - discountCodesToDelete = await prisma.discountCode.findMany({ - where: { - discountId: group.discountId, - }, - }); - } - - const deletedGroup = await prisma.$transaction(async (tx) => { + const discountCodeIdsToDelete = await prisma.$transaction( + async (tx) => { + let idsToDelete: string[] = []; // 1. Update all partners in the group to the default group @@ - if (group.discountId) { - // 3. Update the discount codes - await tx.discountCode.updateMany({ - where: { - discountId: group.discountId, - }, - data: { - discountId: keepDiscountCodes ? defaultGroup.discountId : null, - }, - }); - - // 4. Delete the group's discount - await tx.discount.delete({ - where: { - id: group.discountId, - }, - }); - } - - // 5. Delete the group - await tx.partnerGroup.delete({ - where: { - id: group.id, - }, - }); - - return true; - }); + if (group.discountId) { + if (!keepDiscountCodes) { + const rows = await tx.discountCode.findMany({ + where: { discountId: group.discountId }, + select: { id: true }, + }); + idsToDelete = rows.map(({ id }) => id); + } + await tx.discountCode.updateMany({ + where: { discountId: group.discountId }, + data: { + discountId: keepDiscountCodes ? defaultGroup.discountId : null, + }, + }); + + await tx.discount.delete({ + where: { id: group.discountId }, + }); + } + + await tx.partnerGroup.delete({ + where: { id: group.id }, + }); + + return idsToDelete; + }, + { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }, + ); @@ - if (deletedGroup) { - waitUntil( - Promise.allSettled([ + waitUntil( + Promise.allSettled([ @@ - ...discountCodesToDelete.map((discountCode) => - queueDiscountCodeDeletion(discountCode.id), - ), + discountCodeIdsToDelete.length > 0 && + queueDiscountCodeDeletion(discountCodeIdsToDelete), @@ - ]), - ); - } + ]), + );apps/web/lib/api/discounts/queue-discount-code-deletion.ts (1)
16-39
: Filter and dedupe discount IDs before enqueuing.When
input
isundefined
(or contains blanks),discountCodeIds
becomes[undefined]
, so we enqueue.../undefined/delete
, wasting jobs and tripping the route. We should sanitize and dedupe IDs before chunking.- const discountCodeIds = Array.isArray(input) ? input : [input]; - - if (discountCodeIds.length === 0) { + const discountCodeIds = (Array.isArray(input) ? input : [input]).filter( + (id): id is string => typeof id === "string" && id.trim().length > 0, + ); + + if (discountCodeIds.length === 0) { return; } @@ - const chunks = chunk(discountCodeIds, 100); + const uniqueIds = Array.from(new Set(discountCodeIds)); + + const chunks = chunk(uniqueIds, 100);apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts (1)
44-44
: Verify promotion code extraction covers all Stripe discount sources.Per past review comments,
charge.discounts?.[0]?.promotion_code
may miss applied codes depending on Stripe object shape. Consider also checkingtotal_details.breakdown.discounts
as the canonical source for promotion codes. This is a known issue flagged in previous reviews.
🧹 Nitpick comments (1)
apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts (1)
259-272
: Consider defensive error handling for recordFakeClick.The
recordFakeClick
helper can throw if click recording fails. If this occurs, the entire webhook will fail, preventing customer and sale attribution. Consider wrapping this call in a try-catch block and falling back to a minimal click event or logging the error while continuing with a default attribution strategy.Apply this diff to add error handling:
- clickEvent = await recordFakeClick({ - link, - customer: { - continent: customerAddress?.country - ? COUNTRIES_TO_CONTINENTS[customerAddress.country] - : "NA", - country: customerAddress?.country ?? "US", - region: customerAddress?.state ?? "CA", - }, - }); + try { + clickEvent = await recordFakeClick({ + link, + customer: { + continent: customerAddress?.country + ? COUNTRIES_TO_CONTINENTS[customerAddress.country] + : "NA", + country: customerAddress?.country ?? "US", + region: customerAddress?.state ?? "CA", + }, + }); + } catch (error) { + console.error("Failed to record fake click for promotion code:", error); + return `Failed to record fake click for promotion code ${promotionCode.code}, skipping...`; + }
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (5)
apps/web/app/(ee)/api/cron/groups/remap-discount-codes/route.ts
(1 hunks)apps/web/app/(ee)/api/groups/[groupIdOrSlug]/route.ts
(5 hunks)apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts
(7 hunks)apps/web/lib/api/discounts/is-discount-equivalent.ts
(1 hunks)apps/web/lib/api/discounts/queue-discount-code-deletion.ts
(1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
- apps/web/app/(ee)/api/cron/groups/remap-discount-codes/route.ts
🧰 Additional context used
🧬 Code graph analysis (3)
apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts (2)
apps/web/app/(ee)/api/stripe/integration/webhook/utils.ts (1)
getPromotionCode
(245-268)apps/web/lib/tinybird/record-fake-click.ts (1)
recordFakeClick
(8-53)
apps/web/app/(ee)/api/groups/[groupIdOrSlug]/route.ts (5)
apps/web/lib/zod/schemas/groups.ts (1)
DEFAULT_PARTNER_GROUP
(15-19)apps/web/lib/api/discounts/is-discount-equivalent.ts (1)
isDiscountEquivalent
(3-26)packages/utils/src/constants/main.ts (1)
APP_DOMAIN_WITH_NGROK
(20-25)apps/web/lib/api/discounts/queue-discount-code-deletion.ts (1)
queueDiscountCodeDeletion
(13-41)apps/web/lib/api/audit-logs/record-audit-log.ts (1)
recordAuditLog
(47-73)
apps/web/lib/api/discounts/queue-discount-code-deletion.ts (2)
apps/web/lib/firstpromoter/importer.ts (1)
queue
(34-39)packages/utils/src/constants/main.ts (1)
APP_DOMAIN_WITH_NGROK
(20-25)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: build
🔇 Additional comments (8)
apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts (8)
6-6
: LGTM!The new imports align with the promotion-code attribution flow and are all utilized in the implementation.
Also applies to: 14-14, 24-24, 29-29
51-51
: LGTM!The
shouldSendLeadWebhook
flag appropriately controls lead webhook emission for synthetic promotion-code leads.
210-218
: LGTM!The promotion code retrieval logic correctly delegates to the
getPromotionCode
helper and provides a clear early return message.
221-238
: LGTM!The workspace resolution correctly fetches
defaultProgramId
and provides clear early returns when workspace or program is missing. This aligns with past review recommendations.
274-303
: LGTM!The customer creation and lead event preparation logic is correct:
- Fallback name generation handles missing customer details gracefully.
- The
externalId
is appropriately set to the syntheticclick_id
.- Lead event correctly removes the
timestamp
field before schema validation.- Setting
shouldSendLeadWebhook
tofalse
appropriately suppresses the lead webhook for synthetic promotion-code leads.
133-133
: LGTM!The consistent use of
nanoid(16)
forevent_id
generation across all attribution paths ensures unique event identifiers and proper tracking.Also applies to: 297-297, 368-368
467-467
: LGTM!Referencing
saleData.event_id
for partner commissions correctly maintains event identifier consistency between sales and commissions.
521-521
: LGTM!The
shouldSendLeadWebhook
gate correctly prevents lead webhook emission for synthetic promotion-code leads while preserving existing behavior for other attribution paths.
apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts
Outdated
Show resolved
Hide resolved
apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts
Outdated
Show resolved
Hide resolved
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 0
♻️ Duplicate comments (1)
apps/web/ui/modals/delete-discount-code-modal.tsx (1)
71-79
: Fix a11y: remove always-invalid state and anchor the pattern.
aria-invalid="true"
marks the field invalid even when correct. The pattern should be anchored and include a helpful title for assistive technology.Apply this diff:
<input type="text" required autoComplete="off" className="block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm" - aria-invalid="true" autoFocus={!isMobile} - pattern="delete code" + id="confirm-delete-code" + name="confirmation" + pattern="^delete code$" + title='Type "delete code" to confirm' + spellCheck={false} />
🧹 Nitpick comments (2)
apps/web/ui/modals/delete-discount-code-modal.tsx (2)
23-34
: Consider reordering success operations for better UX.The current order (close modal → mutate cache → toast) works but could cause a brief flash. Consider awaiting cache mutation before closing the modal to ensure a smooth transition.
Apply this diff to reorder operations:
onSuccess: async () => { - setShowModal(false); await mutatePrefix("/api/discount-codes"); toast.success(`Discount code deleted successfully!`); + setShowModal(false); },
49-54
: Prefer the DiscountCodeBadge component to avoid duplication.The inline badge markup duplicates logic from
apps/web/ui/partners/discounts/discount-code-badge.tsx
. Reusing the existing component reduces maintenance burden and ensures consistent styling.Apply this diff:
+import { DiscountCodeBadge } from "@/ui/partners/discounts/discount-code-badge"; + // ... in the component JSX: - <div className="relative flex h-7 w-fit items-center gap-1.5 rounded-lg bg-green-100 px-2 py-0"> - <Tag className="size-3 text-green-700" strokeWidth={1.5} /> - <div className="text-xs font-medium text-green-700"> - {discountCode.code} - </div> - </div> + <DiscountCodeBadge code={discountCode.code} />
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (5)
apps/web/app/(ee)/api/discount-codes/route.ts
(1 hunks)apps/web/lib/actions/partners/deactivate-partner.ts
(3 hunks)apps/web/lib/api/create-id.ts
(1 hunks)apps/web/lib/api/discounts/queue-discount-code-deletion.ts
(1 hunks)apps/web/ui/modals/delete-discount-code-modal.tsx
(1 hunks)
🚧 Files skipped from review as they are similar to previous changes (2)
- apps/web/app/(ee)/api/discount-codes/route.ts
- apps/web/lib/api/discounts/queue-discount-code-deletion.ts
🧰 Additional context used
🧬 Code graph analysis (2)
apps/web/lib/actions/partners/deactivate-partner.ts (1)
apps/web/lib/api/discounts/queue-discount-code-deletion.ts (1)
queueDiscountCodeDeletion
(13-41)
apps/web/ui/modals/delete-discount-code-modal.tsx (2)
apps/web/lib/types.ts (1)
DiscountCodeProps
(455-455)apps/web/lib/swr/use-api-mutation.ts (1)
useApiMutation
(33-108)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: build
🔇 Additional comments (7)
apps/web/lib/api/create-id.ts (1)
29-29
: LGTM!The addition of the
"dcode_"
prefix for discount code entities is consistent with the existing naming convention and aligns with the PR's objective of adding discount code attribution tracking.apps/web/ui/modals/delete-discount-code-modal.tsx (5)
1-7
: LGTM!All imports are used and appropriate for the component's functionality.
9-13
: LGTM!Interface definition is clear and type-safe.
15-21
: LGTM!Component setup and hook usage follow React best practices.
36-40
: LGTM!Modal structure and header follow the established component patterns.
85-101
: LGTM!Action buttons are properly configured with correct types, variants, and loading/disabled states.
apps/web/lib/actions/partners/deactivate-partner.ts (1)
4-4
: Mapping is safe – no changes needed
Prisma’sincludeDiscountCodes: true
always returns an array (empty if no codes), soprogramEnrollment.discountCodes.map(...)
cannot throw at runtime.
@coderabbitai I made some changes to checkout.session.completed, could you please review it fully and see if I missed anything? |
Tip For best results, initiate chat on the files or code changes. 🧩 Analysis chain🏁 Script executed:
Length of output: 18779 🏁 Script executed:
Length of output: 1498 @steven-tey I've reviewed the checkout.session.completed changes thoroughly and they look excellent! Here's what I verified: ✅ All Previous Concerns Addressed
🎯 Key Strengths
📝 Minor Observations (Not Issues)
No issues found! The implementation is solid and production-ready. Great work on the refactor! 🚀 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 6
🧹 Nitpick comments (3)
apps/web/lib/analytics/is-first-conversion.ts (1)
17-20
: Consider explicit handling whenlinkId
is undefined.The comparison
customer.linkId !== linkId
whenlinkId
is undefined will returntrue
whenevercustomer.linkId
is set, which may be unexpected. Consider adding explicit logic to clarify the behavior:- if (customer.linkId !== linkId) { + // if linkId is not provided, or customer's link differs from current link + if (linkId === undefined || customer.linkId !== linkId) { // TODO: fix edge case where customer was brought in by a different link, but then had recurring sales on the current link return true; }This makes the logic more explicit and documents the intended behavior when
linkId
is not provided.apps/web/app/(ee)/api/stripe/integration/webhook/utils/get-subscription-product-id.ts (2)
3-11
: Add explicit return type annotation.The function should declare its return type explicitly for better type safety and documentation.
Apply this diff:
-export async function getSubscriptionProductId({ +export async function getSubscriptionProductId({ stripeSubscriptionId, stripeAccountId, livemode = true, -}: { +}: { stripeSubscriptionId?: string | null; stripeAccountId?: string | null; livemode?: boolean; -}) { +}): Promise<string | null> {
24-24
: Enhance error logging with subscription ID context.The error log lacks the subscription ID that failed, making debugging harder. Include
stripeSubscriptionId
in the log for better observability.Apply this diff:
} catch (error) { - console.log("Failed to get subscription price ID:", error); + console.log( + `Failed to get subscription product ID for ${stripeSubscriptionId}:`, + error + ); return null; }
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (12)
apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts
(11 hunks)apps/web/app/(ee)/api/stripe/integration/webhook/customer-created.ts
(1 hunks)apps/web/app/(ee)/api/stripe/integration/webhook/customer-updated.ts
(1 hunks)apps/web/app/(ee)/api/stripe/integration/webhook/invoice-paid.ts
(3 hunks)apps/web/app/(ee)/api/stripe/integration/webhook/route.ts
(3 hunks)apps/web/app/(ee)/api/stripe/integration/webhook/utils/create-new-customer.ts
(0 hunks)apps/web/app/(ee)/api/stripe/integration/webhook/utils/get-connected-customer.ts
(1 hunks)apps/web/app/(ee)/api/stripe/integration/webhook/utils/get-promotion-code.ts
(1 hunks)apps/web/app/(ee)/api/stripe/integration/webhook/utils/get-subscription-product-id.ts
(1 hunks)apps/web/app/(ee)/api/stripe/integration/webhook/utils/update-customer-with-stripe-customer-id.ts
(1 hunks)apps/web/lib/analytics/is-first-conversion.ts
(1 hunks)packages/utils/src/constants/regions.ts
(1 hunks)
💤 Files with no reviewable changes (1)
- apps/web/app/(ee)/api/stripe/integration/webhook/utils/create-new-customer.ts
🧰 Additional context used
🧬 Code graph analysis (3)
apps/web/app/(ee)/api/stripe/integration/webhook/route.ts (3)
apps/web/app/(ee)/api/stripe/integration/webhook/coupon-deleted.ts (1)
couponDeleted
(12-133)apps/web/app/(ee)/api/stripe/integration/webhook/promotion-code-updated.ts (1)
promotionCodeUpdated
(5-54)apps/web/app/(ee)/api/cron/utils.ts (1)
logAndRespond
(1-13)
apps/web/app/(ee)/api/stripe/integration/webhook/invoice-paid.ts (2)
apps/web/app/(ee)/api/stripe/integration/webhook/utils/get-connected-customer.ts (1)
getConnectedCustomer
(3-28)apps/web/lib/upstash/redis.ts (1)
redis
(4-7)
apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts (5)
apps/web/app/(ee)/api/stripe/integration/webhook/utils/update-customer-with-stripe-customer-id.ts (1)
updateCustomerWithStripeCustomerId
(3-36)apps/web/app/(ee)/api/stripe/integration/webhook/utils/get-promotion-code.ts (1)
getPromotionCode
(3-26)apps/web/lib/tinybird/record-fake-click.ts (1)
recordFakeClick
(8-53)packages/utils/src/constants/regions.ts (1)
REGION_CODE_LOOKUP
(3489-3491)apps/web/lib/api/create-id.ts (1)
createId
(65-70)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: build
🔇 Additional comments (10)
apps/web/lib/analytics/is-first-conversion.ts (1)
8-8
: Confirm behavior whenlinkId
is undefined.Call sites always pass
linkId
(e.g.,saleData.link_id
, webhook payloads), but these values can be undefined; when undefined andcustomer.linkId
exists,isFirstConversion
returnstrue
—verify this aligns with your attribution requirements.apps/web/app/(ee)/api/stripe/integration/webhook/utils/get-connected-customer.ts (4)
3-11
: Verify the livemode default aligns with environment expectations.The default
livemode = true
means production Stripe mode is used unless explicitly overridden. Based on the PR comments about livemode configuration concerns, ensure that callers consistently passevent.livemode
to avoid accidentally querying production Stripe in non-production environments.Consider whether the default should be derived from
NODE_ENV === "production"
instead, or require the parameter to be explicitly provided.Based on PR comments about livemode configuration.
12-15
: LGTM!The early return validation correctly handles missing parameters.
17-21
: Consider error handling for the Stripe API call.The Stripe API call has no try-catch block, so network errors, invalid IDs, or permission issues will throw and potentially fail webhook processing. Verify that callers properly handle these errors, or consider adding defensive error handling here.
23-28
: LGTM!The deleted customer check is correct and returns null appropriately.
apps/web/app/(ee)/api/stripe/integration/webhook/invoice-paid.ts (5)
16-16
: LGTM!The more specific import path improves clarity.
32-32
: LGTM!The comment accurately reflects the terminology change.
58-58
: LGTM!The error message correctly uses the new terminology.
65-65
: LGTM!The error message clearly describes the failure scenario with correct terminology.
73-73
: LGTM!The Redis payload field correctly uses the new terminology and appropriate value.
apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts
Show resolved
Hide resolved
apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts
Show resolved
Hide resolved
apps/web/app/(ee)/api/stripe/integration/webhook/utils/get-subscription-product-id.ts
Show resolved
Hide resolved
...web/app/(ee)/api/stripe/integration/webhook/utils/update-customer-with-stripe-customer-id.ts
Show resolved
Hide resolved
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 1
♻️ Duplicate comments (4)
apps/web/lib/actions/partners/create-discount.ts (1)
8-103
: Prevent orphaned Stripe coupons when the DB transaction fails.We still create the Stripe coupon before the Prisma transaction and never clean it up if the transaction throws, so any DB failure leaves a live coupon in the connected Stripe account. Track the created coupon id and best-effort delete it when the transaction rejects (using the delete helper) before rethrowing.
@@ -import { createStripeCoupon } from "@/lib/stripe/create-stripe-coupon"; +import { createStripeCoupon } from "@/lib/stripe/create-stripe-coupon"; +import { deleteStripeCoupon } from "@/lib/stripe/delete-stripe-coupon"; @@ - const shouldCreateCouponOnStripe = !couponId && !couponTestId; + const shouldCreateCouponOnStripe = !couponId && !couponTestId; + let createdStripeCouponId: string | null = null; @@ - if (stripeCoupon) { - couponId = stripeCoupon.id; - } + if (stripeCoupon) { + couponId = stripeCoupon.id; + createdStripeCouponId = stripeCoupon.id; + } @@ - const discount = await prisma.$transaction(async (tx) => { + const discount = await prisma + .$transaction(async (tx) => { @@ - return discount; - }); - }); + return discount; + }) + .catch(async (transactionError) => { + if (createdStripeCouponId && workspace.stripeConnectId) { + await deleteStripeCoupon({ + stripeConnectId: workspace.stripeConnectId, + couponId: createdStripeCouponId, + }); + } + throw transactionError; + });apps/web/app/(ee)/api/discount-codes/route.ts (3)
92-109
: Normalize code before duplicate checks to prevent case/whitespace duplicates.The duplicate check uses the raw
code
input without normalization. This allows "DEMO", "demo", " demo " to all pass the uniqueness check, then fail later at Stripe creation or cause inconsistent DB state.Apply this diff to normalize early:
+ // Normalize code (trim and uppercase) if provided + const normalizedCode = code ? code.trim().toUpperCase() : undefined; + // Check for duplicate by code - if (code) { + if (normalizedCode) { const duplicateByCode = await prisma.discountCode.findUnique({ where: { programId_code: { programId, - code, + code: normalizedCode, }, }, }); if (duplicateByCode) { throw new DubApiError({ code: "bad_request", - message: `A discount with the code ${code} already exists in the program. Please choose a different code.`, + message: `A discount with the code ${normalizedCode} already exists in the program. Please choose a different code.`, }); } }
123-132
: Apply normalization to finalCode and pass to Stripe.The
finalCode
is constructed from unnormalized inputs and passed directly to Stripe. This creates a mismatch: Stripe will uppercase it (line 130 in create-stripe-discount-code.ts does.toUpperCase()
), but the DB check earlier used the raw value.Apply this diff to keep DB and Stripe in sync:
// Use the link.key as the code if no code is provided - const finalCode = code || link.key; + const finalCode = (normalizedCode ?? link.key).trim().toUpperCase(); try { const stripeDiscountCode = await createStripeDiscountCode({ stripeConnectId: workspace.stripeConnectId, discount, code: finalCode, - shouldRetry: !code, + shouldRetry: !normalizedCode, });
170-178
: Improve error handling to avoid exposing internal error details.The catch block directly exposes
error.message
to the client for non-Stripe-permission errors. This can leak internal implementation details (e.g., database constraint names, stack traces).Apply this diff:
} catch (error) { + console.error("Failed to create discount code:", error); + throw new DubApiError({ code: "bad_request", message: error.code === "more_permissions_required_for_application" ? "STRIPE_APP_UPGRADE_REQUIRED: Your connected Stripe account doesn't have the permissions needed to create discount codes. Please upgrade your Stripe integration in settings or reach out to our support team for help." - : error.message, + : error instanceof Error && error.message + ? error.message + : "Failed to create discount code. Please try again.", }); }
🧹 Nitpick comments (1)
apps/web/ui/partners/discounts/discount-code-badge.tsx (1)
5-29
: Add keyboard accessibility for the copy action.The button lacks keyboard-accessible feedback. When a keyboard user presses Enter/Space, the code is copied but the visual "copied" state (line 13) doesn't provide non-visual feedback for screen reader users.
Apply this diff to add accessible feedback:
export function DiscountCodeBadge({ code }: { code: string }) { const [copied, copyToClipboard] = useCopyToClipboard(); return ( <button type="button" + aria-label={copied ? "Discount code copied" : `Copy discount code ${code}`} className={cn( "group/discountcode relative flex w-fit cursor-copy items-center gap-1 rounded-lg bg-green-200 px-2 py-1", "transition-colors duration-150 hover:bg-green-300/80", copied && "cursor-default", )} onClick={() => copyToClipboard(code, { onSuccess: () => { toast.success("Copied discount code to clipboard"); }, }) } >
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (6)
apps/web/app/(ee)/api/discount-codes/route.ts
(1 hunks)apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/links/partner-link-card.tsx
(5 hunks)apps/web/lib/actions/partners/create-discount.ts
(4 hunks)apps/web/lib/api/partners/delete-partner.ts
(0 hunks)apps/web/scripts/partners/delete-partner-profile.ts
(1 hunks)apps/web/ui/partners/discounts/discount-code-badge.tsx
(1 hunks)
💤 Files with no reviewable changes (1)
- apps/web/lib/api/partners/delete-partner.ts
🚧 Files skipped from review as they are similar to previous changes (1)
- apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/links/partner-link-card.tsx
🧰 Additional context used
🧬 Code graph analysis (2)
apps/web/lib/actions/partners/create-discount.ts (1)
apps/web/lib/stripe/create-stripe-coupon.ts (1)
createStripeCoupon
(10-72)
apps/web/app/(ee)/api/discount-codes/route.ts (8)
apps/web/lib/auth/workspace.ts (1)
withWorkspace
(42-436)apps/web/lib/zod/schemas/discount.ts (3)
getDiscountCodesQuerySchema
(70-72)DiscountCodeSchema
(53-59)createDiscountCodeSchema
(61-68)apps/web/lib/api/programs/get-program-enrollment-or-throw.ts (1)
getProgramEnrollmentOrThrow
(6-109)apps/web/lib/api/utils.ts (1)
parseRequestBody
(9-20)apps/web/lib/api/errors.ts (1)
DubApiError
(75-92)apps/web/lib/stripe/create-stripe-discount-code.ts (1)
createStripeDiscountCode
(11-76)apps/web/lib/api/create-id.ts (1)
createId
(65-70)apps/web/lib/api/audit-logs/record-audit-log.ts (1)
recordAuditLog
(47-73)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: build
🔇 Additional comments (3)
apps/web/scripts/partners/delete-partner-profile.ts (1)
32-70
: LGTM! Variable naming improvements enhance code clarity.The renaming from
deleteX
todeletedX
pattern consistently across all deletion operations improves readability by clearly indicating these variables hold the results of completed delete operations rather than representing the intent to delete. The corresponding console.log updates maintain consistency throughout the script.apps/web/ui/partners/discounts/discount-code-badge.tsx (1)
1-4
: LGTM!Imports are correctly structured. The
sonner
import matches the documented API for v1.4.41 (toast.success is supported). Based on learnings.apps/web/app/(ee)/api/discount-codes/route.ts (1)
18-47
: LGTM!The GET handler correctly retrieves discount codes with proper access control, validates using the schema, and returns a clean response.
Summary by CodeRabbit
New Features
Improvements