Skip to content

fix(reset-pass): prevent double-refunding - #3124

Merged
steebchen merged 1 commit into
mainfrom
claude/devpass-reset-refund-prevention-h2veea
Jul 18, 2026
Merged

steebchen merged 1 commit into
mainfrom
claude/devpass-reset-refund-prevention-h2veea

Conversation

@steebchen

@steebchen steebchen commented Jul 18, 2026 •

Copy link
Copy Markdown
Member

Summary

Updates the Reset Pass refund eligibility logic to prevent multiple purchases from being refunded against the same unredeemed pass. Passes are now attributed to the oldest un-refunded purchase first, and a purchase is only refundable if it ranks within the newest inventory un-refunded purchases of its tier.

Changes

  • Updated checkResetPassEligibility function to accept the full transaction list and compute eligibility based on purchase rank rather than just inventory count

    • Builds a set of refunded transaction IDs to identify un-refunded purchases
    • Counts how many newer un-refunded purchases of the same tier exist
    • Returns pass_already_used if the count meets or exceeds available inventory
    • This prevents a second purchase from being refunded against the same unredeemed pass
  • Added comprehensive test coverage for the new behavior:

    • "with one pass redeemed, only the newest of two purchases is refundable" — validates that older purchases are blocked when inventory is exhausted
    • "both purchases are refundable while inventory covers both" — confirms both can be refunded when inventory allows
    • "a refunded newer purchase no longer blocks the older one" — ensures clawback properly unblocks older purchases
    • "a redeemed lite pass can't be refunded against pro inventory" — verifies tier-bound attribution works correctly

Implementation Details

The fix uses a rank-based approach: for each purchase, count how many newer un-refunded purchases of the same tier exist. If this count is >= available inventory, the purchase is ineligible. This handles both the steady-state case (preventing double-refunds) and the transient window before the charge.refunded webhook records a clawback.

https://claude.ai/code/session_015YhmLC9ModCS2f7AZ9BYSb

Summary by CodeRabbit

  • Bug Fixes

    • Improved self-refund eligibility for development plan reset passes.
    • Prevented refunds for passes already consumed by redeemed inventory.
    • Correctly handled partially redeemed, previously refunded, and tier-specific reset passes.
    • Ensured newer eligible purchases are evaluated accurately when determining refundability.
  • Tests

    • Added coverage for reset-pass inventory, refund, redemption, and tier-boundary scenarios.

A Reset Pass purchase was refundable whenever its tier inventory held
at least one pass, so with multiple purchases of the same tier every
purchase could claim the same unredeemed pass — including one whose
pass was already redeemed. Since refund bookkeeping (credit_refund row
and inventory clawback) only lands via the charge.refunded webhook,
several refunds could also be issued back-to-back against a single
remaining pass within the webhook latency window.

Attribute redemptions to the oldest un-refunded purchase first: a
purchase is only refundable while it ranks within the newest
`inventory` un-refunded purchases of its tier.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YhmLC9ModCS2f7AZ9BYSb
Copilot AI review requested due to automatic review settings July 18, 2026 16:22
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@github-actions github-actions Bot changed the title Fix Reset Pass refund eligibility to prevent double-refunding fix(reset-pass): prevent double-refunding Jul 18, 2026
@coderabbitai

coderabbitai Bot commented Jul 18, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Reset-pass self-refund eligibility now evaluates tier-specific inventory against newer unrefunded purchases, with deterministic ordering and refunded-purchase exclusion. The dispatcher passes all transactions to this logic, and tests cover partial inventory, refunds, and lite/pro tier boundaries.

Changes

Reset-pass refund eligibility

Layer / File(s) Summary
Inventory-based eligibility logic
apps/api/src/lib/self-refund.ts
checkResetPassEligibility uses tier inventory, refunded purchase IDs, newer same-tier purchases, and deterministic ordering to determine whether a pass is already used. The dispatcher passes the full transaction list.
Eligibility behavior coverage
apps/api/src/lib/self-refund.spec.ts
Tests cover partial and complete inventory, refunded newer purchases, and tier-specific lite/pro attribution.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

Suggested reviewers: smakosh

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: fixing reset-pass refund logic to prevent double-refunding.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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.
✨ 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 claude/devpass-reset-refund-prevention-h2veea

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

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 (2)
apps/api/src/lib/self-refund.ts (1)

291-293: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Defensively parse createdAt to prevent potential runtime errors.

The existing code in computeSelfRefundEligibility wraps transaction.createdAt in new Date() before calling .getTime() (see line 343). This implies that timestamps might occasionally be passed as serialized strings (e.g., from an API payload). If t.createdAt is a string, calling .getTime() directly will crash the handler with a TypeError.

