From 16eff52b1c60cd46886d5b84c231af7011be3e60 Mon Sep 17 00:00:00 2001 From: Davey Alvarez Date: Tue, 4 Feb 2025 15:28:03 -0800 Subject: [PATCH] feat(next): Clean up artifacts from checkout process on checkout failure Because: * As the user processes through the checkout process for a subscription, we create business objects to represent their subscription and the stakeholders. If the subscription fails, the artifacts need to be cleaned up. This commit: * Adds additional logic to the cart service wrapWithCartCatch method to clean up defunct artifacts. Closes #FXA-10628 --- .../cart/src/lib/cart.service.spec.ts | 255 +++++++++++++++++- libs/payments/cart/src/lib/cart.service.ts | 93 ++++++- .../customer/src/lib/customer.manager.ts | 7 + .../customer/src/lib/invoice.manager.ts | 21 +- .../customer/src/lib/paymentIntent.manager.ts | 4 + .../src/lib/subscription.manager.spec.ts | 3 +- .../customer/src/lib/subscription.manager.ts | 7 +- libs/payments/stripe/src/index.ts | 2 + .../lib/factories/deleted-customer.factory.ts | 15 ++ .../lib/factories/deleted-invoice.factory.ts | 15 ++ libs/payments/stripe/src/lib/stripe.client.ts | 22 ++ .../stripe/src/lib/stripe.client.types.ts | 2 + 12 files changed, 427 insertions(+), 19 deletions(-) create mode 100644 libs/payments/stripe/src/lib/factories/deleted-customer.factory.ts create mode 100644 libs/payments/stripe/src/lib/factories/deleted-invoice.factory.ts diff --git a/libs/payments/cart/src/lib/cart.service.spec.ts b/libs/payments/cart/src/lib/cart.service.spec.ts index 33a7056ac1a..0493f7f8ecf 100644 --- a/libs/payments/cart/src/lib/cart.service.spec.ts +++ b/libs/payments/cart/src/lib/cart.service.spec.ts @@ -44,6 +44,8 @@ import { StripePaymentIntentFactory, StripeCustomerSessionFactory, StripeApiListFactory, + StripeInvoiceFactory, + StripeDeletedInvoiceFactory, } from '@fxa/payments/stripe'; import { MockProfileClientConfigProvider, @@ -199,6 +201,249 @@ describe('CartService', () => { paymentMethodManager = moduleRef.get(PaymentMethodManager); }); + describe('wrapCartWithCatch', () => { + it('calls cartManager.finishErrorCart', async () => { + const mockCart = ResultCartFactory({ + state: CartState.PROCESSING, + stripeSubscriptionId: null, + stripeCustomerId: null, + }); + jest + .spyOn(cartManager, 'fetchCartById') + .mockRejectedValueOnce(new Error('test')) + .mockResolvedValue(mockCart); + jest.spyOn(cartManager, 'finishErrorCart').mockResolvedValue(); + + await expect( + cartService.finalizeProcessingCart(mockCart.id) + ).rejects.toThrow(Error); + + expect(cartManager.finishErrorCart).toHaveBeenCalled(); + }); + + it('cancels a created subscription', async () => { + const mockCustomer = StripeResponseFactory(StripeCustomerFactory()); + const mockSubscription = StripeResponseFactory( + StripeSubscriptionFactory({ + customer: mockCustomer.id, + latest_invoice: null, + }) + ); + const mockCart = ResultCartFactory({ + state: CartState.PROCESSING, + stripeSubscriptionId: mockSubscription.id, + stripeCustomerId: mockCustomer.id, + }); + + jest + .spyOn(cartManager, 'fetchCartById') + .mockRejectedValueOnce(new Error('test')) + .mockResolvedValue(mockCart); + + jest.spyOn(cartManager, 'finishErrorCart').mockResolvedValue(); + jest + .spyOn(subscriptionManager, 'retrieve') + .mockResolvedValue(mockSubscription); + jest + .spyOn(subscriptionManager, 'getLatestPaymentIntent') + .mockResolvedValue(undefined); + jest + .spyOn(subscriptionManager, 'cancel') + .mockResolvedValue(mockSubscription); + + await expect( + cartService.finalizeProcessingCart(mockCart.id) + ).rejects.toThrow(Error); + + expect(subscriptionManager.cancel).toHaveBeenCalledWith( + mockSubscription.id, + { + cancellation_details: { + comment: 'Automatic Cancellation: Cart checkout failed.', + }, + } + ); + }); + + it('deletes a created draft invoice', async () => { + const mockCustomer = StripeResponseFactory(StripeCustomerFactory()); + const mockInvoice = StripeResponseFactory( + StripeInvoiceFactory({ status: 'draft' }) + ); + const mockDeletedInvoice = StripeResponseFactory( + StripeDeletedInvoiceFactory({ id: mockInvoice.id }) + ); + const mockSubscription = StripeResponseFactory( + StripeSubscriptionFactory({ + customer: mockCustomer.id, + latest_invoice: mockInvoice.id, + }) + ); + const mockCart = ResultCartFactory({ + state: CartState.PROCESSING, + stripeSubscriptionId: mockSubscription.id, + stripeCustomerId: mockCustomer.id, + }); + + jest + .spyOn(cartManager, 'fetchCartById') + .mockRejectedValueOnce(new Error('test')) + .mockResolvedValue(mockCart); + + jest.spyOn(cartManager, 'finishErrorCart').mockResolvedValue(); + jest + .spyOn(subscriptionManager, 'retrieve') + .mockResolvedValue(mockSubscription); + jest.spyOn(invoiceManager, 'retrieve').mockResolvedValue(mockInvoice); + jest + .spyOn(invoiceManager, 'delete') + .mockResolvedValue(mockDeletedInvoice); + jest + .spyOn(subscriptionManager, 'getLatestPaymentIntent') + .mockResolvedValue(undefined); + jest + .spyOn(subscriptionManager, 'cancel') + .mockResolvedValue(mockSubscription); + + await expect( + cartService.finalizeProcessingCart(mockCart.id) + ).rejects.toThrow(Error); + + expect(invoiceManager.delete).toHaveBeenCalledWith(mockInvoice.id); + }); + + it('voids a created finalized invoice', async () => { + const mockCustomer = StripeResponseFactory(StripeCustomerFactory()); + const mockInvoice = StripeResponseFactory( + StripeInvoiceFactory({ status: 'open' }) + ); + const mockSubscription = StripeResponseFactory( + StripeSubscriptionFactory({ + customer: mockCustomer.id, + latest_invoice: mockInvoice.id, + }) + ); + const mockCart = ResultCartFactory({ + state: CartState.PROCESSING, + stripeSubscriptionId: mockSubscription.id, + stripeCustomerId: mockCustomer.id, + }); + + jest + .spyOn(cartManager, 'fetchCartById') + .mockRejectedValueOnce(new Error('test')) + .mockResolvedValue(mockCart); + + jest.spyOn(cartManager, 'finishErrorCart').mockResolvedValue(); + jest + .spyOn(subscriptionManager, 'retrieve') + .mockResolvedValue(mockSubscription); + jest.spyOn(invoiceManager, 'retrieve').mockResolvedValue(mockInvoice); + jest.spyOn(invoiceManager, 'void').mockResolvedValue(mockInvoice); + jest + .spyOn(subscriptionManager, 'getLatestPaymentIntent') + .mockResolvedValue(undefined); + jest + .spyOn(subscriptionManager, 'cancel') + .mockResolvedValue(mockSubscription); + + await expect( + cartService.finalizeProcessingCart(mockCart.id) + ).rejects.toThrow(Error); + + expect(invoiceManager.void).toHaveBeenCalledWith(mockInvoice.id); + }); + + it('cancels a created payment intent', async () => { + const mockCustomer = StripeResponseFactory(StripeCustomerFactory()); + const mockPaymentIntent = StripeResponseFactory( + StripePaymentIntentFactory({ status: 'processing' }) + ); + const mockSubscription = StripeResponseFactory( + StripeSubscriptionFactory({ + customer: mockCustomer.id, + latest_invoice: null, + }) + ); + const mockCart = ResultCartFactory({ + state: CartState.PROCESSING, + stripeSubscriptionId: mockSubscription.id, + stripeCustomerId: mockCustomer.id, + }); + + jest + .spyOn(cartManager, 'fetchCartById') + .mockRejectedValueOnce(new Error('test')) + .mockResolvedValue(mockCart); + + jest.spyOn(cartManager, 'finishErrorCart').mockResolvedValue(); + jest + .spyOn(subscriptionManager, 'retrieve') + .mockResolvedValue(mockSubscription); + jest + .spyOn(subscriptionManager, 'getLatestPaymentIntent') + .mockResolvedValue(mockPaymentIntent); + jest + .spyOn(paymentIntentManager, 'cancel') + .mockResolvedValue(mockPaymentIntent); + jest + .spyOn(subscriptionManager, 'cancel') + .mockResolvedValue(mockSubscription); + + await expect( + cartService.finalizeProcessingCart(mockCart.id) + ).rejects.toThrow(Error); + + expect(paymentIntentManager.cancel).toHaveBeenCalledWith( + mockPaymentIntent.id + ); + }); + + it('does not delete a customer with preexisting subscriptions', async () => { + const mockCustomer = StripeResponseFactory(StripeCustomerFactory()); + const mockSubscription = StripeResponseFactory( + StripeSubscriptionFactory({ + customer: mockCustomer.id, + latest_invoice: null, + }) + ); + const mockPreviousSubscription = StripeResponseFactory( + StripeSubscriptionFactory() + ); + const mockCart = ResultCartFactory({ + state: CartState.PROCESSING, + stripeSubscriptionId: mockSubscription.id, + stripeCustomerId: mockCustomer.id, + }); + + jest + .spyOn(cartManager, 'fetchCartById') + .mockRejectedValueOnce(new Error('test')) + .mockResolvedValue(mockCart); + + jest.spyOn(cartManager, 'finishErrorCart').mockResolvedValue(); + jest + .spyOn(subscriptionManager, 'retrieve') + .mockResolvedValue(mockSubscription); + jest + .spyOn(subscriptionManager, 'getLatestPaymentIntent') + .mockResolvedValue(undefined); + jest + .spyOn(subscriptionManager, 'cancel') + .mockResolvedValue(mockSubscription); + jest + .spyOn(subscriptionManager, 'listForCustomer') + .mockResolvedValue([mockPreviousSubscription]); + jest.spyOn(customerManager, 'delete'); + + await expect( + cartService.finalizeProcessingCart(mockCart.id) + ).rejects.toThrow(Error); + + expect(customerManager.delete).not.toHaveBeenCalledWith(mockCustomer.id); + }); + }); + describe('setupCart', () => { const args = { interval: SubplatInterval.Monthly, @@ -314,7 +559,12 @@ describe('CartService', () => { }); describe('restartCart', () => { + const mockStripeCustomerId = faker.string.uuid(); + const mockAccountCustomer = ResultAccountCustomerFactory({ + stripeCustomerId: mockStripeCustomerId, + }); const mockOldCart = ResultCartFactory({ + uid: mockAccountCustomer.uid, couponCode: faker.word.noun(), }); const mockNewCart = ResultCartFactory(); @@ -333,6 +583,9 @@ describe('CartService', () => { .spyOn(promotionCodeManager, 'assertValidPromotionCodeNameForPrice') .mockResolvedValue(undefined); jest.spyOn(cartManager, 'createCart').mockResolvedValue(mockNewCart); + jest + .spyOn(accountCustomerManager, 'getAccountCustomerByUid') + .mockResolvedValue(mockAccountCustomer); const result = await cartService.restartCart(mockOldCart.id); @@ -344,7 +597,7 @@ describe('CartService', () => { couponCode: mockOldCart.couponCode, taxAddress: mockOldCart.taxAddress, currency: mockOldCart.currency, - stripeCustomerId: mockOldCart.stripeCustomerId, + stripeCustomerId: mockAccountCustomer.stripeCustomerId, email: mockOldCart.email, amount: mockOldCart.amount, eligibilityStatus: mockOldCart.eligibilityStatus, diff --git a/libs/payments/cart/src/lib/cart.service.ts b/libs/payments/cart/src/lib/cart.service.ts index 01c86d6139d..b55f88cc16b 100644 --- a/libs/payments/cart/src/lib/cart.service.ts +++ b/libs/payments/cart/src/lib/cart.service.ts @@ -17,6 +17,7 @@ import { CouponErrorGeneric, CouponErrorLimitReached, CustomerSessionManager, + PaymentIntentManager, } from '@fxa/payments/customer'; import { EligibilityService } from '@fxa/payments/eligibility'; import { @@ -77,7 +78,8 @@ export class CartService { private invoiceManager: InvoiceManager, private productConfigurationManager: ProductConfigurationManager, private subscriptionManager: SubscriptionManager, - private paymentMethodManager: PaymentMethodManager + private paymentMethodManager: PaymentMethodManager, + private paymentIntentManager: PaymentIntentManager ) {} /** @@ -93,18 +95,77 @@ export class CartService { errorReasonId = CartErrorReasonId.BASIC_ERROR; } - await this.cartManager - .finishErrorCart(cartId, { + // All unexpected/untyped errors should go to Sentry + if (errorReasonId === CartErrorReasonId.Unknown) { + Sentry.captureException(error, { + extra: { + cartId, + }, + }); + } + + try { + await this.cartManager.finishErrorCart(cartId, { errorReasonId, - }) - .catch((e) => { - // We silently fail here so as not to eat the original error - Sentry.captureException(e); }); - // All unexpectd/untyped errors should go to Sentry - if (errorReasonId === CartErrorReasonId.Unknown) { - Sentry.captureException(error, { + const cart = await this.cartManager.fetchCartById(cartId); + if (cart.stripeSubscriptionId) { + const subscription = await this.subscriptionManager.retrieve( + cart.stripeSubscriptionId + ); + const invoice = subscription.latest_invoice + ? await this.invoiceManager.retrieve(subscription.latest_invoice) + : undefined; + if (invoice) { + switch (invoice.status) { + case 'draft': + await this.invoiceManager.delete(invoice.id); + break; + case 'open': + case 'uncollectible': + await this.invoiceManager.void(invoice.id); + break; + case 'paid': + throw new CartError('Paid invoice found on failed cart', { + cartId, + stripeCustomerId: cart.stripeCustomerId, + invoiceId: invoice.id, + }); + } + } + + const paymentIntent = + await this.subscriptionManager.getLatestPaymentIntent(subscription); + if (paymentIntent?.status === 'succeeded') { + throw new CartError('Paid payment intent found on failed cart', { + cartId, + stripeCustomerId: cart.stripeCustomerId, + paymentIntentId: paymentIntent.id, + }); + } + try { + if (paymentIntent) { + await this.paymentIntentManager.cancel(paymentIntent.id); + } + } catch (e) { + // swallow the error to allow cancellation of the subscription + Sentry.captureException(e, { + extra: { + cartId, + }, + }); + } + + await this.subscriptionManager.cancel(cart.stripeSubscriptionId, { + cancellation_details: { + comment: 'Automatic Cancellation: Cart checkout failed.', + }, + }); + } + } catch (e) { + // All errors thrown during the cleanup process should go to Sentry + Sentry.captureException(e, { extra: { cartId, }, @@ -246,6 +307,16 @@ export class CartService { } } + const accountCustomer = oldCart.uid + ? await this.accountCustomerManager + .getAccountCustomerByUid(oldCart.uid) + .catch((error) => { + if (!(error instanceof AccountCustomerNotFoundError)) { + throw error; + } + }) + : undefined; + return await this.cartManager.createCart({ uid: oldCart.uid, interval: oldCart.interval, @@ -254,7 +325,7 @@ export class CartService { taxAddress: oldCart.taxAddress || undefined, currency: oldCart.currency || undefined, couponCode: oldCart.couponCode || undefined, - stripeCustomerId: oldCart.stripeCustomerId || undefined, + stripeCustomerId: accountCustomer?.stripeCustomerId || undefined, email: oldCart.email || undefined, amount: oldCart.amount, eligibilityStatus: oldCart.eligibilityStatus, diff --git a/libs/payments/customer/src/lib/customer.manager.ts b/libs/payments/customer/src/lib/customer.manager.ts index 72740769cf2..2e61eeb5fe7 100644 --- a/libs/payments/customer/src/lib/customer.manager.ts +++ b/libs/payments/customer/src/lib/customer.manager.ts @@ -69,6 +69,13 @@ export class CustomerManager { return customer; } + /** + * Delete a customer + */ + delete(customerId: string) { + return this.stripeClient.customersDelete(customerId); + } + async setTaxId(customerId: string, taxId: string) { const customerTaxId = await this.getTaxId(customerId); diff --git a/libs/payments/customer/src/lib/invoice.manager.ts b/libs/payments/customer/src/lib/invoice.manager.ts index add6d88695e..2304717aa13 100644 --- a/libs/payments/customer/src/lib/invoice.manager.ts +++ b/libs/payments/customer/src/lib/invoice.manager.ts @@ -118,6 +118,20 @@ export class InvoiceManager { return this.stripeClient.invoicesRetrieve(invoiceId); } + /** + * Deletes an invoice. Invoice must be in Draft state. + */ + async delete(invoiceId: string) { + return this.stripeClient.invoicesDelete(invoiceId); + } + + /** + * Voids an invoice. Invoice must be in Open or Uncollectable states. + */ + async void(invoiceId: string) { + return this.stripeClient.invoicesVoid(invoiceId); + } + /** * Process an invoice when amount is greater than minimum amount */ @@ -161,10 +175,9 @@ export class InvoiceManager { } satisfies ChargeOptions; let paypalCharge: ChargeResponse; try { - [paypalCharge] = await Promise.all([ - this.paypalClient.chargeCustomer(chargeOptions), - this.stripeClient.invoicesFinalizeInvoice(invoice.id), - ]); + // Charge the PayPal customer after the invoice is finalized to prevent charges with a failed invoice + await this.stripeClient.invoicesFinalizeInvoice(invoice.id); + paypalCharge = await this.paypalClient.chargeCustomer(chargeOptions); } catch (error) { if (PayPalClientError.hasPayPalNVPError(error)) { PayPalClientError.throwPaypalCodeError(error); diff --git a/libs/payments/customer/src/lib/paymentIntent.manager.ts b/libs/payments/customer/src/lib/paymentIntent.manager.ts index 81b18eb0c18..d7c25790cc2 100644 --- a/libs/payments/customer/src/lib/paymentIntent.manager.ts +++ b/libs/payments/customer/src/lib/paymentIntent.manager.ts @@ -20,4 +20,8 @@ export class PaymentIntentManager { async retrieve(paymentIntentId: string) { return this.stripeClient.paymentIntentRetrieve(paymentIntentId); } + + async cancel(paymentIntentId: string) { + return this.stripeClient.paymentIntentCancel(paymentIntentId); + } } diff --git a/libs/payments/customer/src/lib/subscription.manager.spec.ts b/libs/payments/customer/src/lib/subscription.manager.spec.ts index e76b5aef018..87dbe994b02 100644 --- a/libs/payments/customer/src/lib/subscription.manager.spec.ts +++ b/libs/payments/customer/src/lib/subscription.manager.spec.ts @@ -99,7 +99,8 @@ describe('SubscriptionManager', () => { await subscriptionManager.cancel(mockSubscription.id); expect(stripeClient.subscriptionsCancel).toBeCalledWith( - mockSubscription.id + mockSubscription.id, + undefined ); }); }); diff --git a/libs/payments/customer/src/lib/subscription.manager.ts b/libs/payments/customer/src/lib/subscription.manager.ts index 76d79650a3f..045e2d10c8c 100644 --- a/libs/payments/customer/src/lib/subscription.manager.ts +++ b/libs/payments/customer/src/lib/subscription.manager.ts @@ -18,8 +18,11 @@ import { InvalidPaymentIntentError, PaymentIntentNotFoundError } from './error'; export class SubscriptionManager { constructor(private stripeClient: StripeClient) {} - async cancel(subscriptionId: string) { - return this.stripeClient.subscriptionsCancel(subscriptionId); + async cancel( + subscriptionId: string, + params?: Stripe.SubscriptionCancelParams + ) { + return this.stripeClient.subscriptionsCancel(subscriptionId, params); } async create( diff --git a/libs/payments/stripe/src/index.ts b/libs/payments/stripe/src/index.ts index cf1d362c3a8..7d8171e2fc3 100644 --- a/libs/payments/stripe/src/index.ts +++ b/libs/payments/stripe/src/index.ts @@ -12,9 +12,11 @@ export { export { StripeCardFactory } from './lib/factories/card.factory'; export { StripeConfirmationTokenFactory } from './lib/factories/confirmation-token.factory'; export { StripeCouponFactory } from './lib/factories/coupon.factory'; +export { StripeDeletedCustomerFactory } from './lib/factories/deleted-customer.factory'; export { StripeCustomerFactory } from './lib/factories/customer.factory'; export { StripeCustomerSessionFactory } from './lib/factories/customer-session.factory'; export { StripeDiscountFactory } from './lib/factories/discount.factory'; +export { StripeDeletedInvoiceFactory } from './lib/factories/deleted-invoice.factory'; export { StripeInvoiceLineItemFactory } from './lib/factories/invoice-line-item.factory'; export { StripeInvoiceFactory } from './lib/factories/invoice.factory'; export { StripePlanFactory } from './lib/factories/plan.factory'; diff --git a/libs/payments/stripe/src/lib/factories/deleted-customer.factory.ts b/libs/payments/stripe/src/lib/factories/deleted-customer.factory.ts new file mode 100644 index 00000000000..ef4024902f9 --- /dev/null +++ b/libs/payments/stripe/src/lib/factories/deleted-customer.factory.ts @@ -0,0 +1,15 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { faker } from '@faker-js/faker'; +import { StripeDeletedCustomer } from '../stripe.client.types'; + +export const StripeDeletedCustomerFactory = ( + override?: Partial +): StripeDeletedCustomer => ({ + id: `cus_${faker.string.alphanumeric({ length: 24 })}`, + object: 'customer', + deleted: true, + ...override, +}); diff --git a/libs/payments/stripe/src/lib/factories/deleted-invoice.factory.ts b/libs/payments/stripe/src/lib/factories/deleted-invoice.factory.ts new file mode 100644 index 00000000000..b8d39cb2ec0 --- /dev/null +++ b/libs/payments/stripe/src/lib/factories/deleted-invoice.factory.ts @@ -0,0 +1,15 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { faker } from '@faker-js/faker'; +import { StripeDeletedInvoice } from '../stripe.client.types'; + +export const StripeDeletedInvoiceFactory = ( + override?: Partial +): StripeDeletedInvoice => ({ + id: `in_${faker.string.alphanumeric({ length: 24 })}`, + object: 'invoice', + deleted: true, + ...override, +}); diff --git a/libs/payments/stripe/src/lib/stripe.client.ts b/libs/payments/stripe/src/lib/stripe.client.ts index a74004131a9..a0507d698f8 100644 --- a/libs/payments/stripe/src/lib/stripe.client.ts +++ b/libs/payments/stripe/src/lib/stripe.client.ts @@ -10,6 +10,7 @@ import { StripeCustomer, StripeCustomerSession, StripeDeletedCustomer, + StripeDeletedInvoice, StripeInvoice, StripePaymentIntent, StripePaymentMethod, @@ -81,6 +82,17 @@ export class StripeClient { return result as StripeResponse; } + async customersDelete( + customerId: string, + params?: Stripe.CustomerDeleteParams + ) { + const result = await this.stripe.customers.del(customerId, { + ...params, + expand: ['tax'], + }); + return result as StripeResponse; + } + async customersSessionsCreate(params: Stripe.CustomerSessionCreateParams) { const result = await this.stripe.customerSessions.create(params); return result as StripeResponse; @@ -200,6 +212,16 @@ export class StripeClient { } } + async invoicesDelete(invoiceId: string) { + const result = await this.stripe.invoices.del(invoiceId); + return result as StripeResponse; + } + + async invoicesVoid(invoiceId: string) { + const result = await this.stripe.invoices.voidInvoice(invoiceId); + return result as StripeResponse; + } + async paymentIntentRetrieve( paymentIntentId: string, params?: Stripe.PaymentIntentRetrieveParams diff --git a/libs/payments/stripe/src/lib/stripe.client.types.ts b/libs/payments/stripe/src/lib/stripe.client.types.ts index bd01ac9d37a..3fcf1ab6c2d 100644 --- a/libs/payments/stripe/src/lib/stripe.client.types.ts +++ b/libs/payments/stripe/src/lib/stripe.client.types.ts @@ -182,6 +182,8 @@ export type StripeInvoice = NegotiateExpanded< | 'test_clock' >; +export type StripeDeletedInvoice = Stripe.DeletedInvoice; + /** * Stripe.PaymentIntent with expanded fields removed */