Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "Subscription" ADD COLUMN "refundedAt" TIMESTAMP(3);

2 changes: 2 additions & 0 deletions apps/backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -785,6 +785,8 @@ model Subscription {
currentPeriodStart DateTime
cancelAtPeriodEnd Boolean

refundedAt DateTime?

creationSource PurchaseCreationSource
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,16 @@ export const POST = createSmartRouteHandler({
handler: async ({ auth, body }) => {
const prisma = await getPrismaClientForTenancy(auth.tenancy);
if (body.type === "subscription") {
const subscription = await prisma.subscription.findUnique({
where: { tenancyId_id: { tenancyId: auth.tenancy.id, id: body.id } },
select: { refundedAt: true },
Comment on lines +34 to +36
Copy link
Contributor

Choose a reason for hiding this comment

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

logic: the initial query only selects refundedAt, but subscriptions also have a creationSource field that needs checking (subscriptions can be TEST_MODE like one-time purchases)

Suggested change
const subscription = await prisma.subscription.findUnique({
where: { tenancyId_id: { tenancyId: auth.tenancy.id, id: body.id } },
select: { refundedAt: true },
const subscription = await prisma.subscription.findUnique({
where: { tenancyId_id: { tenancyId: auth.tenancy.id, id: body.id } },
select: { refundedAt: true, creationSource: true },
});
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/backend/src/app/api/latest/internal/payments/transactions/refund/route.tsx
Line: 34:36

Comment:
**logic:** the initial query only selects `refundedAt`, but subscriptions also have a `creationSource` field that needs checking (subscriptions can be TEST_MODE like one-time purchases)

```suggestion
      const subscription = await prisma.subscription.findUnique({
        where: { tenancyId_id: { tenancyId: auth.tenancy.id, id: body.id } },
        select: { refundedAt: true, creationSource: true },
      });
```

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

});
if (!subscription) {
throw new KnownErrors.SubscriptionInvoiceNotFound(body.id);
}
if (subscription.refundedAt) {
throw new KnownErrors.SubscriptionAlreadyRefunded(body.id);
}
const subscriptionInvoices = await prisma.subscriptionInvoice.findMany({
where: {
tenancyId: auth.tenancy.id,
Expand Down Expand Up @@ -69,6 +79,7 @@ export const POST = createSmartRouteHandler({
status: SubscriptionStatus.canceled,
cancelAtPeriodEnd: true,
currentPeriodEnd: new Date(),
refundedAt: new Date(),
},
});
} else {
Expand All @@ -78,24 +89,27 @@ export const POST = createSmartRouteHandler({
if (!purchase) {
throw new KnownErrors.OneTimePurchaseNotFound(body.id);
}
if (purchase.refundedAt) {
throw new KnownErrors.OneTimePurchaseAlreadyRefunded(body.id);
}
if (purchase.creationSource === "TEST_MODE") {
throw new KnownErrors.TestModePurchaseNonRefundable();
}
const stripe = await getStripeForAccount({ tenancy: auth.tenancy });
if (!purchase.stripePaymentIntentId) {
throw new KnownErrors.OneTimePurchaseNotFound(body.id);
}
await prisma.oneTimePurchase.update({
where: { tenancyId_id: { tenancyId: auth.tenancy.id, id: body.id } },
data: { refundedAt: new Date() },
});
await stripe.refunds.create({
payment_intent: purchase.stripePaymentIntentId,
metadata: {
tenancyId: auth.tenancy.id,
purchaseId: purchase.id,
},
});
await prisma.oneTimePurchase.update({
where: { tenancyId_id: { tenancyId: auth.tenancy.id, id: body.id } },
data: { refundedAt: new Date() },
});
}

return {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export type ProductWithPrices = {

type ProductSnapshot = (TransactionEntry & { type: "product_grant" })["product"];

const REFUND_TRANSACTION_SUFFIX = ":refund";

export function resolveSelectedPriceFromProduct(product: ProductWithPrices, priceId?: string | null): SelectedPrice | null {
if (!product) return null;
Expand Down Expand Up @@ -145,6 +146,18 @@ function createProductGrantEntry(options: {
};
}

function buildRefundAdjustments(options: { refundedAt?: Date | null, entries: TransactionEntry[], transactionId: string }): Transaction["adjusted_by"] {
if (!options.refundedAt) {
return [];
}
const productGrantIndex = options.entries.findIndex((entry) => entry.type === "product_grant");
const entryIndex = productGrantIndex >= 0 ? productGrantIndex : 0;
return [{
transaction_id: `${options.transactionId}${REFUND_TRANSACTION_SUFFIX}`,
entry_index: entryIndex,
}];
}

export function buildSubscriptionTransaction(options: {
subscription: Subscription,
}): Transaction {
Expand Down Expand Up @@ -179,13 +192,19 @@ export function buildSubscriptionTransaction(options: {
entries.push(moneyTransfer);
}

const adjustedBy = buildRefundAdjustments({
refundedAt: subscription.refundedAt,
entries,
transactionId: subscription.id,
});

return {
id: subscription.id,
created_at_millis: subscription.createdAt.getTime(),
effective_at_millis: subscription.createdAt.getTime(),
type: "purchase",
entries,
adjusted_by: [],
adjusted_by: adjustedBy,
test_mode: testMode,
};
}
Expand Down Expand Up @@ -224,13 +243,19 @@ export function buildOneTimePurchaseTransaction(options: {
entries.push(moneyTransfer);
}

const adjustedBy = buildRefundAdjustments({
refundedAt: purchase.refundedAt,
entries,
transactionId: purchase.id,
});

return {
id: purchase.id,
created_at_millis: purchase.createdAt.getTime(),
effective_at_millis: purchase.createdAt.getTime(),
type: "purchase",
entries,
adjusted_by: [],
adjusted_by: adjustedBy,
test_mode: testMode,
};
}
Expand Down
25 changes: 20 additions & 5 deletions apps/dashboard/src/components/data-table/transaction-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { useAdminApp } from '@/app/(main)/(protected)/projects/[projectId]/use-a
import type { Transaction, TransactionEntry, TransactionType } from '@stackframe/stack-shared/dist/interface/crud/transactions';
import { TRANSACTION_TYPES } from '@stackframe/stack-shared/dist/interface/crud/transactions';
import { deepPlainEquals } from '@stackframe/stack-shared/dist/utils/objects';
import { ActionCell, ActionDialog, AvatarCell, DataTableColumnHeader, DataTableManualPagination, DateCell, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, TextCell, Tooltip, TooltipContent, TooltipTrigger } from '@stackframe/stack-ui';
import { ActionCell, ActionDialog, AvatarCell, Badge, DataTableColumnHeader, DataTableManualPagination, DateCell, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, TextCell, Tooltip, TooltipContent, TooltipTrigger } from '@stackframe/stack-ui';
import type { ColumnDef, ColumnFiltersState, SortingState } from '@tanstack/react-table';
import type { LucideIcon } from 'lucide-react';
import { Ban, CircleHelp, RefreshCcw, RotateCcw, Settings, ShoppingCart, Shuffle } from 'lucide-react';
Expand All @@ -26,6 +26,7 @@ type TransactionSummary = {
detail: string,
amountDisplay: string,
refundTarget: RefundTarget | null,
refunded: boolean,
};

type EntryWithCustomer = Extract<TransactionEntry, { customer_type: string, customer_id: string }>;
Expand Down Expand Up @@ -173,6 +174,7 @@ function getTransactionSummary(transaction: Transaction): TransactionSummary {
const customerEntry = transaction.entries.find(isEntryWithCustomer);
const moneyTransferEntry = transaction.entries.find(isMoneyTransferEntry);
const refundTarget = getRefundTarget(transaction);
const refunded = transaction.adjusted_by.length > 0;

return {
sourceType,
Expand All @@ -182,14 +184,17 @@ function getTransactionSummary(transaction: Transaction): TransactionSummary {
detail: describeDetail(transaction, sourceType),
amountDisplay: transaction.test_mode ? 'Test mode' : pickChargedAmountDisplay(moneyTransferEntry),
refundTarget,
refunded,
};
}

function RefundActionCell({ transaction, refundTarget }: { transaction: Transaction, refundTarget: RefundTarget | null }) {
const app = useAdminApp();
const [isDialogOpen, setIsDialogOpen] = React.useState(false);
const target = transaction.type === 'purchase' ? refundTarget : null;
const canRefund = !!target && !transaction.test_mode;
const alreadyRefunded = transaction.adjusted_by.length > 0;
const productEntry = transaction.entries.find(isProductGrantEntry);
const canRefund = !!target && !transaction.test_mode && !alreadyRefunded && productEntry?.price_id;
Copy link

Choose a reason for hiding this comment

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

Suggested change
const canRefund = !!target && !transaction.test_mode && !alreadyRefunded && productEntry?.price_id;
const canRefund = !!target && !transaction.test_mode && !alreadyRefunded;

The refund button is now disabled for transactions with null price_id, but the backend allows refunding such transactions. This prevents refunding valid purchases created via server-side product grant without specifying a price_id.

View Details

Analysis

Frontend prevents refunding server-granted subscriptions with null price_id

What fails: RefundActionCell.canRefund() in transaction-table.tsx checks productEntry?.price_id, preventing refund attempts for server-granted subscriptions that have price_id: null

How to reproduce:

  1. Create a server-granted subscription via POST /api/latest/payments/products/user/{userId} with product_id and quantity
  2. View the transaction in the dashboard transactions table
  3. Observe refund button is disabled despite subscription being a valid purchase

Result: Refund button disabled in UI, users cannot attempt refunds Expected: Refund button should be enabled, allowing users to attempt refunds (backend will handle validation and return appropriate errors for non-refundable items)

Technical details: Server-granted subscriptions use priceId: undefined in grantProductToCustomer() which stores null in the database, while frontend incorrectly assumes all refundable transactions must have a price_id. Backend refund logic in /api/latest/internal/payments/transactions/refund/route.tsx does not check price_id, creating a frontend/backend inconsistency.


return (
<>
Expand Down Expand Up @@ -217,6 +222,7 @@ function RefundActionCell({ transaction, refundTarget }: { transaction: Transact
item: "Refund",
danger: true,
disabled: !canRefund,
disabledTooltip: "This transaction cannot be refunded",
onClick: () => {
if (!target) return;
setIsDialogOpen(true);
Expand Down Expand Up @@ -307,9 +313,18 @@ export function TransactionTable() {
header: ({ column }) => <DataTableColumnHeader column={column} columnTitle="Details" />,
cell: ({ row }) => {
const summary = summaryById.get(row.original.id);
return <TextCell>
{summary?.detail ?? '—'}
</TextCell>;
return (
<TextCell size={120}>
<div className="flex items-center gap-2">
<span className="truncate">{summary?.detail ?? '—'}</span>
{summary?.refunded ? (
<Badge variant="outline" className="text-xs">
Refunded
</Badge>
) : null}
</div>
</TextCell>
);
},
enableSorting: false,
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,37 @@ it("refunds non-test mode one-time purchases created via Stripe webhooks", async
expect(refundRes.status).toBe(200);
expect(refundRes.body).toEqual({ success: true });

const transactionsAfterRefund = await niceBackendFetch("/api/latest/internal/payments/transactions", {
accessType: "admin",
});
const refundedTransaction = transactionsAfterRefund.body.transactions.find((tx: any) => tx.id === purchaseTransaction.id);
expect(refundedTransaction?.adjusted_by).toEqual([
{
entry_index: 0,
transaction_id: expect.stringContaining(`${purchaseTransaction.id}:refund`),
},
]);

const secondRefundAttempt = await niceBackendFetch("/api/latest/internal/payments/transactions/refund", {
accessType: "admin",
method: "POST",
body: { type: "one-time-purchase", id: purchaseTransaction.id },
});
expect(secondRefundAttempt).toMatchInlineSnapshot(`
NiceResponse {
"status": 400,
"body": {
"code": "ONE_TIME_PURCHASE_ALREADY_REFUNDED",
"details": { "one_time_purchase_id": "<stripped UUID>" },
"error": "One-time purchase with ID \\"<stripped UUID>\\" was already refunded.",
},
"headers": Headers {
"x-stack-known-error": "ONE_TIME_PURCHASE_ALREADY_REFUNDED",
<some fields may have been hidden>,
},
}
`);

const productsAfterRes = await niceBackendFetch(`/api/v1/payments/products/user/${userId}`, {
accessType: "client",
});
Expand Down
28 changes: 28 additions & 0 deletions packages/stack-shared/src/known-errors.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1518,6 +1518,32 @@ const OneTimePurchaseNotFound = createKnownErrorConstructor(
(json) => [json.one_time_purchase_id] as const,
);

const SubscriptionAlreadyRefunded = createKnownErrorConstructor(
KnownError,
"SUBSCRIPTION_ALREADY_REFUNDED",
(subscriptionId: string) => [
400,
`Subscription with ID ${JSON.stringify(subscriptionId)} was already refunded.`,
{
subscription_id: subscriptionId,
},
] as const,
(json) => [json.subscription_id] as const,
);

const OneTimePurchaseAlreadyRefunded = createKnownErrorConstructor(
KnownError,
"ONE_TIME_PURCHASE_ALREADY_REFUNDED",
(purchaseId: string) => [
400,
`One-time purchase with ID ${JSON.stringify(purchaseId)} was already refunded.`,
{
one_time_purchase_id: purchaseId,
},
] as const,
(json) => [json.one_time_purchase_id] as const,
);

const TestModePurchaseNonRefundable = createKnownErrorConstructor(
KnownError,
"TEST_MODE_PURCHASE_NON_REFUNDABLE",
Expand Down Expand Up @@ -1726,6 +1752,8 @@ export const KnownErrors = {
ProductAlreadyGranted,
SubscriptionInvoiceNotFound,
OneTimePurchaseNotFound,
SubscriptionAlreadyRefunded,
OneTimePurchaseAlreadyRefunded,
TestModePurchaseNonRefundable,
ItemQuantityInsufficientAmount,
StripeAccountInfoNotFound,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -601,6 +601,7 @@ export class _StackAdminAppImplIncomplete<HasTokenStore extends boolean, Project

async refundTransaction(options: { type: "subscription" | "one-time-purchase", id: string }): Promise<void> {
await this._interface.refundTransaction({ type: options.type, id: options.id });
await this._transactionsCache.invalidateWhere(() => true);
}

async listTransactions(params: { cursor?: string, limit?: number, type?: TransactionType, customerType?: 'user' | 'team' | 'custom' }): Promise<{ transactions: Transaction[], nextCursor: string | null }> {
Expand Down