Skip to content

payments: rework refund flow to three-knob API#1429

Open
BilalG1 wants to merge 15 commits into
devfrom
fix/reworked-refunds
Open

payments: rework refund flow to three-knob API#1429
BilalG1 wants to merge 15 commits into
devfrom
fix/reworked-refunds

Conversation

@BilalG1
Copy link
Copy Markdown
Collaborator

@BilalG1 BilalG1 commented May 13, 2026

Summary

  • Replaces per-entry refund schema with a flat { amount_usd, revoke_product, end_subscription? } shape; refund state is now derived from bulldozer ledger rows (refund:<sourceTxnId>:<uuid>) instead of the legacy refundedAt column, enabling multiple partial refunds up to the remaining cap.
  • Adds invoice_id for refunding any subscription invoice (start or renewal), Stripe idempotency keys derived from (tenancyId, sourceTxnId, amount, prior_refunded) so retries dedupe but intentional partials don't collide, and a legacy backstop that rejects pre-rework refundedAt purchases.
  • Dashboard refund dialog rebuilt around the three toggles (revoke→end coupling cascades into the UI); refund rows surface in the listing as type: "refund" with adjusted_by linkage handling both new and legacy formats.

Implements

STA2-52 — Build in refund logic for payments

Documented limitations (planned follow-up work)

These are called out in code comments and intentionally deferred to a follow-up PR:

  • Cap-check race under concurrent refunds. Bulldozer's embedded BEGIN/COMMIT prevents an outer Prisma tx from scoping the writes, so two concurrent refunds can both pass the cap check. Needs a bulldozer-aware mutex or pending-refund-intent pattern. In practice refunds are admin-only and rare, so the race window is small.
  • Stripe + DB non-atomicity on the DB-success → response-loss path. The Stripe idempotency key is keyed on (tenancyId, sourceTxnId, amount, priorRefunded), so a retry after Stripe-success → DB-fail self-heals (Stripe dedupes; the next attempt writes the bulldozer row). The hole is the reverse direction: if the bulldozer row commits but the response is lost, a retry sees a higher priorRefunded and generates a fresh key — Stripe would issue a second real refund. No out-of-band reconciliation today.
  • Dashboard can't reach the invoice_id path. Refund actions are only enabled on purchase rows and the submit call never passes invoice_id, so admins refunding a renewal must use the API directly. Follow-up: enable the action on subscription-renewal rows and thread invoice_id through.

Architectural note

active-subscription-end and item-quantity-expire entries are not emitted on the refund row itself. They're produced by the derived sub-end transaction (transactions.ts:158-228) once Prisma subscription.endedAt is updated, keeping the expiresWhen / when-repeated semantics in one place. This is the main structural divergence from the ticket's literal entry recipe.

Review follow-ups addressed in this PR

First-pass review:

  • KnownError back-compat preserved: SubscriptionAlreadyRefunded / OneTimePurchaseAlreadyRefunded are once again thrown by the legacy-refundedAt backstop, and TestModePurchaseNonRefundable is thrown when an admin sends amount_usd > 0 against a test-mode purchase. Callers catching by error code keep working through the rework.
  • Idempotency-key comment corrected: now accurately describes the (tenancyId, sourceTxnId, amount, priorRefunded) key and its self-healing behaviour on the Stripe-success → DB-fail retry path (see Documented limitations above for the remaining hole).
  • Renewal-invoice e2e coverage added: new test sets up a live-mode subscription via Stripe webhooks (subscription_create + subscription_cycle invoices), refunds the renewal invoice via invoice_id, and asserts the resulting refund_transaction_id starts with refund:sub-renewal: and is linked back via adjusted_by on the renewal row (not the start row). Plus negative cases: cross-subscription invoice_id → 404, invoice_id on a one-time purchase → SchemaError.

Second-pass review:

  • Idempotent sub-cancel error-code string fix: the Stripe code for re-cancelling an already-canceled sub is subscription_already_canceled, not subscription_canceled — the previous catch would have re-thrown.
  • End-only sub refund replay rejected: when amount=0, revoke=false, end=true and the sub is already cancelAtPeriodEnd or endedAt, throw SchemaError. Otherwise readPriorRefundSummary doesn't see end-only events and the call would be a forever-no-op accumulating empty refund rows.
  • revoke_product=true with renewal invoice_id rejected: the product grant lives on the sub-start txn, not on renewal txns — a renewal-scoped revocation would write a back-reference to a non-existent entry. Forces admin to revoke against the start invoice (or the default no-invoice_id call).
  • Refund row id matches the linkage: the listing route now returns the full refund txnId as id for type: "refund" rows so it matches adjusted_by.transaction_id — the dashboard can join source rows to their refund rows.
  • +2 e2e tests for the above (end-only replay rejection, revoke+renewal rejection).

