Skip to content

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

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

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

    • Added refund transaction tracking—refunds now appear as distinct transactions in your transaction history with unique identifiers.
    • Simplified refund interface in the dashboard with streamlined controls for refund amount, product revocation, and subscription management.
  • Tests

    • Expanded refund endpoint test coverage for new refund flow scenarios.

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 7:48pm
stack-auth-mcp Ready Ready Preview, Comment May 13, 2026 7:48pm
stack-backend Ready Ready Preview, Comment May 13, 2026 7:48pm
stack-dashboard Ready Ready Preview, Comment May 13, 2026 7:48pm
stack-demo Ready Ready Preview, Comment May 13, 2026 7:48pm
stack-docs Ready Ready Preview, Comment May 13, 2026 7:48pm
stack-preview-backend Ready Ready Preview, Comment May 13, 2026 7:48pm
stack-preview-dashboard Ready Ready Preview, Comment May 13, 2026 7:48pm

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 13, 2026

📝 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.
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 buildAdjustedByLookupFromRefundRows to link refund rows to source transactions via new-format parseRefundTxnId or legacy product-revocation fallback. Updates getTransactions query logic to handle both refund formats.
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.
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, and negative amounts.
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 clearly and concisely describes the main change: reworking the refund flow to a three-knob API, which is the primary architectural change across all modified files.
Description check ✅ Passed The description is comprehensive and well-structured, covering summary, implementation details, architectural notes, documented limitations, review follow-ups, and a detailed test plan. However, it does not follow the minimal repository template provided.
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), shifting refund state from the legacy refundedAt column to bulldozer ledger rows keyed as refund:<sourceTxnId>:<uuid>, which enables multiple partial refunds up to the remaining cap.

  • Adds invoice_id support to target any subscription invoice (start or renewal), Stripe idempotency keys derived from (tenancyId, sourceTxnId, amount, priorRefunded) for safe retry semantics, and a legacy refundedAt backstop that rejects pre-rework purchases while preserving existing KnownError codes.
  • The listing route is extended to surface type: "refund" rows with dual-format adjusted_by linkage (new LIKE-prefix lookup + legacy product-revocation entry scan), and the dashboard dialog is rebuilt around three toggles with cascade logic (revoke forces end-sub on subscriptions).
  • Three known limitations are intentionally deferred and well-documented in code: concurrent cap-check race, Stripe–DB non-atomicity on DB-success → response-loss retries, and the dashboard's inability to reach the invoice_id path.

Confidence Score: 3/5

The backend refund logic is sound and well-tested, but the dashboard's async submit handler drops backend errors silently — an admin triggering a backend rejection (cap exceeded, already revoked, network error) will see no feedback.

The backend changes are carefully written with documented caveats, good test coverage (+5 tests), and correct idempotency key design. The dashboard regression — removing runAsynchronouslyWithAlert from the ActionDialog submit handler while ActionDialog itself has no try/catch — means any error thrown by app.refundTransaction() is an unhandled promise rejection, silently lost from the UI. This is on the primary refund action path in the dashboard.

apps/dashboard/src/components/data-table/transaction-table.tsx — async submit handler missing error handling wrapper

Important Files Changed

