Skip to content

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

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

payments: rework refund flow to three-knob API#1429
BilalG1 wants to merge 6 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

    • Refunds are now listed as distinct "refund" transactions with stable refund IDs and invoice-aware refund support.
    • Dashboard refund UI simplified to USD-only amount, product revocation toggle, and optional subscription-end control; actions return a refund transaction id.
  • Bug Fixes / Improvements

    • Stronger validation to prevent double refunds and enforce refundable caps.
  • Tests

    • Expanded end-to-end and unit tests for the new refund request shape and invoice/refund 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 13, 2026 10:07pm
stack-auth-mcp Ready Ready Preview, Comment May 13, 2026 10:07pm
stack-backend Ready Ready Preview, Comment May 13, 2026 10:07pm
stack-dashboard Ready Ready Preview, Comment May 13, 2026 10:07pm
stack-demo Ready Ready Preview, Comment May 13, 2026 10:07pm
stack-docs Ready Ready Preview, Comment May 13, 2026 10:07pm
stack-preview-backend Ready Ready Preview, Comment May 13, 2026 10:07pm
stack-preview-dashboard Ready Ready Preview, Comment May 13, 2026 10:07pm

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 13, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: d7156cbd-9352-4cd4-ba5b-43bc5be5a801

📥 Commits

Reviewing files that changed from the base of the PR and between 8fc2027 and 08ff1bd.

📒 Files selected for processing (2)
  • apps/backend/src/app/api/latest/internal/payments/transactions/route.tsx
  • apps/dashboard/src/components/data-table/transaction-table.tsx
🚧 Files skipped from review as they are similar to previous changes (2)
  • apps/backend/src/app/api/latest/internal/payments/transactions/route.tsx
  • apps/dashboard/src/components/data-table/transaction-table.tsx

📝 Walkthrough

Walkthrough

This PR refactors the refund system from a complex per-entry interface to a simpler three-knob model. The route now accepts amount_usd, revoke_product, optional invoice_id, and optional end_subscription instead of refund_entries. Refunds are now tracked as first-class ledger transactions with deterministic IDs, prior-refund lookups enforce remaining-amount caps, and the dashboard UI and SDK contracts have been updated to match.

Changes

Refund Flow Refactor to Three-Knob Model

Layer / File(s) Summary
Refund Transaction ID Constants and Parsing
apps/backend/src/lib/payments/refund-txn-id.ts
Introduces REFUND_TXN_PREFIX, REFUND_SOURCE_TXN_PREFIXES, and parseRefundTxnId() to parse refund IDs with format refund:<sourceTxnId>:<uuid>. Includes support for colon-containing source IDs and SQL LIKE-safety test coverage.
Transaction Schema Entry Index Constants
apps/backend/src/lib/payments/schema/phase-1/transactions.ts
Exports entry-index constants for subscription-start and one-time-purchase product-grant entries, both set to index 0, documenting legacy compatibility for refund product-revocation entries.
Refund Route Handler Refactor
apps/backend/src/app/api/latest/internal/payments/transactions/refund/route.tsx
Replaces the POST handler with a new ledger-driven interface accepting amount_usd, revoke_product, optional invoice_id, and optional end_subscription. Introduces helpers for Stripe unit conversion, deterministic txn ID generation, ledger entry builders, and prior-refund lookups. Implements separate flows for subscriptions and one-time purchases with Stripe refund calls, state updates via Prisma, and ledger writes via bulldozer; returns refund_transaction_id.
Ledger Transaction Type Support for Refunds
apps/backend/src/app/api/latest/internal/payments/transactions/route.tsx
Extends the ledger type system to recognize refund as a valid transaction type. Updates type union, filtering, row parsing, sourceId derivation, and API type mapping. Implements adjusted_by resolution using parseRefundTxnId for new-format refunds and a legacy fallback scanning product-revocation entries. Updates getTransactions refund discovery query logic.
Transaction Builder Cleanup
apps/backend/src/app/api/latest/internal/payments/transactions/transaction-builder.ts
Removes old refund transaction construction code and ProductSnapshot type declarations. Replaces imports with documentation explaining that only resolveSelectedPriceFromProduct remains in use.
Shared Type Contracts and Interfaces
packages/stack-shared/src/interface/crud/transactions.ts, packages/stack-shared/src/interface/admin-interface.ts
Adds "refund" to TRANSACTION_TYPES. Updates StackAdminInterface.refundTransaction options to use invoiceId?, amountUsd, revokeProduct, and endSubscription?. Changes return type to include refundTransactionId.
Admin SDK Implementation Updates
packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts, packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts
Updates admin-app interface and implementation signatures to match new refund contract. Forwards new options fields to the shared interface, returns { refundTransactionId }, and invalidates transaction cache entries.
Legacy Refund Backstop Documentation
packages/stack-shared/src/known-errors.tsx
Documents the legacy refundedAt backstop gate that prevents double-refunding when the ledger flow cannot see prior rows.
Dashboard Refund UI Refactor
apps/dashboard/src/components/data-table/transaction-table.tsx
Refactors refund dialog and RefundActionCell to match the new three-knob flow. Removes per-entry refund quantity selection, replaces with simple amountUsd, revokeProduct, and optional endSubscription state. Adds refund type label/icon, relocates product display naming helper, and simplifies dialog UI to single amount input and checkboxes.
E2E Test Helpers and Schema Validation
apps/e2e/tests/backend/endpoints/api/v1/internal/transactions-refund.test.ts
Adds createTestModeSubscription and createLiveModeSubscriptionWithRenewal helpers. Rewrites schema validation tests for new request contract, covering missing targets, invalid knob combinations, no-op refunds, negative amounts, and test-mode constraints.
E2E Tests for One-Time Purchase Refunds
apps/e2e/tests/backend/endpoints/api/v1/internal/transactions-refund.test.ts
Validates test-mode product revocation, live-mode full/partial refunds with USD flow, replay protection, and remaining-amount cap enforcement. Verifies refund:otp:* transaction ID format and adjusted_by linkage.
E2E Tests for Subscription Refunds
apps/e2e/tests/backend/endpoints/api/v1/internal/transactions-refund.test.ts
Validates test-mode refunds with product revocation and subscription end, replay protection against double-revocation and double-end, and end-only refunds. Verifies refund:sub-start:* transaction ID format and subscription lifecycle state updates.
E2E Tests for Subscription Renewal Invoice Refunds
apps/e2e/tests/backend/endpoints/api/v1/internal/transactions-refund.test.ts
Validates optional invoice_id path for renewal invoices using mocked Stripe webhook setup. Verifies refund:sub-renewal:* format, correct adjusted_by linkage to subscription-renewal (not start), and constraint enforcement (no product revocation on renewal, invoice ownership validation).

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • hexclave/stack-auth#1378: Centralizes Stripe refund RefundCreateParams validation and the refund_application_fee: false invariant within the same refund route handler.

