Skip to content

Fix discount code Stripe/DB drift on creation failure#3985

Merged
steven-tey merged 2 commits into
mainfrom
discount-code-stripe-db-orphan
Jun 4, 2026
Merged

Fix discount code Stripe/DB drift on creation failure#3985
steven-tey merged 2 commits into
mainfrom
discount-code-stripe-db-orphan

Conversation

@pepeladeira
Copy link
Copy Markdown
Collaborator

@pepeladeira pepeladeira commented Jun 4, 2026

Summary by CodeRabbit

  • Bug Fixes
    • Improved duplicate discount-code detection to use the correct program matching.
    • Prevented assigning more than one discount code to the same link.
    • Better error responses for conflicts when external provider or storage report a taken code.
    • Safer creation flow with rollback of external codes if persisting fails, and clearer conflict handling.

@vercel
Copy link
Copy Markdown
Contributor

vercel Bot commented Jun 4, 2026

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

Project Deployment Actions Updated (UTC)
dub Ready Ready Preview Jun 4, 2026 8:30pm

Request Review

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Jun 4, 2026

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 62ebf0f5-5af9-4f1f-ad25-8ae250b9954e

📥 Commits

Reviewing files that changed from the base of the PR and between aee71e7 and eb0ef3b.

📒 Files selected for processing (1)
  • apps/web/lib/discounts/create-discount-code.ts

📝 Walkthrough

Walkthrough

Adds link pre-checks, converts duplicate/taken external provider errors to API errors, wraps DB create with rollback of the external code on failure, and corrects the API duplicate-check to use discount.programId.

Changes

Discount Code Validation and Error Handling

Layer / File(s) Summary
Discount code creation with validation and error handling
apps/web/lib/discounts/create-discount-code.ts
Adds DubApiError and Prisma imports; computes finalCode with a single expression; pre-checks prisma.link for an existing discountCode and throws bad_request; wraps discountProvider.createDiscountCode to map duplicate/taken outcomes to DubApiError(conflict); wraps prisma.discountCode.create to rollback the external code via discountProvider.disableDiscountCode on failure and maps Prisma P2002 to DubApiError(conflict) before rethrowing other errors.
API handler duplicate check fix
apps/web/app/(ee)/api/discount-codes/route.ts
Updates the Prisma duplicate-check where clause so the programId component uses discount.programId rather than the earlier programId variable.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant APIHandler
  participant createDiscountCode
  participant prisma_link as prisma.link
  participant discountProvider
  participant prisma_discountCode as prisma.discountCode
  Client->>APIHandler: POST /api/discount-codes (payload)
  APIHandler->>createDiscountCode: call createDiscountCode(...)
  createDiscountCode->>prisma_link: findUnique(link by id)
  alt link has discountCode
    createDiscountCode-->>APIHandler: throw DubApiError(bad_request)
    APIHandler-->>Client: 400 response
  else proceed
    createDiscountCode->>discountProvider: createDiscountCode(finalCode,...)
    discountProvider-->>createDiscountCode: externalCode
    createDiscountCode->>prisma_discountCode: create(mapping)
    alt success
      prisma_discountCode-->>createDiscountCode: saved record
      createDiscountCode-->>APIHandler: return record
      APIHandler-->>Client: 200 response
    else Prisma P2002 uniqueness error
      createDiscountCode->>discountProvider: disableDiscountCode(rollback)
      createDiscountCode-->>APIHandler: throw DubApiError(conflict)
      APIHandler-->>Client: 409 response
    else other error
      createDiscountCode->>discountProvider: disableDiscountCode(rollback)
      createDiscountCode-->>APIHandler: rethrow error
      APIHandler-->>Client: 500 response
    end
  end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 I stitched a code with care and checks in line,
I asked the link if someone else had signed,
If the provider gave a code then DB fell through,
I rolled it back, logged softly, and finished true.
Hooray for tidy hops and errors handled fine!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ 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%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Fix discount code Stripe/DB drift on creation failure' directly addresses the main change: handling synchronization issues between Stripe and database when discount code creation fails, with rollback logic and error handling.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch discount-code-stripe-db-orphan

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.

@pepeladeira
Copy link
Copy Markdown
Collaborator Author

