Skip to content

Commit 8d5332f

Browse files
Merge pull request #18874 from mozilla/FXA-11413
feat(payments-next): Enable no-charge payments with stripe
2 parents 6b69726 + 8d7f887 commit 8d5332f

6 files changed

Lines changed: 141 additions & 40 deletions

File tree

libs/payments/cart/src/lib/cart.error.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,3 +209,14 @@ export class CartSubscriptionNotFoundError extends CartError {
209209
Object.setPrototypeOf(this, CartSubscriptionNotFoundError.prototype);
210210
}
211211
}
212+
213+
export class PaidInvoiceOnFailedCartError extends CartError {
214+
constructor(cartId: string, args?: Record<string, any>) {
215+
super('Paid invoice found on failed cart', {
216+
cartId,
217+
...args,
218+
});
219+
this.name = 'PaidInvoiceOnFailedCartError';
220+
Object.setPrototypeOf(this, PaidInvoiceOnFailedCartError.prototype);
221+
}
222+
}

libs/payments/cart/src/lib/cart.service.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ import {
5858
CartInvalidStateForActionError,
5959
CartStateProcessingError,
6060
CartSubscriptionNotFoundError,
61+
PaidInvoiceOnFailedCartError,
6162
} from './cart.error';
6263
import { CartManager } from './cart.manager';
6364
import type {
@@ -176,11 +177,26 @@ export class CartService {
176177
await this.invoiceManager.void(invoice.id);
177178
break;
178179
case 'paid':
179-
throw new CartError('Paid invoice found on failed cart', {
180+
const paidInvoiceError = new PaidInvoiceOnFailedCartError(
180181
cartId,
181-
stripeCustomerId: cart.stripeCustomerId,
182-
invoiceId: invoice.id,
183-
});
182+
{
183+
error,
184+
stripeCustomerId: cart.stripeCustomerId,
185+
invoiceId: invoice.id,
186+
}
187+
);
188+
this.log.error(paidInvoiceError);
189+
190+
// swallow the error to allow cancellation of the subscription only when payment total is 0
191+
if (invoice.total !== 0) {
192+
this.statsd.increment('non_zero_paid_invoice_on_failed_cart');
193+
Sentry.captureException(paidInvoiceError, {
194+
extra: {
195+
cartId,
196+
},
197+
});
198+
throw paidInvoiceError;
199+
}
184200
}
185201
}
186202