Third-pass review:

  • Dashboard refund dialog seeds state on open: previously the reset block lived in ActionDialog's onOpenChange, which doesn't fire on the open transition for a controlled dialog. As a result the dialog opened with the initial useState defaults (amountUsd = '0'), and an admin submitting unchanged on a paid purchase would revoke/end at $0 instead of refunding the charged amount. The seed now runs in the menu onClick before setIsDialogOpen(true).
  • SUBSCRIPTION_START_PRODUCT_GRANT_ENTRY_INDEX corrected from 1 → 0: the constant is persisted as adjustedEntryIndex on product-revocation entries and copied through verbatim by mapLedgerEntry. That mapper drops the hidden active-subscription-start entry, so the public-API layout puts the product grant at index 0. The prior value of 1 pointed at the money-transfer entry (or out of range on test-mode subs) through the public listing.
  • amountTotal cap gated behind a USD pre-flight: SubscriptionInvoice doesn't persist invoice currency, and the previous code took invoice.amountTotal as USD cents directly. Now getTotalUsdStripeUnits (which throws on non-USD pricing) is always called first; amountTotal is only preferred as the actual cap after that pre-flight succeeds.

Test plan

  • pnpm typecheck — 28/28 pass
  • pnpm lint — 28/28 pass
  • pnpm test run apps/e2e/tests/backend/endpoints/api/v1/internal/transactions-refund.test.ts19/19 pass (was 14/14 on the original PR; +3 for invoice_id path: renewal refund happy path, unrelated invoice_id rejection, invoice_id on OTP rejection; +2 for second-pass: end-only replay rejection, revoke+renewal rejection)
  • curl smoke against /api/latest/internal/payments/transactions/refund — unknown purchase → 404, no-op → 400, negative → 400, sub-revoke-without-end → 400
  • Dashboard UI end-to-end re-run pending — the original agent-browser pass ran before the third-pass dialog-seed fix, so any "money + revoke" submissions may have actually sent amount_usd = "0". Re-test before un-drafting: open the refund dialog from the menu, confirm the amount field pre-fills with the charged amount, exercise validation (negative / exceeds-cap / no-op), and submit both an end-subscription-only sub refund and a money+revoke OTP refund; verify bulldozer rows and Prisma cancelAtPeriodEnd updates.

Summary by CodeRabbit

  • New Features

    • Ledger-driven refund flow with stable refund IDs, invoice-aware refunds, OTP/product-revocation support, tri-state end_action (now / at-period-end / none), and API responses that include refund_transaction_id.
  • Bug Fixes / Improvements

    • Deterministic Stripe idempotency, stronger replay protection, refundable-amount caps, test-mode constraints, and transactions listing updated to surface refunds.
  • Tests

    • Expanded unit and E2E coverage for new request shape, invoice paths, money-unit conversion, and edge cases.

Review Change Stack

Replaces the per-entry `refund_entries: [{ entry_index, quantity, amount_usd }]`
schema with a flat `{ amount_usd, revoke_product, end_subscription? }` shape on
the admin refund endpoint. Refund state is now derived from the bulldozer ledger
(`refund:<sourceTxnId>:<uuid>` rows) rather than the legacy `refundedAt` Prisma
column, so multiple partial refunds can run against a single purchase up to the
remaining cap. Adds support for refunding any subscription invoice via
`invoice_id` (start or renewal). Refund rows surface in the listing endpoint as
`type: "refund"` with adjusted_by linkage that handles both new and legacy
formats. Stripe idempotency keys are now derived from
`(tenancyId, sourceTxnId, amount, prior_refunded)` so network retries dedupe at
Stripe while intentional partials still get distinct keys. Dashboard refund
dialog rebuilt around the three toggles. The `transaction-builder.ts` helpers
that the old listing path used are gone — the listing reads bulldozer directly.

Known follow-ups (documented in code): cap-check race window under concurrent
refunds (a Postgres advisory lock would help, but bulldozer's embedded
BEGIN/COMMIT prevents an outer Prisma tx from scoping the writes), and Stripe
vs. DB non-atomicity if a write fails after a successful Stripe refund.
@vercel
Copy link
Copy Markdown