Filename Overview
apps/backend/src/app/api/latest/internal/payments/transactions/refund/route.tsx Core refund handler reworked to three-knob API (amount/revoke/end); adds invoice_id path, Stripe idempotency, bulldozer-derived prior-refund summary, and separate OTP/subscription handlers. Well-documented concurrency caveats.
apps/backend/src/app/api/latest/internal/payments/transactions/route.tsx Listing route extended to surface refund rows (type "refund"), with dual lookup for new-format (LIKE on txnId prefix) and legacy (product-revocation entry scan) refund rows.
apps/backend/src/app/api/latest/internal/payments/transactions/transaction-builder.ts All Prisma-derived transaction builders removed; only resolveSelectedPriceFromProduct remains, now that the listing and refund flows use the bulldozer-derived path.
apps/backend/src/lib/payments/refund-txn-id.ts New module defining REFUND_TXN_PREFIX, source-prefix allowlist, and parseRefundTxnId with inline unit tests; clean and well-tested.
apps/dashboard/src/components/data-table/transaction-table.tsx Refund dialog rebuilt around three toggles (amount/revoke/end-sub); removes runAsynchronouslyWithAlert from the async submit handler, leaving backend errors unhandled in the UI.
apps/e2e/tests/backend/endpoints/api/v1/internal/transactions-refund.test.ts Test suite expanded from 14 to 19 tests; adds renewal-invoice happy path, cross-subscription rejection, OTP invoice_id rejection, end-only replay guard, and revoke+renewal rejection.
packages/stack-shared/src/interface/crud/transactions.ts Adds "refund" to TRANSACTION_TYPES enum; straightforward one-line addition.
packages/stack-shared/src/known-errors.tsx Adds comment explaining the legacy backstop for SubscriptionAlreadyRefunded/OneTimePurchaseAlreadyRefunded; no functional change to error codes.
packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts Updates refundTransaction interface signature from per-entry array to flat three-knob shape; return type upgraded to include refundTransactionId.
packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts Implementation updated to match new interface; threads invoice_id/revokeProduct/endSubscription through, returns refundTransactionId, and invalidates cache.
packages/stack-shared/src/interface/admin-interface.ts Admin interface updated to three-knob shape; correctly maps request/response fields including new refund_transaction_id field.
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); well-documented with mapping invariants.

Sequence Diagram

sequenceDiagram
    participant Admin
    participant Dashboard
    participant RefundRoute as POST /refund
    participant Prisma
    participant Bulldozer
    participant Stripe

    Admin->>Dashboard: Click Refund (amount, revoke, end-sub)
    Dashboard->>RefundRoute: POST body with type/id/amount_usd/revoke_product

    RefundRoute->>Prisma: Look up subscription or OTP record
    Prisma-->>RefundRoute: record (legacy refundedAt backstop check)

    RefundRoute->>Bulldozer: readPriorRefundSummary via LIKE pattern on txnId
    Bulldozer-->>RefundRoute: refundedStripeUnits + productRevoked flags

    RefundRoute->>RefundRoute: Cap check and schema validation

    alt live mode and amount greater than zero
        RefundRoute->>Stripe: refunds.create with SHA-256 idempotency key
        Stripe-->>RefundRoute: refund confirmed
    end

    alt revoke_product is true
        RefundRoute->>Stripe: subscriptions.cancel (idempotent)
        RefundRoute->>Prisma: subscription.update status and endedAt
        RefundRoute->>Bulldozer: bulldozerWriteSubscription
    else end_subscription is true
        RefundRoute->>Stripe: subscriptions.update cancel_at_period_end
        RefundRoute->>Prisma: subscription.update cancelAtPeriodEnd
        RefundRoute->>Bulldozer: bulldozerWriteSubscription
    end

    RefundRoute->>Bulldozer: bulldozerWriteManualTransaction for refund row
    RefundRoute-->>Dashboard: success + refund_transaction_id
    Dashboard-->>Admin: Dialog closes
Loading
Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 2
apps/dashboard/src/components/data-table/transaction-table.tsx:287-297
The async `onClick` handler calls `app.refundTransaction()` without wrapping it in `runAsynchronouslyWithAlert`. `ActionDialog` calls `await okButton.onClick?.()` directly without a try/catch (see `action-dialog.tsx:130-131`), so any backend error (network failure, `SchemaError`, `SubscriptionAlreadyRefunded`, etc.) becomes an unhandled promise rejection — the dialog may close silently with no feedback to the admin.

```suggestion
            onClick: async () => {
              if (!validation.canSubmit) {
                return "prevent-close";
              }
              await runAsynchronouslyWithAlert(async () => {
                await app.refundTransaction({
                  ...target,
                  amountUsd: amountUsd as MoneyAmount,
                  revokeProduct,
                  ...(isSubscription ? { endSubscription } : {}),
                });
              });
            },
```

