Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
Expand Up @@ -2,9 +2,28 @@ import { getStripeForAccount } from "@/lib/stripe";
import { getPrismaClientForTenancy } from "@/prisma-client";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { KnownErrors } from "@stackframe/stack-shared/dist/known-errors";
import { adaptSchema, adminAuthTypeSchema, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors";
import { adaptSchema, adminAuthTypeSchema, moneyAmountSchema, productSchema, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
import { SubscriptionStatus } from "@/generated/prisma/client";
import { SUPPORTED_CURRENCIES, type MoneyAmount } from "@stackframe/stack-shared/dist/utils/currency-constants";
import { moneyAmountToStripeUnits } from "@stackframe/stack-shared/dist/utils/currencies";
import { resolveSelectedPriceFromProduct } from "@/app/api/latest/internal/payments/transactions/transaction-builder";
import { InferType } from "yup";

const USD_CURRENCY = SUPPORTED_CURRENCIES.find((currency) => currency.code === "USD")
?? throwErr("USD currency configuration missing in SUPPORTED_CURRENCIES");

function getTotalUsdStripeUnits(options: { product: InferType<typeof productSchema>, priceId: string | null, quantity: number }) {
const selectedPrice = resolveSelectedPriceFromProduct(options.product, options.priceId ?? null);
const usdPrice = selectedPrice?.USD;
if (typeof usdPrice !== "string") {
throw new KnownErrors.SchemaError("Refund amounts can only be specified for USD-priced purchases.");
}
if (!Number.isFinite(options.quantity) || Math.trunc(options.quantity) !== options.quantity) {
throw new StackAssertionError("Purchase quantity is not an integer", { quantity: options.quantity });
}
return moneyAmountToStripeUnits(usdPrice as MoneyAmount, USD_CURRENCY) * options.quantity;
}

export const POST = createSmartRouteHandler({
metadata: {
Expand All @@ -19,6 +38,7 @@ export const POST = createSmartRouteHandler({
body: yupObject({
type: yupString().oneOf(["subscription", "one-time-purchase"]).defined(),
id: yupString().defined(),
amount_usd: moneyAmountSchema(USD_CURRENCY).optional(),
}).defined()
}),
response: yupObject({
Expand All @@ -30,10 +50,16 @@ export const POST = createSmartRouteHandler({
}),
handler: async ({ auth, body }) => {
const prisma = await getPrismaClientForTenancy(auth.tenancy);
const refundAmountUsd = body.amount_usd ?? null;
if (body.type === "subscription") {
const subscription = await prisma.subscription.findUnique({
where: { tenancyId_id: { tenancyId: auth.tenancy.id, id: body.id } },
select: { refundedAt: true },
select: {
refundedAt: true,
product: true,
priceId: true,
quantity: true,
},
});
if (!subscription) {
throw new KnownErrors.SubscriptionInvoiceNotFound(body.id);
Expand Down Expand Up @@ -72,7 +98,25 @@ export const POST = createSmartRouteHandler({
if (!paymentIntentId || typeof paymentIntentId !== "string") {
throw new StackAssertionError("Payment has no payment intent", { invoiceId: subscriptionInvoice.stripeInvoiceId });
}
await stripe.refunds.create({ payment_intent: paymentIntentId });
let refundAmountStripeUnits: number | null = null;
if (refundAmountUsd) {
const totalStripeUnits = getTotalUsdStripeUnits({
product: subscription.product as InferType<typeof productSchema>,
priceId: subscription.priceId ?? null,
quantity: subscription.quantity,
});
refundAmountStripeUnits = moneyAmountToStripeUnits(refundAmountUsd as MoneyAmount, USD_CURRENCY);
if (refundAmountStripeUnits <= 0) {
throw new KnownErrors.SchemaError("Refund amount must be greater than zero.");
}
if (refundAmountStripeUnits > totalStripeUnits) {
throw new KnownErrors.SchemaError("Refund amount cannot exceed the charged amount.");
}
}
Comment thread
BilalG1 marked this conversation as resolved.
await stripe.refunds.create({
payment_intent: paymentIntentId,
...(refundAmountStripeUnits !== null ? { amount: refundAmountStripeUnits } : {}),
});
await prisma.subscription.update({
where: { tenancyId_id: { tenancyId: auth.tenancy.id, id: body.id } },
data: {
Expand All @@ -99,8 +143,24 @@ export const POST = createSmartRouteHandler({
if (!purchase.stripePaymentIntentId) {
throw new KnownErrors.OneTimePurchaseNotFound(body.id);
}
let refundAmountStripeUnits: number | null = null;
if (refundAmountUsd) {
const totalStripeUnits = getTotalUsdStripeUnits({
product: purchase.product as InferType<typeof productSchema>,
priceId: purchase.priceId ?? null,
quantity: purchase.quantity,
});
refundAmountStripeUnits = moneyAmountToStripeUnits(refundAmountUsd as MoneyAmount, USD_CURRENCY);
if (refundAmountStripeUnits <= 0) {
throw new KnownErrors.SchemaError("Refund amount must be greater than zero.");
}
if (refundAmountStripeUnits > totalStripeUnits) {
throw new KnownErrors.SchemaError("Refund amount cannot exceed the charged amount.");
}
}
await stripe.refunds.create({
payment_intent: purchase.stripePaymentIntentId,
...(refundAmountStripeUnits !== null ? { amount: refundAmountStripeUnits } : {}),
metadata: {
tenancyId: auth.tenancy.id,
purchaseId: purchase.id,
Expand Down
72 changes: 68 additions & 4 deletions apps/dashboard/src/components/data-table/transaction-table.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
'use client';

import { useAdminApp } from '@/app/(main)/(protected)/projects/[projectId]/use-admin-app';
import { ActionCell, ActionDialog, AvatarCell, Badge, DataTableColumnHeader, DataTableManualPagination, DateCell, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, TextCell, Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui';
import { ActionCell, ActionDialog, Alert, AlertDescription, AvatarCell, Badge, DataTableColumnHeader, DataTableManualPagination, DateCell, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, TextCell, Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui';
import type { Icon as PhosphorIcon } from '@phosphor-icons/react';
import { ArrowClockwiseIcon, ArrowCounterClockwiseIcon, GearIcon, ProhibitIcon, QuestionIcon, ShoppingCartIcon, ShuffleIcon } from '@phosphor-icons/react';
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 type { MoneyAmount } from '@stackframe/stack-shared/dist/utils/currency-constants';
import { SUPPORTED_CURRENCIES } from '@stackframe/stack-shared/dist/utils/currency-constants';
import { moneyAmountToStripeUnits } from '@stackframe/stack-shared/dist/utils/currencies';
import { moneyAmountSchema } from '@stackframe/stack-shared/dist/schema-fields';
import { deepPlainEquals } from '@stackframe/stack-shared/dist/utils/objects';
import type { ColumnDef, ColumnFiltersState, SortingState } from '@tanstack/react-table';
import React, { useCallback } from 'react';
Expand Down Expand Up @@ -34,6 +38,7 @@ type MoneyTransferEntry = Extract<TransactionEntry, { type: 'money_transfer' }>;
type ProductGrantEntry = Extract<TransactionEntry, { type: 'product_grant' }>;
type ItemQuantityChangeEntry = Extract<TransactionEntry, { type: 'item_quantity_change' }>;
type RefundTarget = { type: 'subscription' | 'one-time-purchase', id: string };
const USD_CURRENCY = SUPPORTED_CURRENCIES.find((currency) => currency.code === 'USD');

function isEntryWithCustomer(entry: TransactionEntry): entry is EntryWithCustomer {
return 'customer_type' in entry && 'customer_id' in entry;
Expand Down Expand Up @@ -191,10 +196,41 @@ function getTransactionSummary(transaction: Transaction): TransactionSummary {
function RefundActionCell({ transaction, refundTarget }: { transaction: Transaction, refundTarget: RefundTarget | null }) {
const app = useAdminApp();
const [isDialogOpen, setIsDialogOpen] = React.useState(false);
const [refundAmountUsd, setRefundAmountUsd] = React.useState<string>('');
const target = transaction.type === 'purchase' ? refundTarget : null;
const alreadyRefunded = transaction.adjusted_by.length > 0;
const productEntry = transaction.entries.find(isProductGrantEntry);
const canRefund = !!target && !transaction.test_mode && !alreadyRefunded && productEntry?.price_id;
const moneyTransferEntry = transaction.entries.find(isMoneyTransferEntry);
const chargedAmountUsd = moneyTransferEntry ? (moneyTransferEntry.charged_amount.USD ?? null) : null;

React.useEffect(() => {
if (isDialogOpen) {
setRefundAmountUsd(chargedAmountUsd ?? '');
}
}, [chargedAmountUsd, isDialogOpen]);

const refundValidation = React.useMemo(() => {
if (!chargedAmountUsd || !USD_CURRENCY) {
return { canSubmit: true, error: null, amountUsd: undefined };
}
if (!refundAmountUsd) {
return { canSubmit: false, error: "Enter a refund amount.", amountUsd: undefined };
}
const isValid = moneyAmountSchema(USD_CURRENCY).defined().isValidSync(refundAmountUsd);
if (!isValid) {
return { canSubmit: false, error: "Refund amount must be a valid USD amount.", amountUsd: undefined };
}
const refundUnits = moneyAmountToStripeUnits(refundAmountUsd as MoneyAmount, USD_CURRENCY);
const maxUnits = moneyAmountToStripeUnits(chargedAmountUsd as MoneyAmount, USD_CURRENCY);
if (refundUnits <= 0) {
return { canSubmit: false, error: "Refund amount must be greater than zero.", amountUsd: undefined };
}
if (refundUnits > maxUnits) {
return { canSubmit: false, error: `Refund amount cannot exceed $${chargedAmountUsd}.`, amountUsd: undefined };
}
return { canSubmit: true, error: null, amountUsd: refundAmountUsd as MoneyAmount };
}, [chargedAmountUsd, refundAmountUsd]);

return (
<>
Expand All @@ -208,13 +244,41 @@ function RefundActionCell({ transaction, refundTarget }: { transaction: Transact
okButton={{
label: "Refund",
onClick: async () => {
await app.refundTransaction(target);
setIsDialogOpen(false);
if (chargedAmountUsd && !refundValidation.canSubmit) {
Comment thread
BilalG1 marked this conversation as resolved.
return "prevent-close";
}
await app.refundTransaction({ ...target, amountUsd: refundValidation.amountUsd });
},
props: chargedAmountUsd ? { disabled: !refundValidation.canSubmit } : undefined,
Comment thread
BilalG1 marked this conversation as resolved.
}}
confirmText="Refunds cannot be undone and will revoke access to the purchased product."
>
{`Refund this ${target.type === 'subscription' ? 'subscription' : 'one-time purchase'} transaction?`}
<div className="space-y-4">
<p>{`Refund this ${target.type === 'subscription' ? 'subscription' : 'one-time purchase'} transaction?`}</p>
{chargedAmountUsd ? (
<div className="space-y-2">
<Label htmlFor={`refund-amount-${transaction.id}`}>Refund amount (USD)</Label>
<Input
id={`refund-amount-${transaction.id}`}
inputMode="decimal"
placeholder={chargedAmountUsd}
value={refundAmountUsd}
onChange={(event) => setRefundAmountUsd(event.target.value)}
/>
{refundValidation.error ? (
<Alert variant="destructive">
<AlertDescription>{refundValidation.error}</AlertDescription>
</Alert>
) : null}
</div>
) : (
<Alert>
<AlertDescription>
Partial refunds are only available for USD charges. This will issue a full refund.
</AlertDescription>
</Alert>
)}
</div>
</ActionDialog>
) : null}
<ActionCell
Expand Down
8 changes: 5 additions & 3 deletions apps/dashboard/src/components/ui/action-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ export function ActionDialog(props: ActionDialogProps) {
const [confirmed, setConfirmed] = React.useState(false);
const confirmId = useId();
const [invalidationCount, setInvalidationCount] = React.useState(0);
const okButtonExtraProps = okButton && typeof okButton === "object" ? okButton.props : undefined;
const { disabled: okButtonDisabledProp, ...okButtonProps } = okButtonExtraProps ?? {};
const okButtonDisabled = (!!props.confirmText && !confirmed) || !!okButtonDisabledProp;

const onOpenChange = (open: boolean) => {
if (!open) {
Expand Down Expand Up @@ -122,14 +125,14 @@ export function ActionDialog(props: ActionDialogProps) {
)}
{okButton && (
<Button
disabled={!!props.confirmText && !confirmed}
disabled={okButtonDisabled}
variant={props.danger ? "destructive" : "default"}
onClick={async () => {
if (await okButton.onClick?.() !== "prevent-close") {
onOpenChange(false);
}
}}
{...okButton.props}
{...okButtonProps}
>
{okButton.label ?? "OK"}
</Button>
Expand All @@ -139,4 +142,3 @@ export function ActionDialog(props: ActionDialogProps) {
</Dialog>
);
}

Loading
Loading