vercel Bot commented May 13, 2026

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

Project Deployment Actions Updated (UTC)
stack-auth-hosted-components Ready Ready Preview, Comment May 15, 2026 6:11pm
stack-auth-mcp Ready Ready Preview, Comment May 15, 2026 6:11pm
stack-backend Ready Ready Preview, Comment May 15, 2026 6:11pm
stack-dashboard Ready Ready Preview, Comment May 15, 2026 6:11pm
stack-demo Ready Ready Preview, Comment May 15, 2026 6:11pm
stack-docs Ready Ready Preview, Comment May 15, 2026 6:11pm
stack-preview-backend Ready Ready Preview, Comment May 15, 2026 6:11pm
stack-preview-dashboard Ready Ready Preview, Comment May 15, 2026 6:11pm

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 13, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Replaces per-entry refund UI with a USD-only single-amount + end_action dialog; simplifies product display and refund label rendering; seeds dialog defaults from the transaction row; validates amount/end_action client-side; and calls the new admin refundTransaction contract on confirm.

Changes

Dashboard refund UI update

Layer / File(s) Summary
Imports and icon adjustments
apps/dashboard/src/components/data-table/transaction-table.tsx
Cleans imports (removes unused throwErr, useEffect), adds receipt-X icon used for refund label.
Remove per-product refund types
apps/dashboard/src/components/data-table/transaction-table.tsx
Deletes the RefundEntrySelection type and prior per-product partial-refund selection plumbing.
Refund label rendering
apps/dashboard/src/components/data-table/transaction-table.tsx
Adds transactionType === 'refund' branch to formatTransactionTypeLabel to render a Refund label with icon.
Product display name simplification
apps/dashboard/src/components/data-table/transaction-table.tsx
Moves and simplifies getProductDisplayName to prefer entry.product.display_name with fallbacks.
RefundActionCell: state, validation, seed
apps/dashboard/src/components/data-table/transaction-table.tsx
Replaces per-product selection UI with single amountUsd input and endAction select; computes validation in useMemo; adds seedFromTransaction() to initialize dialog from adjusted_by.
Dialog confirm flow and UI
apps/dashboard/src/components/data-table/transaction-table.tsx
Blocks submit unless validation.canSubmit; on confirm calls app.refundTransaction with amountUsd and optional endAction; replaces prior UI with simplified inputs and a destructive Alert for errors.
Seed dialog on row action
apps/dashboard/src/components/data-table/transaction-table.tsx
Calls seedFromTransaction() immediately before opening the refund dialog so defaults reflect the selected transaction.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • hexclave/stack-auth#1378: Centralizes Stripe refund parameter handling and the refund_application_fee: false invariant.

Suggested reviewers

  • nams1570

"I'm a rabbit in the ledger glen,
hopping through refunds now and then.
Idempotent keys and vanish'd grants,
carrots for tests and tidy plants.
Hop, patch, and ship — the builds say when."

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 38.24% 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
Title check ✅ Passed The title 'payments: rework refund flow to three-knob API' accurately summarizes the main change—refactoring the refund API to use a simplified three-parameter input model (amount_usd, end_action, and implicit product revocation), replacing the prior per-entry schema.
Description check ✅ Passed The description is comprehensive and well-structured, covering the summary of changes, implementation details, architectural decisions, documented limitations, review follow-ups, and a detailed test plan. It thoroughly explains the refund flow rework and addresses key concerns from prior reviews.
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 unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/reworked-refunds

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


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.

- Restore SubscriptionAlreadyRefunded / OneTimePurchaseAlreadyRefunded
  KnownErrors as the legacy-refundedAt backstop, and
  TestModePurchaseNonRefundable for test-mode amount>0, so callers
  catching by error code still work.
- Correct the misleading idempotency-key comment: the key is keyed on
  (tenancyId, sourceTxnId, amount, priorRefunded) — not refundTxnId —
  which means Stripe-success → DB-fail self-heals on retry, while
  DB-success → response-loss is the remaining hole.
- Add e2e coverage for the invoice_id (renewal-invoice) refund path,
  plus rejection paths for unrelated invoice_id and invoice_id on OTPs.
- Fix Stripe error-code string for idempotent sub cancel
  (`subscription_already_canceled`, not `subscription_canceled`).
- Reject end-only sub refund replay when the sub is already scheduled
  to end — otherwise `readPriorRefundSummary` doesn't see end-only
  events and the call is a forever-no-op accumulating empty rows.