🛠️ Proposed fix to parse dates defensively
-			(t.createdAt > transaction.createdAt ||
-				(t.createdAt.getTime() === transaction.createdAt.getTime() &&
-					t.id > transaction.id)),
+			(new Date(t.createdAt).getTime() > new Date(transaction.createdAt).getTime() ||
+				(new Date(t.createdAt).getTime() === new Date(transaction.createdAt).getTime() &&
+					t.id > transaction.id)),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/lib/self-refund.ts` around lines 291 - 293, Update the date
comparison in computeSelfRefundEligibility to defensively normalize t.createdAt
before calling getTime(), matching the existing transaction.createdAt parsing
behavior. Preserve the ordering logic using the parsed timestamp and t.id as the
tie-breaker.
apps/api/src/lib/self-refund.spec.ts (1)

503-535: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Add a test verifying that incomplete refunds do not unblock older purchases.

To lock in the security fix regarding isCompleted(t) for refunds, consider adding a sister test to this one that explicitly seeds a credit_refund with status: "pending" or "failed" for the newer purchase. The test should assert that the older transaction remains ineligible because the incomplete refund hasn't definitively clawed back inventory yet.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/lib/self-refund.spec.ts` around lines 503 - 535, The refund
eligibility tests around “a refunded newer purchase no longer blocks the older
one” lack coverage for incomplete refunds. Add sister cases that seed the newer
purchase’s credit_refund with pending and/or failed status, then assert
getEligibility(older.id) returns ineligible because the refund has not
completed; preserve the existing completed-refund test.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/api/src/lib/self-refund.ts`:
- Around line 279-283: Update the refundedIds construction near the existing
credit_refund filter to include only transactions for which isCompleted(t) is
true. Keep requiring type === "credit_refund" and relatedTransactionId, so
pending or failed refunds do not affect newerUnrefundedSameTier rank counting.

---

Nitpick comments:
In `@apps/api/src/lib/self-refund.spec.ts`:
- Around line 503-535: The refund eligibility tests around “a refunded newer
purchase no longer blocks the older one” lack coverage for incomplete refunds.
Add sister cases that seed the newer purchase’s credit_refund with pending
and/or failed status, then assert getEligibility(older.id) returns ineligible
because the refund has not completed; preserve the existing completed-refund
test.

In `@apps/api/src/lib/self-refund.ts`:
- Around line 291-293: Update the date comparison in
computeSelfRefundEligibility to defensively normalize t.createdAt before calling
getTime(), matching the existing transaction.createdAt parsing behavior.
Preserve the ordering logic using the parsed timestamp and t.id as the
tie-breaker.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 8352c131-0ce8-49bb-a128-6bc397d921fc

📥 Commits

Reviewing files that changed from the base of the PR and between 5cc7d30 and 0f59fa6.

📒 Files selected for processing (2)
  • apps/api/src/lib/self-refund.spec.ts
  • apps/api/src/lib/self-refund.ts

Comment on lines +279 to +283
const refundedIds = new Set(
transactions
.filter((t) => t.type === "credit_refund" && t.relatedTransactionId)
.map((t) => t.relatedTransactionId),
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🔴 Critical | ⚡ Quick win

Require credit_refund transactions to be completed to prevent double-refunds.

If a newer pass has a pending or failed refund, its relatedTransactionId is added to refundedIds here. This causes the newer pass to be excluded from the newerUnrefundedSameTier count below, bypassing the rank check. Because an incomplete refund has not clawed back the inventory (and won't, if failed), this artificially lowers the rank count and allows older passes to be refunded against the same unredeemed inventory, creating a double-refund loophole.

Filter for isCompleted(t) to ensure rank count and inventory stay properly in sync.

🔒️ Proposed fix to require completed refunds
 	const refundedIds = new Set(
 		transactions
-			.filter((t) => t.type === "credit_refund" && t.relatedTransactionId)
+			.filter((t) => t.type === "credit_refund" && isCompleted(t) && t.relatedTransactionId)
 			.map((t) => t.relatedTransactionId),
 	);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const refundedIds = new Set(
transactions
.filter((t) => t.type === "credit_refund" && t.relatedTransactionId)
.map((t) => t.relatedTransactionId),
);
const refundedIds = new Set(
transactions
.filter((t) => t.type === "credit_refund" && isCompleted(t) && t.relatedTransactionId)
.map((t) => t.relatedTransactionId),
);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/lib/self-refund.ts` around lines 279 - 283, Update the
refundedIds construction near the existing credit_refund filter to include only
transactions for which isCompleted(t) is true. Keep requiring type ===
"credit_refund" and relatedTransactionId, so pending or failed refunds do not
affect newerUnrefundedSameTier rank counting.

@steebchen
steebchen added this pull request to the merge queue Jul 18, 2026
Merged via the queue into main with commit faf3abe Jul 18, 2026
12 checks passed
@steebchen
steebchen deleted the claude/devpass-reset-refund-prevention-h2veea branch July 18, 2026 17:17
steebchen added a commit that referenced this pull request Jul 19, 2026
Sync with main's Reset Pass refunds (#3120/#3124): the reset-pass
refunded-purchase attribution and the new admin devpass refund
queries only matched credit_refund rows; match subscription_refund
too since plan refunds are now recorded with that type.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
smakosh pushed a commit that referenced this pull request Oct 11, 2026
Sync with main's Reset Pass refunds (#3120/#3124): the reset-pass
refunded-purchase attribution and the new admin devpass refund
queries only matched credit_refund rows; match subscription_refund
too since plan refunds are now recorded with that type.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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