Skip to content

fix(devpass): surface refunds in billing history - #2915

Open
steebchen wants to merge 2 commits into
mainfrom
devpass-refund-revenue-tracking
Open

steebchen wants to merge 2 commits into
mainfrom
devpass-refund-revenue-tracking

Conversation

@steebchen

@steebchen steebchen commented Jul 4, 2026 •

Copy link
Copy Markdown
Member

Problem

A DevPass subscriber was refunded in prod, but the refund was invisible in the billing history — the admin org-detail page and the user's DevPass billing page both showed only the original charge, while the subscriber's all-time revenue read $0.00 ($29 − $29 refund).

Root cause: the refund is recorded as a credit_refund transaction and already nets out of the reported revenue/margin, but both transaction-list queries filtered credit_refund out:

  • Admin per-org detail — GET /admin/devpass/{orgId} (apps/api/src/routes/admin.ts)
  • User DevPass billing — GET /dev-plans/invoices (apps/api/src/routes/dev-plans.ts)

So refunds affected the aggregates but never appeared as a line item. (The API already had credit-note PDF support for refunds — isInvoiceableTransaction/isRefundTransaction — confirming refunds were always meant to be listed.)

Changes

Show refunds in both histories

  • Add credit_refund to both transaction-list queries.
  • Admin detail (ee/admin): refund renders with a destructive badge and a negative amount.
  • User billing (apps/code): refund renders as a Refund line with a negative amount and a downloadable credit note.

Backfill missing refunds (packages/scripts/src/backfill-stripe-refunds.ts)

  • New Phase 2 discovers DevPass refunds that exist in Stripe but have no credit_refund row at all (webhook missed/undelivered) and inserts them — resolving the invoice → payment-intent → succeeded-refunds chain.
  • Phase 1 (reconcile legacy rows with NULL stripe_refund_id) is preserved.
  • New --org=<id> scope flag. Both phases are idempotent on stripe_refund_id.

Screenshots

DevPass billing page (apps/code) — the refund now appears as a Refund line with a negative amount (-$15.00) and a downloadable credit note, alongside the original renewal charge. Light and dark:

DevPass invoices — refund line item (light)

DevPass invoices — refund line item (dark)

Verification (local, against the running stack)

  • Reproduced the exact prod state: a real Stripe test refund with no DB row → the enhanced script's Phase 2 discovered it (via invoice→PI resolution) and inserted the correct credit_refund row; re-run is a no-op (idempotent).
  • Confirmed GET /admin/devpass/{orgId} and GET /dev-plans/invoices now return the credit_refund row.
  • Confirmed the refund credit-note PDF downloads (application/pdf).
  • pnpm format + full pnpm build (17/17) pass.

Follow-up (not in this PR)

Run the backfill in prod (dry-run → --commit), e.g. scoped first: --org=2t6ZtQyGpTEohmrJXEgX, with the live STRIPE_SECRET_KEY and prod DATABASE_URL.

🤖 Generated with Claude Code

Summary by CodeRabbit

Summary by CodeRabbit

  • New Features

    • Refund transactions now appear in DevPass invoices and subscriber transaction history.
    • Refunds are clearly labeled, displayed as negative amounts, and available for download as credit notes.
  • Bug Fixes

    • Billing views now display charges and refunds consistently.
    • Historical refund records can be restored more reliably, including previously missing entries.

Copilot AI review requested due to automatic review settings July 4, 2026 21:32
@coderabbitai

coderabbitai Bot commented Jul 4, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 15 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used all 4 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration
  • Configuration used: Repository UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 85b7ae03-44a6-442f-9b2c-5fae9bc6105b

📥 Commits

Reviewing files that changed from the base of the PR and between fe79671 and 2c6fe40.


📒 Files selected for processing (1)
  • packages/scripts/src/backfill-stripe-refunds.ts

Walkthrough

This PR adds credit_refund support across DevPass APIs, invoice interfaces, admin history, and Stripe refund maintenance. The backfill script now reconciles legacy rows and inserts missing refund rows in two phases.

Changes

credit_refund support across API, UI, and backfill script