@@ -199,6 +215,7 @@ export class CartService {
199215
}
200216
} catch (e) {
201217
// swallow the error to allow cancellation of the subscription
218+
this.log.error(e);
202219
Sentry.captureException(e, {
203220
extra: {
204221
cartId,

libs/payments/cart/src/lib/checkout.service.spec.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -688,6 +688,50 @@ describe('CheckoutService', () => {
688688
});
689689
});
690690

691+
it('handles free payments for customers with default payment method', async () => {
692+
const freeInvoice = StripeResponseFactory(
693+
StripeInvoiceFactory({
694+
payment_intent: mockPaymentIntent.id,
695+
amount_due: 0,
696+
status: 'paid',
697+
})
698+
);
699+
const existingPayentMethod = StripeResponseFactory(
700+
StripePaymentMethodFactory()
701+
);
702+
jest.spyOn(invoiceManager, 'retrieve').mockResolvedValue(freeInvoice);
703+
jest
704+
.spyOn(customerManager, 'getDefaultPaymentMethod')
705+
.mockResolvedValue(existingPayentMethod);
706+
await expect(
707+
checkoutService.payWithStripe(
708+
mockCart,
709+
mockConfirmationToken.id,
710+
mockCustomerData
711+
)
712+
).resolves;
713+
});
714+
it('rejects free payments for customers without default payment method', async () => {
715+
const freeInvoice = StripeResponseFactory(
716+
StripeInvoiceFactory({
717+
payment_intent: mockPaymentIntent.id,
718+
amount_due: 0,
719+
status: 'paid',
720+
})
721+
);
722+
jest.spyOn(invoiceManager, 'retrieve').mockResolvedValue(freeInvoice);
723+
jest
724+
.spyOn(customerManager, 'getDefaultPaymentMethod')
725+
.mockResolvedValue(undefined);
726+
await expect(
727+
checkoutService.payWithStripe(
728+
mockCart,
729+
mockConfirmationToken.id,
730+
mockCustomerData
731+
)
732+
).rejects.toThrow();
733+
});
734+
691735
describe('upgrade', () => {
692736
const mockEligibilityResult =
693737
SubscriptionEligibilityUpgradeDowngradeResultFactory({

libs/payments/cart/src/lib/checkout.service.ts

Lines changed: 51 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -347,47 +347,62 @@ export class CheckoutService {
347347
subscription.latest_invoice
348348
);
349349

350-
assert(
351-
invoice.payment_intent,
352-
'payment_intent does not exist on subscription'
353-
);
354-
// Confirm intent with collected payment method
355-
const paymentIntent = await this.paymentIntentManager.confirm(
356-
invoice.payment_intent,
357-
{
358-
confirmation_token: confirmationTokenId,
359-
off_session: false,
360-
}
361-
);
350+
if (invoice.amount_due === 0) {
351+
// invoices without charge do not generate a payent intent
352+
assert(
353+
invoice.status === 'paid',
354+
'Zero-charge stripe invoice expected to be in a paid status'
355+
);
356+
const paymentMethod = await this.customerManager.getDefaultPaymentMethod(
357+
customer.id
358+
);
359+
assert(
360+
!!paymentMethod,
361+
'Payment method expected on customer for stripe zero-charge payment processing'
362+
);
363+
} else {
364+
assert(
365+
invoice.payment_intent,
366+
'payment_intent does not exist on subscription'
367+
);
368+
// Confirm intent with collected payment method
369+
const paymentIntent = await this.paymentIntentManager.confirm(
370+
invoice.payment_intent,
371+
{
372+
confirmation_token: confirmationTokenId,
373+
off_session: false,
374+
}
375+
);
362376

363-
if (paymentIntent.status === 'requires_action') {
364-
await this.cartManager.setNeedsInputCart(cart.id);
365-
return;
366-
} else if (paymentIntent.status === 'succeeded') {
367-
if (paymentIntent.payment_method) {
368-
await this.customerManager.update(customer.id, {
369-
invoice_settings: {
370-
default_payment_method: paymentIntent.payment_method,
371-
},
372-
});
377+
if (paymentIntent.status === 'requires_action') {
378+
await this.cartManager.setNeedsInputCart(cart.id);
379+
return;
380+
} else if (paymentIntent.status === 'succeeded') {
381+
if (paymentIntent.payment_method) {
382+
await this.customerManager.update(customer.id, {
383+
invoice_settings: {
384+
default_payment_method: paymentIntent.payment_method,
385+
},
386+
});
387+
} else {
388+
throw new CartError(
389+
'Failed to update customer default payment method',
390+
{ cartId: cart.id }
391+
);
392+
}
373393
} else {
374-
throw new CartError(
375-
'Failed to update customer default payment method',
376-
{ cartId: cart.id }
394+
throw new CheckoutPaymentError(
395+
`Expected payment intent status to be one of [requires_action, succeeded], instead found: ${paymentIntent.status}`
377396
);
378397
}
379-
await this.postPaySteps({
380-
cart,
381-
version: updatedVersion,
382-
subscription,
383-
uid,
384-
paymentProvider: 'stripe',
385-
});
386-
} else {
387-
throw new CheckoutPaymentError(
388-
`Expected payment intent status to be one of [requires_action, succeeded], instead found: ${paymentIntent.status}`
389-
);
390398
}
399+
await this.postPaySteps({
400+
cart,
401+
version: updatedVersion,
402+
subscription,
403+
uid,
404+
paymentProvider: 'stripe',
405+
});
391406
}
392407

393408
async payWithPaypal(

libs/payments/customer/src/lib/customer.manager.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,4 +105,8 @@ export class CustomerManager {
105105
isTaxEligible(customer: StripeCustomer) {
106106
return isCustomerTaxEligible(customer);
107107
}
108+
109+
async getDefaultPaymentMethod(customerId: string) {
110+
return this.stripeClient.customerDefaultPaymentMethodRetrieve(customerId);
111+
}
108112
}

libs/payments/stripe/src/lib/stripe.client.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,16 @@ export class StripeClient {
325325
return result as StripeResponse<StripePaymentMethod>;
326326
}
327327

328+
@CaptureTimingWithStatsD()
329+
async customerDefaultPaymentMethodRetrieve(customerId: string) {
330+
const result = await this.stripe.customers.retrieve(customerId, {
331+
expand: ['invoice_settings.default_payment_method'],
332+
});
333+
if (result.deleted) return undefined;
334+
return result.invoice_settings
335+
.default_payment_method as StripeResponse<StripePaymentMethod>;
336+
}
337+
328338
@Cacheable({
329339
cacheKey: (args: any) =>
330340
cacheKeyForClient('pricesRetrieve', args[0], args[1]),

0 commit comments

Comments
 (0)