Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "OneTimePurchase" ADD COLUMN "refundedAt" TIMESTAMP(3);

Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "Subscription" ADD COLUMN "refundedAt" TIMESTAMP(3);

3 changes: 3 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 Expand Up @@ -821,6 +823,7 @@ model OneTimePurchase {
quantity Int
stripePaymentIntentId String?
createdAt DateTime @default(now())
refundedAt DateTime?
creationSource PurchaseCreationSource

@@id([tenancyId, id])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,6 @@ async function processStripeWebhookEvent(event: Stripe.Event): Promise<void> {
}
});
}

if (isSubscriptionChangedEvent(event)) {
const accountId = event.account;
const customerId = event.data.object.customer;
Expand All @@ -105,6 +104,9 @@ async function processStripeWebhookEvent(event: Stripe.Event): Promise<void> {
await handleStripeInvoicePaid(stripe, accountId, event.data.object);
}
}
if (event.type === "refund.created") {
const refund = event.data.object;
}
Comment on lines +107 to +109
Copy link
Contributor

Choose a reason for hiding this comment

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

style: Empty refund.created webhook handler has no implementation

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/backend/src/app/api/latest/integrations/stripe/webhooks/route.tsx
Line: 107:109

Comment:
**style:** Empty `refund.created` webhook handler has no implementation

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

}

export const POST = createSmartRouteHandler({
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
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 { SubscriptionStatus } from "@prisma/client";

export const POST = createSmartRouteHandler({
metadata: {
hidden: true,
},
request: yupObject({
auth: yupObject({
type: adminAuthTypeSchema.defined(),
project: adaptSchema.defined(),
tenancy: adaptSchema.defined(),
}).defined(),
body: yupObject({
type: yupString().oneOf(["subscription", "one-time-purchase"]).defined(),
id: yupString().defined(),
}).defined()
}),
response: yupObject({
statusCode: yupNumber().oneOf([200]).defined(),
bodyType: yupString().oneOf(["json"]).defined(),
body: yupObject({
success: yupBoolean().defined(),
}).defined(),
}),
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 },
});
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,
isSubscriptionCreationInvoice: true,
subscription: {
tenancyId: auth.tenancy.id,
id: body.id,
}
}
});
if (subscriptionInvoices.length === 0) {
throw new KnownErrors.SubscriptionInvoiceNotFound(body.id);
}
if (subscriptionInvoices.length > 1) {
throw new StackAssertionError("Multiple subscription creation invoices found for subscription", { subscriptionId: body.id });
}
const subscriptionInvoice = subscriptionInvoices[0];
const stripe = await getStripeForAccount({ tenancy: auth.tenancy });
const invoice = await stripe.invoices.retrieve(subscriptionInvoice.stripeInvoiceId, { expand: ["payments"] });
const payments = invoice.payments?.data;
if (!payments || payments.length === 0) {
throw new StackAssertionError("Invoice has no payments", { invoiceId: subscriptionInvoice.stripeInvoiceId });
}
const paidPayment = payments.find((payment) => payment.status === "paid");
if (!paidPayment) {
throw new StackAssertionError("Invoice has no paid payment", { invoiceId: subscriptionInvoice.stripeInvoiceId });
}
const paymentIntentId = paidPayment.payment.payment_intent;
if (!paymentIntentId || typeof paymentIntentId !== "string") {
throw new StackAssertionError("Payment has no payment intent", { invoiceId: subscriptionInvoice.stripeInvoiceId });
}
await stripe.refunds.create({ payment_intent: paymentIntentId });
await prisma.subscription.update({
where: { tenancyId_id: { tenancyId: auth.tenancy.id, id: body.id } },
data: {
status: SubscriptionStatus.canceled,
cancelAtPeriodEnd: true,
currentPeriodEnd: new Date(),
refundedAt: new Date(),
},
});
} else {
const purchase = await prisma.oneTimePurchase.findUnique({
where: { tenancyId_id: { tenancyId: auth.tenancy.id, id: body.id } },
});
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 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 {
statusCode: 200,
bodyType: "json",
body: {
success: true,
},
};
},
});
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
1 change: 1 addition & 0 deletions apps/backend/src/lib/payments.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -675,6 +675,7 @@ export async function getOwnedProductsForCustomer(options: {
tenancyId: options.tenancy.id,
customerId: options.customerId,
customerType: typedToUppercase(options.customerType),
refundedAt: null,
},
}),
]);
Expand Down
Loading
Loading