@coderabbitai full review, any breaking changes?

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Jun 4, 2026

Tip

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Fetch the changed files
cat apps/web/app/\(ee\)/api/discount-codes/route.ts

echo "=== SEPARATOR ==="

cat apps/web/lib/discounts/create-discount-code.ts

echo "=== SEPARATOR ==="

# Check the discount provider interface/types
fd -e ts "discount-provider" --exec cat {}

echo "=== SEPARATOR ==="

# Check disable-discount-code utility
fd -e ts "disable" --exec grep -l "disableDiscountCode" {} 2>/dev/null
rg -n "disableDiscountCode" --type ts -A 5

echo "=== SEPARATOR ==="

# Check how discountCode relation is modeled on Link
rg -n "discountCode" --type prisma -A 2 -B 2
fd -e prisma --exec grep -n "discountCode" {}

echo "=== SEPARATOR ==="

# Check DubApiError usage
rg -n "conflict\|bad_request" --type ts apps/web/lib/discounts/ -A 2

Length of output: 28563



@pepeladeira Here's the full review of PR #3985.


Summary

No breaking changes. Both files only affect internal behavior and error handling. API response shapes and endpoint signatures are unchanged.


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

Change: programIddiscount.programId in duplicate-code check

// Before
programId: programId,
// After
programId: discount.programId,

Assessment: Correct fix, not breaking. Since getProgramEnrollmentOrThrow is called with the workspace's programId, discount.programId and programId should always be the same value. Using discount.programId is semantically tighter because it's scoped directly to the discount object. No consumer impact.


apps/web/lib/discounts/create-discount-code.ts

1. Pre-check for existing discountCode on the link

const linkWithCode = await prisma.link.findUnique({
  where: { id: link.id },
  select: { discountCode: { select: { code: true } } },
});

if (linkWithCode?.discountCode) {
  throw new DubApiError({ code: "bad_request", ... });
}

Good: This is defense-in-depth. The route already checks programEnrollment.discountCodes (stale data from the enrollment fetch), but this check is a fresh DB read, making it more reliable. The extra round-trip cost is negligible.

Minor note: There is still a TOCTOU window between this pre-check and the prisma.discountCode.create call — a concurrent request could slip through. However, this is fully mitigated by the P2002 unique-constraint catch + rollback below. ✅

2. Try/catch around external provider call

try {
  externalDiscountCode = await discountProvider.createDiscountCode({...});
} catch (error) {
  const isDuplicateCode = ...;
  if (isDuplicateCode) {
    throw new DubApiError({ code: "conflict", ... });
  }
  throw error;
}

Good: Correctly surfaces duplicate-code errors before touching the DB. Works consistently with both Stripe and Shopify providers (both throw with "already exists" / TAKEN / DUPLICATE after exhausting their internal retries).

3. Rollback on DB save failure — the core fix

try {
  return await prisma.discountCode.create({...});
} catch (error) {
  try {
    await discountProvider.disableDiscountCode({
      workspace,
      code: externalDiscountCode.code,
    });
  } catch (rollbackError) {
    console.error("Failed to rollback external discount code", {...});
  }

  if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
    throw new DubApiError({ code: "conflict", ... });
  }
  throw error;
}

Good: Solves the core drift problem. Best-effort rollback is the right pattern here — if rollback itself fails (e.g., Stripe/Shopify is unreachable), logging and continuing is correct. The external code becomes orphaned in that edge case, but that's a pre-existing operational concern.

Rollback behavior asymmetry (pre-existing, not introduced here):

  • Stripe: deactivates the promotion code (active: false) — reversible
  • Shopify: deletes the discount code permanently

One minor concern — P2002 error message:

message: "This discount code is already in use, or this link already has a code. Please refresh and try again.",

This message bundles two distinct conflict reasons (code uniqueness vs linkId uniqueness). Since the prisma.discountCode table likely has a unique constraint on both programId_code and linkId, distinguishing between the two scenarios in the message would give users clearer guidance. Consider something like:

const isCodeConflict = (error.meta?.target as string[])?.includes("code");
message: isCodeConflict
  ? `The discount code ${externalDiscountCode.code} is already in use. Please refresh and try again.`
  : "This link already has a discount code assigned. Please refresh and try again.",