Suggested reviewers

  • nams1570
  • N2D4

🐰 Three knobs replace the entry array,
Ledger rows dance in their new ballet,
Idempotent Stripe calls ring true,
Dashboard and SDK both brand new. ✨💰

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 38.71% 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' clearly summarizes the main change: replacing the per-entry refund schema with a simplified three-parameter interface (amount_usd, revoke_product, end_subscription), which is the core refactoring across the entire changeset.
Description check ✅ Passed The PR description is comprehensive and well-structured, covering the summary of changes, implementation details, architectural notes, review follow-ups, documented limitations, and a detailed test plan. It directly addresses the objectives and provides clear context for reviewers.
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 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 three-knob API (amount_usd, revoke_product, end_subscription), derives refund state from bulldozer ledger rows keyed as refund:<sourceTxnId>:<uuid> instead of the legacy refundedAt column, and adds invoice_id support for refunding specific subscription invoices (start or renewal). The dashboard dialog is rebuilt around the three toggles with a seed-on-open fix, and refund rows are now surfaced as a first-class type: \"refund\" transaction in the listing with adjusted_by linkage covering both new and legacy formats.

  • Backend: handleSubscriptionRefund and handleOneTimePurchaseRefund read prior-refund summaries from bulldozer via raw SQL LIKE patterns, compute remaining caps, call Stripe with a deterministic idempotency key, write the refund row, and optionally cancel or schedule the subscription end — all with documented atomicity/concurrency caveats.
  • Listing route: refund promoted to a recognized ledger type; buildAdjustedByLookupFromRefundRows parses new refund:<sourceTxnId>:<uuid> txnIds and falls back to scanning product-revocation entries for legacy rows.
  • Dashboard: dialog state is seeded in the menu onClick before the controlled dialog opens, fixing the pre-fill bug; runAsynchronouslyWithAlert now wraps the refund call correctly per the codebase convention."

Confidence Score: 5/5

Safe to merge; the refund flow is well-tested (19/19 e2e), documented limitations are intentional and tracked, and no correctness gaps were found that affect live-mode paths.

The rework is thorough and self-consistent: the new bulldozer-derived prior-refund summary, idempotency-key derivation, legacy backstop, and end-only replay guard all hold up under review. The only latent issue found is the invoice ownership check comparing stripeSubscriptionId (null for test-mode subs), which is logically fragile if test-mode subscriptions ever start creating invoice records — but that path is presently unreachable and does not affect live-mode behavior. Known concurrency and atomicity gaps are transparently documented in comments and the PR description.

apps/backend/src/app/api/latest/internal/payments/transactions/refund/route.tsx — specifically the invoice ownership check at line 396 (stripeSubscriptionId comparison).

Important Files Changed