Layer / File(s) Summary
Admin and invoices API include credit_refund
apps/api/src/routes/admin.ts, apps/api/src/routes/dev-plans.ts
DevPass transaction queries and invoice responses now include credit_refund rows.
DevPass invoices UI refund labeling and formatting
apps/code/src/app/dashboard/components/DevPassInvoices.tsx
Refunds use credit-note labels and filenames. Refund amounts display as negative values.
EE admin refund badge and amount sign
ee/admin/src/app/devpass/[orgId]/page.tsx
Refund transactions use destructive badges and negative amount formatting.
Backfill helpers and legacy-row reconciliation
packages/scripts/src/backfill-stripe-refunds.ts
Shared helpers support Stripe refund lookup and row insertion. Phase 1 reconciles legacy rows with scoped, idempotent, transactional updates.
Missing-refund backfill and orchestration
packages/scripts/src/backfill-stripe-refunds.ts
Phase 2 inserts missing refunds for completed DevPass transactions. main() runs both phases and reports separate summaries.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant main
  participant reconcileLegacyRows
  participant backfillMissingRefunds
  participant Stripe
  participant Database

  main->>reconcileLegacyRows: run shared filters
  reconcileLegacyRows->>Database: query legacy credit_refund rows
  reconcileLegacyRows->>Stripe: list succeeded refunds
  reconcileLegacyRows->>Database: reconcile and insert refund rows
  main->>backfillMissingRefunds: run shared filters
  backfillMissingRefunds->>Database: scan completed DevPass originals
  backfillMissingRefunds->>Stripe: resolve payment intents and list refunds
  backfillMissingRefunds->>Database: insert missing credit_refund rows
  main-->>main: print phase summaries
Loading

Possibly related PRs

Suggested reviewers: smakosh

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 31.25% 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
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.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: surfacing DevPass refunds in billing history.

✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch

🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch devpass-refund-revenue-tracking


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.

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.

Pull request overview

Surfaces DevPass refund transactions (credit_refund) as first-class line items in both the admin org detail view and the user DevPass billing history, and enhances the one-time backfill script to also insert missing refund rows by reconciling Stripe refund data against the DB.

Changes:

  • Include credit_refund in the API transaction history queries for both admin (/admin/devpass/{orgId}) and user (/dev-plans/invoices) views.
  • Update admin + user UI to render refunds with destructive styling and negative displayed amounts, and allow downloading a credit note PDF for refund line items.
  • Extend the refund backfill script with a “Phase 2” that discovers Stripe refunds missing from the DB and inserts the corresponding credit_refund transactions.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
packages/scripts/src/backfill-stripe-refunds.ts Adds Phase 2 to discover/insert missing DevPass refund rows; adds org scoping and Stripe invoice→PI resolution logic.
ee/admin/src/app/devpass/[orgId]/page.tsx Renders refund transactions with destructive badges and negative displayed amounts in admin DevPass history.
apps/code/src/app/dashboard/components/DevPassInvoices.tsx Shows refunds in user billing history, displays negative amounts, and labels refund PDFs as credit notes.
apps/api/src/routes/dev-plans.ts Includes credit_refund in /dev-plans/invoices response schema and query filtering.
apps/api/src/routes/admin.ts Includes credit_refund in admin DevPass org transaction list filtering.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +371 to +391
// Map each unique payment intent to the earliest original it settled (the
// first invoice can back both a dev_plan_start and dev_plan_renewal — either
// is a valid refund anchor for revenue attribution, so pick deterministically).
const originalByPI = new Map<string, TransactionRow>();
let skippedNoPI = 0;
for (const original of originalRows) {
let paymentIntentIds: string[] = [];
if (original.stripePaymentIntentId) {
paymentIntentIds = [original.stripePaymentIntentId];
} else if (original.stripeInvoiceId) {
try {
paymentIntentIds = await resolveInvoicePaymentIntents(
stripe,
original.stripeInvoiceId,
);
} catch (err) {
console.warn(
` Failed to resolve PI for invoice ${original.stripeInvoiceId}: ${(err as Error).message}`,
);
}
}
Comment on lines +431 to +442
const missing: Stripe.Refund[] = [];
for (const r of refunds) {
const already = await db.query.transaction.findFirst({
where: {
stripeRefundId: { eq: r.id },
type: { eq: "credit_refund" },
},
});
if (!already) {
missing.push(r);
}
}
Comment thread apps/api/src/routes/dev-plans.ts Outdated
Comment on lines 2154 to 2158
"dev_plan_start",
"dev_plan_renewal",
"dev_plan_upgrade",
"credit_refund",
]),