Verdict

Area Status
API contract ✅ No breaking changes
Core drift fix (rollback) ✅ Correct
programId fix in route ✅ Correct
Pre-check defense-in-depth ✅ Good
TOCTOU race condition ✅ Mitigated by P2002 catch
Rollback failure handling ✅ Best-effort + logging
P2002 error message clarity ⚠️ Minor — bundles two conflict reasons

The fix is solid. The only actionable suggestion is refining the P2002 error message for better UX. Everything else looks good to ship.


🧠 Learnings used
Learnt from: devkiran
Repo: dubinc/dub PR: 3804
File: apps/web/lib/discounts/discount-provider-shopify.ts:144-152
Timestamp: 2026-04-27T09:44:44.629Z
Learning: In the dubinc/dub codebase, `discount.amount` is stored as an integer in the smallest currency unit (e.g., cents for flat discounts, whole percentage points for percentage discounts). When creating Shopify discount codes, dividing by 100 (e.g., `discount.amount / 100`) correctly converts to the decimal amount in the store's own currency — there is no USD-specific currency mismatch because Shopify applies the discount in the store's native currency. Also, Shopify's GraphQL Decimal scalar accepts string representations (e.g., from `toFixed(2)`), so this is valid. Do not flag these patterns as bugs in future reviews.

Learnt from: steven-tey
Repo: dubinc/dub PR: 3679
File: apps/web/lib/stripe/disable-stripe-discount-code.ts:2-2
Timestamp: 2026-04-01T17:57:43.749Z
Learning: In dubinc/dub, there is a known StripeMode type divergence between packages: `apps/web/lib/types.ts` defines `StripeMode = "test" | "sandbox" | "live"`, while `packages/stripe-app/src/utils/types.ts` defines `StripeMode = "test" | "live"` (missing "sandbox"). This is intentional for now; the Stripe app package needs to be updated separately to support "sandbox" mode. Do not flag this divergence as a blocking issue in future reviews.

Learnt from: steven-tey
Repo: dubinc/dub PR: 0
File: :0-0
Timestamp: 2026-06-01T20:11:39.391Z
Learning: In the Dub codebase (`apps/web/app/(ee)/api/stripe/integration/webhook/`), both `invoice.paid` and `checkout.session.completed` handlers deduplicate sale recording using a shared Redis key `trackSale:stripe:invoiceId:${invoiceId}` with `nx: true`. This means even if both handlers attempt to process the same invoice, only the first one to acquire the key will record the sale. `attributeViaPromoCode` also independently deduplicates customer creation via an `existingCustomer` re-check and a `P2002` (Prisma unique constraint) catch.

Learnt from: steven-tey
Repo: dubinc/dub PR: 0
File: :0-0
Timestamp: 2026-06-03T20:04:17.294Z
Learning: In dubinc/dub PR `#3824`, replacing `context.sale.productId` with `context.sale.products` (array) in `checkout-session-completed.ts` and `invoice-paid.ts` is NOT a breaking change for existing reward conditions. The updated `create-partner-commission/route.ts` workflow mediates this: when `context.sale.products` is present and a `sale.productId` modifier is detected, the workflow iterates through each product, reconstructs `productId` from each product entry, and calls `determinePartnerReward` with a per-product context. So `evaluate-reward-conditions.ts` still receives `productId` correctly — it just comes from the `products` array iteration rather than a top-level `context.sale.productId`. Do not flag this as a breaking change in future reviews.

Learnt from: devkiran
Repo: dubinc/dub PR: 2635
File: packages/prisma/schema/payout.prisma:24-25
Timestamp: 2025-07-11T16:28:55.693Z
Learning: In the Dub codebase, multiple payout records can now share the same stripeTransferId because payouts are grouped by partner and processed as single Stripe transfers. This is why the unique constraint was removed from the stripeTransferId field in the Payout model - a single transfer can include multiple payouts for the same partner.

@steven-tey steven-tey merged commit 8c854a4 into main Jun 4, 2026
11 checks passed
@steven-tey steven-tey deleted the discount-code-stripe-db-orphan branch June 4, 2026 22:03
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