- Reject `revoke_product=true` with `invoice_id` pointing to a
  renewal invoice: the product grant lives on the sub-start txn,
  so a renewal-scoped revocation would write a back-reference to a
  non-existent entry.
- Return the full refund txnId as the listing's `id` for refund
  rows so it matches `adjusted_by.transaction_id` linkage.
- Document the dashboard's missing renewal-refund path as a gap.
- Tests: +2 (end-only replay, revoke+renewal rejection); extended
  OTP-full-refund and renewal-invoice tests to assert id linkage.
… amountTotal currency guard

- Dashboard: seed the refund dialog's amountUsd / revokeProduct /
  endSubscription state from the current transaction in the menu
  onClick before opening. ActionDialog's onOpenChange doesn't fire on
  the open transition for a controlled dialog, so the previous reset
  block was dead on open — admins hitting "Refund" on a paid purchase
  and submitting defaults would revoke/end at $0 instead of refunding
  the charged amount.
- Refund product-revocation: persist the public-API entry index
  (`SUBSCRIPTION_START_PRODUCT_GRANT_ENTRY_INDEX = 0`), not the
  internal ledger position. `mapLedgerEntry` drops the hidden
  `active-subscription-start` entry, so the prior value of `1` pointed
  at the money-transfer entry (or out of range on test-mode subs)
  through the public listing.
- Subscription cap: split the USD product cap from the actual cap. The
  invoice's `amountTotal` is the more accurate cap (proration,
  quantity changes, discounts), but `SubscriptionInvoice` doesn't
  persist currency — so always run `getTotalUsdStripeUnits` first as a
  USD pre-flight (it throws on non-USD pricing), and only then prefer
  `amountTotal`.
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 13, 2026

Greptile Summary

This PR replaces the per-entry refund schema with a flat { amount_usd, end_action, invoice_id? } API and migrates refund state tracking from the legacy refundedAt column to bulldozer ledger rows (refund:<sourceTxnId>:<uuid>), enabling multiple partial refunds up to the remaining cap. A new productRevokedAt Prisma column lets phase-1 distinguish refund-driven subscription ends from natural ones, preventing double-revocation entries.

  • Refund route (refund/route.tsx): rewritten into handleSubscriptionRefund + handleOneTimePurchaseRefund; adds Stripe idempotency keys, readPriorRefundSummary and readOutstandingItemGrants for bulldozer-derived state, invoice-specific refund paths, and a legacy refundedAt backstop.
  • Phase-1 (transactions.ts, subscription-timefold-algo.ts): adds naturalSubscriptionEndEvents filter; exports entry-index constants corrected to 0.
  • Dashboard (transaction-table.tsx): refund dialog rebuilt around the three-knob UI; dialog state now seeded in the menu onClick to fix the $0 default bug.
  • currencies.tsx: fixes a pre-existing bug in moneyAmountToStripeUnits where whole-number inputs like \"5\" (USD) returned 5 instead of 500.

Confidence Score: 3/5

The core money flow and Stripe idempotency logic are solid, but the at-period-end path leaves subscriptions in an inconsistent state when currentPeriodEnd is null.

The at-period-end branch writes cancelAtPeriodEnd: true while setting endedAt to subscription.endedAt ?? subscription.currentPeriodEnd. When both are null — test-mode subscriptions and any subscription without a persisted period boundary — endedAt stays null. Phase-1 uses endedAtMillis to trigger the subscription-end event; without it the subscription is stuck scheduled to cancel indefinitely with no product access revoked and no end entries on the refund row.

The endAtPeriodEnd branch in handleSubscriptionRefund around lines 1030-1040 of refund/route.tsx, and the corresponding test-mode guard removal in transaction-table.tsx.

Important Files Changed