@chatgpt-codex-connector chatgpt-codex-connector Bot 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 374049e4bf

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +433 to +437
const already = await db.query.transaction.findFirst({
where: {
stripeRefundId: { eq: r.id },
type: { eq: "credit_refund" },
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Prevent duplicating legacy no-PI refund rows

When a malformed credit_refund row has NULL stripe_refund_id and no stripe_payment_intent_id, phase 1 explicitly skips it and leaves it in place, but phase 2 still scans the related original transaction and this check only dedupes rows that already have the Stripe refund id. In --commit mode that inserts a second credit_refund for the same Stripe refund while the legacy row remains, so billing histories and refund totals double-count that refund; exclude originals with unreconciled legacy rows or treat those NULL-refund rows as existing before adding to missing.

Useful? React with 👍 / 👎.

@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.

🧹 Nitpick comments (1)
packages/scripts/src/backfill-stripe-refunds.ts (1)

376-405: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Apply scopePI earlier to avoid unnecessary Stripe calls when scoping to one PI.

When --payment-intent=pi_x is passed, this loop still calls resolveInvoicePaymentIntents (a Stripe API request) for every invoice-based DevPass original before the scopePI filter at Line 397 discards them. For a direct-PI original you can skip non-matching rows before doing any work, and you only need invoice resolution for the scoped case. This meaningfully cuts Stripe API traffic (and rate-limit risk) when debugging a single PI.

♻️ Proposed early scoping
 	for (const original of originalRows) {
 		let paymentIntentIds: string[] = [];
 		if (original.stripePaymentIntentId) {
+			if (scopePI && original.stripePaymentIntentId !== scopePI) {
+				continue;
+			}
 			paymentIntentIds = [original.stripePaymentIntentId];
 		} else if (original.stripeInvoiceId) {
🤖 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 `@packages/scripts/src/backfill-stripe-refunds.ts` around lines 376 - 405,
Apply the scopePI filter earlier in the originalRows loop in
backfill-stripe-refunds.ts so non-matching Payment Intents are skipped before
any Stripe work. In the loop that builds originalByPI, check the direct
stripePaymentIntentId case against scopePI before assigning or resolving
anything, and avoid calling resolveInvoicePaymentIntents for invoice-backed rows
unless the scoped PI could actually come from that row. Keep the existing
originalByPI population logic in place, but move the scopePI guard ahead of
unnecessary API calls to reduce Stripe traffic.
🤖 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.

Nitpick comments:
In `@packages/scripts/src/backfill-stripe-refunds.ts`:
- Around line 376-405: Apply the scopePI filter earlier in the originalRows loop
in backfill-stripe-refunds.ts so non-matching Payment Intents are skipped before
any Stripe work. In the loop that builds originalByPI, check the direct
stripePaymentIntentId case against scopePI before assigning or resolving
anything, and avoid calling resolveInvoicePaymentIntents for invoice-backed rows
unless the scoped PI could actually come from that row. Keep the existing
originalByPI population logic in place, but move the scopePI guard ahead of
unnecessary API calls to reduce Stripe traffic.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 806bb213-3d87-4f5e-9ec2-944798630094

📥 Commits

Reviewing files that changed from the base of the PR and between a7b70b0 and 374049e.

📒 Files selected for processing (5)
  • apps/api/src/routes/admin.ts
  • apps/api/src/routes/dev-plans.ts
  • apps/code/src/app/dashboard/components/DevPassInvoices.tsx
  • ee/admin/src/app/devpass/[orgId]/page.tsx
  • packages/scripts/src/backfill-stripe-refunds.ts

@steebchen
steebchen force-pushed the devpass-refund-revenue-tracking branch from 374049e to 70f55d3 Compare July 28, 2026 11:08
@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.

@steebchen
steebchen force-pushed the devpass-refund-revenue-tracking branch from 70f55d3 to 4588a71 Compare August 7, 2026 17:59
@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.

@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

@steebchen
steebchen force-pushed the devpass-refund-revenue-tracking branch from 4588a71 to fe79671 Compare August 7, 2026 18:00
@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.

@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

steebchen and others added 2 commits October 11, 2026 14:12
Refunds against DevPass payments are recorded as `credit_refund`
transactions and already net out of the reported revenue/margin, but
both billing-history surfaces filtered them out of the transaction list:

- Admin per-org detail (`GET /admin/devpass/{orgId}`)
- User DevPass billing (`GET /dev-plans/invoices`)

The result was a subscriber showing $0 net revenue with no visible
refund line. Add `credit_refund` to both queries and render it as a
negative "Refund" line item (the API already supports a downloadable
credit note for it).

Also enhance the backfill script so it can insert DevPass refunds that
exist in Stripe but have no `credit_refund` row at all (webhook
missed/undelivered), in addition to reconciling legacy rows with a
NULL `stripe_refund_id`. Adds an `--org=` scope flag. Both passes are
idempotent on `stripe_refund_id`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@smakosh
smakosh force-pushed the devpass-refund-revenue-tracking branch from fe79671 to 2c6fe40 Compare October 11, 2026 12:15

@smakosh smakosh left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Rebased onto upstream/main (7d17e5f). I resolved conflicts in apps/api/src/routes/admin.ts, apps/api/src/routes/dev-plans.ts, apps/code/.../DevPassInvoices.tsx and ee/admin/src/app/devpass/[orgId]/page.tsx by keeping main's version. Main already lists credit_refund in both histories (DEV_PLAN_HISTORY_TYPES, admin type list), shows refunds as negative amounts and labels refund downloads "Credit note". After the rebase, this PR only changes packages/scripts/src/backfill-stripe-refunds.ts, so the title and body are now stale.

Findings

  • packages/scripts/src/backfill-stripe-refunds.ts:355 — P1 (Codex): Phase 2 deduped only by stripe_refund_id. Phase 1 deliberately leaves some legacy credit_refund rows in place: rows with NULL stripe_refund_id and no PI, and rows where the Stripe lookup failed. For those, --commit inserted a second row for the same refund and double-counted it. In a dry run, every legacy PI was also reported twice (once by Phase 1, once by Phase 2). Fixed in 2c6fe40: Phase 2 now skips any PI or original that still has an unresolved legacy row, and logs which ones it skipped.
  • packages/scripts/src/backfill-stripe-refunds.ts:479 — N+1 findFirst per refund (Copilot). Fixed in 2c6fe40: one inArray lookup per PI. It no longer filters on type, because transaction_stripe_refund_id_unique covers every row type, so a match of any type would block the insert.
  • packages/scripts/src/backfill-stripe-refunds.ts:290 — s + r.amount / 100 failed lint (no-mixed-operators), and adding parentheses conflicts with prettier. Fixed in 2c6fe40: sums integer cents, then divides once.
  • packages/scripts/src/backfill-stripe-refunds.ts:402 — Copilot asked to cache invoice→PI lookups. Not needed: transaction_stripe_invoice_id_unique (packages/db/src/schema.ts:728) means each invoice belongs to one row. The comment here about one invoice backing both start and renewal is stale. Non-blocking.
  • packages/scripts/src/backfill-stripe-refunds.ts:36,50 — dev_plan_reset_pass is refundable in handleChargeRefunded (apps/api/src/stripe.ts:3641) but is missing from both type lists, so missed Reset Pass refunds are not backfilled. Adding it would record the refund without the webhook's pass clawback. Needs author decision, non-blocking.
  • apps/api/src/routes/dev-plans.ts route comment (Copilot) — moot, since main's code replaces it.

Verification

  • eslint packages/scripts/src/backfill-stripe-refunds.ts: pass. prettier --check: pass.
  • tsc --noEmit -p packages/scripts (after building db, shared, models and actions): 0 errors in this file. The package still has errors in other files that also exist on main (api-ping.ts, export-models-dev.ts, poc-end-user-bonus.ts).
  • No vitest spec covers this one-off script, and I did not run it against Stripe or a database.

This branch has not been deployed

No deployments
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