stack/apps/backend/src/lib/payments.test.tsx
BilalG1 d0202eeef9
Some checks failed
all-good: Did all the other checks pass? / all-good (push) Has been cancelled
Ensure Prisma migrations are in sync with the schema / check_prisma_migrations (22.x) (push) Has been cancelled
DB migration compat / Check if migrations changed (push) Has been cancelled
Docker Server Build and Push / Docker Build and Push Server (push) Has been cancelled
Docker Server Build and Run / docker (push) Has been cancelled
Runs E2E API Tests (Local Emulator) / E2E Tests (Local Emulator, Node ${{ matrix.node-version }}) (22.x) (push) Has been cancelled
Runs E2E API Tests / E2E Tests (Node ${{ matrix.node-version }}, Freestyle ${{ matrix.freestyle-mode }}) (mock, 22.x) (push) Has been cancelled
Runs E2E API Tests / E2E Tests (Node ${{ matrix.node-version }}, Freestyle ${{ matrix.freestyle-mode }}) (prod, 22.x) (push) Has been cancelled
Runs E2E API Tests with custom port prefix / build (22.x) (push) Has been cancelled
Runs E2E Fallback Tests / E2E Fallback Tests (Node ${{ matrix.node-version }}) (22.x) (push) Has been cancelled
Lint & build / lint_and_build (24) (push) Has been cancelled
TOC Generator / TOC Generator (push) Has been cancelled
DB migration compat / Back-compat — Current branch migrations with ${{ needs.check-migrations-changed.outputs.base_branch }} branch code (push) Has been cancelled
DB migration compat / Forward-compat — Current branch code with ${{ needs.check-migrations-changed.outputs.base_branch }} branch migrations (push) Has been cancelled
DB migration compat / No migration changes (skipped) (push) Has been cancelled
payments: rework refund flow to three-knob API (#1429)
## 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](https://linear.app/stack-auth/issue/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
- [x] `pnpm typecheck` — 28/28 pass
- [x] `pnpm lint` — 28/28 pass
- [x] `pnpm test run
apps/e2e/tests/backend/endpoints/api/v1/internal/transactions-refund.test.ts`
— **19/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)
- [x] curl smoke against
`/api/latest/internal/payments/transactions/refund` — unknown purchase →
404, no-op → 400, negative → 400, sub-revoke-without-end → 400
- [x] **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.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

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

* **Bug Fixes / Improvements**
* Deterministic Stripe idempotency, stronger replay protection,
refundable-amount caps, test-mode constraints, and transactions listing
updated to surface refunds.

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

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/hexclave/stack-auth/pull/1429)

<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-15 19:29:21 -07:00

156 lines
6.6 KiB
TypeScript