Filename Overview
apps/backend/src/app/api/latest/internal/payments/transactions/refund/route.tsx Core refund handler rewritten; introduces Stripe idempotency keys, bulldozer-based prior-refund summary, item-grant expiry, and productRevokedAt sentinel. Contains the endAtPeriodEnd + null currentPeriodEnd inconsistency.
apps/dashboard/src/components/data-table/transaction-table.tsx Refund dialog rebuilt around three-knob UI; dialog seeding moved to menu onClick to fix the $0 default bug. canRefund simplification exposes at-period-end for test-mode subscriptions.
apps/backend/src/app/api/latest/internal/payments/transactions/route.tsx Listing route adds refund as a first-class ledger type; buildAdjustedByLookupFromRefundRows gains a dual new/legacy parse path.
apps/backend/src/lib/payments/schema/phase-1/transactions.ts Adds naturalSubscriptionEndEvents filter to suppress refund-driven ends; exports entry-index constants corrected to 0.
packages/stack-shared/src/utils/currencies.tsx Fixes pre-existing bug in moneyAmountToStripeUnits where whole-number inputs like '5' (USD) returned 5 instead of 500.
apps/backend/src/lib/payments/refund-txn-id.ts New helper: REFUND_TXN_PREFIX, parseRefundTxnId, and REFUND_SOURCE_TXN_PREFIXES with inline tests for LIKE-safety invariant.
apps/backend/prisma/schema.prisma Adds nullable productRevokedAt DateTime? column to Subscription; migration is additive with no constraint risk on existing data.
apps/backend/src/lib/payments/schema/phase-1/subscription-timefold-algo.ts Threads productRevokedAtMillis through the timefold state and into the subscription-end event row so naturalSubscriptionEndEvents can filter refund-driven ends.
apps/backend/src/app/api/latest/internal/payments/transactions/transaction-builder.ts Reduced to just resolveSelectedPriceFromProduct; all build*Transaction helpers removed.
apps/e2e/tests/backend/endpoints/api/v1/internal/transactions-refund.test.ts E2E test suite expanded from 14 to 19 tests covering renewal invoice happy path and new edge cases.
Prompt To Fix All With AI
Fix the following 3 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 3
apps/backend/src/app/api/latest/internal/payments/transactions/refund/route.tsx:1030-1040
**`end_action="at-period-end"` silently writes `endedAt: null` when `currentPeriodEnd` is absent**

When `subscription.endedAt` and `subscription.currentPeriodEnd` are both `null` — which happens for test-mode subscriptions (no Stripe backing, so no period boundaries) — the expression `subscription.endedAt ?? subscription.currentPeriodEnd` evaluates to `null`. The Prisma update therefore writes `cancelAtPeriodEnd: true` while leaving `endedAt` as `null`. Phase-1's subscription-end pipeline uses `endedAtMillis` as the trigger for emitting the sub-end transaction; with it absent, the subscription is permanently stuck "scheduled to cancel" with no actual end event ever fired and no product access revoked.

The `end_action="at-period-end"` case is explicitly blocked for one-time purchases ("no period"), but an identical guard isn't present for subscriptions that lack a period end. A test-mode subscription or any subscription whose `currentPeriodEnd` is null would silently produce inconsistent state through either the dashboard (which no longer gates on `!transaction.test_mode`) or the API directly.

### Issue 2 of 3
apps/backend/src/app/api/latest/internal/payments/transactions/refund/route.tsx:258-280
**Raw SQL construction passes `Prisma.raw` on a composed string — LIKE-pattern safety relies on comment-only invariant**