Filename Overview
apps/backend/src/app/api/latest/internal/payments/transactions/refund/route.tsx Core refund handler rewritten to three-knob API (amount + revoke + end-sub), with bulldozer-derived prior-refund summary, Stripe idempotency keys, and invoice-based refund targeting; invoice ownership check uses stripeSubscriptionId comparison (fragile for test-mode null case)
apps/backend/src/app/api/latest/internal/payments/transactions/route.tsx Listing route extended to include refund as a first-class transaction type; adjusted_by lookup upgraded to handle both new txnId-parsed format and legacy product-revocation fallback
apps/backend/src/lib/payments/refund-txn-id.ts New module introducing REFUND_TXN_PREFIX, REFUND_SOURCE_TXN_PREFIXES, and parseRefundTxnId with inline vitest tests covering colon-containing source IDs and the LIKE-safety invariant
apps/dashboard/src/components/data-table/transaction-table.tsx Refund dialog rebuilt with amount + revoke + end-sub toggles; seed-on-open fix moves state initialization to menu onClick before dialog open; runAsynchronouslyWithAlert now used correctly
apps/e2e/tests/backend/endpoints/api/v1/internal/transactions-refund.test.ts E2E tests expanded from 14 to 19, adding renewal-invoice happy path, cross-subscription invoice rejection, OTP+invoice rejection, end-only replay rejection, and revoke+renewal rejection
apps/backend/src/lib/payments/schema/phase-1/transactions.ts Adds SUBSCRIPTION_START_PRODUCT_GRANT_ENTRY_INDEX and ONE_TIME_PURCHASE_PRODUCT_GRANT_ENTRY_INDEX constants (both 0) with detailed commentary on the public-API entry layout
packages/stack-shared/src/interface/admin-interface.ts refundTransaction signature updated to three-knob shape with optional invoiceId; response now surfaces refundTransactionId
packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts Admin app impl updated to pass through new three-knob params and return refundTransactionId after cache invalidation
packages/stack-shared/src/known-errors.tsx Adds clarifying comment to existing SubscriptionAlreadyRefunded/OneTimePurchaseAlreadyRefunded constructors explaining their role in the legacy-refundedAt backstop
apps/backend/src/app/api/latest/internal/payments/transactions/transaction-builder.ts Gutted to retain only resolveSelectedPriceFromProduct; all build*Transaction helpers removed now that the listing and refund paths use bulldozer-derived rows

Sequence Diagram

sequenceDiagram
    participant Admin as Dashboard Admin
    participant Route as POST /refund
    participant Prisma as Prisma DB
    participant Bulldozer as Bulldozer Ledger
    participant Stripe as Stripe

    Admin->>Route: amount_usd, revoke_product, end_subscription, invoice_id?
    Route->>Route: Validate inputs and coupling rules
    Route->>Prisma: Find subscription or purchase (legacy-refundedAt backstop)
    Route->>Bulldozer: Read prior refund summary (LIKE pattern on txnId)
    Bulldozer-->>Route: refundedStripeUnits, productRevoked
    Route->>Route: Check remaining cap and revoke guards

    alt "amount > 0 and live mode"
        Route->>Stripe: Create refund with deterministic idempotency key
        Stripe-->>Route: OK
    end

    alt revoke_product
        Route->>Stripe: Cancel subscription (idempotent)
        Route->>Prisma: "Update subscription endedAt=now"
        Route->>Bulldozer: Write subscription state
    else end_subscription only
        Route->>Stripe: "Update cancel_at_period_end=true"
        Route->>Prisma: Update subscription cancelAtPeriodEnd
        Route->>Bulldozer: Write subscription state
    end

    Route->>Bulldozer: "Write refund row (type=refund, entries=[money-transfer?, product-revocation?])"
    Route-->>Admin: "success=true, refund_transaction_id"
Loading
Prompt To Fix All With AI
Fix the following 1 code review issue. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 1
apps/backend/src/app/api/latest/internal/payments/transactions/refund/route.tsx:396-397
**Invoice ownership validated via Stripe ID, which is null for test-mode subs**

The check `found.stripeSubscriptionId !== subscription.stripeSubscriptionId` evaluates to `null !== null = false` when both the invoice and the subscription are test-mode (i.e. `stripeSubscriptionId` is `null` for both). In that scenario any `SubscriptionInvoice` row in the same tenancy with a null `stripeSubscriptionId` would pass the guard, even if it belongs to a different subscription — allowing `isSubscriptionCreationInvoice` from the wrong invoice to influence the `revoke_product` gate and the `sourceTxnId` on `subscription_B` when the caller supplies `invoiceId` from `subscription_A`.

Today test-mode subscriptions don't create `SubscriptionInvoice` rows (as noted in the inline comment `"test-mode sub has no invoice"`), so the null-equals-null branch is unreachable in practice. But the correctness of this gate then relies on that data-model invariant being stable. A direct FK comparison — `found.subscriptionId !== subscription.id` — is the semantically correct check and doesn't depend on the Stripe ID being populated.

Reviews (2): Last reviewed commit: "payments: address third-pass refund revi..." | 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
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.
Comment thread packages/stack-shared/src/interface/admin-interface.ts
@nams1570
Copy link
Copy Markdown
Collaborator

@greptileai please rereview comprehensively

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