import { KnownErrors } from '@stackframe/stack-shared';
import { describe, expect, it } from 'vitest';
import { validatePurchaseSession } from './payments';
import { bulldozerWriteOneTimePurchase, bulldozerWriteSubscription } from "@/lib/payments/bulldozer-dual-write";
import { globalPrismaClient } from "@/prisma-client";
// Uses globalPrismaClient which connects to the real dev DB (with BulldozerStorageEngine).
// customerType: 'custom' avoids needing a real ProjectUser/Team in the DB.
// Each test writes data to Bulldozer stored tables via the dual-write functions,
// then calls validatePurchaseSession which reads from the owned products LFold.
describe.sequential('validatePurchaseSession - purchase guards (real DB)', () => {
const prisma = globalPrismaClient;
const testId = Math.random().toString(36).slice(2, 8);
const tenancyId = `test-tenancy-${testId}`;
const customerId = `test-customer-${testId}`;
const makeProduct = (overrides: Record<string, unknown> = {}) => ({
displayName: 'Test Product',
productLineId: null as string | null,
customerType: 'custom' as const,
prices: { p1: { USD: '10' } },
includedItems: {},
isAddOnTo: false as false | Record<string, true>,
stackable: false,
...overrides,
});
const grantOtp = async (id: string, productId: string, product: ReturnType<typeof makeProduct>) => {
await bulldozerWriteOneTimePurchase(prisma as any, {
id, tenancyId, customerId, customerType: 'CUSTOM',
productId, priceId: null, product: product as any, quantity: 1,
stripePaymentIntentId: null, revokedAt: null, refundedAt: null,
creationSource: 'TEST_MODE', createdAt: new Date(),
});
};
const grantSub = async (id: string, productId: string, product: ReturnType<typeof makeProduct>) => {
await bulldozerWriteSubscription(prisma as any, {
id, tenancyId, customerId, customerType: 'CUSTOM',
productId, priceId: null, product: product as any, quantity: 1,
stripeSubscriptionId: `stripe-${id}`, status: 'active',
currentPeriodStart: new Date(), currentPeriodEnd: new Date(Date.now() + 86400000),
cancelAtPeriodEnd: false, canceledAt: null, endedAt: null, refundedAt: null, productRevokedAt: null,
creationSource: 'TEST_MODE', createdAt: new Date(),
});
};
const callValidate = (product: ReturnType<typeof makeProduct>, overrides: Record<string, unknown> = {}) =>
validatePurchaseSession({
prisma: prisma as any,
tenancyId,
customerType: 'custom',
customerId,
product: product as any,
productId: `prod-new-${testId}`,
priceId: undefined,
quantity: 1,
...overrides,
});
it('blocks non-stackable product if customer already owns it', async () => {
const prodId = `prod-dup-${testId}`;
await grantOtp(`otp-dup-${testId}`, prodId, makeProduct());
await expect(callValidate(makeProduct(), { productId: prodId })).rejects.toThrowError(/already owns/);
});
it('allows stackable product even if customer already owns it', async () => {
const prodId = `prod-stack-${testId}`;
await grantOtp(`otp-stack-${testId}`, prodId, makeProduct({ stackable: true }));
const res = await callValidate(makeProduct({ stackable: true }), { productId: prodId });
expect(res.selectedPrice).toBeDefined();
});
it('blocks non-stackable quantity > 1', async () => {
await expect(callValidate(makeProduct(), { quantity: 3 }))
.rejects.toThrowError('not stackable');
});
it('blocks purchase when OTP exists in same product line (no sub to cancel)', async () => {
const lineId = `line-block-${testId}`;
await grantOtp(`otp-line-${testId}`, `prod-in-line-${testId}`, makeProduct({ productLineId: lineId }));
await expect(callValidate(makeProduct({ productLineId: lineId }), { productId: `prod-other-${testId}` }))
.rejects.toThrowError('one-time purchase in this product line');
});
it('allows purchase when existing product is in different product line', async () => {
const res = await callValidate(
makeProduct({ productLineId: `line-different-${testId}` }),
{ productId: `prod-diff-${testId}` },
);
expect(res.conflictingSubscriptions).toHaveLength(0);
});
it('finds conflicting subscription in same product line', async () => {
const lineId = `line-conflict-${testId}`;
const subId = `sub-conflict-${testId}`;
await grantSub(subId, `prod-sub-${testId}`, makeProduct({ productLineId: lineId }));
const res = await callValidate(
makeProduct({ productLineId: lineId }),
{ productId: `prod-replace-${testId}` },
);
expect(res.conflictingSubscriptions).toHaveLength(1);
expect(res.conflictingSubscriptions[0].id).toBe(subId);
});
it('blocks add-on if base product not owned', async () => {
await expect(callValidate(makeProduct({ isAddOnTo: { [`base-${testId}`]: true } })))
.rejects.toThrowError('add-on');
});
it('allows add-on if base product is owned', async () => {
const baseId = `base-addon-${testId}`;
await grantOtp(`otp-base-${testId}`, baseId, makeProduct());
const res = await callValidate(makeProduct({ isAddOnTo: { [baseId]: true } }));
expect(res.selectedPrice).toBeDefined();
});
it('allows add-on in same product line as its base product', async () => {
const lineId = `line-addon-${testId}`;
const baseId = `base-sameline-${testId}`;
await grantOtp(`otp-sameline-${testId}`, baseId, makeProduct({ productLineId: lineId }));
const res = await callValidate(
makeProduct({ productLineId: lineId, isAddOnTo: { [baseId]: true } }),
{ productId: `addon-sameline-${testId}` },
);
expect(res.selectedPrice).toBeDefined();
expect(res.conflictingSubscriptions).toHaveLength(0);
});
// TODO: reconsider coupling — product-line blocking infers OTP vs subscription
// ownership. OTPs can be refunded, so "blocked because OTP" is debatable.
it('resolves first price when no priceId given', async () => {
const res = await callValidate(makeProduct({ prices: { p1: { USD: '10' }, p2: { USD: '20' } } }));
expect(res.selectedPrice).toBeDefined();
expect((res.selectedPrice as any).USD).toBe('10');
});
it('resolves specific priceId when given', async () => {
const res = await callValidate(
makeProduct({ prices: { p1: { USD: '10' }, p2: { USD: '20' } } }),
{ priceId: 'p2' },
);
expect(res.selectedPrice).toBeDefined();
expect((res.selectedPrice as any).USD).toBe('20');
});
it('rejects invalid priceId', async () => {
await expect(callValidate(
makeProduct({ prices: { p1: { USD: '10' } } }),
{ priceId: 'nonexistent' },
)).rejects.toThrowError('Price not found');
});
});