`readPriorRefundSummary` and `readOutstandingItemGrants` both build a SQL string manually and pass it to `prisma.$queryRaw` via `Prisma.raw`. The LIKE value is escaped through `quoteSqlStringLiteral`, but the safety guarantee that txnId prefixes (`sub-start:`, `otp:`, etc.) never contain LIKE metacharacters lives only in a code comment and an inline unit test in `refund-txn-id.ts`. There is no runtime check or schema-level constraint preventing a future source format from introducing `%`, `_`, or `\`, which would silently broaden the LIKE match to unrelated rows.

This is consistent with how the listing route builds its refund queries, but the `readPriorRefundSummary` path is security-sensitive: a broader match means `priorRefundedStripeUnits` is over-counted, which shrinks the remaining cap and allows under-refunding. Consider adding a runtime assertion that `sourceTxnId` matches one of the `REFUND_SOURCE_TXN_PREFIXES` before building the LIKE clause.

### Issue 3 of 3
apps/dashboard/src/components/data-table/transaction-table.tsx:373-374
**`canRefund = !!target` exposes `end_action="at-period-end"` for test-mode transactions in the UI**

The old guard included `!transaction.test_mode`, which prevented the refund action from being available on test-mode purchases. The new `canRefund = !!target` removes it, so an admin can open the refund dialog on a test-mode subscription and select "Ends at period end". As noted above, this path silently produces a subscription row with `cancelAtPeriodEnd: true` and `endedAt: null` when `currentPeriodEnd` is null. The "Ends immediately" path works fine for test-mode (the backend handles it), so the issue is narrowly the `at-period-end` option being rendered and submittable for test-mode subscription rows.

Reviews (5): Last reviewed commit: "merge origin/dev" | Re-trigger Greptile

Comment thread apps/dashboard/src/components/data-table/transaction-table.tsx
Comment thread apps/dashboard/src/components/data-table/transaction-table.tsx Outdated
Copy link
Copy Markdown
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: 6

🧹 Nitpick comments (1)
apps/dashboard/src/components/data-table/transaction-table.tsx (1)

239-240: ⚡ Quick win

Fail loudly if the USD invariant is broken.

Returning canSubmit: false with no error leaves the dialog dead with no explanation. If SUPPORTED_CURRENCIES ever stops containing USD, this should throw an explicit invariant error or at least surface an actionable alert instead of silently disabling the refund flow.

As per coding guidelines "Code defensively. Prefer ?? throwErr(...) over non-null assertions, with good error messages explicitly stating the assumption that must've been violated for the error to be thrown" and "When building frontend code, always carefully deal with loading and error states. Be very explicit with these... and make sure errors are NEVER just silently swallowed".

🤖 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/dashboard/src/components/data-table/transaction-table.tsx` around lines
239 - 240, The current check returns { canSubmit: false, error: null } when
USD_CURRENCY (derived from SUPPORTED_CURRENCIES) or target is missing, which
silently disables the dialog; instead, make the USD invariant fail loudly:
replace the silent return by throwing or returning an explicit error via a
helper (e.g., using the project's throwErr/Invariant helper) when USD_CURRENCY
is missing and keep a clear actionable error when target is missing; locate the
guard around USD_CURRENCY and target in the function that computes canSubmit
(references: USD_CURRENCY, SUPPORTED_CURRENCIES, target, canSubmit) and ensure
you surface a descriptive message like "Invariant violated: USD must be in
SUPPORTED_CURRENCIES for refunds" rather than silently returning null error.
🤖 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/backend/src/app/api/latest/internal/payments/transactions/refund/route.tsx`:
- Around line 67-80: getTotalUsdStripeUnits incorrectly uses the product's USD
price (resolveSelectedPriceFromProduct / moneyAmountToStripeUnits) rather than
the actual charge currency; change the flow to read the original charge/invoice
currency (from the Invoice or PaymentIntent currency field) and either reject
refunds when that currency is not USD or compute refund units using the original
charge currency's moneyAmountToStripeUnits conversion, and update
getTotalUsdStripeUnits (or rename to reflect currency) to accept the charge
currency and amount source instead of product USD. Separately, remove
priorRefundedStripeUnits from the idempotency key calculation in
makeStripeIdempotencyKey and build the key from immutable request-specific
values (e.g., original charge id, refund amount, currency, and a client-supplied
refund reference or timestamp) so retries do not change the idempotency key
after persistence.
- Around line 97-105: makeStripeIdempotencyKey currently builds the key using
priorRefundedStripeUnits which can change after a successful-but-response-lost
refund, causing retries to generate a different idempotency key and duplicate
refunds; instead generate and persist a stable idempotency token (e.g. uuid) on
the refund request before calling Stripe, reuse that token for all retry
attempts in makeStripeIdempotencyKey (or replace its usage), and make the
ledger/DB write idempotent by recording and checking that persisted token
(abort/return existing result if token already has a completed refund) so
retries reuse the same Stripe idempotency key; apply the same pattern to the
subscription and one-time purchase refund flows referenced in the codebase (the
other call sites noted in the review).

In `@apps/backend/src/app/api/latest/internal/payments/transactions/route.tsx`:
- Around line 594-600: In the loop over entries handling entry.type ===
"product-revocation", don't pass the loop index i to addLink for legacy refunds;
instead read the original source index from the entry's adjustedEntryIndex field
(e.g., const adjustedEntryIndex = Reflect.get(entry, "adjustedEntryIndex")),
validate it's a non-negative number, and pass that into addLink (fall back to i
only if adjustedEntryIndex is missing/invalid). Keep the existing checks for
adjustedTransactionId and use adjustedEntryIndex when calling addLink to
preserve back-compat.

In `@apps/dashboard/src/components/data-table/transaction-table.tsx`:
- Around line 270-273: The seedFromTransaction function currently preloads
setAmountUsd with chargedAmountUsd which can be larger than the remaining
refundable balance after a partial refund; change it to detect when the
transaction has been adjusted (e.g., transaction.adjusted_by or similar adjusted
flag is non-empty) and in that case call setAmountUsd('0') instead of
chargedAmountUsd, otherwise keep the existing chargedAmountUsd ?? '0' behavior;
update the logic in seedFromTransaction (referencing seedFromTransaction,
chargedAmountUsd, transaction.adjusted_by, and setAmountUsd) so reopened dialogs
default to 0 for follow-up partial refunds.

In
`@apps/e2e/tests/backend/endpoints/api/v1/internal/transactions-refund.test.ts`:
- Around line 240-283: The test "supports multiple partial refunds capped at
remaining amount" uses inconsistent cent notation for the third refund: change
the amount_usd value passed to niceBackendFetch for refund3 from the decimal
string "0.01" to the cent integer string "1" so it matches the other calls
(e.g., "2000", "3000") and keeps createLiveModeOneTimePurchaseTransaction /
refund assertions correct.

In `@packages/stack-shared/src/interface/admin-interface.ts`:
- Around line 920-921: The response handling in admin-interface.ts currently
maps json.refund_transaction_id directly to refundTransactionId, which can be
undefined for malformed responses; update the mapping to validate and fail fast
by using the nullish coalescing-with-throw pattern (e.g.,
json.refund_transaction_id ?? throwErr(...)) so that refundTransactionId is
guaranteed, and use a clear error message referencing refund_transaction_id when
calling throwErr; ensure you apply this around the response.json() handling that
returns { success: json.success, refundTransactionId: ... }.

---

Nitpick comments:
In `@apps/dashboard/src/components/data-table/transaction-table.tsx`:
- Around line 239-240: The current check returns { canSubmit: false, error: null
} when USD_CURRENCY (derived from SUPPORTED_CURRENCIES) or target is missing,
which silently disables the dialog; instead, make the USD invariant fail loudly:
replace the silent return by throwing or returning an explicit error via a
helper (e.g., using the project's throwErr/Invariant helper) when USD_CURRENCY
is missing and keep a clear actionable error when target is missing; locate the
guard around USD_CURRENCY and target in the function that computes canSubmit
(references: USD_CURRENCY, SUPPORTED_CURRENCIES, target, canSubmit) and ensure
you surface a descriptive message like "Invariant violated: USD must be in
SUPPORTED_CURRENCIES for refunds" rather than silently returning null error.
🪄 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: defaults

Review profile: CHILL

Plan: Pro

Run ID: c5f08f0b-dc78-4949-80ed-0e020866491a

📥 Commits

Reviewing files that changed from the base of the PR and between 748d708 and 8fc2027.

📒 Files selected for processing (12)
  • apps/backend/src/app/api/latest/internal/payments/transactions/refund/route.tsx
  • apps/backend/src/app/api/latest/internal/payments/transactions/route.tsx
  • apps/backend/src/app/api/latest/internal/payments/transactions/transaction-builder.ts
  • apps/backend/src/lib/payments/refund-txn-id.ts
  • apps/backend/src/lib/payments/schema/phase-1/transactions.ts
  • apps/dashboard/src/components/data-table/transaction-table.tsx
  • apps/e2e/tests/backend/endpoints/api/v1/internal/transactions-refund.test.ts
  • packages/stack-shared/src/interface/admin-interface.ts
  • packages/stack-shared/src/interface/crud/transactions.ts
  • packages/stack-shared/src/known-errors.tsx
  • packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts
  • packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts

Comment thread apps/backend/src/app/api/latest/internal/payments/transactions/route.tsx Outdated
Comment thread apps/dashboard/src/components/data-table/transaction-table.tsx Outdated
Comment thread packages/stack-shared/src/interface/admin-interface.ts
- Wrap refund dialog submit in runAsynchronouslyWithAlert so backend
  errors surface to the admin instead of becoming unhandled rejections.
- Tailor the "refund must do something" validation message to OTP vs
  subscription so OTP admins aren't told to use a checkbox the UI hides.
- Seed the refund dialog amount to 0 when the source transaction has
  already been adjusted, avoiding a stale default that exceeds the cap.
- Use adjustedEntryIndex (not the loop index) when materialising
  legacy refund -> source links in the transactions listing.
@nams1570
Copy link
Copy Markdown
Collaborator

@greptileai please do a full rereview

The endNow branch already swallows `subscription_already_canceled` and
`resource_missing` from Stripe (so an admin re-canceling an
already-terminal sub doesn't 500), but the endAtPeriodEnd branch was
missing the same guard. Because the Stripe money refund happens before
the lifecycle update, an unhandled error here would leave the customer
refunded with no ledger row written.

Reachable when an admin pairs `amount > 0` with
`end_action="at-period-end"` on a sub that's already terminal — the
empty-amount case is caught earlier by the replay guard.

Mirror the same try/catch from the endNow branch.
@nams1570
Copy link
Copy Markdown
Collaborator

@greptileai can you do a full rereview

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 14, 2026

Want your agent to iterate on Greptile's feedback? Try greploops.

Copy link
Copy Markdown
Collaborator

@nams1570 nams1570 left a comment

Choose a reason for hiding this comment

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

One bug - refund issues prod revocation, active-subscription-end and also sets endedAt. Bulldozer checks to see if sub has endedAt set and if it does, it creates a sub-end txn which has its own prod revocation entry. What this means is a refund that ends the subscription will also cause a sub-end txn to be created, and the two product revocations will mean the owned products of the end-user will be tallied wrong.

…edAt

A refund with end_action="now" used to leave two product-revocation
entries in the ledger: one on the refund row (manually written) and
one on the subscription-end transaction (auto-emitted because the
handler sets endedAt=now and the sub timefold picks it up). Phase 3's
owned-products LFold subtracts quantity per revocation; the
GREATEST(..., 0) clamp masked the bug for single-sub customers but
broke stackable subs — refunding one of N dropped the owned count to 0
instead of N-1, eating into still-active sibling grants.

Coordinate the two emitters via a new Subscription.productRevokedAt
column:

  - Refund handler sets productRevokedAt=now alongside endedAt=now in
    the end_action="now" path. Past endedAt is preserved (avoid stale
    sub-end re-emission); future endedAt is pulled forward to now so
    the lifecycle stays aligned.
  - Subscription timefold reads productRevokedAtMillis from the source
    row, threads it through state, and forwards it on the sub-end
    event row.
  - Subscription-end → transaction mapper omits its auto-emitted
    product-revocation when productRevokedAtMillis is set. Natural
    ends (where the marker stays null) still emit it as before.
  - Replay gate now requires *both* the durable subscription marker
    AND a refund-row product-revocation in bulldozer before rejecting
    a retry. If a previous attempt set the sub marker but failed
    before writing the refund row, the retry can still repair the
    canonical entry.

Adds a Prisma migration, threads productRevokedAtMillis through the
bulldozer dual-write + SubscriptionRow + SubscriptionEndEventRow
types, and updates every schema unit test that constructs a
SubscriptionRow inline.

New tests:
  - Stackable-sub e2e regression: two stackable subs, refund one,
    expect sibling quantity 1 (would be 0 before this fix).
  - Unit tests for shouldRejectSubscriptionProductRevocationReplay
    (retry-repair semantics) and getRefundDrivenImmediateEndedAt
    (past preserved, future pulled forward, null → now).
Copy link
Copy Markdown
Collaborator

@nams1570 nams1570 left a comment

Choose a reason for hiding this comment

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

Currently, it seems like refund for a subscription will contain the prod-revocation but not the item-quantity-expire entries. It relies on the subscription-end event to clean up the items. This is inconsistent with how otps are refunded. Could you switch subscription refunds to walk and grab the relevant item-quantity-expire entries from the item-grant-repeat(s) and sub-start entries from the relevant subscription?

@github-actions github-actions Bot assigned BilalG1 and unassigned nams1570 May 15, 2026
…sub-end txn

Refund-driven immediate subscription ends no longer produce a separate
subscription-end transaction. Phase-1 filters out subscription-end events
whose productRevokedAtMillis is set; the refund row instead carries
active-subscription-end, product-revocation, and item-quantity-expire
itself — making subscription refunds symmetric with one-time-purchase
refunds, which already walk their grants onto the refund row.

The item-quantity-expire walk reuses the OTP logic: computeOutstandingItemGrants
is source-agnostic and readOutstandingItemGrants is parameterized by
sourceTxnId + igrSourceId, so sub-start/igr txns work the same as otp/igr.

Also reject end_action='now' on a subscription that already ended naturally
(endedAt in the past, productRevokedAt null): re-ending it would re-stamp
its lifecycle entries at refund time and corrupt point-in-time history for
the [endedAt, now] window. Refund-driven ends (productRevokedAt set) still
fall through to the existing replay guard so a crashed prior attempt can
repair its missing refund row.
@nams1570
Copy link
Copy Markdown
Collaborator

@greptileai do a full rereview

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