### Issue 2 of 2
apps/dashboard/src/components/data-table/transaction-table.tsx:258-260
The error message mentions "end subscription" as an option even for OTP refunds, but the "End subscription" checkbox is not rendered for OTP purchases. An admin who unchecks "Revoke product" and clears the amount on an OTP row sees an instruction to do something the UI doesn't expose.

```suggestion
    if (refundUnits === 0 && !revokeProduct && !endSubscription) {
      return {
        canSubmit: false,
        error: isSubscription
          ? "Refund must do something: enter an amount, revoke product, or end subscription."
          : "Refund must do something: enter an amount or revoke product.",
      };
    }
```

Reviews (1): Last reviewed commit: "payments: fix refund dialog defaults, su..." | Re-trigger Greptile

Comment on lines 287 to 297
onClick: async () => {
if (chargedAmountUsd && !refundValidation.canSubmit) {
if (!validation.canSubmit) {
return "prevent-close";
}
await app.refundTransaction({
...target,
refundEntries: refundValidation.refundEntries ?? throwErr("Refund entries missing for refund"),
amountUsd: amountUsd as MoneyAmount,
revokeProduct,
...(isSubscription ? { endSubscription } : {}),
});
},
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.

P1 The async onClick handler calls app.refundTransaction() without wrapping it in runAsynchronouslyWithAlert. ActionDialog calls await okButton.onClick?.() directly without a try/catch (see action-dialog.tsx:130-131), so any backend error (network failure, SchemaError, SubscriptionAlreadyRefunded, etc.) becomes an unhandled promise rejection — the dialog may close silently with no feedback to the admin.

Suggested change
onClick: async () => {
if (chargedAmountUsd && !refundValidation.canSubmit) {
if (!validation.canSubmit) {
return "prevent-close";
}
await app.refundTransaction({
...target,
refundEntries: refundValidation.refundEntries ?? throwErr("Refund entries missing for refund"),
amountUsd: amountUsd as MoneyAmount,
revokeProduct,
...(isSubscription ? { endSubscription } : {}),
});
},
onClick: async () => {
if (!validation.canSubmit) {
return "prevent-close";
}
await runAsynchronouslyWithAlert(async () => {
await app.refundTransaction({
...target,
amountUsd: amountUsd as MoneyAmount,
revokeProduct,
...(isSubscription ? { endSubscription } : {}),
});
});
},

Rule Used: Use runAsynchronouslyWithAlert from `@stackframe... (source)

Learned From
stack-auth/stack-auth#943

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/dashboard/src/components/data-table/transaction-table.tsx
Line: 287-297

Comment:
The async `onClick` handler calls `app.refundTransaction()` without wrapping it in `runAsynchronouslyWithAlert`. `ActionDialog` calls `await okButton.onClick?.()` directly without a try/catch (see `action-dialog.tsx:130-131`), so any backend error (network failure, `SchemaError`, `SubscriptionAlreadyRefunded`, etc.) becomes an unhandled promise rejection — the dialog may close silently with no feedback to the admin.

```suggestion
            onClick: async () => {
              if (!validation.canSubmit) {
                return "prevent-close";
              }
              await runAsynchronouslyWithAlert(async () => {
                await app.refundTransaction({
                  ...target,
                  amountUsd: amountUsd as MoneyAmount,
                  revokeProduct,
                  ...(isSubscription ? { endSubscription } : {}),
                });
              });
            },
```

**Rule Used:** Use `runAsynchronouslyWithAlert` from `@stackframe... ([source](https://app.greptile.com/review/custom-context?memory=5e671275-7493-402a-93a8-969537ec4d63))

**Learned From**
[stack-auth/stack-auth#943](https://github.com/stack-auth/stack-auth/pull/943)

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +258 to 260
if (refundUnits === 0 && !revokeProduct && !endSubscription) {
return { canSubmit: false, error: "Refund must do something: enter an amount, revoke product, or end subscription." };
}
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.

P2 The error message mentions "end subscription" as an option even for OTP refunds, but the "End subscription" checkbox is not rendered for OTP purchases. An admin who unchecks "Revoke product" and clears the amount on an OTP row sees an instruction to do something the UI doesn't expose.

Suggested change
if (refundUnits === 0 && !revokeProduct && !endSubscription) {
return { canSubmit: false, error: "Refund must do something: enter an amount, revoke product, or end subscription." };
}
if (refundUnits === 0 && !revokeProduct && !endSubscription) {
return {
canSubmit: false,
error: isSubscription
? "Refund must do something: enter an amount, revoke product, or end subscription."
: "Refund must do something: enter an amount or revoke product.",
};
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/dashboard/src/components/data-table/transaction-table.tsx
Line: 258-260

Comment:
The error message mentions "end subscription" as an option even for OTP refunds, but the "End subscription" checkbox is not rendered for OTP purchases. An admin who unchecks "Revoke product" and clears the amount on an OTP row sees an instruction to do something the UI doesn't expose.

```suggestion
    if (refundUnits === 0 && !revokeProduct && !endSubscription) {
      return {
        canSubmit: false,
        error: isSubscription
          ? "Refund must do something: enter an amount, revoke product, or end subscription."
          : "Refund must do something: enter an amount or revoke product.",
      };
    }
```

How can I resolve this? If you propose a fix, please make it concise.

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 on lines +97 to 105
function makeStripeIdempotencyKey(args: {
tenancyId: string,
sourceTxnId: string,
amountStripeUnits: number,
priorRefundedStripeUnits: number,
}): string {
const fingerprint = `${args.tenancyId}:${args.sourceTxnId}:${args.amountStripeUnits}:${args.priorRefundedStripeUnits}`;
return `refund:${createHash("sha256").update(fingerprint).digest("hex").slice(0, 32)}`;
}
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.

⚠️ Potential issue | 🔴 Critical | 🏗️ Heavy lift

🧩 Analysis chain

🌐 Web query:

For Stripe refund creation, if a retry uses a different idempotency key from the original successful request, will Stripe treat it as a brand-new refund?

💡 Result:

Yes—if the retry uses a different idempotency key, Stripe will treat it as a new request (i.e., it will not return the cached result for the original retry), so it can create a new refund if the underlying request would succeed. Stripe’s idempotency behavior is keyed on the specific idempotency key value: Stripe saves the status code/body for the first request made for any given idempotency key, and subsequent requests with the same key return the same result. If you change the idempotency key, you’re no longer reusing the same cached result, so Stripe won’t deduplicate based on the original successful request’s key.[1][2] Practical implication for refunds: creating a refund is a “create new refund” operation on a charge/payment_intent, and if the charge isn’t already fully refunded, a new (non-idempotently deduped) call can create another refund. Stripe notes that once entirely refunded, a charge can’t be refunded again, and calling it again will raise an error.[3] Therefore: using a different idempotency key on retry does not guarantee “same refund” behavior; it can result in another refund attempt unless the API rejects it due to refund state (e.g., already fully refunded) or you otherwise guard against it.[1][3]

Citations:


🏁 Script executed:

# Check if the file exists and get context around the mentioned lines
fd -t f "route.tsx" | grep -i "refund"

Repository: hexclave/stack-auth

Length of output: 143


🏁 Script executed:

# List files in the api path to understand structure
git ls-files "apps/backend/src/app/api/latest/internal/payments/transactions/" | head -20

Repository: hexclave/stack-auth

Length of output: 302


🏁 Script executed:

# Read the refund route file to understand the context
wc -l apps/backend/src/app/api/latest/internal/payments/transactions/refund/route.tsx

Repository: hexclave/stack-auth

Length of output: 147


🏁 Script executed:

# Get the full file content
cat -n apps/backend/src/app/api/latest/internal/payments/transactions/refund/route.tsx

Repository: hexclave/stack-auth

Length of output: 37892


Use a response-loss-safe idempotency token.

The Stripe idempotency key includes priorRefundedStripeUnits, which changes as soon as the refund row commits. If a successful Stripe refund's response is lost and the handler retries, prior.refundedStripeUnits will now include the refunded amount, generating a fresh idempotency key. Stripe will treat this as a new request and create a second real refund.

The fix requires a stable retry token persisted before the Stripe call and reused across retries, with the ledger write made idempotent on that token. The developers acknowledge this gap in lines 330–341 as an open hole without out-of-band reconciliation.

Also applies to: lines 485–490 (subscription), 672–677 (one-time purchase).

🤖 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/backend/src/app/api/latest/internal/payments/transactions/refund/route.tsx`
around lines 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).

Comment on lines +594 to +600
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
if (!isRecord(entry)) continue;
if (entry.type !== "product-revocation") continue;
const adjustedTxnId = Reflect.get(entry, "adjustedTransactionId");
if (typeof adjustedTxnId !== "string" || adjustedTxnId.length === 0) continue;
addLink(adjustedTxnId, refundTxnId, i);
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.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Use adjustedEntryIndex for legacy refund links.

i is the entry's position inside the refund row, not the original source entry index. That changes adjusted_by.entry_index for legacy refunds and breaks the back-compat this comment promises.

💡 Proposed fix
     for (let i = 0; i < entries.length; i++) {
       const entry = entries[i];
       if (!isRecord(entry)) continue;
       if (entry.type !== "product-revocation") continue;
       const adjustedTxnId = Reflect.get(entry, "adjustedTransactionId");
+      const adjustedEntryIndex = Reflect.get(entry, "adjustedEntryIndex");
       if (typeof adjustedTxnId !== "string" || adjustedTxnId.length === 0) continue;
-      addLink(adjustedTxnId, refundTxnId, i);
+      if (typeof adjustedEntryIndex !== "number" || !Number.isInteger(adjustedEntryIndex) || adjustedEntryIndex < 0) continue;
+      addLink(adjustedTxnId, refundTxnId, adjustedEntryIndex);
     }
📝 Committable suggestion

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

Suggested change
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
if (!isRecord(entry)) continue;
if (entry.type !== "product-revocation") continue;
const adjustedTxnId = Reflect.get(entry, "adjustedTransactionId");
if (typeof adjustedTxnId !== "string" || adjustedTxnId.length === 0) continue;
addLink(adjustedTxnId, refundTxnId, i);
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
if (!isRecord(entry)) continue;
if (entry.type !== "product-revocation") continue;
const adjustedTxnId = Reflect.get(entry, "adjustedTransactionId");
const adjustedEntryIndex = Reflect.get(entry, "adjustedEntryIndex");
if (typeof adjustedTxnId !== "string" || adjustedTxnId.length === 0) continue;
if (typeof adjustedEntryIndex !== "number" || !Number.isInteger(adjustedEntryIndex) || adjustedEntryIndex < 0) continue;
addLink(adjustedTxnId, refundTxnId, adjustedEntryIndex);
🤖 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/backend/src/app/api/latest/internal/payments/transactions/route.tsx`
around lines 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.

Comment on lines +270 to +273
const seedFromTransaction = () => {
setAmountUsd(chargedAmountUsd ?? '0');
setRevokeProduct(true);
setEndSubscription(isSubscription);
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.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't reuse the original charge as the default for follow-up partial refunds.

After a partial money refund, chargedAmountUsd is larger than the remaining refundable balance, so reopening the dialog and submitting unchanged now fails on the repeat-partial-refund path. If this payload doesn’t expose the remaining amount, defaulting to 0 once transaction.adjusted_by is non-empty is safer than preloading a potentially invalid value.

Suggested fix
  const seedFromTransaction = () => {
-    setAmountUsd(chargedAmountUsd ?? '0');
+    setAmountUsd(transaction.adjusted_by.length > 0 ? '0' : (chargedAmountUsd ?? '0'));
     setRevokeProduct(true);
     setEndSubscription(isSubscription);
   };
📝 Committable suggestion

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

Suggested change
const seedFromTransaction = () => {
setAmountUsd(chargedAmountUsd ?? '0');
setRevokeProduct(true);
setEndSubscription(isSubscription);
const seedFromTransaction = () => {
setAmountUsd(transaction.adjusted_by.length > 0 ? '0' : (chargedAmountUsd ?? '0'));
setRevokeProduct(true);
setEndSubscription(isSubscription);
};
🤖 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
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.

Comment thread packages/stack-shared/src/interface/admin-interface.ts
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