chore: Handle stripe refunds slightly differently to reduce hard errors (#24150)

* chore: Handle stripe refunds slightly differently to reduce hard errors

* Remove 'Received and discarded' error when a credential is not found for a triggered subscription

* Fix handling of no credential found (do show in stripe debugger)

* Minimal overhaul of delete handling

* Fix doc typo
This commit is contained in:
Alex van Andel
2025-09-29 23:07:08 +00:00
committed by GitHub
parent 0055dd1142
commit 259e480baa
4 changed files with 46 additions and 21 deletions
@@ -6,7 +6,7 @@ import stripe from "@calcom/features/ee/payments/server/stripe";
import { IS_PRODUCTION } from "@calcom/lib/constants";
import { getErrorFromUnknown } from "@calcom/lib/errors";
import { HttpError as HttpCode } from "@calcom/lib/http-error";
import prisma from "@calcom/prisma";
import { prisma } from "@calcom/prisma";
export const config = {
api: {
@@ -27,7 +27,10 @@ const handleSubscriptionUpdate = async (event: Stripe.Event) => {
});
if (!app) {
throw new HttpCode({ statusCode: 202, message: "Received and discarded" });
throw new HttpCode({
statusCode: 202,
message: `No credential found with subscription ID ${subscription.id}`,
});
}
await prisma.credential.update({
@@ -51,7 +54,10 @@ const handleSubscriptionDeleted = async (event: Stripe.Event) => {
});
if (!app) {
throw new HttpCode({ statusCode: 202, message: "Received and discarded" });
throw new HttpCode({
statusCode: 202,
message: `No credential found with subscription ID ${subscription.id}`,
});
}
// should we delete the credential here rather than marking as inactive?
@@ -103,7 +109,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}
} catch (_err) {
const err = getErrorFromUnknown(_err);
console.error(`Webhook Error: ${err.message}`);
if (!err.message.includes("No credential found with subscription ID")) {
console.error(`Webhook Error: ${err.message}`);
}
res.status(err.statusCode ?? 500).send({
message: err.message,
stack: IS_PRODUCTION ? undefined : err.stack,
@@ -46,8 +46,14 @@ export class PaymentService implements IAbstractPaymentService {
private async getPayment(where: Prisma.PaymentWhereInput) {
const payment = await prisma.payment.findFirst({ where });
if (!payment) throw new Error("Payment not found");
if (!payment.externalId) throw new Error("Payment externalId not found");
// if payment isn't found, return null.
if (!payment) {
return null;
}
// if it is found, but there's no externalId - it indicates invalid state and an error should be thrown.
if (!payment.externalId) {
throw new Error("Payment externalId not found");
}
return { ...payment, externalId: payment.externalId };
}
@@ -326,13 +332,21 @@ export class PaymentService implements IAbstractPaymentService {
throw new Error("Method not implemented.");
}
async refund(paymentId: Payment["id"]): Promise<Payment> {
async refund(paymentId: Payment["id"]): Promise<Payment | null> {
const payment = await this.getPayment({
id: paymentId,
});
if (!payment) {
return null;
}
if (!payment.success) {
throw new Error("Unable to refund failed payment");
}
if (payment.refunded) {
// refunded already, bail early as success without throwing an error.
return payment;
}
try {
const payment = await this.getPayment({
id: paymentId,
success: true,
refunded: false,
});
const refund = await this.stripe.refunds.create(
{
payment_intent: payment.externalId,
@@ -399,8 +413,12 @@ export class PaymentService implements IAbstractPaymentService {
const payment = await this.getPayment({
id: paymentId,
});
const stripeAccount = (payment.data as unknown as StripePaymentData).stripeAccount;
// no payment found, return false.
if (!payment) {
return false;
}
const stripeAccount = (payment.data as unknown as StripePaymentData).stripeAccount;
if (!stripeAccount) {
throw new Error("Stripe account not found");
}
@@ -2,6 +2,7 @@ import z from "zod";
import { getCalendar } from "@calcom/app-store/_utils/getCalendar";
import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData";
import { DailyLocationType } from "@calcom/app-store/locations";
import {
type EventTypeAppMetadataSchema,
eventTypeAppMetadataOptionalSchema,
@@ -14,7 +15,6 @@ import { deleteWebhookScheduledTriggers } from "@calcom/features/webhooks/lib/sc
import { buildNonDelegationCredential } from "@calcom/lib/delegationCredential/server";
import { isPrismaObjOrUndefined } from "@calcom/lib/isPrismaObj";
import { parseRecurringEvent } from "@calcom/lib/isRecurringEvent";
import { DailyLocationType } from "@calcom/app-store/locations";
import { getTranslation } from "@calcom/lib/server/i18n";
import { bookingMinimalSelect, prisma } from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
@@ -284,11 +284,7 @@ const handleDeleteCredential = async ({
});
for (const payment of booking.payment) {
try {
await deletePayment(payment.id, credential);
} catch (e) {
console.error(e);
}
await deletePayment(payment.id, credential);
await prisma.payment.delete({
where: {
id: payment.id,
@@ -353,7 +349,7 @@ const handleDeleteCredential = async ({
seatsPerTimeSlot: booking.eventType?.seatsPerTimeSlot,
seatsShowAttendees: booking.eventType?.seatsShowAttendees,
hideOrganizerEmail: booking.eventType?.hideOrganizerEmail,
team: !!booking.eventType?.team
team: booking.eventType?.team
? {
name: booking.eventType.team.name,
id: booking.eventType.team.id,
+4 -1
View File
@@ -39,7 +39,10 @@ export interface IAbstractPaymentService {
): Promise<Payment>;
update(paymentId: Payment["id"], data: Partial<Prisma.PaymentUncheckedCreateInput>): Promise<Payment>;
refund(paymentId: Payment["id"]): Promise<Payment>;
/**
* @returns Payment if successful, null if payment to refund does not exist (anymore)
*/
refund(paymentId: Payment["id"]): Promise<Payment | null>;
getPaymentPaidStatus(): Promise<string>;
getPaymentDetails(): Promise<Payment>;
afterPayment(